Skip to content

refactor(labels): share one visibility condition between the list and single reads - #3673

Merged
kolaente merged 2 commits into
mainfrom
labels-shared-visibility-cond
Aug 30, 2026
Merged

refactor(labels): share one visibility condition between the list and single reads#3673
kolaente merged 2 commits into
mainfrom
labels-shared-visibility-cond

Conversation

@tink-bot

@tink-bot tink-bot commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #3660 — review that one first; this PR's diff is only its own commit.

Follow-up to a review question on #3660, split out at the maintainer's request.

The problem

Two functions decided which labels a caller may see, expressing the same rule in two different SQL shapes:

shape
GetLabelsForUser (the list) labels.id IN (SELECT label_id FROM label_tasks INNER JOIN tasks …)
hasAccessToLabel (CanRead, so GET /labels/{id} and every label attach) LEFT JOIN label_tasks + label_tasks.task_id IN (SELECT id FROM tasks …)

Two hand-maintained shapes of one rule is a standing drift risk, and it already cost us once: before #3660, the list resolved its projects through getRawProjectsForUser, which drops archived projects, so a label reachable only through a task in an archived-but-owned project was returned by CanRead and hidden from the list.

The change

labelVisibleCond(s, a) (builder.Cond, error) is now the single definition, used by both. hasAccessToLabel loses its LEFT JOIN on label_tasks — the shared cond references that table only inside an uncorrelated subquery, so the join has nothing left to do.

The GHSA-hj5c-mhh2-g7jq warning moves onto the helper, restated as an instruction to callers: hand the cond to Where in one shot, never chain it onto the session. That flattening (A OR B OR C AND D) is what leaked every label with any label_tasks row to any authenticated user.

Verification

This rewrites the condition tree of the function that advisory fixed, so it was checked rather than assumed:

  • Generated SQL, not read Go. The OR group is parenthesised in every shape — single-label read, list with no search, list with a text search, list with an id search, link share (single branch), and the empty-accessible-project case, which renders 0=1 rather than a dropped match-everything IN.
  • The two shapes proven equivalent empirically. The verbatim pre-refactor cond and the new one were run side by side over the fixtures plus synthetic rows the fixtures do not cover: archived-but-owned projects, foreign projects, soft-deleted tasks, an orphan label_tasks row pointing at a task that does not exist, a link-share project's child, and duplicate attachments. Identical label sets for every auth.
  • Full CanRead matrix unchanged. 25 users × 5 link shares × 13 labels = 390 verdicts, comparing the boolean and the returned permission against the parent commit: byte-identical. GHSA regression label Importing from Trello does not work properly #6 stays reachable only by user 13; label Add details of using NGINX Proxy Manager to the Reverse Proxy docs #13 stays hidden from the owner of the project holding its soft-deleted task.
  • pkg/models, pkg/webtests, pkg/caldavtests pass; lint clean.

On the new test

TestLabel_ReadAllMatchesCanRead pins that ReadAll returns exactly what CanRead allows — the invariant whose absence let the original drift go unnoticed. Mutation testing showed a pure parity assertion is structurally blind to the shared cond widening (deleting taskNotDeletedCond from the helper left it green), so it also pins the expected id sets outright. Both mutations now fail it.

@tink-bot
tink-bot temporarily deployed to preview-trusted August 30, 2026 12:13 — with GitHub Actions Inactive
@tink-bot tink-bot changed the title labels shared visibility cond refactor(labels): share one visibility condition between the list and single reads Aug 30, 2026
@tink-bot
tink-bot marked this pull request as ready for review August 30, 2026 12:14
@github-actions github-actions Bot added area/labels Task labels and label management (the product feature) area/permissions Sharing, link sharing, roles, access control, assignee roles labels Aug 30, 2026
@tink-bot
tink-bot force-pushed the labels-shared-visibility-cond branch from 8f8946a to f1be75a Compare August 30, 2026 12:31
@tink-bot
tink-bot temporarily deployed to preview-trusted August 30, 2026 12:31 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Preview Deployment

Preview deployments for this PR are available at:

