Skip to content

OUT-3661 | Limited access IUs shouldn't see tasks that are associated with clients/companies beyond their access list#1208

Open
arpandhakal wants to merge 1 commit intomainfrom
OUT-3661
Open

OUT-3661 | Limited access IUs shouldn't see tasks that are associated with clients/companies beyond their access list#1208
arpandhakal wants to merge 1 commit intomainfrom
OUT-3661

Conversation

@arpandhakal
Copy link
Copy Markdown
Collaborator

@arpandhakal arpandhakal commented May 7, 2026

Summary

Closes OUT-3661.

Limited-access IUs were filtered only by task assignee company. Tasks merely associated with an out-of-scope client/company still leaked through. This PR closes that gap across the API, cascade SQL, and realtime paths.

To make the SQL filter clean and avoid expensive Copilot SDK enumeration of all clients/companies, Tasks.associations is migrated from jsonb[] to jsonb (still holding an array). That unlocks Prisma's array_contains (@> containment), which lets us match associations by companyId directly using companyAccessList.

Schema + migration

  • Tasks.associations column: jsonb[]jsonb
  • Single in-place migration with USING coalesce(to_jsonb(associations), '[]'::jsonb) — Postgres rewrites every row during ALTER. No separate app-side backfill. JS shape ([{clientId?, companyId}]) is preserved across the migration.
  • AccessExclusiveLock duration ~ a few seconds at ~80k rows.

Core fix (tasksShared.service.ts)

  • filterTasksByClientAccess now also rejects tasks whose associations reference a company outside companyAccessList — this is the actual user-visible fix for the read path.
  • getAccessFilterForTasks ANDs the new getAccessibleAssociationsFilter for limited IUs, so cascade workflow updates and subtask counts stay in sync with the read filter.
  • getDisjointTasksFilter (limited IU branch) treats a parent as inaccessible when its associations are out of scope. Combined into one parent: { OR: [...] } to avoid Prisma double-joining the parent relation.
  • New helper getAccessibleAssociationsFilter uses array_contains (Postgres @>) for partial-match — {companyId: cid} matches both stored {companyId} and {clientId, companyId} shapes without needing to enumerate clientIds.
  • Existing hasSome filters (which relied on jsonb exact-element equality) converted to OR: [{equals: ...}, ...]. Behavior is identical for max=1 arrays — semantics-preserving.
  • validateTaskShare updated for the new Json (single) shape with an Array.isArray guard.

hasSomeequals conversions in adjacent files

Mechanical, semantics-preserving:

  • src/app/api/tasks/tasks.service.tsgetIncompleteTasksForCompany, resetAllSharedTasks
  • src/app/api/comments/comment.service.ts — three CU/CRM filter sites
  • src/app/api/webhook/webhook.service.tshandleCompanyUnassignment
  • OUT-2898's IU-company-preview filters in tasksShared and comment services

Realtime sync (src/lib/realtime.ts)

Realtime guardrails for limited IUs only checked task.companyId. Now also check associations so a task with companyId = null but an out-of-scope association can no longer slip into the store via realtime.

  • New private helper hasInaccessibleAssociation mirrors the API filter.
  • Four call sites updated: isSubtaskAccessible, handleRealtimeTaskInsert guardrail, isReassignedOutOfLimitedIUScope, isReassignedIntoLimitedIUScope.

Disjoint duplicate fix (src/hooks/useFilter.tsx)

Pre-existing latent bug: limited-IU disjoint-promoted subtasks rendered twice in the board after AllTasksFetcher Suspense resolved (the "second card appears with a delay" symptom). The standalone-subtasks dedup checked only parentId. Now also checks the subtask's own id, so a task that's already in filteredParentTasks (via disjoint promotion) isn't re-added as a standalone.

Type tightening

  • src/app/api/tasks/subtasks.service.ts: JsonValue[]JsonValue
  • src/app/api/tasks/public/public.service.ts: Prisma.JsonValue[]Prisma.JsonValue

Pre-existing oddities preserved (NOT introduced or worsened by this PR — worth a follow-up ticket)

  • getIncompleteTasksForCompany filters for {clientId: null, companyId} but stored objects don't store null clientIds (the key is either present with a UUID or absent) — this branch likely never matches.
  • resetAllSharedTasks filters for {clientId: assigneeId} (no companyId) but companyId is required by AssociationSchema — that half of the OR likely never matches.

Both behaviors carried over identically.

Test plan

  • As a limited-access IU, view the board with a task assigned to a teammate IU but associated with an out-of-scope CU — should NOT appear (was the OUT-3661 repro).
  • As a limited-access IU, view a parent task that is inaccessible (out-of-scope companyId or out-of-scope association) with an accessible subtask — the subtask appears at root level exactly once (was duplicated).
  • As a limited-access IU, watch the board while another session creates an inaccessible-via-association task — does NOT appear via realtime.
  • As a limited-access IU, when a task on your board is updated to add an out-of-scope association — task is removed from the board via realtime.
  • As a CU, verify you do NOT see tasks associated with another client at the same company (security-critical: equals preserves the prior hasSome strict-match semantics).
  • As a CU, verify you DO see tasks shared with you ({clientId, companyId} association) and tasks associated company-wide ({companyId} association).
  • CRM IU previewing a client — tasks for that client appear, including shared tasks.
  • CRM IU previewing a company — tasks for that company AND tasks for clients within that company appear (OUT-2898 behavior preserved).
  • Full-access IU — board behavior unchanged.

