-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(tasks): link a Channel to the desktop-fs folder that renders it #72463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 ( | ||||||||||||||
|
|
@@ -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
+5008
to
+5009
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After an Fix by refreshing from the database: except IntegrityError:
channel.refresh_from_db()This same bug affects line 5103 where
Suggested change
Spotted by Graphite |
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| 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() | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||||
| 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: | ||||||||||||||
|
|
@@ -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 | ||||||||||||||
|
|
@@ -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) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| 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", | ||
| ), | ||
| ), | ||
| ] |
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This serializer is used for both POST and PATCH, so making Context Used: docs/published/handbook/engineering/type-system.md (source) Prompt To Fix With AIThis 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.", | ||
| ) | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Any team member who knows another member's personal-channel UUID can change its folder association. This call passes only Rule Used: When implementing new features, ensure that owners... (source) Learned From Prompt To Fix With AIThis 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): | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 reportsfolder_id: nullwhile the database still contains the old UUID, so a later read returns different state.Prompt To Fix With AI