Skip to content
Closed
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
46 changes: 44 additions & 2 deletions products/tasks/backend/facade/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@

from posthog.event_usage import groups
from posthog.models import Team, User
from posthog.models.file_system.constants import DESKTOP_SURFACE, surface_q
from posthog.models.file_system.file_system import FileSystem
from posthog.models.integration import Integration

from products.tasks.backend.constants import (
Expand Down Expand Up @@ -4980,9 +4982,43 @@ def _channel_to_dto(channel: Channel) -> contracts.ChannelDTO:
channel_type=channel.channel_type,
created_at=channel.created_at,
created_by=_user_basic_info(channel.created_by if channel.created_by_id else None),
folder_id=channel.folder_id,
)


def _link_channel_folder(channel: Channel, *, folder_id: UUID) -> None:
"""Record the desktop-fs folder that renders this channel. First claim wins:
an already-linked channel keeps its folder unless that row is gone (repair),
and a folder already claimed by another live channel stays theirs (the partial
unique constraint rejects the steal; we swallow it and leave this one unlinked).
The folder must be a desktop-surface folder row on the same team."""
if channel.folder_id == folder_id:
return
folder_exists = FileSystem.objects.filter(
surface_q(DESKTOP_SURFACE), id=folder_id, team_id=channel.team_id, type="folder"
).exists()
if not folder_exists:
return
if channel.folder_id is not None and FileSystem.objects.filter(id=channel.folder_id).exists():
return
channel.folder_id = folder_id
try:
with transaction.atomic():
channel.save(update_fields=["folder", "updated_at"])
except IntegrityError:
channel.folder_id = None
Comment on lines +5004 to +5009

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 Failed Repair Returns Wrong Link

When a channel contains a dangling folder ID and the requested replacement is already claimed, the save rolls back but this handler changes only the in-memory object to None. The response reports folder_id: null while the database still contains the old UUID, so a later read returns different state.

Suggested change
channel.folder_id = folder_id
try:
with transaction.atomic():
channel.save(update_fields=["folder", "updated_at"])
except IntegrityError:
channel.folder_id = None
channel.folder_id = folder_id
try:
with transaction.atomic():
channel.save(update_fields=["folder", "updated_at"])
except IntegrityError:
channel.refresh_from_db(fields=["folder"])
Prompt To Fix With AI
This is a comment left during a code review.
Path: products/tasks/backend/facade/api.py
Line: 5004-5009

Comment:
**Failed Repair Returns Wrong Link**

When a channel contains a dangling folder ID and the requested replacement is already claimed, the save rolls back but this handler changes only the in-memory object to `None`. The response reports `folder_id: null` while the database still contains the old UUID, so a later read returns different state.

```suggestion
    channel.folder_id = folder_id
    try:
        with transaction.atomic():
            channel.save(update_fields=["folder", "updated_at"])
    except IntegrityError:
        channel.refresh_from_db(fields=["folder"])
```

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

Comment on lines +5008 to +5009

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.

After an IntegrityError, the in-memory channel object has stale data. The transaction was rolled back, so the database still has the original folder_id value, but the in-memory object now has folder_id = None. When the caller link_channel_folder (line 5019) returns _channel_to_dto(channel), it will return incorrect data showing folder_id as None when the database actually has a different value.

Fix by refreshing from the database:

except IntegrityError:
    channel.refresh_from_db()

This same bug affects line 5103 where resolve_channel calls _link_channel_folder and then returns the DTO without refreshing.

Suggested change
except IntegrityError:
channel.folder_id = None
except IntegrityError:
channel.refresh_from_db()

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.



def link_channel_folder(channel_id: str | UUID, team_id: int, *, folder_id: UUID) -> contracts.ChannelDTO | str:
"""Attach the desktop-fs folder that renders a channel (personal channels
included). Returns the DTO — unchanged if the claim was ignored — or ``not_found``."""
channel = Channel.objects.select_related("created_by").filter(id=channel_id, team_id=team_id, deleted=False).first()

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.

Medium: Personal channel ownership bypass

This query allows any team member who obtains another user's personal channel ID to attach an arbitrary team folder to it and receive its DTO. Pass the requesting user ID into this function and reject personal channels whose created_by_id does not match, consistent with _visible_channel and task channel validation.

if channel is None:
return "not_found"
_link_channel_folder(channel, folder_id=folder_id)
return _channel_to_dto(channel)


def _ensure_personal_channel(team_id: int, user_id: int) -> Channel:
# select_related so _channel_to_dto doesn't lazy-load created_by per call.
try:
Expand Down Expand Up @@ -5038,9 +5074,13 @@ def _emit_channel_created(channel: Channel, user_id: int | None) -> None:
logger.exception("Failed to emit channel_created feed message", extra={"channel_id": str(channel.id)})


