-
Notifications
You must be signed in to change notification settings - Fork 5.4k
feat(membership): realtime kick when admin removes a member #9665
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
base: preview
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| import pytest | ||
|
|
||
| from plane.utils.membership_realtime import ( | ||
| EVENT_PROJECT_MEMBER_REMOVED, | ||
| EVENT_WORKSPACE_MEMBER_REMOVED, | ||
| build_membership_removed_event, | ||
| membership_realtime_channel, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.unit | ||
| def test_membership_realtime_channel(): | ||
| assert membership_realtime_channel("user-1") == "plane:membership:user-1" | ||
|
|
||
|
|
||
| @pytest.mark.unit | ||
| def test_build_workspace_member_removed_event(): | ||
| event = build_membership_removed_event( | ||
| event_type=EVENT_WORKSPACE_MEMBER_REMOVED, | ||
| actor_id="admin-1", | ||
| user_id="user-1", | ||
| workspace_id="ws-1", | ||
| workspace_slug="acme", | ||
| ) | ||
| assert event == { | ||
| "type": "workspace.member.removed", | ||
| "actor_id": "admin-1", | ||
| "user_id": "user-1", | ||
| "workspace_id": "ws-1", | ||
| "workspace_slug": "acme", | ||
| "project_id": None, | ||
| } | ||
|
|
||
|
|
||
| @pytest.mark.unit | ||
| def test_build_project_member_removed_event_requires_project_id(): | ||
| assert ( | ||
| build_membership_removed_event( | ||
| event_type=EVENT_PROJECT_MEMBER_REMOVED, | ||
| actor_id="admin-1", | ||
| user_id="user-1", | ||
| workspace_slug="acme", | ||
| ) | ||
| is None | ||
| ) | ||
|
|
||
| event = build_membership_removed_event( | ||
| event_type=EVENT_PROJECT_MEMBER_REMOVED, | ||
| actor_id="admin-1", | ||
| user_id="user-1", | ||
| workspace_id="ws-1", | ||
| workspace_slug="acme", | ||
| project_id="proj-1", | ||
| ) | ||
| assert event["type"] == "project.member.removed" | ||
| assert event["project_id"] == "proj-1" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| import json | ||
|
|
||
| from django.core.serializers.json import DjangoJSONEncoder | ||
|
|
||
| from plane.settings.redis import redis_instance | ||
| from plane.utils.exception_logger import log_exception | ||
|
|
||
| MEMBERSHIP_REALTIME_CHANNEL_PREFIX = "plane:membership:" | ||
|
|
||
| EVENT_WORKSPACE_MEMBER_REMOVED = "workspace.member.removed" | ||
| EVENT_PROJECT_MEMBER_REMOVED = "project.member.removed" | ||
|
|
||
|
|
||
| def membership_realtime_channel(user_id) -> str: | ||
| return f"{MEMBERSHIP_REALTIME_CHANNEL_PREFIX}{user_id}" | ||
|
|
||
|
|
||
| def build_membership_removed_event( | ||
| *, | ||
| event_type: str, | ||
| actor_id, | ||
| user_id, | ||
| workspace_id=None, | ||
| workspace_slug: str | None = None, | ||
| project_id=None, | ||
| ) -> dict | None: | ||
| if event_type not in {EVENT_WORKSPACE_MEMBER_REMOVED, EVENT_PROJECT_MEMBER_REMOVED}: | ||
| return None | ||
| if not user_id or not workspace_slug: | ||
| return None | ||
| if event_type == EVENT_PROJECT_MEMBER_REMOVED and not project_id: | ||
| return None | ||
|
|
||
| return { | ||
| "type": event_type, | ||
| "actor_id": str(actor_id) if actor_id else "", | ||
| "user_id": str(user_id), | ||
| "workspace_id": str(workspace_id) if workspace_id else "", | ||
| "workspace_slug": workspace_slug, | ||
| "project_id": str(project_id) if project_id else None, | ||
| } | ||
|
|
||
|
|
||
| def publish_membership_removed( | ||
| *, | ||
| event_type: str, | ||
| actor_id, | ||
| user_id, | ||
| workspace_id=None, | ||
| workspace_slug: str | None = None, | ||
| project_id=None, | ||
| ) -> bool: | ||
| try: | ||
| event = build_membership_removed_event( | ||
| event_type=event_type, | ||
| actor_id=actor_id, | ||
| user_id=user_id, | ||
| workspace_id=workspace_id, | ||
| workspace_slug=workspace_slug, | ||
| project_id=project_id, | ||
| ) | ||
| if event is None: | ||
| return False | ||
|
|
||
| ri = redis_instance() | ||
| ri.publish( | ||
| membership_realtime_channel(user_id), | ||
| json.dumps(event, cls=DjangoJSONEncoder), | ||
| ) | ||
| return True | ||
| except Exception as e: | ||
| log_exception(e) | ||
| return False | ||
|
Comment on lines
+69
to
+77
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. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Persist removal events when Redis publishing fails. This handler logs the Redis error and returns Write the event to a transactional outbox before returning from the removal request. Retry publishing after commit until delivery succeeds. 🧰 Tools🪛 ast-grep (0.45.1)[info] 71-71: use jsonify instead of json.dumps for JSON output (use-jsonify) 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,41 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Copyright (c) 2023-present Plane Software, Inc. and contributors | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * SPDX-License-Identifier: AGPL-3.0-only | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * See the LICENSE file for details. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { Request } from "express"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { WebSocket } from "ws"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Controller, WebSocket as WSDecorator } from "@plane/decorators"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { logger } from "@plane/logger"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { handleAuthentication } from "@/lib/auth"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { membershipRealtimeHub } from "@/services/membership-realtime.service"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Controller("/membership") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export class MembershipController { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [key: string]: unknown; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @WSDecorator("/") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleConnection(ws: WebSocket, req: Request) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| void this.bindConnection(ws, req); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private async bindConnection(ws: WebSocket, req: Request) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const url = new URL(req.url || "", "http://localhost"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const userId = url.searchParams.get("userId") || ""; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cookie = req.headers.cookie?.toString(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!userId || !cookie) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ws.close(4401, "Missing realtime credentials"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+23
to
+32
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. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Validate the This controller authenticates with the session cookie in 🔒️ Proposed origin check const url = new URL(req.url || "", "http://localhost");
const userId = url.searchParams.get("userId") || "";
const cookie = req.headers.cookie?.toString();
+ const origin = req.headers.origin?.toString();
+ const allowedOrigins = env.CORS_ALLOWED_ORIGINS.split(",").map((value) => value.trim());
+ if (!origin || !allowedOrigins.includes(origin)) {
+ ws.close(4403, "Origin not allowed");
+ return;
+ }
if (!userId || !cookie) {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await handleAuthentication({ cookie, userId }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await membershipRealtimeHub.addClient(ws, { userId }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.error("MEMBERSHIP_CONTROLLER: Failed to bind membership socket", error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ws.close(1011, "Internal server error"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+36
to
+39
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. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Close authentication failures with 4401, not 1011.
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: makeplane/plane
Length of output: 50372
🏁 Script executed:
Repository: makeplane/plane
Length of output: 50371
🏁 Script executed:
Repository: makeplane/plane
Length of output: 50371
🏁 Script executed:
Repository: makeplane/plane
Length of output: 4285
Publish removal events for PATCH deactivation.
WorkSpaceMemberSerializerexposes the writableWorkspaceMember.is_activefield.partial_updatecan deactivate an active member, but it does not callpublish_membership_removed.Publish the workspace-member removal event after this update and add a regression test.
🤖 Prompt for AI Agents