🤖 Generated with Claude Code

…ies from limited-access IUs

Migrates Tasks.associations from jsonb[] to jsonb to enable Prisma's
array_contains operator, then filters tasks by association companyId
(not just assignee) for limited-access IUs across API, cascade SQL,
and realtime. Also fixes a pre-existing useFilter dedup bug that
caused disjoint-promoted subtasks to render twice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 7, 2026

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tasks-app Ready Ready Preview, Comment May 7, 2026 11:30am

Request Review

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 7, 2026

Greptile Summary

This PR closes a security gap where limited-access IUs could see tasks associated with out-of-scope clients/companies, even when the task's assignee was an IU or the task was unassigned. To enable clean SQL filtering, Tasks.associations is migrated from jsonb[] to jsonb (still holding a single-element array), unlocking Prisma's array_contains (@>) operator for containment checks.

  • Core access fix (tasksShared.service.ts): getAccessFilterForTasks now ANDs a new getAccessibleAssociationsFilter into the limited-IU SQL filter; filterTasksByClientAccess removes the blanket early-return for IU-assigned tasks and adds an explicit per-association scope check across all code paths (API, cascade, subtask counts).
  • Realtime parity (realtime.ts): New hasInaccessibleAssociation helper mirrors the API filter at four realtime call sites; also fixes a latent bug where full-access IUs incorrectly entered the "reassigned into scope" branch.
  • Duplicate-card fix (useFilter.tsx): Standalone-subtask dedup now also checks the subtask's own id against filteredParentTasks, preventing disjoint-promoted subtasks from rendering twice.

Confidence Score: 3/5

The security logic is consistent across API, realtime, and in-memory paths, but the unguarded parse in filterTasksByClientAccess and the absent DB-level array-length constraint warrant verification before deploying to production.

The unguarded AssociationsSchema.parse in filterTasksByClientAccess is new for all tasks — previously IU-assigned and fully-unassigned tasks returned early before any parse was attempted. A single malformed row now causes the entire call to throw, making tasks unavailable for all users whose batches include that row. Combined with the absence of a DB-level CHECK constraint on array length, historical or direct-DB-written multi-element arrays could silently drop tasks from CU/CRM filters without any error surfacing.

src/app/api/tasks/tasksShared.service.ts (filterTasksByClientAccess parse call) and prisma/migrations/…/migration.sql (missing max-1 CHECK constraint) are the two areas that benefit from a second look before merging.

Important Files Changed

Filename Overview
src/app/api/tasks/tasksShared.service.ts Core access-control changes: getAccessFilterForTasks now ANDs association scope into the limited-IU SQL filter; filterTasksByClientAccess removes the IU early-return and adds an AssociationsSchema.parse call that can throw on malformed rows; new getAccessibleAssociationsFilter helper is well-structured and uses array_contains correctly.
prisma/migrations/20260507083136_associations_jsonb_array_to_jsonb/migration.sql Correct in-place ALTER TABLE with USING clause; adds NOT NULL + default []. Missing a DB-level CHECK (jsonb_array_length(...) <= 1) to enforce the max(1) invariant that all new Prisma equals-based filters depend on.
src/lib/realtime.ts New hasInaccessibleAssociation helper mirrors the API filter correctly; four call sites updated. isReassignedIntoLimitedIUScope now correctly gates on isClientAccessLimited (fixing a latent bug where full-access IUs incorrectly entered the "reassigned in" path).
src/hooks/useFilter.tsx Dedup fix is correct: adding !filteredParentIds.has(t.id) prevents a promoted disjoint subtask from being double-counted in standaloneSubtasks.
src/app/api/comments/comment.service.ts Mechanical hasSomeequals conversion for three filter sites; semantically equivalent for max-1 arrays. TempClientFilter type removed; CU-portal isShared: true condition correctly preserved.
src/app/api/tasks/tasks.service.ts Two hasSomeequals conversions; getIncompleteTasksForCompany preserves the pre-existing {clientId: null, companyId} oddity (noted in PR desc as never-matching).
src/app/api/webhook/webhook.service.ts Single hasSomeequals conversion for handleCompanyUnassignment; semantically equivalent for single-element arrays.
src/app/api/tasks/public/public.service.ts Type tightening only: prevAssociations parameter changed from Prisma.JsonValue[] to Prisma.JsonValue to match the new schema column type.
src/app/api/tasks/subtasks.service.ts Type-only change: associations on Assignable updated from JsonValue[] to JsonValue matching the new Prisma model.
prisma/schema/task.prisma Schema correctly updated: Json[] @db.JsonB @default([])Json @db.JsonB @default("[]").

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Task request — limited-access IU] --> B{getAccessFilterForTasks}
    B --> C[AND]
    C --> D["OR: internalUserId≠null OR unassigned OR companyId IN accessList"]
    C --> E["getAccessibleAssociationsFilter: associations=[] OR array_contains companyId in accessList"]
    D & E --> F[SQL query filtered task rows]
    F --> G{filterTasksByClientAccess in-memory pass}
    G --> H{task.companyId in accessList?}
    H -- No companyId or in list --> I{any association companyId out of scope?}
    I -- No --> J[Task visible]
    I -- Yes --> K[Task blocked]
    H -- Out of scope --> K
    F --> L[Realtime path]
    L --> M{hasInaccessibleAssociation?}
    M -- Yes --> N[Block INSERT/UPDATE]
    M -- No --> O[Apply to store]