def resolve_channel(team_id: int, user_id: int | None, *, name: str) -> contracts.ChannelDTO | None:
def resolve_channel(
team_id: int, user_id: int | None, *, name: str, folder_id: UUID | None = None
) -> contracts.ChannelDTO | None:
"""Resolve-or-create a public channel by (normalized) name. ``None`` for empty names.
Emits a ``channel_created`` feed message the first time a channel is created."""
Emits a ``channel_created`` feed message the first time a channel is created. When the
caller passes the desktop-fs ``folder_id`` it renders the channel from, the folder is
linked (see ``_link_channel_folder``) so consumers can join the two by id, not name."""
normalized = normalize_channel_name(name)
if not normalized:
return None
Expand All @@ -5059,6 +5099,8 @@ def resolve_channel(team_id: int, user_id: int | None, *, name: str) -> contract
)
if created:
_emit_channel_created(channel, user_id)
if folder_id is not None:
_link_channel_folder(channel, folder_id=folder_id)
return _channel_to_dto(channel)


Expand Down
2 changes: 2 additions & 0 deletions products/tasks/backend/facade/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ class ChannelDTO:
channel_type: str
created_at: datetime
created_by: "TaskUserBasicInfo | None" = None
# Desktop-fs folder that renders this channel; None until a client links it.
folder_id: UUID | None = None


@dataclass(frozen=True)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 5.2.14 on 2026-07-20 23:50

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("posthog", "1261_alter_personalapikey_scoped_organizations_and_more"),
("tasks", "0062_sandbox_custom_image_base_reference"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddField(
model_name="channel",
name="folder",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="posthog.filesystem",
),
),
migrations.AddConstraint(
model_name="channel",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted", False), ("folder__isnull", False)),
fields=("folder",),
name="task_channel_folder_unique",
),
),
]
2 changes: 1 addition & 1 deletion products/tasks/backend/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0062_sandbox_custom_image_base_reference
0063_channel_folder_channel_task_channel_folder_unique
14 changes: 14 additions & 0 deletions products/tasks/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ class ChannelType(models.TextChoices):
created_by = models.ForeignKey(
"posthog.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", db_constraint=False
)
# The desktop-surface FileSystem folder that renders this channel (canvases and
# task filings nest under its path). This is the id join between the two —
# without it the only folder↔channel link is name matching. Written by the
# client that owns folder creation; SET_NULL keeps the channel alive if the
# folder row goes away. db_constraint=False for the same deploy-lock reason as
# the FKs above (posthog_filesystem is rewritten constantly).
folder = models.ForeignKey(
"posthog.FileSystem", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", db_constraint=False
)
deleted = models.BooleanField(default=False)
created_at = models.DateTimeField(default=django_timezone.now)
updated_at = models.DateTimeField(auto_now=True)
Expand All @@ -97,6 +106,11 @@ class Meta:
condition=models.Q(channel_type="personal", deleted=False),
name="task_channel_team_user_personal_unique",
),
models.UniqueConstraint(
fields=["folder"],
condition=models.Q(deleted=False, folder__isnull=False),
name="task_channel_folder_unique",
),
]

def __str__(self):
Expand Down
14 changes: 11 additions & 3 deletions products/tasks/backend/presentation/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1339,14 +1339,22 @@ class ChannelSerializer(DataclassSerializer):

class Meta:
dataclass = ChannelDTO
fields = ["id", "name", "channel_type", "created_at", "created_by"]
fields = ["id", "name", "channel_type", "created_at", "created_by", "folder_id"]


class ChannelWriteSerializer(serializers.Serializer):
"""Request body for creating (resolve-or-create) or renaming a public channel."""
"""Request body for creating (resolve-or-create), renaming, or folder-linking a
channel. ``name`` is required on create; updates take either or both fields."""

name = serializers.CharField(
max_length=128, help_text="Channel name, rendered as #<name>. Normalized to lowercase-dashed."
max_length=128,
required=False,
Comment on lines 1349 to +1351

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 Create Schema Marks Name Optional

This serializer is used for both POST and PATCH, so making name optional also marks it optional in the generated create schema. Generated clients can submit a schema-valid create request without a name, but create() always rejects that request with 400; separate create and update serializers are needed to keep the API contract accurate.

Context Used: docs/published/handbook/engineering/type-system.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/tasks/backend/presentation/serializers.py
Line: 1349-1351

Comment:
**Create Schema Marks Name Optional**

This serializer is used for both POST and PATCH, so making `name` optional also marks it optional in the generated create schema. Generated clients can submit a schema-valid create request without a name, but `create()` always rejects that request with 400; separate create and update serializers are needed to keep the API contract accurate.

**Context Used:** docs/published/handbook/engineering/type-system.md ([source](https://app.greptile.com/posthog-org-19734/github/PostHog/posthog/-/custom-context?memory=2d5b82d8-8608-4823-8983-4faaa9415b96))

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

help_text="Channel name, rendered as #<name>. Normalized to lowercase-dashed.",
)
folder_id = serializers.UUIDField(
required=False,
allow_null=True,
help_text="Desktop file-system folder that renders this channel; links the two by id.",
)


Expand Down
51 changes: 39 additions & 12 deletions products/tasks/backend/presentation/views/channels_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,34 +58,61 @@ def list(self, request, *args, **kwargs):
request=ChannelWriteSerializer,
responses={200: ChannelSerializer},
summary="Resolve or create a public channel",
description="Returns the existing public channel with the (normalized) name, creating it if needed.",
description=(
"Returns the existing public channel with the (normalized) name, creating it if needed. "
"Pass folder_id to link the desktop file-system folder that renders the channel."
),
)
def create(self, request, **kwargs):
serializer = ChannelWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
channel = tasks_facade.resolve_channel(self.team_id, self._user_id(), name=serializer.validated_data["name"])
name = serializer.validated_data.get("name")
if not name:
return Response({"detail": "Channel name is required"}, status=status.HTTP_400_BAD_REQUEST)
channel = tasks_facade.resolve_channel(
self.team_id,
self._user_id(),
name=name,
folder_id=serializer.validated_data.get("folder_id"),
)
if channel is None:
return Response({"detail": "Invalid channel name"}, status=status.HTTP_400_BAD_REQUEST)
return Response(ChannelSerializer(channel).data)

@extend_schema(
request=ChannelWriteSerializer,
responses={200: ChannelSerializer},
summary="Rename a public channel",
summary="Rename or folder-link a channel",
description=(
"Pass name to rename (public channels only), folder_id to link the desktop "
"file-system folder that renders the channel (personal channels included), or both."
),
)
def partial_update(self, request, pk=None, **kwargs):
serializer = ChannelWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
result = tasks_facade.rename_channel(pk, self.team_id, name=serializer.validated_data["name"])
if result == "not_found":
name = serializer.validated_data.get("name")
folder_id = serializer.validated_data.get("folder_id")
if name is None and folder_id is None:
return Response({"detail": "Nothing to update"}, status=status.HTTP_400_BAD_REQUEST)
if name is not None:
result = tasks_facade.rename_channel(pk, self.team_id, name=name)
if result == "not_found":
raise NotFound()
if result == "personal":
raise PermissionDenied("Personal channels cannot be renamed")
if result == "invalid_name":
return Response({"detail": "Invalid channel name"}, status=status.HTTP_400_BAD_REQUEST)
if result == "name_taken":
return Response(
{"detail": "A channel with this name already exists"}, status=status.HTTP_400_BAD_REQUEST
)
if folder_id is None:
return Response(ChannelSerializer(result).data)
linked = tasks_facade.link_channel_folder(pk, self.team_id, folder_id=folder_id)

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 security Personal Channel Ownership Bypass

Any team member who knows another member's personal-channel UUID can change its folder association. This call passes only team_id, and link_channel_folder does not check the channel's created_by_id, so the new personal-channel mutation bypasses the owner-specific boundary used when reading personal channels.

Rule Used: When implementing new features, ensure that owners... (source)

Learned From
PostHog/posthog#31236

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/tasks/backend/presentation/views/channels_api.py
Line: 112

Comment:
**Personal Channel Ownership Bypass**

Any team member who knows another member's personal-channel UUID can change its folder association. This call passes only `team_id`, and `link_channel_folder` does not check the channel's `created_by_id`, so the new personal-channel mutation bypasses the owner-specific boundary used when reading personal channels.

**Rule Used:** When implementing new features, ensure that owners... ([source](https://app.greptile.com/posthog-org-19734/-/custom-context?memory=9655b466-451a-401a-9ba0-5bf3e7b7f9f8))

**Learned From**
[PostHog/posthog#31236](https://github.com/PostHog/posthog/pull/31236)

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

if linked == "not_found":
raise NotFound()
if result == "personal":
raise PermissionDenied("Personal channels cannot be renamed")
if result == "invalid_name":
return Response({"detail": "Invalid channel name"}, status=status.HTTP_400_BAD_REQUEST)
if result == "name_taken":
return Response({"detail": "A channel with this name already exists"}, status=status.HTTP_400_BAD_REQUEST)
return Response(ChannelSerializer(result).data)
return Response(ChannelSerializer(linked).data)

@extend_schema(responses={204: None}, summary="Delete a public channel")
def destroy(self, request, pk=None, **kwargs):
Expand Down
65 changes: 65 additions & 0 deletions products/tasks/backend/tests/test_channels_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from rest_framework.test import APIClient

from posthog.models import Organization, OrganizationMembership, Team, User
from posthog.models.file_system.constants import DESKTOP_SURFACE
from posthog.models.file_system.file_system import FileSystem

from products.tasks.backend.models import Channel, ChannelFeedMessage, Task, TaskRun, TaskThreadMessage

Expand Down Expand Up @@ -67,6 +69,69 @@ def test_resolve_or_create_public_channel(self):
second = self.client.post(self._channels_url(), {"name": "growth ideas"})
self.assertEqual(second.json()["id"], first.json()["id"])

def _desktop_folder(self, path: str, team: Team | None = None) -> FileSystem:
return FileSystem.objects.create(
team=team or self.team, path=path, depth=1, type="folder", surface=DESKTOP_SURFACE
)

def test_resolve_links_desktop_folder_first_claim_wins(self):
folder = self._desktop_folder("growth")
first = self.client.post(self._channels_url(), {"name": "growth", "folder_id": str(folder.id)})
self.assertEqual(first.status_code, status.HTTP_200_OK)
self.assertEqual(first.json()["folder_id"], str(folder.id))

other = self._desktop_folder("growth-2")
second = self.client.post(self._channels_url(), {"name": "growth", "folder_id": str(other.id)})
self.assertEqual(second.json()["folder_id"], str(folder.id))

def test_folder_claim_is_exclusive_across_channels(self):
folder = self._desktop_folder("shared")
claimed = self.client.post(self._channels_url(), {"name": "one", "folder_id": str(folder.id)})
self.assertEqual(claimed.json()["folder_id"], str(folder.id))
thief = self.client.post(self._channels_url(), {"name": "two", "folder_id": str(folder.id)})
self.assertEqual(thief.status_code, status.HTTP_200_OK)
self.assertIsNone(thief.json()["folder_id"])

def test_patch_links_folder_on_personal_channel(self):
self.client.get(self._channels_url())
personal = Channel.objects.unscoped().get(team=self.team, channel_type=Channel.ChannelType.PERSONAL)
folder = self._desktop_folder("me")
response = self.client.patch(f"{self._channels_url()}{personal.id}/", {"folder_id": str(folder.id)})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["folder_id"], str(folder.id))

def test_link_ignores_foreign_and_non_desktop_folders(self):
other_team = Team.objects.create(organization=self.organization, name="Other Team")
foreign = self._desktop_folder("foreign", team=other_team)
web_row = FileSystem.objects.create(team=self.team, path="webby", depth=1, type="folder")
non_folder = FileSystem.objects.create(
team=self.team, path="board/canvas", depth=2, type="dashboard", surface=DESKTOP_SURFACE
)
for bad in (foreign, web_row, non_folder):
response = self.client.post(self._channels_url(), {"name": "strict", "folder_id": str(bad.id)})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNone(response.json()["folder_id"])

def test_relink_allowed_when_claimed_folder_row_is_gone(self):
folder = self._desktop_folder("ephemeral")
created = self.client.post(self._channels_url(), {"name": "moves", "folder_id": str(folder.id)}).json()
folder.delete()
replacement = self._desktop_folder("ephemeral-new")
response = self.client.patch(f"{self._channels_url()}{created['id']}/", {"folder_id": str(replacement.id)})
self.assertEqual(response.json()["folder_id"], str(replacement.id))

def test_rename_keeps_folder_link(self):
folder = self._desktop_folder("old-name")
created = self.client.post(self._channels_url(), {"name": "old name", "folder_id": str(folder.id)}).json()
renamed = self.client.patch(f"{self._channels_url()}{created['id']}/", {"name": "new name"})
self.assertEqual(renamed.status_code, status.HTTP_200_OK)
self.assertEqual(renamed.json()["folder_id"], str(folder.id))

def test_patch_with_no_fields_is_a_400(self):
created = self.client.post(self._channels_url(), {"name": "plain"}).json()
response = self.client.patch(f"{self._channels_url()}{created['id']}/", {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_personal_channel_cannot_be_renamed_or_deleted(self):
self.client.get(self._channels_url())
# Direct ORM reads in tests bypass the DRF-set team context, so opt out
Expand Down
20 changes: 17 additions & 3 deletions products/tasks/frontend/generated/api.schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading