Skip to content

fix: reject unassignable assignees before Plane silently clears the field - #194

Open
Semih702 wants to merge 2 commits into
makeplane:mainfrom
Semih702:fix/assignee-silent-wipe
Open

fix: reject unassignable assignees before Plane silently clears the field#194
Semih702 wants to merge 2 commits into
makeplane:mainfrom
Semih702:fix/assignee-silent-wipe

Conversation

@Semih702

@Semih702 Semih702 commented Jul 30, 2026

Copy link
Copy Markdown

Fixes #193.

What's going on

Plane filters assignee ids it won't accept out of the payload during validation instead of rejecting them, and an update deletes the work item's existing assignees before writing that filtered list. So one unassignable id clears the assignees and still answers 200 — the caller sees a write that both failed and destroyed data.

There are three ways to land there and none of them show up in the response: the user is a workspace member but not a member of this project, their project role is guest (below the role >= 15 floor), or the project membership is inactive.

manage_work_item_assignee is the worst of the three call sites, because it reads the current assignees, appends one id, and writes the whole list back — so a single bad id discards the list it just read, which is exactly what that tool exists to prevent.

What this changes

_assert_assignable() in plane_mcp/tools/work_items.py looks up the project's members and raises before the write if any requested id isn't assignable. The message names both what was rejected and what would work, so a caller can recover without guessing:

Plane silently drops assignees it will not accept, and an update clears the work item's
existing assignees in the process. Rejected: 3f2b9c14-… (not a member of this project).
Only active project members at member role or above can be assigned. Assignable members
of this project: alice@example.com=7d4e1a05-…, bob@example.com=c81f6b32-…
Add the user to the project first, or use one of the IDs above.

It's wired into create_work_item, update_work_item and manage_work_item_assignee. The last one checks only the incoming add_user_id rather than the merged list — validating the merged list would let an already-assigned member who has since lost project access block a legitimate removal, and such a member gets dropped by Plane on any write anyway, so that isn't a loss this change introduces.

A few things I was deliberate about:

Scope is one source file plus tests. No return types, tool signatures or existing behaviour change.

Testing

Added 8 tests to tests/test_work_items.py (10 cases): the three rejection reasons parametrized, a valid member passing through, role/is_active absent, the member lookup failing, no assignees meaning no extra request, the create_work_item and manage_work_item_assignee call sites, and a removal not being blocked by a stale assignee. I checked these against the pre-fix tree to make sure they weren't vacuous — 5 of the cases fail without the change.

ruff check and ruff format --check are clean on both files and the repo-wide lint count is unchanged. Full non-integration suite: 66 passed (56 before, 10 new cases).

For an end-to-end check I built the wheel, installed it into a clean venv with no editable link to the source tree, and ran plane-mcp-server stdio as a real subprocess with a client talking JSON-RPC over its pipes. Against self-managed Plane 1.2.0: create with a non-member refused, update_work_item with a non-member refused and the original assignee still intact, manage_work_item_assignee with a bad id refused with the list preserved, a real member applied correctly, removals unaffected, non-assignee writes unaffected.

I repeated the core scenario over the HTTP header-auth transport (/http/api-key/mcp) as well, since that path builds the Plane client from the request's API key rather than from the environment.

I've since built the Docker image from the repo Dockerfile and run the server inside the container as well, in both stdio and the default http mode — same scenario, same results. (An earlier version of this description said the Docker path was unverified; it no longer is.)

One open question

I could only verify against self-managed 1.2.0, so I'm not claiming Plane Cloud is affected. What I can say is that it isn't specific to that version: both halves are still on makeplane/plane preview at 3985693 — the filter that drops unassignable ids and the unconditional delete before the write.

If someone with a Cloud workspace can check, it takes about a minute: assign a work item to a user who's in the workspace but not in that project, then re-read it and see whether the previous assignee survived. If Cloud behaves the same, this stops being a self-hosting fix and becomes a general one.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Prevented inactive users and users without sufficient project membership from being assigned to work items.
    • Added validation before creating or updating assignments to prevent unintended assignee removal.
    • Preserved existing assignees when an invalid new assignee is rejected.
    • Allowed removal of assignees who are no longer active or present in the project.
    • Continued processing when member verification is unavailable or incomplete.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d7bc7406-4982-4f90-bdcd-50c1ba59315b

📥 Commits

Reviewing files that changed from the base of the PR and between 95cbc82 and 81ea6db.

📒 Files selected for processing (1)
  • tests/test_work_items.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_work_items.py

📝 Walkthrough

Walkthrough

Adds project-member assignability pre-validation for work item creation, updates, and assignee additions. Invalid assignments raise before writes. Lookup failures and removals preserve existing behavior. Tests cover role, activity, incomplete payloads, and lookup scenarios.

Changes

Assignee validation

Layer / File(s) Summary
Validation and tool integration
plane_mcp/tools/work_items.py
Adds assignability checks for project membership, active status, and role thresholds. Applies them to create, update, and assignee-add flows.
Assignability behavior tests
tests/test_work_items.py
Adds member lookup fakes and tests for rejected, accepted, incomplete, empty, failed-lookup, create, add, and removal scenarios.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • makeplane/plane#9517: Covers rejection of inactive or insufficiently privileged project members before assignee writes.

Possibly related PRs

Suggested reviewers: prashant-surya

Sequence Diagram(s)

sequenceDiagram
  participant WorkItemTool
  participant ProjectMembers
  participant AssigneeValidator
  participant WorkItemAPI
  WorkItemTool->>AssigneeValidator: submit assignee IDs
  AssigneeValidator->>ProjectMembers: get_members(project_id)
  ProjectMembers-->>AssigneeValidator: member payloads
  AssigneeValidator-->>WorkItemTool: accept IDs or raise ToolError
  WorkItemTool->>WorkItemAPI: write work item with validated assignees
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: rejecting unassignable assignees before writes to prevent silent data loss.
Linked Issues check ✅ Passed The changes validate assignees before writes, reject non-members and inactive or low-role members, and protect existing assignees as required by issue #193.
Out of Scope Changes check ✅ Passed The implementation and tests directly support the assignee-validation requirements in issue #193 without unrelated changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
tests/test_work_items.py (1)

115-118: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Boundary role value (_MEMBER_ROLE = 15) isn't directly exercised.

MEMBER uses role 20 (admin-tier) rather than exactly 15 (member-tier). Since _is_assignable uses role >= _MEMBER_ROLE, a member with role exactly 15 is the boundary case that most needs coverage; the current fixtures skip it entirely.

✅ Suggested addition
 MEMBER = {"id": "member-1", "email": "member@example.com", "role": 20, "is_active": True}
+MEMBER_AT_THRESHOLD = {"id": "member-2", "email": "member2@example.com", "role": 15, "is_active": True}
 GUEST = {"id": "guest-1", "email": "guest@example.com", "role": 5, "is_active": True}

Then add a small test asserting MEMBER_AT_THRESHOLD's id passes through unchanged.

