Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 4 additions & 4 deletions posthog/api/sharing.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@
return SharePasswordSerializer(obj.share_passwords.filter(is_active=True), many=True).data


@extend_schema(tags=["core"])

Check warning on line 266 in posthog/api/sharing.py

View workflow job for this annotation

GitHub Actions / Validate migrations and OpenAPI types

could not derive type of path parameter "password_id" because model "posthog.models.sharing_configuration.SharingConfiguration" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".
class SharingConfigurationViewSet(TeamAndOrgViewSetMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
scope_object = "sharing_configuration"
scope_object_write_actions = [
Expand Down Expand Up @@ -774,13 +774,13 @@
session_id=session_recording_id, team=resource.team
)

# Create a JWT for the recording
# Create a scoped JWT for the recording
export_access_token = ""
if resource.created_by and resource.created_by.id:
export_access_token = encode_jwt(
{"id": resource.created_by.id},
timedelta(minutes=5), # 5 mins should be enough for the export to complete
PosthogJwtAudience.IMPERSONATED_USER,
PosthogJwtAudience.EXPORT_RENDERER,
)

asset_title = "Session Recording"
Expand Down Expand Up @@ -831,13 +831,13 @@
raise NotFound("Invalid heatmap export - missing heatmap_type")

try:
# Create a JWT to access the heatmap data
# Create a scoped JWT to access the heatmap data
export_access_token = ""
if resource.created_by and resource.created_by.id:
export_access_token = encode_jwt(
{"id": resource.created_by.id},
timedelta(minutes=5),
PosthogJwtAudience.IMPERSONATED_USER,
PosthogJwtAudience.EXPORT_RENDERER,
)

asset_title = "Heatmap"
Expand Down
27 changes: 27 additions & 0 deletions posthog/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,33 @@ def authenticate_header(cls, request) -> str:
return cls.keyword


class ExportRendererAuthentication(authentication.BaseAuthentication):
"""
Scoped JWT auth for the export renderer. Only accepted on viewsets that opt in.
"""

keyword = "Bearer"

def authenticate(self, request: Union[HttpRequest, Request]) -> Optional[tuple[Any, None]]:
Copy link
Member

Choose a reason for hiding this comment

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

I believe this JWT is only intended for read-only actions. If so, you should verify that the request method is either GET or HEAD like in SharingAccessTokenAuthentication below. And also add some tests to verify POST/PUT/PATCH/DELETE are rejected.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Scoped down to just GET and HEAD.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Confirmed that exportToken, is only used with fetch, listSnapshotSources, and getSnapshots.

if "authorization" not in request.headers:
return None
authorization_match = re.match(rf"^Bearer\s+(\S.+)$", request.headers["authorization"])
if not authorization_match:
return None
try:
token = authorization_match.group(1).strip()
info = decode_jwt(token, PosthogJwtAudience.EXPORT_RENDERER)
user = User.objects.get(pk=info["id"])
return user, None
except (jwt.DecodeError, jwt.InvalidAudienceError, jwt.ExpiredSignatureError):
Copy link
Member

Choose a reason for hiding this comment

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

jwt.ExpiredSignatureError should probably raise AuthenticationFailed, rather than passing through to the next authenticator.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

return None
except Exception:
raise AuthenticationFailed(detail="Token invalid.")

def authenticate_header(self, request) -> str:
return self.keyword


class SharingAccessTokenAuthentication(authentication.BaseAuthentication):
"""Limited access for sharing views e.g. insights/dashboards for refreshing.
Remember to add access restrictions based on `sharing_configuration` using `SharingTokenPermission` or manually.
Expand Down
2 changes: 2 additions & 0 deletions posthog/heatmaps/heatmaps_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import UserBasicSerializer
from posthog.api.utils import action
from posthog.auth import ExportRendererAuthentication
from posthog.heatmaps.heatmaps_utils import DEFAULT_TARGET_WIDTHS
from posthog.models import User
from posthog.models.activity_logging.activity_log import Detail, log_activity
Expand Down Expand Up @@ -224,6 +225,7 @@ class HeatmapEventsResponseSerializer(serializers.Serializer):


class HeatmapViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet):
authentication_classes = [ExportRendererAuthentication]
scope_object = "heatmap"
scope_object_read_actions = ["list", "retrieve", "events"]

Expand Down
3 changes: 2 additions & 1 deletion posthog/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
class PosthogJwtAudience(Enum):
UNSUBSCRIBE = "posthog:unsubscribe"
EXPORTED_ASSET = "posthog:exported_asset"
IMPERSONATED_USER = "posthog:impersonted_user" # This is used by background jobs on behalf of the user e.g. exports
IMPERSONATED_USER = "posthog:impersonted_user"
EXPORT_RENDERER = "posthog:export_renderer"
LIVESTREAM = "posthog:livestream"
SHARING_PASSWORD_PROTECTED = "posthog:sharing_password_protected"

Expand Down
2 changes: 2 additions & 0 deletions posthog/session_recordings/session_recording_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.utils import ServerTimingsGathered, action, safe_clickhouse_string
from posthog.auth import (
ExportRendererAuthentication,
JwtAuthentication,
OAuthAccessTokenAuthentication,
PersonalAPIKeyAuthentication,
Expand Down Expand Up @@ -673,6 +674,7 @@ def clean_referer_url(current_url: str | None) -> str:
class SessionRecordingViewSet(
TeamAndOrgViewSetMixin, AccessControlViewSetMixin, viewsets.GenericViewSet, UpdateModelMixin
):
authentication_classes = [ExportRendererAuthentication]
scope_object = "session_recording"
scope_object_read_actions = ["list", "retrieve", "snapshots"]
throttle_classes = [ClickHouseBurstRateThrottle, ClickHouseSustainedRateThrottle]
Expand Down
68 changes: 68 additions & 0 deletions posthog/test/test_export_renderer_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from datetime import timedelta

from posthog.test.base import APIBaseTest

from parameterized import parameterized
from rest_framework import status
from rest_framework.test import APIClient

from posthog.jwt import PosthogJwtAudience, encode_jwt


class TestExportRendererAuthentication(APIBaseTest):
def _make_export_renderer_token(self) -> str:
return encode_jwt(
{"id": self.user.id},
timedelta(minutes=5),
PosthogJwtAudience.EXPORT_RENDERER,
)

@staticmethod
def _unauthenticated_client() -> APIClient:
return APIClient()

@parameterized.expand(
[
("session_recordings", "/api/environments/{team_id}/session_recordings"),
(
"heatmaps",
"/api/environments/{team_id}/heatmaps?type=click&date_from=2024-01-01&url_exact=https://example.com&viewport_width_min=0",
),
]
)
def test_export_renderer_token_accepted_on_opted_in_endpoint(self, _name: str, url_template: str):
client = self._unauthenticated_client()
token = self._make_export_renderer_token()
response = client.get(
url_template.format(team_id=self.team.id),
headers={"authorization": f"Bearer {token}"},
)
assert response.status_code == status.HTTP_200_OK

@parameterized.expand(
[
("dashboards", "/api/projects/{team_id}/dashboards/"),
("user_api", "/api/users/@me/"),
]
)
def test_export_renderer_token_rejected_on_non_opted_in_endpoint(self, _name: str, url_template: str):
client = self._unauthenticated_client()
token = self._make_export_renderer_token()
response = client.get(
url_template.format(team_id=self.team.id),
headers={"authorization": f"Bearer {token}"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

def test_expired_export_renderer_token_rejected(self):
client = self._unauthenticated_client()
token = encode_jwt(
{"id": self.user.id},
timedelta(seconds=-1),
PosthogJwtAudience.EXPORT_RENDERER,
)
response = client.get(
f"/api/environments/{self.team.id}/session_recordings",
headers={"authorization": f"Bearer {token}"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
Loading