Loading

Reviews (1): Last reviewed commit: "fix(OUT-3661): hide tasks associated wit..." | Re-trigger Greptile

return false
}
// If task is associated with a client/company, every association's company must be in access list
const associations = AssociationsSchema.parse(task.associations) || []
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Unguarded AssociationsSchema.parse can throw for all tasks

AssociationsSchema.parse(task.associations) will raise a ZodError if any row's associations column is not a valid association array (e.g. a bare JSON object {} or a string). Previously, IU-assigned tasks and fully-unassigned tasks returned early before this branch was reached, so only client/company tasks were ever parsed. Now every task in the batch is parsed. If a single malformed row exists — whether from a backfill bug, a direct-DB write, or pre-schema-max(1) data — the entire filterTasksByClientAccess call throws, rendering the task list completely inaccessible for all callers on that path. Use AssociationsSchema.safeParse with a fallback to avoid cascading failures.

Comment on lines +4 to +9
ALTER TABLE "Tasks"
ALTER COLUMN "associations" DROP DEFAULT,
ALTER COLUMN "associations" TYPE JSONB
USING coalesce(to_jsonb("associations"), '[]'::jsonb),
ALTER COLUMN "associations" SET DEFAULT '[]'::jsonb,
ALTER COLUMN "associations" SET NOT NULL;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 equals-based association filters silently break if any task has multi-element associations

The hasSomeequals conversion relies entirely on the application-level AssociationsSchema.max(1) constraint. There is no CHECK constraint in the DB, so any row written before max(1) was enforced, or via a direct DB write, would have more than one element. For such a row equals: [{clientId, companyId}] never matches, causing that task to silently disappear from the CU/CRM read path. Consider adding a max(1) DB check constraint as part of this migration to make the invariant enforceable at the storage layer.

Suggested change
ALTER TABLE "Tasks"
ALTER COLUMN "associations" DROP DEFAULT,
ALTER COLUMN "associations" TYPE JSONB
USING coalesce(to_jsonb("associations"), '[]'::jsonb),
ALTER COLUMN "associations" SET DEFAULT '[]'::jsonb,
ALTER COLUMN "associations" SET NOT NULL;
ALTER TABLE "Tasks"
ALTER COLUMN "associations" DROP DEFAULT,
ALTER COLUMN "associations" TYPE JSONB
USING coalesce(to_jsonb("associations"), '[]'::jsonb),
ALTER COLUMN "associations" SET DEFAULT '[]'::jsonb,
ALTER COLUMN "associations" SET NOT NULL;
-- Enforce the application-level max(1) invariant at the DB layer so that
-- `equals`-based Prisma filters remain correct even after direct DB writes.
ALTER TABLE "Tasks"
ADD CONSTRAINT "Tasks_associations_max_one_element"
CHECK (jsonb_array_length("associations") <= 1);

Comment thread src/lib/realtime.ts
Comment on lines 326 to 339
const isReassignedOutOfLimitedIUScope = (() => {
if (this.userRole !== AssigneeType.internalUser) return false
const iu = InternalUsersSchema.parse(this.user)
if (!iu.isClientAccessLimited) return false
const companyAccessList = iu.companyAccessList || []
// Out of scope via association: applies even when assignee is an IU or unassigned
if (this.hasInaccessibleAssociation(updatedTask.associations, companyAccessList)) {
return true
}
if (updatedTask.internalUserId || !updatedTask.assigneeId) {
return false
}
return iu.isClientAccessLimited && !iu.companyAccessList?.includes(updatedTask.companyId || '__')
return !companyAccessList.includes(updatedTask.companyId || '__')
})()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 isReassignedOutOfLimitedIUScope evaluates the updated state only — missing explicit documentation of the downstream guard

The early-return at line 332 (hasInaccessibleAssociation(updatedTask.associations, ...)) can fire even when the task was never in the board. The caller guards against this on line 343 (tasks.find((task) => task.id === updatedTask.id)), so there is no data corruption. A clarifying comment here would make the correctness argument self-evident without requiring readers to trace through to the guard.

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.

1 participant