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
16 changes: 8 additions & 8 deletions frontend/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14773,9 +14773,9 @@ snapshots:
scenes-app-replay-vision--observation-detail-classifier--light:
hash: v1.k794b7964.812c52630f07aad627bbef36145b4499599d5df16411d81dfe884abac4e5b48c.YNeITkL81fdUmXdlHQSOaVOxIi1otp_nLJPYEjWSWkc
scenes-app-replay-vision--observation-detail-failed--dark:
hash: v1.k794b7964.07b7872338385fd4798f0d5ba9bc83103e7eded996ad25bacbfac44e78141850.ELDT3n6Usls8RGZ-22vo6KPDOrh05j5HP6-RnDy4wHI
hash: v1.k794b7964.178910865db4028b36eac2c728a188654bd41ec72ce8e3f1f7c9fd7f04546b88.x5-JMI3AJDOufpCjRtOOcqWRrQmcWDUFqpx2YE2VC-0
scenes-app-replay-vision--observation-detail-failed--light:
hash: v1.k794b7964.865a18ec9e2c1d9616742ac66c69072d90ded6efb4bc8e973d43a7e9cdc205ee.8UP6XrRPnVlsT97P9jstn4CnaeBZZJD9oMPPfx4DwcQ
hash: v1.k794b7964.d785723c2d89ed620d75d0ef2055f898a01b7325101229d4e54cf37e2dc17af7.fv0LS2k7i2Mak73ZKguMZkf_kkciOPKble3HlvFiBPQ
scenes-app-replay-vision--observation-detail-inline-scan--dark:
hash: v1.k794b7964.ae52ae6b7550bd29bb072461a76f6adb7225017bef54712ae0983da16d73d38c.TMsKlJUqYj_m_dfJE700BerOPX_sIpZYfuZHqrEhGIE
scenes-app-replay-vision--observation-detail-inline-scan--light:
Expand All @@ -14789,21 +14789,21 @@ snapshots:
scenes-app-replay-vision--observation-detail-monitor-inconclusive--light:
hash: v1.k794b7964.51caae084374f8732810fc955d637cc0cba2049e991f6e8265cd01fc5ed53487.rT7EJy5Z5T4fOiZeaJTpzZkQQA29DIaN-hcZ2GtegCs
scenes-app-replay-vision--observation-detail-not-scanned--dark:
hash: v1.k794b7964.5aaf0fce8c31111ab54cc6b9f977c306d10a7490720f1f519b8071658531dc6f.anx8zQcKtVWrkZII_qKhA5Za18rbKNC6ZJ7UNu0lYHc
hash: v1.k794b7964.d92f751579ed56cd5e6dec23891b430b3a43d82065e160e62396fcca3fa973db.kGlivzbryjSCgzG6mAtZ3lI9fyGBLPrCUj65uibKE2k
scenes-app-replay-vision--observation-detail-not-scanned--light:
hash: v1.k794b7964.6ee2b084470e459f2fd89d32589b547dab570db7f939a20d7a19def3094ec69b.qyNB5ElOhbpF099xZGOqXkf2ZZvAHzmHEsJ1rw468Gs
hash: v1.k794b7964.8293b9fb2a7338d4f83eff3d0d5f557de7641017aec799f733efacd45bc7baee.gxeOxuDbSSIpryNYiDFrdUwVEUQ3gpuNjb3c5J_sdFE
scenes-app-replay-vision--observation-detail-recording-expired--dark:
hash: v1.k794b7964.7c51a1c2d8e2e76dac0d6ee37ca3e3765755cc2ec7eafe33fcbab9818c3205b2.qFfCtRUTcXPgLJ1GMkNYRMr7HXjtS2mREQYEs1n0dZk
hash: v1.k794b7964.fcbe89006a1bb557b6bc43713c4701692f2732250b5e7c6a414cfce39b5a718b.b-xw0njT9ETXR7a-1_gXHSmjIVdw8JUH2pSlRKGJCYY
scenes-app-replay-vision--observation-detail-recording-expired--light:
hash: v1.k794b7964.7d41766ac840c8497df9533ceb88f0086afdef57975b1f951c69183f8d060140.LFlTkcd2zNKfvqkEVgBcZBWPyVDizpCV1JoCmj8JdsE
hash: v1.k794b7964.8cff206a81bfe127a9f25dd0cdb7105d79251147aefeb7b5f8acf802d2c7c832.BpijYHiG7JszmH6HCyTLl0HIrdk5HQLZvZ_4HzfHud4
scenes-app-replay-vision--observation-detail-scorer--dark:
hash: v1.k794b7964.35969efd42660b47ef1ebb786fc9472428cd09dcb09c415f72674dd029c0c1f8.V0_UrhVG3UA6I3S9buZ63q0J2_NbT9k1gnOg3GiwnEU
scenes-app-replay-vision--observation-detail-scorer--light:
hash: v1.k794b7964.22324e39500a751d81afa2f8182d9c5bfa3210cab96e87275823a68bd742675a.UgutZacJ0sLcHH7UdObmCzZPgJhEVpkio7zxH_r90b4
scenes-app-replay-vision--observation-detail-summarize-button--dark:
hash: v1.k794b7964.b6352f9e6927ea338746776eef45109f3b7a13879aae0ad7fe5171fca1939d65.pSQTGMRBL8PO4VxW_Y3J3ZZREZDuconTOwUJnXnUyn4
hash: v1.k794b7964.e50ba21fe7a4a5d690ab033b46af9e0a38ccb003e3fdf84e5e2fd1c23d071f7e.DPWsjNBZn4T8iV76Lsg6X4txN827VTHFpCsB_qgslBQ
scenes-app-replay-vision--observation-detail-summarize-button--light:
hash: v1.k794b7964.6b8d659d537b98e11ff5a73e1d80f801d50980450845a7ab8bc574e1bb999d89.eASeUu7GVtBYfK9fq4HRPArLXsJJ_tYVZ1lItGfqbl8
hash: v1.k794b7964.491a0e0883bcbf0cc6206b910db87e1aa01c8cc9adbd51a7f11a6cd5662010a8.jBI2OFokY37mhDn-XB3OIj7htiD2eLmUjzSHlaeulsk
scenes-app-replay-vision--observation-detail-summary-with-timeline--dark:
hash: v1.k794b7964.5f217eb61e6b3a843dc8fb6d9b255f55aff8f95e29581c4ee303db78fc4eb2ad.u9RtPaF134pNlgU-fbrbUnPrw3hkRV0lvxlBXeALgUc
scenes-app-replay-vision--observation-detail-summary-with-timeline--light:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const SOURCE_TYPE_TO_PATH: Record<NotificationEventSourceTypeEnumApi, ((id: stri
error_tracking: (id) => urls.errorTrackingIssue(id),
customer_analytics: null,
ticket: (id) => urls.supportTicketDetail(id),
replay_observation: (id) => urls.replayVisionObservation(id),
}

export interface NotificationGroup {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { sidePanelDiscussionLogic } from './sidePanelDiscussionLogic'
const HedgehogStop = pngHoggie(stopPng)

export const SidePanelDiscussion = (): JSX.Element => {
const { commentsLogicProps } = useValues(sidePanelDiscussionLogic)
const { commentsLogicProps, sceneSidePanelContext } = useValues(sidePanelDiscussionLogic)
const disabledReason = sceneSidePanelContext.discussions_disabled_reason
const { scope, item_id } = commentsLogicProps ?? {}

const discussionTitle = (
Expand All @@ -43,6 +44,8 @@ export const SidePanelDiscussion = (): JSX.Element => {
<DiscussionContent logicProps={commentsLogicProps}>
<SidePanelPaneHeader title={discussionTitle} />
</DiscussionContent>
) : disabledReason ? (
<p className="m-0 p-4 text-secondary">{disabledReason}</p>
) : (
<div className="mx-auto p-8 max-w-160 mt-8 ">
<div className="max-w-24 mx-auto">
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/layout/navigation-3000/sidepanel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type SidePanelSceneContext = {
// only currently used for comments which are very similar to activity items
activity_item_context?: CommentType['item_context']
discussions_disabled?: boolean
/** Why this scene has turned discussions off, shown in place of the generic "not supported" message. */
discussions_disabled_reason?: string
/** The canvas on screen. Its panel tabs replace the general ones. */
canvas_id?: string
/** Whether the canvas on screen is being edited by hand, which adds its Blocks tab. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ const SCOPE_DISPLAY_NAMES: Partial<Record<ActivityScope, { singular: string; plu
singular: 'Project secret API key',
plural: 'Project secret API keys',
},
[ActivityScope.REPLAY_OBSERVATION]: { singular: 'Observation', plural: 'Observations' },
[ActivityScope.TICKET]: { singular: 'Support ticket', plural: 'Support tickets' },
}

Expand Down
8 changes: 6 additions & 2 deletions frontend/src/lib/components/Comments/Comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

import { CommentComposer } from './CommentComposer'
import { CommentsLogicProps, CommentWithRepliesType, commentsLogic } from './commentsLogic'
import { getCommentText, getRecordingLinkInfo, isViewingRecording } from './commentUtils'
import { getCommentText, getRecordingLinkInfo, isViewingRecording, scopeSyncsToSlack } from './commentUtils'
import { sendCommentToSlackLogic } from './sendCommentToSlackLogic'

// Comments that came in from a synced Slack thread have no PostHog author; their Slack identity
Expand Down Expand Up @@ -186,7 +186,7 @@
)
}

const CommentTopRow = ({ comment }: { comment: CommentType }): JSX.Element => {

Check warning on line 189 in frontend/src/lib/components/Comments/Comment.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`CommentTopRow` has cyclomatic complexity 13 (warn >10)
const { disabledReasonFor } = useValues(commentsLogic)
const { deleteComment, setEditingComment, sendEmojiReaction, setReplyingComment } = useActions(commentsLogic)
const { featureFlags } = useValues(featureFlagLogic)
Expand All @@ -197,7 +197,11 @@
const slackThread = comment.slack_thread
// Only an untracked top-level comment (a thread root) can be sent to Slack; replies follow their parent.
const canSendToSlack =
featureFlags[FEATURE_FLAGS.DISCUSSIONS_SLACK_SYNC] && !comment.source_comment && !fromSlack && !slackThread
featureFlags[FEATURE_FLAGS.DISCUSSIONS_SLACK_SYNC] &&
scopeSyncsToSlack(comment.scope) &&
!comment.source_comment &&
!fromSlack &&
!slackThread

return (
<div className="flex items-center justify-between gap-2">
Expand Down Expand Up @@ -280,7 +284,7 @@
)
}

const Comment = ({ comment }: { comment: CommentType }): JSX.Element => {

Check warning on line 287 in frontend/src/lib/components/Comments/Comment.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`Comment` has cyclomatic complexity 15 (warn >10)
const { editingComment, selectedCommentId, commentContexts } = useValues(commentsLogic)
const { setSelectedComment, completeComment, reopenComment } = useActions(commentsLogic)
const contextText = commentContexts[comment.id]
Expand Down Expand Up @@ -375,7 +379,7 @@
)
}

export const CommentWithReplies = ({ commentWithReplies, composerLogicProps }: CommentProps): JSX.Element => {

Check warning on line 382 in frontend/src/lib/components/Comments/Comment.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`CommentWithReplies` has cyclomatic complexity 14 (warn >10)
const { comment, replies } = commentWithReplies
const { replyingCommentId, expandedThreadIds, editingComment, itemContext } = useValues(commentsLogic)
const { setReplyingComment, setThreadExpanded } = useActions(commentsLogic)
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/components/Comments/CommentComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'

import { CommentsLogicProps, commentsLogic } from './commentsLogic'
import { scopeSyncsToSlack } from './commentUtils'
import { SlackDestinationPicker } from './SlackDestinationPicker'

export type CommentComposerProps = CommentsLogicProps & {
Expand All @@ -18,7 +19,7 @@
variant?: 'footer' | 'inline-reply'
}

export const CommentComposer = ({ variant = 'footer', ...props }: CommentComposerProps): JSX.Element => {

Check warning on line 22 in frontend/src/lib/components/Comments/CommentComposer.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`CommentComposer` has cyclomatic complexity 21 (warn >10)
const {
key,
isSendingComment,
Expand All @@ -43,7 +44,8 @@
const { featureFlags } = useValues(featureFlagLogic)

// Toggling a brand-new top-level comment straight to Slack; replies sync automatically.
const showSlackToggle = !replyingCommentId && !!featureFlags[FEATURE_FLAGS.DISCUSSIONS_SLACK_SYNC]
const showSlackToggle =
!replyingCommentId && !!featureFlags[FEATURE_FLAGS.DISCUSSIONS_SLACK_SYNC] && scopeSyncsToSlack(props.scope)

const placeholder = replyingCommentId
? 'Reply...'
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/components/Comments/commentUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export function getRecordingLinkInfo(comment: RecordingCommentTarget): Recording
}
}

/** Scopes whose discussions never mirror to Slack, because a Slack channel cannot apply their access checks. */
const SCOPES_WITHOUT_SLACK_SYNC: ReadonlySet<string> = new Set([ActivityScope.REPLAY_OBSERVATION])

export function scopeSyncsToSlack(scope: string): boolean {
return !SCOPES_WITHOUT_SLACK_SYNC.has(scope)
}

export function getCommentText(comment: { content?: string | null; rich_content?: JSONContent | null }): string {
// This is only temporary until all comments are backfilled to rich content
const content = comment.rich_content
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6192,6 +6192,7 @@ export enum ActivityScope {
CROSS_PROJECT_DASHBOARD = 'CrossProjectDashboard',
REPLAY = 'Replay',
REPLAY_SCANNER = 'ReplayScanner',
REPLAY_OBSERVATION = 'ReplayObservation',
VISION_ALERT_CONFIGURATION = 'VisionAlertConfiguration',
// TODO: doh! we don't need replay and recording
RECORDING = 'recording',
Expand Down
101 changes: 91 additions & 10 deletions posthog/api/comments.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import uuid
from contextlib import nullcontext
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
Expand Down Expand Up @@ -32,6 +33,7 @@
from posthog.models.comment.comment import (
CANVAS_COMMENT_SCOPES,
COMMENT_SCOPES_BLOCKED_FROM_GENERIC_API,
OBSERVATION_COMMENT_SCOPES,
TICKET_COMMENT_SCOPES,
activity_log_scope_for,
canonical_comment_scope,
Expand All @@ -51,6 +53,11 @@
from posthog.tasks.email import send_discussions_mentioned

from products.conversations.backend import reply_dedupe
from products.replay_vision.backend.facade.api import (
can_view_observation,
observation_note_write_rejection,
observation_viewer_user_ids,
)

if TYPE_CHECKING:
from products.access_control.backend.facade.user_access_control import UserAccessControl
Expand Down Expand Up @@ -90,6 +97,18 @@
raise exceptions.PermissionDenied("You do not have access to this ticket")


def _can_view_observation(*, team_id: int, item_id: str | None, user_access_control: "UserAccessControl") -> bool:
return bool(item_id) and can_view_observation(user_access_control, team_id, cast(str, item_id))


def _canonical_observation_id(item_id: Any) -> Any:
"""Store observation ids in one spelling, so lookups and cleanup that compare strings find every note."""
try:
return str(uuid.UUID(str(item_id)))
except ValueError:
return item_id


# A reservation with no posted root older than this is a crashed send — safe to retry.
STALE_SLACK_RESERVATION_GRACE = timedelta(minutes=2)

Expand Down Expand Up @@ -248,6 +267,11 @@
def _mentions_allowed_for_comment_target(
*, team_id: int, scope: str, item_id: str | None, item_context: dict | None, mentioned_user_ids: list[int]
) -> list[int]:
if scope in OBSERVATION_COMMENT_SCOPES:
if not item_id:
return []
viewer_ids = observation_viewer_user_ids(team_id=team_id, observation_id=item_id, user_ids=mentioned_user_ids)
return [user_id for user_id in mentioned_user_ids if user_id in viewer_ids]
Comment on lines +273 to +274

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.

P1 · Recheck observation access when reading mention notifications

After a mentioned member loses scanner, recording, or experiment access, the notifications API still returns the stored note body. This filter checks access only before fan-out; send_mention_notifications stores resource_type=comment, which NotificationsViewSet._filter_by_access_control skips. Gate notification reads on current observation access.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 18486b0. A mention notification for an observation note no longer stores the note text. Its body is a fixed placeholder that tells the reader to open the observation, and the comments API checks observation access each time the note is read. So a member who loses access later sees no note text in their notifications.

Comment thread
veria-ai[bot] marked this conversation as resolved.
if scope not in DESKTOP_COMMENT_SCOPES:
return mentioned_user_ids
if scope in CANVAS_COMMENT_SCOPES:
Expand Down Expand Up @@ -370,7 +394,7 @@
def validate_scope(self, value: str) -> str:
return canonical_comment_scope(value)

def validate(self, data):

Check warning on line 397 in posthog/api/comments.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`validate` has cyclomatic complexity 34 (warn >10)

Check warning on line 397 in posthog/api/comments.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`validate` has cyclomatic complexity 34 (warn >10)
request = self.context["request"]
instance = cast(Comment, self.instance)

Expand Down Expand Up @@ -441,6 +465,19 @@
scope = root.scope
item_id = root.item_id

if scope in OBSERVATION_COMMENT_SCOPES and "item_id" in data:
data["item_id"] = item_id = _canonical_observation_id(item_id)

# A note's replies are read through its thread, which checks only the root. Fixing where an observation
# note sits, and which thread it is in, keeps a whole thread under one access check.
if instance and ({instance.scope, scope, getattr(source_comment, "scope", None)} & OBSERVATION_COMMENT_SCOPES):
if (scope, item_id) != (instance.scope, instance.item_id):
raise exceptions.ValidationError({"item_id": "An observation note cannot move to another item."})
if getattr(source_comment, "id", None) != instance.source_comment_id:
raise exceptions.ValidationError(
{"source_comment": "An observation note cannot move to another thread."}
)

scopes_and_items = {(scope, item_id)}
if instance:
scopes_and_items.add((instance.scope, instance.item_id))
Expand All @@ -453,6 +490,18 @@
item_id=target_item_id,
user_access_control=self.context["get_user_access_control"](),
)
elif target_scope in OBSERVATION_COMMENT_SCOPES:
rejection = (
observation_note_write_rejection(
self.context["get_user_access_control"](), self.context["get_team"]().id, target_item_id
)
if target_item_id
else "forbidden"
)
if rejection == "forbidden":
raise exceptions.PermissionDenied("You do not have access to this observation")
if rejection == "not_finished":
raise exceptions.ValidationError({"item_id": "You can add notes after the scan succeeds."})

target_scope = data.get("scope", instance.scope if instance else None)
target_item_id = data.get("item_id", instance.item_id if instance else None)
Expand Down Expand Up @@ -697,7 +746,7 @@
scope_object = "comment"
scope_object_read_actions = ["list", "retrieve", "thread", "count"]

def dangerously_get_required_scopes(self, request: Request, view: Any) -> list[str] | None:

Check warning on line 749 in posthog/api/comments.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`dangerously_get_required_scopes` has cyclomatic complexity 11 (warn >10)

Check warning on line 749 in posthog/api/comments.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`dangerously_get_required_scopes` has cyclomatic complexity 11 (warn >10)
"""Ticket-scoped comments require ticket API scope access instead of comment access.

Candidate scopes are the union of every scope that determines what the request can
Expand Down Expand Up @@ -734,12 +783,17 @@
except (ValueError, django_exceptions.ValidationError):
return None
candidate_scopes.discard(None)
if not candidate_scopes & TICKET_COMMENT_SCOPES:
is_ticket = bool(candidate_scopes & TICKET_COMMENT_SCOPES)
is_observation = bool(candidate_scopes & OBSERVATION_COMMENT_SCOPES)
if not is_ticket and not is_observation:
return None
access = "read" if self.action in self.scope_object_read_actions else "write"
required: list[str] = [f"ticket:{access}"]
required: list[str] = [f"ticket:{access}"] if is_ticket else []
if candidate_scopes - TICKET_COMMENT_SCOPES:
required.append(f"comment:{access}")
if is_observation:
# Observation notes are recording-derived content, so they need the observation API's read scopes too.
required.extend(["replay_scanner:read", "session_recording:read"])
return required

@extend_schema(parameters=[CommentListQueryParamsSerializer])
Expand Down Expand Up @@ -878,13 +932,7 @@
Ticket,
)

pk = self.kwargs.get("pk")
if not pk:
return
try:
target = Comment.objects.filter(team_id=self.team_id, pk=pk).values_list("scope", "item_id").first()
except (ValueError, django_exceptions.ValidationError):
return
target = self._pk_target()
if not target:
return
scope, item_id = target
Expand All @@ -904,6 +952,28 @@
# Match the list path, where a denied ticket's comments are simply absent.
raise exceptions.NotFound()

def _pk_target(self) -> tuple[str, str | None] | None:
"""The (scope, item_id) of the detail action's comment, read once for every scope gate."""
if not hasattr(self, "_pk_target_cache"):
target = None
if pk := self.kwargs.get("pk"):
try:
target = Comment.objects.filter(team_id=self.team_id, pk=pk).values_list("scope", "item_id").first()
except (ValueError, django_exceptions.ValidationError):
target = None
self._pk_target_cache = target
return self._pk_target_cache

def _require_observation_viewer_access_for_pk(self) -> None:
"""Gate a detail action on the observation its target comment belongs to, as the list path does."""
target = self._pk_target()
if not target or target[0] not in OBSERVATION_COMMENT_SCOPES:
return
if not _can_view_observation(
team_id=self.team_id, item_id=target[1], user_access_control=self.user_access_control
):
Comment on lines +972 to +974

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.

P1 · Keep observation threads within one authorization boundary

Changing a thread root's item_id to another observation leaves its replies on the original observation. /thread checks only the root here and returns replies by source_comment_id, so members allowed on the new observation can read other users' notes from a denied scanner. Reject target changes on populated threads or authorize each returned reply.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0d2bd31. Instead of authorizing each reply in /thread, an observation note now stays on the observation it was written on. An update that changes the scope or item_id of an observation note, or that moves another comment into the observation scope, returns a 400. Replies already take the root's scope and item_id on create, so a whole thread is always on one observation and under one access check. Covered by test_comment_cannot_move_across_an_observation_boundary.

raise exceptions.NotFound()

def _require_task_comment_viewer_access_for_pk(self) -> None:
pk = self.kwargs.get("pk")
if not pk:
Expand Down Expand Up @@ -970,7 +1040,7 @@
).values_list("id", flat=True)
return queryset.filter(item_id__in=[str(ticket_id) for ticket_id in visible_ticket_ids])

def safely_get_queryset(self, queryset: QuerySet) -> QuerySet:

Check warning on line 1043 in posthog/api/comments.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`safely_get_queryset` has cyclomatic complexity 22 (warn >10)

Check warning on line 1043 in posthog/api/comments.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`safely_get_queryset` has cyclomatic complexity 22 (warn >10)
params = self.request.GET.dict()
queryset = queryset.exclude(scope__in=COMMENT_SCOPES_BLOCKED_FROM_GENERIC_API)

Expand All @@ -991,6 +1061,11 @@
)
if scope in TICKET_COMMENT_SCOPES:
queryset = self._filter_ticket_scoped_queryset(queryset, params.get("item_id"))
elif scope in OBSERVATION_COMMENT_SCOPES:
if not _can_view_observation(
team_id=self.team_id, item_id=params.get("item_id"), user_access_control=self.user_access_control
):
return queryset.none()
elif scope in DESKTOP_COMMENT_SCOPES:
task_id = params.get("task_id")
item_id = params.get("item_id")
Expand All @@ -1011,9 +1086,12 @@
elif self.action in ("list", "count"):
# Product-owned scopes require their own object-level access checks and must
# never leak through an unscoped generic comments query.
queryset = queryset.exclude(scope__in=[*TICKET_COMMENT_SCOPES, *DESKTOP_COMMENT_SCOPES])
queryset = queryset.exclude(
scope__in=[*TICKET_COMMENT_SCOPES, *DESKTOP_COMMENT_SCOPES, *OBSERVATION_COMMENT_SCOPES]
)
else:
self._require_ticket_viewer_access_for_pk()
self._require_observation_viewer_access_for_pk()
self._require_task_comment_viewer_access_for_pk()

if params.get("item_id"):
Expand Down Expand Up @@ -1150,7 +1228,7 @@
),
)
@action(methods=["POST"], detail=True)
def send_to_slack(self, request: Request, *args: Any, **kwargs: Any) -> Response:

Check warning on line 1231 in posthog/api/comments.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`send_to_slack` has cyclomatic complexity 12 (warn >10)

Check warning on line 1231 in posthog/api/comments.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`send_to_slack` has cyclomatic complexity 12 (warn >10)
team = self.team
if not self._slack_mirror_flag_enabled():
raise exceptions.NotFound()
Expand All @@ -1160,6 +1238,9 @@
raise exceptions.ValidationError("Only a top-level comment (a thread root) can be sent to Slack")
if comment.scope == "conversations_ticket":
raise exceptions.ValidationError("Conversations tickets sync to Slack through the support product")
if comment.scope in OBSERVATION_COMMENT_SCOPES:
# A Slack channel has no observation access check, in either direction.
raise exceptions.ValidationError("Replay Vision observation notes cannot be sent to Slack")

params = SendCommentToSlackSerializer(data=request.data)
params.is_valid(raise_exception=True)
Expand Down
Loading
Loading