URL Tag Commit
https://pr-3673.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:pr-3673 latest
https://sha-a42efd3c75897c4ed1076f8dacde1cea6f6fa696.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-a42efd3c75897c4ed1076f8dacde1cea6f6fa696 a42efd3
https://sha-64ddc47b0716e7afd7a51d26ede85ceb55d613b8.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-64ddc47b0716e7afd7a51d26ede85ceb55d613b8 64ddc47
https://sha-1d4196b42bb42d9ed9a19913bc935ac5320c5a73.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-1d4196b42bb42d9ed9a19913bc935ac5320c5a73 1d4196b
https://sha-8f8946a3ec87de17242048d9bfecfd4ecb85390b.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-8f8946a3ec87de17242048d9bfecfd4ecb85390b 8f8946a
https://sha-f1be75ac5ef91954c0e095917825decb8ecb90f4.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-f1be75ac5ef91954c0e095917825decb8ecb90f4 f1be75a

The preview environment will start automatically on first visit. Subsequent pushes to this PR will update the pr-3673 image — the preview picks up the new version on restart. The per-commit URLs point to a specific version and will not change.

Run locally with Docker
docker pull ghcr.io/go-vikunja/vikunja:pr-3673
docker run -p 3456:3456 ghcr.io/go-vikunja/vikunja:pr-3673

Last updated for commit a42efd3

@tink-bot
tink-bot force-pushed the labels-shared-visibility-cond branch from f1be75a to 1d4196b Compare August 30, 2026 13:52
@tink-bot
tink-bot temporarily deployed to preview-trusted August 30, 2026 13:52 — with GitHub Actions Inactive
@tink-bot
tink-bot temporarily deployed to preview-trusted August 30, 2026 14:39 — with GitHub Actions Inactive
Base automatically changed from labels-query-rewrite to main August 30, 2026 15:04
The rule deciding which labels a caller may see was written twice, in two
different SQL shapes: labels.id IN (subquery) in GetLabelsForUser, a LEFT JOIN
plus label_tasks.task_id IN (subquery) in hasAccessToLabel. Two hand-maintained
shapes of one rule is a standing drift risk, and it already cost us once - on
main before this branch, the list resolved its projects through
getRawProjectsForUser, which drops archived projects, so a label reachable only
through a task in an archived but owned project was returned by CanRead and
hidden from the list.

labelVisibleCond is now the single definition, and hasAccessToLabel loses its
LEFT JOIN on label_tasks with it - the new cond references label_tasks only
inside an uncorrelated subquery. The GHSA-hj5c-mhh2-g7jq warning moves onto the
helper, restated as an instruction to callers: hand the cond to Where in one
shot, never chain it onto the session.

TestLabel_ReadAllMatchesCanRead pins the invariant this exists to protect, that
ReadAll returns exactly what CanRead allows, for two users, a bot owner and a
link share. It also pins the expected id sets outright: deriving both sides from
one helper makes a pure parity assertion blind to that helper widening, which
mutation testing confirmed.
builder.And silently drops an argument whose IsValid() is false, so an empty
or invalid cond out of labelVisibleCond would leave hasAccessToLabel querying
`WHERE labels.id = ?` alone - every label readable by everyone, the same class
of bypass as GHSA-hj5c-mhh2-g7jq, and silent at compile time and at runtime.

Not reachable today: the cond always carries the task branch, whose IsValid()
is unconditionally true. Guarded inside the helper rather than at the two call
sites so a future third consumer cannot inherit the contract without knowing
it. This catches the bad state rather than making it unconstructable - the
type-level version would need a cond wrapper that cannot be empty.
@kolaente
kolaente force-pushed the labels-shared-visibility-cond branch from 64ddc47 to a42efd3 Compare August 30, 2026 15:04
@kolaente
kolaente merged commit 5694ef6 into main Aug 30, 2026
43 checks passed
@kolaente
kolaente deleted the labels-shared-visibility-cond branch August 30, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/labels Task labels and label management (the product feature) area/permissions Sharing, link sharing, roles, access control, assignee roles

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants