* [WEB-8060] fix(security): enforce authz on is_active member (de)activation
ProjectMemberViewSet.partial_update nested every authorization guard inside
`if "role" in request.data:`. Because ProjectMemberSerializer exposes is_active
through fields="__all__" with no read-only, a project GUEST could PATCH
{"is_active": false} while omitting "role" to deactivate any member — including
admins — and take over the project (GHSA-hpgm-9r34-c4x5 / GHSA-25gg-cxm8-g7h9).
Add an independent is_active guard, mirroring the role block and destroy(): only
a project admin (or workspace admin) may (de)activate a member, and never one
whose role is equal to or higher than the requester's own.
Adds contract regression tests covering guest/member deactivation attempts and
the legitimate project-admin path.
Co-authored-by: Plane AI <noreply@plane.so>
* [WEB-8060] test(security): cover workspace-admin is_active bypass
Address Copilot review on #9367: add a positive-control test asserting a
workspace admin holding only a project GUEST role can still deactivate a project
admin (the intended is_workspace_admin bypass), so future changes cannot silently
remove it.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
* [WEB-8017] fix(security): sanitize order_by on external REST API list endpoints
Close a partial bypass of WEB-7813 (GHSA-2r95 / GHSA-w45q): the external
REST API project-list and work-item-list endpoints passed a raw order_by
query parameter to Django's .order_by(). Because Django resolves
__-separated relational paths, an attacker could order by sensitive
columns on related tables (created_by__password / token / email) to build
a blind ordering oracle, or crash the endpoint (HTTP 500) with an unknown
field.
Route both endpoints through the existing sanitize_order_by() helper with
the appropriate allowlist (PROJECT_ORDER_BY_ALLOWLIST, default sort_order;
ISSUE_ORDER_BY_ALLOWLIST, default -created_at), mirroring how
order_issue_queryset() already sanitizes. Non-allowlisted values collapse
to the safe default; legitimate orderings are unchanged.
Adds unit tests (allowlist neutralisation + passthrough) and contract
tests asserting both endpoints return 200 (not 500) for injected fields;
fail-before verified via git stash.
Advisory: GHSA-p885-6jpg-cr2p
Co-authored-by: Plane AI <noreply@plane.so>
* [WEB-8019] fix(security): scope CycleIssue reassignment lookup to workspace/project
CycleIssueViewSet.create looked up "issues already in another cycle" with
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) —
without scoping to the caller's workspace/project. An ADMIN/MEMBER of their
own project could pass a work-item UUID from a different tenant and have that
foreign CycleIssue row reassigned to their cycle, silently evicting the
victim's work item from the victim's cycle (cross-tenant write / BOLA).
Scope the lookup to workspace__slug + project_id, mirroring the adjacent
create-path guard. Foreign-tenant rows are excluded from reassignment and
already dropped from the create path by the scoped new_issues query.
Adds a contract regression test proving a foreign-tenant CycleIssue row is
not reassigned (fail-before verified via git stash) plus a same-project
reassignment test to confirm the legitimate flow is unaffected.
Advisory: GHSA-4w5x-wc9w-f47x
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
Close a partial bypass of WEB-7813 (GHSA-2r95 / GHSA-w45q): the external
REST API project-list and work-item-list endpoints passed a raw order_by
query parameter to Django's .order_by(). Because Django resolves
__-separated relational paths, an attacker could order by sensitive
columns on related tables (created_by__password / token / email) to build
a blind ordering oracle, or crash the endpoint (HTTP 500) with an unknown
field.
Route both endpoints through the existing sanitize_order_by() helper with
the appropriate allowlist (PROJECT_ORDER_BY_ALLOWLIST, default sort_order;
ISSUE_ORDER_BY_ALLOWLIST, default -created_at), mirroring how
order_issue_queryset() already sanitizes. Non-allowlisted values collapse
to the safe default; legitimate orderings are unchanged.
Adds unit tests (allowlist neutralisation + passthrough) and contract
tests asserting both endpoints return 200 (not 500) for injected fields;
fail-before verified via git stash.
Advisory: GHSA-p885-6jpg-cr2p
Co-authored-by: Plane AI <noreply@plane.so>
The `create` handler validated the network (SECRET/PUBLIC) check against
a workspace-scoped queryset but then used the raw client-supplied
`project_ids` list in the subsequent bulk_create and update calls.
An attacker could include UUIDs of projects from other workspaces: those
are absent from the validation queryset (no network check performed),
yet get inserted as ProjectMember rows via bulk_create(ignore_conflicts=True),
granting cross-workspace project access.
Fix: derive `validated_project_ids` from the filtered queryset (projects
already scoped to the requested workspace and passed the SECRET check),
and use it exclusively for all subsequent DB writes.
Co-authored-by: Plane AI <noreply@plane.so>
* fix(security): enforce token + auth validation on project invite accept/reject
ProjectJoinEndpoint.post() only checked that the caller-supplied email matched
the invited email — no token required, no authentication required. Anyone who
knew the workspace slug, project ID, invite UUID, and invitee email could
accept or reject the invitation on the invitee's behalf (GHSA-g36h-p63v-g9c7).
Mirror WorkspaceJoinEndpoint.post() exactly:
- Validate `token` from request body against project_invite.token (→ 403 on mismatch)
- Require authenticated session (→ 401 if unauthenticated)
- Validate request.user.email against project_invite.email (→ 403 on mismatch)
- Remove the old request.data["email"] guard
- Use project_invite.email for downstream User lookup
Co-authored-by: Plane AI <noreply@plane.so>
* fix(security): address CR review on project invite token validation
- Use request.user directly instead of re-querying User by exact
project_invite.email — avoids case-variant miss after the case-insensitive
email check already validated the authenticated user (CR comment 1)
- Validate `accepted` as a real boolean before saving — form-encoded
strings like "false" are truthy and could accidentally create memberships
(CR comment 2)
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
* [WEB-7778] fix(security): reject unverified OAuth provider emails to prevent ATO
An attacker controlling a self-hosted OAuth provider (Gitea, GitLab) could
assert any email address in the OAuth response and be matched to an existing
Plane account, bypassing authentication entirely.
- Add OAUTH_PROVIDER_UNVERIFIED_EMAIL (5124) error code
- GitHub: require both primary=True AND verified=True on email (was primary-only)
- Google: check verified_email=False field in userinfo response
- GitLab: check confirmed_at is non-null before accepting email
- Gitea __get_email: remove unverified fallbacks (primary-unverified, any-unverified)
- Gitea set_user_data: remove fast-path using .email from user object (no
verification flag); always go through __get_email() which enforces verified
Fixes GHSA-7j95-vh8g-f365 (critical ATO).
Note: GHSA-cv9p-325g-wmv5 and GHSA-hx79-5pj5-qh42 (avatar SSRF) were
already fixed in PR #9163.
Co-authored-by: Plane AI <noreply@plane.so>
* fix(security): add read:user scope to Gitea; fail-closed on absent Google verified_email
Gitea's /api/v1/user/emails endpoint requires the read:user granular
scope — openid+email+profile alone is insufficient and __get_email()
would return a 401/403. Add read:user to the scope string.
Google: change default from True to fail-closed (is not True) so a
userinfo response that omits verified_email is rejected rather than
trusted. The service-account justification was incorrect — service
accounts do not go through the interactive OAuth2 callback flow.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
Bot service accounts (User.is_bot=True, e.g. the WORKSPACE_SEED bot) are
internal identities meant to act only through API tokens. Nothing stopped
one from being driven through the interactive login flow if its email was
known, letting a human assume a service identity.
Reject bot accounts at the shared login chokepoint,
Adapter.complete_login_or_signup(), right beside the existing
deactivated-account check. This covers every interactive provider in one
place: email/password, magic code, and all OAuth providers (Google, GitHub,
GitLab, Gitea) across both the app and space surfaces. Bot API-token access
is left untouched, since that is how bots are meant to operate.
Also add a defense-in-depth is_bot guard to InstanceAdminSignInEndpoint,
which mints its own admin session outside the chokepoint (a bot is never an
InstanceAdmin today, so this is not currently reachable, but it closes the
path regardless).
Surface the rejection with a new dedicated error code
BOT_USER_LOGIN_FORBIDDEN (5017), plumbed into the app and space frontend
error helpers as well as the shared @plane/constants and @plane/utils
packages (message map + banner-alert list) so any consumer of the shared
auth-error handler renders it correctly. The admin path reuses the existing
ADMIN_AUTHENTICATION_FAILED code so it discloses no bot-specific error.
Add contract regression tests: a bot blocked via password and via magic
code, a bot blocked at the admin sign-in endpoint, and a non-bot control
that still logs in.
* [WEB-7894] fix: eliminate TOCTOU race in InstanceAdminSignUp (GHSA-p548-28jp-wr4p)
Two concurrent POST requests to InstanceAdminSignUpEndpoint could both
pass the "no admin yet" check before either created the InstanceAdmin
row, resulting in dual instance admins.
Fix: wrap the check + create in transaction.atomic() with
select_for_update() on the Instance singleton row. The pre-check
(is_setup_done / existing admin) outside the lock is kept as a fast
early-exit for the common post-setup path. The re-check inside the
lock is the authoritative guard; user_login() is kept outside the
transaction to avoid holding the DB lock during session writes.
Co-authored-by: Plane AI <noreply@plane.so>
* fix: use global InstanceAdmin.objects.exists() guard (coderabbit)
The pre-check and re-check inside the atomic block were scoped to
filter(instance=instance), which could be bypassed if a stray second
Instance row existed. Changed both guards to InstanceAdmin.objects.exists()
to match the original global check and make them consistent with each other.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
All three V2 issue attachment handlers (PATCH, DELETE, GET single) looked
up FileAsset by (pk, workspace, project_id) only — issue_id in the URL
was silently ignored. Any project member could target another user's
attachment UUID using their own issue_id, and PATCH would transfer
ownership via unconditional created_by = request.user.
Add issue_id=issue_id to all three FileAsset.objects.get() calls so the
lookup is correctly scoped to the attachment's owning issue. Remove the
created_by overwrite in PATCH — created_by is set at creation time and
must not be reassigned by a subsequent upload-confirm call.
Co-authored-by: Plane AI <noreply@plane.so>
ProjectJoinEndpoint.get() was AllowAny and used ProjectMemberInviteSerializer
(fields = "__all__"), leaking the invitee's email and token to anyone who
knew the workspace slug, project ID, and invite UUID (GHSA-2r58-hgv7-635q).
Introduce ProjectMemberInvitePublicSerializer with an explicit safe field list
that excludes `email` and `token`, and swap it in for the public GET endpoint.
The full serializer is retained for authenticated admin viewsets.
Co-authored-by: Plane AI <noreply@plane.so>
* fix: enforce workspace membership on entity-search endpoint (GHSA-32q3-mqpc-3mhv)
SearchEndpoint required authentication but did not verify the requesting user
was a member of the queried workspace. Any authenticated Plane user could
enumerate members across workspaces they don't belong to by guessing slugs.
Add a WorkspaceMember guard at the top of get() — returns 403 if the user is
not an active member of the target workspace. Brings OSS to parity with EE,
which already had this protection via @can(WorkspacePermissions.VIEW).
Co-authored-by: Plane AI <noreply@plane.so>
* refactor(security): replace inline WS membership check with WorkspaceUserPermission
Use the existing WorkspaceUserPermission permission class on SearchEndpoint
instead of a manual WorkspaceMember.objects.filter() guard inside the
method body. Enforcement behaviour is unchanged (GHSA-32q3-mqpc-3mhv).
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
* [WEB-7776] fix(security): scope FileAsset queries to prevent cross-project IDOR (Cluster F)
Multiple asset endpoints were missing project-level scoping on FileAsset
queryset filters, allowing authenticated users to access, mark-uploaded,
or restore assets belonging to other projects/workspaces.
- ProjectBulkAssetEndpoint.post: add project_id= scope to asset filter
- EntityAssetEndpoint.get/patch: add project_id=deploy_board.project_id
- AssetRestoreEndpoint.post: add project_id=deploy_board.project_id
- FileAssetEndpoint (V1): add workspace membership check on get/post/delete
- FileAssetViewSet.restore (V1): add workspace membership check
- WorkspaceFileAssetEndpoint.post: gate WORKSPACE_LOGO on ADMIN role
- DuplicateAssetEndpoint.post: restrict source asset to same workspace
Fixes GHSA-r2hw, GHSA-jh4v, GHSA-8688, GHSA-3hrj and related advisories.
Co-authored-by: Plane AI <noreply@plane.so>
* refactor(security): replace inline membership checks with WorkspaceMemberPermission class
Add WorkspaceMemberPermission to workspace.py — resolves workspace by
'workspace_id' UUID or 'slug' kwarg, covering the mixed URL patterns on
FileAssetEndpoint. Apply to FileAssetEndpoint and FileAssetViewSet so
membership enforcement lives in the permission layer, not inside each
method handler.
Co-authored-by: Plane AI <noreply@plane.so>
* refactor: remove dead 404 guard in FileAssetEndpoint.post()
WorkspaceMemberPermission denies requests for non-existent slugs before
the view method runs, making the filter().first() + if not workspace
branch unreachable. Switch to .get() so any TOCTOU race still surfaces
as a 404 via ObjectDoesNotExist.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
* fix(auth): restore activation flow and narrow deactivation guard (GHSA-rmmf-rj2q-3rrg)
PR #9290 introduced two regressions in adapter/base.py:
1. is_signup = bool(user) was inverted — True when the user EXISTS means
the IDP sync ran on signup instead of login, and the callback received
the wrong value. Fixed to is_signup = not bool(user) matching EE.
2. The deactivation check blocked ALL inactive users, including accounts
provisioned with is_active=False that have never completed a first
login. Fixed by adding `and user.last_login_time is not None` — only
accounts that have previously logged in (and were then explicitly
deactivated by an admin) are rejected. Provisioned/never-logged-in
accounts still pass through to save_user_data().
3. Restore is_active=True and user_activation_email in save_user_data()
so provisioned accounts are properly activated on first login.
Co-authored-by: Plane AI <noreply@plane.so>
* fix(auth): save before email, use last_logout_time as deactivation discriminator
Two CR fixes on PR #9304:
1. save_user_data(): capture was_inactive flag, save() first, then send
activation email as a best-effort side-effect so a failed enqueue
cannot abort account activation.
2. complete_login_or_signup(): switch deactivation discriminator from
last_login_time to last_logout_time. The deactivation endpoint always
sets last_logout_time, making it a direct signal of explicit
deactivation. A provisioned account that was never deactivated has
last_logout_time=None and is correctly allowed through for first login,
even if it also has no last_login_time.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
GHSA-rmmf-rj2q-3rrg: save_user_data() was unconditionally setting
is_active=True on every login, silently reactivating any admin-deactivated
account. Fix: add an early guard in complete_login_or_signup() that raises
USER_ACCOUNT_DEACTIVATED (5019) before any session or save logic if the
existing user's is_active=False. Remove the is_active=True assignment and
the associated user_activation_email call from save_user_data(). Also
remove the now-unused user_activation_email and base_host imports.
GHSA-wjgv-cq7w-258v: WorkspaceOwnerPermission in both app/permissions/
and utils/permissions/ was filtering WorkspaceMember without is_active=True,
allowing a deactivated workspace owner/admin to retain API access. Add
is_active=True to both copies to match every other permission class.
Co-authored-by: Plane AI <noreply@plane.so>
* fix: prevent ORM order_by injection via user-supplied query params (GHSA-2r95, GHSA-w45q)
Add field-name allowlists and a sanitize_order_by() utility in order_queryset.py.
All allowlists are centralised there; each call site imports the named constant
so there are no inline sets scattered across view files.
- order_queryset.py: ISSUE_ORDER_BY_ALLOWLIST, INTAKE_ISSUE_ORDER_BY_ALLOWLIST,
ACTIVITY_ORDER_BY_ALLOWLIST, PROJECT_ORDER_BY_ALLOWLIST, VIEW_ORDER_BY_ALLOWLIST,
NOTIFICATION_ORDER_BY_ALLOWLIST + sanitize_order_by() utility; validation added
at the top of order_issue_queryset() — fixes all callers including the
unauthenticated ProjectIssuesPublicEndpoint (GHSA-w45q)
- api/views/cycle.py, api/views/module.py: cycle/module issue list endpoints
- api/views/issue.py: IssueActivity list and detail endpoints
- app/views/intake/base.py: IntakeIssue list
- app/views/view/base.py: saved-view list
- app/views/notification/base.py: notification paginator
- app/views/project/base.py: project list paginator
- app/views/user/base.py, app/views/workspace/user.py: activity paginators
Closes WEB-7813
Co-authored-by: Plane AI <noreply@plane.so>
* fix: harden sanitize_order_by against multi-dash malformed inputs
lstrip("-") stripped all leading dashes, allowing "--created_at" to
pass the allowlist check unchanged and reach .order_by() as a malformed
token (causing FieldError). Now strips only one leading dash; any
remaining dash prefix is rejected to the safe default.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
* fix: remove hardcoded SECRET_KEY from community deployment manifests (GHSA-cmwv-pjmw-8483)
Replace the publicly-known default SECRET_KEY and LIVE_SERVER_SECRET_KEY values
in AIO and CLI community deployment manifests with a safe placeholder.
- deployments/aio: variables.env now ships with placeholder values;
start.sh auto-generates a random key on first boot (or on upgrade from the old
insecure default) and persists it in plane.env across restarts
- deployments/cli: variables.env ships with placeholder; docker-compose.yml
fallbacks that referenced the publicly-known default are removed
- apps/api/plane/settings/common.py: SECRET_KEY resolution now uses `or`
so an empty env var falls back to get_random_secret_key() (not ""); adds a
startup warning if the known insecure default or placeholder is detected
Closes WEB-7805
Co-authored-by: Plane AI <noreply@plane.so>
* fix: use logger.critical instead of print for insecure SECRET_KEY warning
Address code review feedback — replace module-level print() with _logger.critical()
and move _logger definition before the SECRET_KEY block to avoid duplicate assignment.
Also removes the now-unused `import sys`.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
GHSA-933r-rxg8-f3h2 — EstimatePointEndpoint.create trusted the
estimate_id URL parameter without verifying it belonged to the caller's
workspace and project. An authenticated user in project A could inject
estimate points into any other workspace's estimate by supplying a
foreign estimate_id.
Fix: added a workspace+project scoped Estimate ownership check before
EstimatePoint.objects.create().
GHSA-933r-rxg8-f3h2 (destroy) — old_estimate_point was fetched with
pk only (unscoped), allowing cross-tenant key disclosure and
manipulation during the key-rearrangement step.
Fix: scoped the old_estimate_point lookup to estimate_id + project_id +
workspace__slug; added 404 guard for missing/foreign points.
Note: BulkEstimatePointEndpoint.partial_update (GHSA-vm3j-5j49-gwrf)
was already correctly scoped at lines 116 and 125-130 — no change needed.
Co-authored-by: Plane AI <noreply@plane.so>
GHSA-6qrq-f73q-r67j / GHSA-j9pv-f5wm-p4g2 — IssueCommentSerializer in
both app and api layers stored comment_html without sanitization. The app
layer had no validate() at all; the api layer only ran lxml structural
normalization which does not strip XSS payloads.
Fix: both serializers now call validate_html_content() (nh3-backed) in
their validate() methods, replacing the raw value with sanitized HTML.
GHSA-hh2r-3hwp-mvq3 — space/views/intake.py and api/views/intake.py
both used bare Issue.objects.create() with description_html taken
directly from request data, bypassing any serializer validation.
Fix: both paths now call validate_html_content() and pass the sanitized
value to Issue.objects.create(). Falls back to "<p></p>" if sanitizer
returns None (empty/invalid input).
The nh3 sanitizer (validate_html_content in content_validator.py) was
already present and used by IssueCreateSerializer — this change extends
coverage to the two remaining unsanitized comment and intake paths.
Co-authored-by: Plane AI <noreply@plane.so>
- Add WorkSpaceMemberInvitePublicSerializer that excludes token and
invite_link; use it in WorkspaceJoinEndpoint.get() so an unauthenticated
caller cannot retrieve the acceptance token from the GET endpoint
(GHSA-86mg-259g-pwgg / GHSA-gf48-p6jp-cwc4).
- Require authentication and verify request.user.email matches the
invited email before accepting a workspace invitation so an attacker
who registers with the invited address cannot hijack the invite
(GHSA-4vj8-p63v-8p24).
Co-authored-by: Plane AI <noreply@plane.so>
* fix: scope workspace user preference filter to current user
Without user=request.user on the PATCH filter, the ORM could match
another user's preference record in the same workspace, causing
pin/unpin state to leak across users or silently fail to persist.
Fixes#9260
Signed-off-by: okxint <cashmein.eth@gmail.com>
* test: add regression coverage for workspace user preference scoping (#9260)
Adds contract tests for the sidebar preference PATCH endpoint:
- test_patch_only_updates_requesting_users_preference: in a multi-member
workspace, a member's PATCH must update only their own preference row,
never another member's. Fails against the pre-fix code (the unscoped
.first() mutates the most-recently-created row regardless of user).
- test_patch_updates_own_preference: baseline that a member's PATCH
persists to their own row.
Verified RED on the unpatched view and GREEN with the user=request.user
filter from #9261.
* fix(api): wrap long line to satisfy ruff E501 in user preference view
---------
Signed-off-by: okxint <cashmein.eth@gmail.com>
Co-authored-by: okxint <cashmein.eth@gmail.com>
* fix: Use APP_DOMAIN env var for bot user email instead of hardcoded plane.so
Signed-off-by: okxint <cashmein.eth@gmail.com>
* use settings.WEB_URL instead of APP_DOMAIN env var for bot email domain
---------
Signed-off-by: okxint <cashmein.eth@gmail.com>
* fix(api): require at least one alphanumeric char in workspace name
Workspace name validation was enforced only on the frontend
(validateWorkspaceName), which gates the UI submit but is bypassable
via a direct API call. The backend WorkSpaceSerializer.validate_name
only rejected URLs, so a symbol-only name like "-_________-" could
still be saved via create or the rename (partial_update) path.
Add a Unicode-aware has_alphanumeric() helper and enforce it in both
the app and instance/license workspace serializers, mirroring the
frontend HAS_ALPHANUMERIC_REGEX (/[\p{L}\p{N}]/u) added in #9263.
International names (日本語, José, محمد) still pass since str.isalnum()
covers all scripts.
Adds unit tests covering symbol-only rejection and international
acceptance on both serializers.
Refs #9255
Signed-off-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
* fix(api): reject URLs in instance workspace name for parity
Address CodeRabbit review on #9278: the instance/license
WorkspaceSerializer.validate_name rejected symbol-only names but, unlike
the app-level WorkSpaceSerializer, still accepted names containing URLs.
Add the same contains_url() guard (imported from plane.utils.url, not
content_validator) so both workspace-create paths validate identically.
Add unit tests asserting URL-containing names are rejected on both
serializers.
Signed-off-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
---------
Signed-off-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
* fix(security): scope issue ID validation to workspace/project in bulk endpoints
Prevents cross-tenant IDOR by filtering incoming issue IDs through
workspace+project scope before bulk_create/bulk_update in:
- CycleIssueListCreateAPIEndpoint: validate new_issues against workspace+project (GHSA-22g9-9xfv-q3fr)
- SubIssuesEndpoint: validate sub_issue_ids against workspace (GHSA-38vj-gf85-7q5x)
- IssueRelationListCreateAPIEndpoint: validate issues against workspace (GHSA-8cvv-8jh5-g6mj)
- ModuleIssueListCreateAPIEndpoint: already scoped at line 673, no change needed (GHSA-x5c5-hmvm-94v9)
Co-authored-by: Plane AI <noreply@plane.so>
* fix(security): extend IDOR scope validation to app-layer endpoints
Same cross-tenant IDOR fix applied to the app/views/ counterparts
which are used by the web frontend (api/views/ covered in previous commit):
- app/views/cycle/issue.py: filter new_issues to workspace+project (GHSA-22g9-9xfv-q3fr)
- app/views/module/issue.py: filter issues to workspace+project before bulk_create (GHSA-x5c5-hmvm-94v9)
- app/views/issue/relation.py: filter issues to workspace before bulk_create (GHSA-8cvv-8jh5-g6mj)
Co-authored-by: Plane AI <noreply@plane.so>
* chore: remove advisory ID references from code comments
---------
Co-authored-by: Plane AI <noreply@plane.so>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
* fix(security): scope cascade deletes to workspace in BulkDeleteIssuesEndpoint
CycleIssue and ModuleIssue cascade deletes used raw issue_ids from the
request instead of the already workspace+project scoped issues queryset,
allowing cross-workspace deletion of related records.
Fixes GHSA-6cw7-h92q-p9hg and GHSA-2rr4-rp7r-32p4.
GHSA-7q7r-mrr4-2wwx (sub-issue parent reassign) covered in WEB-7727.
Co-authored-by: Plane AI <noreply@plane.so>
* chore: remove advisory ID reference from code comment
---------
Co-authored-by: Plane AI <noreply@plane.so>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
The custom API key authentication only verified that the APIToken row was
active and unexpired; it never checked the owning user's is_active flag.
DRF's IsAuthenticated only checks user.is_authenticated (always True for a
real User), so a user whose account was deactivated could keep using a
previously issued API key indefinitely.
Add user__is_active=True to the validate_api_token() lookup so a token tied
to a disabled account is treated as invalid (a generic AuthenticationFailed,
avoiding account-state disclosure). Applied to both the external API
middleware (plane/api) and the identical, currently unused copy in
plane/app to prevent the gap from being reintroduced.
Adds unit coverage on validate_api_token and an end-to-end contract test
proving GET /api/v1/users/me/ is denied once the account is deactivated.
User-controlled values (work item titles, labels, etc.) were written
raw into openpyxl worksheet cells, so values beginning with = were
stored as live formula cells in exported XLSX files. Apply the same
formula-trigger sanitization already used for CSV exports to XLSX
cell values and header rows in both export formatters, and sanitize
CSV header rows in the porters formatter for parity.
The public REST API GenericAssetEndpoint (/api/v1/workspaces/<slug>/assets/)
declared no permission class, inheriting only IsAuthenticated. Since
APIKeyAuthentication does not bind a token to a workspace and the workspace is
read straight from the URL slug, any valid Personal Access Token could read
(GET), create (POST), and modify (PATCH) assets in a workspace the caller is
not a member of — a cross-workspace IDOR, the public-API sibling of the
CVE-2026-46558 dashboard asset fix.
Add permission_classes = [WorkspaceUserPermission] so every method requires
active workspace membership, matching the dashboard fix semantics. Also add
contract regression tests covering cross-workspace GET/POST/PATCH (now 403)
and a positive control confirming members retain access.
Also ignore the local /security/ advisory notes folder.
* fix(api): rate-limit magic-code verification and bound per-token attempts
The magic-link sign-in / sign-up endpoints accept a 6-digit numeric code
(900k-value space, 600s TTL) but never increment a failure counter on a
wrong-code verify and extend django.views.View rather than DRF APIView,
so DRF's AuthenticationThrottle never runs against them. The space-side
generate endpoint also lacked throttle_classes. Combined, this allowed
an unauthenticated attacker who knew a victim's email to brute-force
the code within the TTL window and log in as the victim.
- Add MAX_VERIFY_ATTEMPTS=5 in MagicCodeProvider.set_user_data: failed
comparisons now persist verify_attempts in Redis under the remaining
TTL and, on hitting the limit, delete the key and raise
EMAIL_CODE_ATTEMPT_EXHAUSTED. This is the load-bearing fix - it caps
total attempts per issued token regardless of request rate.
- Add authentication_throttle_allows() so plain Django Views can apply
AuthenticationThrottle without converting to APIView (would change
CSRF + request-parsing semantics for the redirect-flow endpoints).
- Apply the throttle to MagicSignIn/UpEndpoint and the space variants;
add throttle_classes to MagicGenerateSpaceEndpoint to match its app
sibling.
Refs GHSA-9pvm-fcf6-9234.
* fix(api): make verify-attempt increment atomic, expose throttle rate via env
Address PR review feedback:
- Replace the JSON read-modify-write of verify_attempts with a Lua
EVAL script that INCRs a dedicated counter key and EXPIREs it only
on the first increment. The previous round-trip was racy: parallel
wrong-code requests could read the same value and both write the
same incremented count, letting an attacker exceed MAX_VERIFY_ATTEMPTS
under concurrency. Counter is now reset on each new token issuance
and cleared on successful verify / exhaustion.
- Make AuthenticationThrottle.rate configurable via the
AUTHENTICATION_RATE_LIMIT env var (default 10/minute, down from 30
to tighten the budget on unauth auth-adjacent endpoints). Document
it in deployments/aio and deployments/cli variables.env.
* test(api): cover magic-code attempt cap, counter reset, and auth throttle
Add the contract tests called out in the PR test plan:
- TestMagicSignInVerifyAttempts:
- test_exhausted_after_max_wrong_attempts: after MAX_VERIFY_ATTEMPTS
wrong codes the next verify redirects with EMAIL_CODE_ATTEMPT_
EXHAUSTED_SIGN_IN and both Redis keys are deleted; a follow-up
verify reports EXPIRED.
- test_counter_increments_on_each_wrong_attempt: the dedicated
verify_attempts counter advances by exactly one per wrong POST,
matching the atomic Lua INCR.
- test_counter_resets_on_token_regeneration: regenerating the
magic-link clears the counter so the user isn't pre-locked-out by
a prior session's wrong attempts.
- TestMagicSignUpVerifyAttempts.test_signup_exhausted_after_max_wrong_attempts:
the sign-up endpoint returns EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP on
the exhausting attempt.
- TestAuthenticationThrottle: exercises authentication_throttle_allows
on the plain-View redirect-flow endpoints by patching the rate down
and asserting RATE_LIMIT_EXCEEDED is appended to the redirect URL
once the per-IP budget is exceeded, for both magic-sign-in and
magic-sign-up.
Each new class clears Django cache (DRF throttle storage) and the
per-email Redis keys around every test so runs are independent.
* fix(api): clamp remaining_ttl to >=1 for verify-attempt counter EXPIRE
ri.ttl() returns 0 when the token has less than one second remaining
(Redis floors to whole seconds). The previous clamp only caught
None and < 0, so a sub-second TTL would pass through and the Lua
script's EXPIRE counter 0 would immediately delete the key — letting
an attacker bypass MAX_VERIFY_ATTEMPTS during the final second of the
token's life. Switch the comparison to <= 0.
Narrow real-world impact (sub-second window, throttle still bounds
the rate) but the cap should hold regardless of timing.
* fix(api): return HTTP response from dispatch() exception handler
BaseAPIView.dispatch() and BaseViewSet.dispatch() built the proper
error Response via handle_exception() but returned the raw exception
object instead, causing Django to raise
"TypeError: 'Exception' object is not a valid HTTP response".
Fix all six occurrences across the api, app, license and space view
bases, and add a regression test covering every affected base class.
Fixes#9157
* chore(api): add copyright header to tests/unit/views/__init__.py
The empty package init file was missing the AGPL copyright header,
failing the Copy Right Check CI (addlicense -check on all tracked
.py files).
* fix(api): harden webhook & link-unfurl SSRF (advisory clusters A/B/C)
Resolves three overlapping SSRF advisory clusters around webhook delivery
and work-item link unfurling:
- Cluster A (private-IP validation + PATCH bypass): the webhook PATCH
handler passed context={request: request} (the request object as the
dict key) so the loopback/disallowed-domain guard silently no-op'd —
now context={"request": request}. Hardened IP classification
(is_blocked_ip) to also block multicast, unspecified, CGNAT
(100.64.0.0/10), and IPv4 embedded in IPv6 transition addresses
(IPv4-mapped, NAT64, 6to4, Teredo), robust across Python versions.
- Cluster B (DNS-rebinding TOCTOU): validators resolved DNS, then
requests resolved it again at connect time. New pinned-IP client
(plane/utils/url_security.py) resolves+validates once and connects to
the validated IP literal so urllib3 performs no second lookup, while
preserving Host header, TLS SNI and certificate verification against
the real hostname.
- Cluster C (redirect SSRF): webhook delivery never follows redirects;
the link crawler follows them manually, re-resolving + re-validating +
re-pinning every hop.
Also: pin requests==2.33.0 in base.txt (imported directly; the pinning
adapter needs the >=2.32 get_connection_with_tls_context hook), and log
webhook URL-validation rejections to WebhookLog instead of swallowing
them.
Tests: new test_url_security.py (pinning, rebinding, redirect
re-validation, IP edge cases, TLS SNI) + updated link-task tests.
Full unit suite: 178 passed.
* fix(api): block OAuth avatar SSRF + add per-advisory SSRF regression tests
Verified every SSRF-class advisory against the current code. The webhook /
link / favicon reports — including the published CVE-2026-30242 and
CVE-2026-39843 and the newer "still bypassable" reports (DNS rebinding
GHSA-3856/-fgcv/-9292/-whh3/-4mjx/-6p39/-fv24/-8wvv, IP-classification gaps
GHSA-75fg, redirect GHSA-6v37/-jw6g/-mq87) — are resolved by the pinned-IP
client + hardened classifier in this branch.
The one SSRF family still unresolved was the OAuth avatar path:
download_and_upload_avatar() fetched the provider-supplied avatar_url with a
raw requests.get (no IP validation, default redirect following), so an
attacker-controlled avatar could reach internal addresses and be exfiltrated
via the static-asset endpoint (GHSA-cv9p-325g-wmv5, and the avatar hop of the
Gitea SSRF GHSA-hx79-5pj5-qh42). It now uses pinned_fetch_following_redirects,
which validates + pins every hop and blocks internal targets.
Adds test_ssrf_advisories.py: a per-advisory regression map covering webhook
IP validation, the PATCH context-key guard, webhook DNS rebinding, webhook
redirect, favicon redirect + rebinding, and OAuth avatar SSRF.
docker compose test: 199 unit tests pass.
* fix(api): address PR review feedback on the SSRF pinned client
- url_security: preserve URL-embedded credentials (user:pass@host) as Basic
Auth instead of silently dropping them when rewriting to the IP literal
(Copilot); bracket IPv6-literal hostnames in the Host header (Copilot);
add stream=True support that keeps the session open until the response is
closed, and release intermediate redirect hops.
- ip_address / work_item_link_task: treat UnicodeError (IDNA failures) from
getaddrinfo as a resolution failure, not an uncaught exception (CodeRabbit).
- authentication/adapter/base: stream the avatar download so the size cap
actually bounds memory, upload the size-bounded buffer (not response.content),
and always close the response (CodeRabbit, major).
- tests: cover auth preservation, IPv6 Host bracketing, IDNA handling, and
streamed session lifetime; drop an unused import.
docker compose test: 204 unit tests pass.
- Define API_KEY_RATE_LIMIT in plane/settings/common.py and read it via
django.conf.settings in ApiKeyRateThrottle instead of os.environ.
- Remove ServiceTokenRateThrottle and the service-token branch in
BaseAPIView.get_throttles; all API key requests now go through
ApiKeyRateThrottle.
* fix: harden API token handling against rate-limit tampering and plaintext logging
- Make `allowed_rate_limit` read-only on APITokenSerializer so users can no
longer raise their own API token rate limit via PATCH (GHSA-xfgr-2x3f-g2cf).
- Stop persisting API keys in plaintext in APITokenLogMiddleware: store a
SHA-256 hash as the token identifier and redact sensitive request headers
(X-Api-Key, Authorization, Cookie) before logging (GHSA-r5p8-cj3q-38cc).
* refactor: remove MongoDB log sink and add per-log-type retention
Logs are now written to and cleared from PostgreSQL only; MongoDB is no
longer used as a log sink or archive.
- Drop the MongoDB write/archival paths from the API request logger, the
webhook log writer, and the cleanup tasks; Postgres is the sole sink.
- Cleanup tasks now hard-delete expired rows in batches via `all_objects`
(rows are removed immediately, not soft-deleted).
- Add env-backed, per-log-type retention settings: API activity logs
(API_ACTIVITY_LOG_RETENTION_DAYS, default 14), webhook logs
(WEBHOOK_LOG_RETENTION_DAYS, default 14), email logs
(EMAIL_LOG_RETENTION_DAYS, default 7). HARD_DELETE_AFTER_DAYS no longer
drives any log cleanup.
- Delete settings/mongo.py, remove MONGO_DB_* settings and the plane.mongo
loggers, and drop the pymongo dependency.
* chore: gitignore local advisories.md notes file
* fix: use keyed HMAC-SHA256 for API token log identifier
Address CodeQL "weak hashing of sensitive data" by hashing the API key with
a SECRET_KEY-keyed HMAC instead of a bare SHA-256. The identifier is a
non-reversible tokenization of a high-entropy key (not password storage);
keying it also prevents precomputing the digest from a known key value.
* chore: address review feedback on log cleanup and request logging
- process_logs accepts extra kwargs so jobs enqueued by an older release
(with a mongo_log arg) don't fail during a rolling deploy.
- Log-cleanup batch delete failures are logged and skipped rather than
aborting the run, so a single bad batch can't block the rest.
- Extend logger middleware test to assert Authorization and Cookie headers
are redacted; add a test that a failing cleanup batch is swallowed.
* fix: fall back to default when a log retention env value is invalid
Negative (or unparseable) retention values would compute a future cutoff and
delete every log row. The retention settings now fall back to their defaults
in that case via a shared `_retention_days` helper.
* chore(api): add docker compose test runner
Adds docker-compose-test.yml at the repo root that boots an isolated
postgres / valkey / rabbitmq / minio stack with health checks and tmpfs
data dirs, then runs pytest against it and exits. Includes a usage doc
under apps/api/tests/RUNNING_TESTS.md and a pointer in AGENTS.md.
Prereq: ./setup.sh (generates apps/api/.env).
Usage:
docker compose -f docker-compose-test.yml up --build \
--abort-on-container-exit --exit-code-from api-tests
docker compose -f docker-compose-test.yml down -v
* fix(api): correct bugs surfaced by the pytest suite
Five small bugs caught by enabling the pytest contract suite end-to-end.
Each is independently justifiable:
- api/serializers/cycle.py + api/views/cycle.py: CycleCreateSerializer.validate
required project_id in the request body, but the view only ever passes
it through the URL kwarg. Cycle create/update via the public API was
returning 400 "Project ID is required". Read project_id from
serializer context (passed by the view) in addition to body/instance.
- app/views/api.py: ApiTokenEndpoint.get(pk) and patch(pk) did not filter
out is_service=True tokens, so a user could read and modify service
tokens through the user token endpoint. The list mode and delete
already filter is_service=False; aligned the other two.
- bgtasks/work_item_link_task.py: validate_url_ip checked hostname before
scheme, so file:///etc/passwd raised "No hostname found" instead of
the documented "Only HTTP and HTTPS" error. Swapped the order so the
scheme guard matches the docstring intent.
- utils/path_validator.py: get_allowed_hosts used `WEB_URL or APP_BASE_URL`
so when both are configured to different hosts (the standard local
setup: WEB_URL=:8000, APP_BASE_URL=:3000), only one was added to the
allow-list. Redirects to APP_BASE_URL then had their next_path stripped
because the host wasn't allowed. Include every configured base URL.
* chore(api): align pytest tests with current behavior, clear warnings
Test-side fixes paired with the product fixes in the previous commit, plus
deprecation cleanup that drops the test run from 104 warnings to 0.
Tests:
- tests/contract/api/test_cycles.py: project fixture sets cycle_view=True;
the Project model defaults the flag to False, so cycle create/update
always tripped "Cycles are not enabled for this project".
- tests/contract/app/test_authentication.py: next_path uses "/workspaces"
(validate_next_path rejects values without a leading slash and returns
empty, which dropped the path from the redirect URL).
- tests/unit/bg_tasks/test_copy_s3_objects.py: mocked sync_with_external_service
now returns description_json; the task unconditionally writes the value
back to the Issue, and Issue.description_json is NOT NULL on UPDATE.
- tests/unit/utils/test_url.py: three length-limit tests placed the URL at
char 970+ on a single line, which contains_url truncates away as ReDoS
defense (500-char per-line cap). Restructured to keep test intent intact
while staying inside the per-line window.
Warning cleanup (104 → 0):
- settings/common.py: removed USE_L10N=True (deprecated in Django 4.0,
removed in 5.0; default is True).
- celery.py, settings/local.py, settings/production.py: pythonjsonlogger
moved jsonlogger → json; update the import / formatter path.
The webhook dispatcher validated webhook.url before posting but called
requests.post() without allow_redirects=False, so a webhook destination
could return a 3xx redirect to an internal address (cloud metadata,
internal services) and have the worker fetch it and persist the
response body to webhook_logs, readable back via the webhook-logs API.
Pass allow_redirects=False so the original validate_url() guard is
authoritative. Matches the pattern already used by safe_get() in
work_item_link_task.py and the behavior of GitHub/Stripe/Slack webhooks.
* test(api): add regression tests for create-project endpoint
Cover three scenarios:
- project_lead set to the creator's own user_id
- project_lead set to a different workspace member
- project_lead omitted (baseline)
The first two currently fail on preview because of a UUID coercion
bug in ProjectMember.objects.create — see follow-up commit.
* fix(api): pass project_lead_id (not User instance) when creating ProjectMember
The create-project endpoint built a ProjectMember row with
member_id=serializer.instance.project_lead, which resolves to a User
instance via Django's related descriptor instead of a UUID. Django's
UUIDField coercion then fails with AttributeError: 'User' object has
no attribute 'replace', which the generic exception handler converts
to a 400 "Please provide valid detail" — but only after the Project
row was already persisted, leaving an orphaned project without
default states.
Fix:
- Use project_lead_id (FK ID, no descriptor lookup) on both the guard
comparison and the ProjectMember creation.
- Wrap the post-save flow in transaction.atomic() so any future
exception triggers a clean rollback.
- Defer model_activity.delay() with transaction.on_commit() so the
activity log only fires after a successful commit.
- Capture the exception with log_exception() in the generic catch so
future regressions surface in api logs.
Note: a related data integrity issue exists where
ProjectCreateSerializer doesn't create a ProjectIdentifier row
(unlike its frontend counterpart). Out of scope here, will follow
up in a separate PR.
* fix(api): return 500 on unexpected errors and harden project create
Address review feedback from @sriramveeraghanta on PR #8966:
- The catch-all `except Exception` now returns 500 instead of 400.
Reusing the generic 400 response on a server-side crash was the
anti-pattern that hid the original ghost-create bug for nine months;
a 500 lets clients distinguish between "bad input" and "server fault".
- The `IntegrityError` branch no longer falls through silently when the
message is unrecognised. It re-raises so the catch-all `except` logs
the exception and returns a 500.
- `transaction.on_commit()` now schedules `model_activity.delay` via
`functools.partial` instead of a lambda, avoiding late-binding closure
semantics.
- `ProjectCreateSerializer.validate()` now rejects `project_lead`
values that are not active workspace members, surfacing the error
under the `project_lead` field key (rather than as `non_field_errors`)
so API clients can react programmatically.
* test(api): harden assertions and cover rollback / workspace-membership
Address review feedback from @sriramveeraghanta on PR #8966:
- The three existing tests now look up the created project via
`Project.objects.get(id=response.data["id"])` instead of
`.first()`. The assertion now fails for the right reason if the
wrong project is returned by the endpoint.
- New `test_create_project_with_lead_not_in_workspace_returns_400`
guards the workspace-membership validation added to
`ProjectCreateSerializer.validate()`. Expects a 400 with a
field-shaped error and zero rows persisted.
- New `test_model_activity_not_called_on_rollback` locks in the
`transaction.on_commit()` semantics: when an exception is raised
inside the atomic block (forced via mocking `State.objects.bulk_create`),
the response is 500, no Project / ProjectMember / State rows are
persisted, and the deferred `model_activity.delay` task is never
dispatched. This prevents a future refactor from silently
regressing the rollback contract.
* fix(api): mark on_commit dispatch as robust against broker failures
Address coderabbit re-review feedback on PR #8966.
Without robust=True, an exception raised by model_activity.delay
(e.g., a Celery broker outage) propagates out of the on_commit
callback and is caught by the outer `except Exception` handler,
which returns a 500 despite the project, ProjectMember rows and
default States having already been committed. The client sees a
500 and assumes the create failed — the same class of mismatch
between actual state and reported status that the original bug
exhibited, just at the post-commit phase.
Set robust=True so Django logs the dispatch failure internally
via the standard transaction logger and the response stays 201,
reflecting the persisted state.
Switch from `functools.partial` to a nested function
(`_dispatch_model_activity`) for the on_commit callable. Django's
robust on_commit logging path reads `func.__qualname__` to format
the error message; `partial` objects lack that dunder by default,
and the `functools.update_wrapper` workaround turns out to be
brittle when the wrapped callable is replaced by a Mock (which
the new regression test relies on). A nested function exposes
`__qualname__` natively, and the locals it closes over are
bound at definition time and never rebound before the callback
fires, so the late-binding-closure motivation for `partial` over
`lambda` does not apply here.
A new test, test_response_still_201_when_broker_dispatch_fails,
mirrors test_model_activity_not_called_on_rollback to lock in the
post-commit branch. It uses `@pytest.mark.django_db(transaction=True)`
so the surrounding test transaction is actually committed and the
`on_commit` callback fires (the default wrapper suppresses it via
rollback).
* fix(api): handle unrecognised IntegrityError consistently
Address coderabbit re-review feedback on PR #8966.
The previous fix used `raise` inside the IntegrityError handler with
the intent of "letting the catch-all `except Exception` below log it
and return 500". Coderabbit correctly flagged that `raise` exits the
try/except entirely — sibling except clauses don't fire — so
unrecognised integrity errors actually skipped `log_exception` and
the consistent 500 JSON shape, contradicting the stated intent.
Replicate the catch-all behaviour inline: log the exception via
`log_exception(e)` and return the same generic 500 response with
`{"error": "An unexpected error occurred"}`. The client now gets a
uniform error shape regardless of which `except` branch handled it.
---------
Co-authored-by: Jose Antonio Martinez <257598434+jamartineztelecoengineer84-dotcom@users.noreply.github.com>
* fix: add WEBHOOK_ALLOWED_HOSTS allowlist for internal webhook targets
The IP-based allowlist alone isn't practical for containerised deployments
where service IPs are dynamic. Adds a hostname-based bypass for trusted
internal services (e.g. Silo via docker-compose / k8s service DNS) and
makes the previously hardcoded ["plane.so"] domain blocklist configurable
via WEBHOOK_DISALLOWED_DOMAINS.
- validate_url accepts allowed_hosts (exact, case-insensitive match;
skips DNS lookup for trusted names)
- WebhookSerializer wires both settings through and lets allowlisted
hosts bypass the disallowed-domain check
- Exposes WEBHOOK_ALLOWED_HOSTS in aio/cli deployment env files
* fix: default WEBHOOK_DISALLOWED_DOMAINS to empty for self-hosted
* fix: pass WEBHOOK_ALLOWED_HOSTS to send-time webhook re-validation
* chore: update completed_at logic updation in Issue save method
* fix: update error handling
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
* fix: use StateGroup
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
is_workspace_admin in ProjectMemberViewSet.partial_update was derived
from the target member's workspace role, not the requester's. When the
target happened to be a workspace admin, all three project-role guards
(L231/238/247) were bypassed regardless of who was making the request,
allowing a non-admin requester to re-role a workspace admin's project
membership. Compute is_workspace_admin from the requester instead and
keep the target's workspace role under a distinct name for the existing
new-role-vs-workspace-role cap.
`ProjectViewSet.partial_update`, `BulkEstimatePointEndpoint.partial_update`,
and `WorkspaceUserProfileEndpoint.get` previously fetched objects by primary
key alone after a workspace-scoped permission check, allowing an authenticated
caller to act on resources belonging to other workspaces by supplying a
foreign UUID with their own workspace slug in the URL.
- Project partial_update: scope `Project.objects.get` by `workspace__slug`,
matching the existing pattern in `destroy`.
- Bulk estimate partial_update: scope `Estimate.objects.get` by
`workspace__slug` and `project_id`, matching `retrieve` and `destroy`.
- Workspace user profile: require the target `user_id` to be an active
member of the requested workspace before returning email and other PII.
* fix: filter out soft-deleted states from API endpoints
- Add deleted_at__isnull=True filter to StateListCreateAPIEndpoint.get_queryset()
- Add deleted_at__isnull=True filter to StateDetailAPIEndpoint.get_queryset()
- Prevents soft-deleted states from reappearing in UI after navigation
- Fixes#8829
* Fix: exclude issues linked to soft-deleted states