Also applies to: 149-157

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_work_items.py` around lines 115 - 118, Update the work-item test
fixtures and related test around _is_assignable to add a member with role
exactly _MEMBER_ROLE (15), such as MEMBER_AT_THRESHOLD, and assert that its id
passes through unchanged. Keep the existing higher-role MEMBER fixture and other
coverage intact.
plane_mcp/tools/work_items.py (1)

70-131: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Narrow the pre-check fallback or mark the blind except as intentional.

except Exception is too broad for the assignment pre-check: it can hide bugs like an AttributeError from the member payload, while Ruff reports BLE001. Catch the SDK/network errors you expect plus unexpected shape failures such as AttributeError, KeyError, or TypeError; alternatively add # noqa: BLE001 with a concise comment referencing the deliberate fallback behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane_mcp/tools/work_items.py` around lines 70 - 131, The broad exception
handler in _assert_assignable should not silently mask arbitrary programming
errors. Narrow the except clause to expected SDK/network failures plus
payload-shape errors such as AttributeError, KeyError, and TypeError, preserving
the existing warning-and-continue fallback; alternatively, retain Exception only
with a concise BLE001 suppression comment documenting the intentional fallback.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@plane_mcp/tools/work_items.py`:
- Around line 70-131: The broad exception handler in _assert_assignable should
not silently mask arbitrary programming errors. Narrow the except clause to
expected SDK/network failures plus payload-shape errors such as AttributeError,
KeyError, and TypeError, preserving the existing warning-and-continue fallback;
alternatively, retain Exception only with a concise BLE001 suppression comment
documenting the intentional fallback.

In `@tests/test_work_items.py`:
- Around line 115-118: Update the work-item test fixtures and related test
around _is_assignable to add a member with role exactly _MEMBER_ROLE (15), such
as MEMBER_AT_THRESHOLD, and assert that its id passes through unchanged. Keep
the existing higher-role MEMBER fixture and other coverage intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c9e144e-d528-484b-aa2f-817f3b6c413d

📥 Commits

Reviewing files that changed from the base of the PR and between 96cf4d5 and f9bfb66.

📒 Files selected for processing (2)
  • plane_mcp/tools/work_items.py
  • tests/test_work_items.py

…ield

Plane filters ids it will not accept out of `assignees` during validation
instead of rejecting them, and an update deletes the work item's existing
assignees before writing that filtered list. A single unassignable id
therefore clears the assignees and still answers 200 — the caller sees a
successful write that both failed and destroyed data.

Three ways to trip it, none of them visible in the response: the user is a
workspace member but not a member of this project, their project role is
guest (below the member floor), or the project membership is inactive.
`manage_work_item_assignee` is the worst case, because it reads the current
assignees, appends one id and writes the whole list back — so a single bad
id discards the list it just read, which is exactly what that tool exists
to avoid.

Check the project's members before the write and raise instead, naming both
the rejected ids and the ids that would work so the caller can recover.
Verified against self-managed Plane 1.2.0; the filtering lives in the shared
issue serializer, so Cloud may behave the same way, but that is unverified.

The check degrades safely. Deployments whose member payload omits `role` and
`is_active` fall back to a membership-only check, so an unreported role does
not disqualify every member. If the member lookup fails or answers in an
unexpected shape, the write proceeds exactly as it did before — a pre-check
must not become a new failure mode. Writes that carry no assignees issue no
extra request.
@Semih702
Semih702 force-pushed the fix/assignee-silent-wipe branch from f9bfb66 to 95cbc82 Compare July 31, 2026 11:07
@Semih702

Copy link
Copy Markdown
Author

Small update on the open question from the description — whether this is specific to self-managed or not.

I still can't test Plane Cloud directly, so I went looking at the source instead. The same two pieces are on makeplane/plane preview (the default branch) as of 3985693, in apps/api/plane/api/serializers/issue.py:

  • the filter that drops unassignable ids instead of rejecting them — L106-L113
  • the unconditional IssueAssignee.objects.filter(issue=instance).delete() that runs before the filtered list is written — L244-L245

So it isn't something that only exists in the 1.2.0 I tested against; it's still there at the current head. That said, I don't know what Cloud actually runs, so I'm not claiming it's affected — someone with a Cloud workspace could confirm in about a minute: assign a work item to a user who's in the workspace but not in that project, then re-read the work item and see whether the previous assignee survived.

Also worth noting the same reasoning covers the guest case, which is easier to hit than the non-member one: a guest is a real project member, so nothing about them looks wrong to a caller, but role__gte=15 filters them out just the same.

I've also since verified this build end-to-end through the Docker image (docker build from the repo Dockerfile, server run inside the container in both stdio and the default http mode) — the earlier description said the Docker path was unverified, and it no longer is.

@Semih702

Copy link
Copy Markdown
Author

@coderabbitai review

(pushed docstrings for the helpers the coverage check flagged — re-running so the summary reflects the current commit)

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

@Semih702 I will review the current changes, including the helper docstrings.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Plane's cutoff is `role >= 15`, but the fixtures only carried role 20 and
role 5 — nothing sat on 15 itself. An off-by-one in that comparison would
have locked out every plain member while the suite stayed green.

Parametrizes the pass-through test over both an above-threshold role and one
exactly at it. Flipping `>=` to `>` now fails the boundary case and nothing
else.
@Semih702

Semih702 commented Aug 1, 2026

Copy link
Copy Markdown
Author

Thanks — both looked at.

Boundary role value — good catch, that was a real gap. The fixtures had role 20 and role 5, so nothing sat on 15 itself and an off-by-one in role >= _MEMBER_ROLE would have locked out every plain member with the suite still green. Fixed in 81ea6db by parametrizing the pass-through test over an above-threshold role and one exactly at the threshold. Sanity-checked by flipping >= to >: only the exactly-at-threshold case fails, nothing else.

Broad except — I'd like to keep this one, and one detail in the suggestion doesn't hold for this repo. Ruff doesn't report BLE001 here: pyproject.toml selects ["E", "F", "I", "UP", "B"], and ruff check is clean on both files. BLE001 only appears with an explicit --select BLE. (I actually had a # noqa: BLE001 on that line in an earlier revision and removed it precisely because it was suppressing a rule the project doesn't enable.)

On the substance: catching AttributeError/KeyError/TypeError from an unexpected member payload is the intent rather than an oversight. This is a pre-check in front of a write that works today — if the member lookup returns a shape I didn't anticipate, the correct outcome is to log it and let the write proceed exactly as it did before, not to break a call that used to succeed. Narrowing the catch would convert "I couldn't verify the assignees" into a new failure mode, which is the one thing this guard shouldn't introduce. That's what the comment above the except is there to record, and it's covered by test_member_lookup_failure_does_not_block_the_write.

Happy to narrow it if a maintainer would rather have the pre-check fail loudly on unexpected shapes — that's a judgement call about which risk is worse, and I don't feel strongly enough to argue past the person who'll maintain it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: assigning a non-project-member silently clears a work item's existing assignees

1 participant