main ← fix-tag-search-category-visibility-leaks
opened 07:00AM - 21 Apr 26 UTC
Tag search (`/tags/filter/search`) was not fully consistent with category-based …tag visibility. A tag restricted to a private category via `CategoryTag` or `CategoryTagGroup` could still appear in several of the endpoint's outputs for users who did not have access to that category: as a disabled entry with an explanation in the composer autocomplete, as a regular allowed result when the tag filter dropdown on topic lists queried without `filterForInput`, or as a `forbidden_message` when the exact tag name was typed. The missing-parent-tag reason could include a parent tag name even when the parent itself was restricted to a category the viewer could not access, and the synonym-exclusion reason could include the synonym's target name without checking whether the target was itself visible. The serialized tag payload also included the `target_tag: { id, name, slug }` field for every synonym regardless of target visibility, and `fetch_category` resolved any `categoryId` the caller passed without checking visibility, which made the behaviour for an invisible existing category distinguishable from the behaviour for a non-existent one. Finally, the fallback disabled-tag reason always said "cannot be used in this category" even when the request had no category context at all.
The underlying cause is that "visibility" in `DiscourseTagging` was defined purely in terms of `TagGroupPermission` and ignored category access entirely, so each code path in `Tags::Search` was relying on its own partial checks (or none). Most of the behaviour is long-standing; the disabled-entries path in autocomplete is a regression from #39072, but the others have been in place for years.
The fix redefines visibility to include category access and routes every tag-search code path through that single definition.
`DiscourseTagging.visible_tags` now composes a new `filter_visible_in_accessible_categories` helper on top of the existing tag-group-permission check: a tag is kept only if it has no category restriction, or if at least one of its restrictions points to a category in `guardian.allowed_category_ids`. The helper is a single `WHERE` clause with one named bind reused twice.
`DiscourseTagging.filter_allowed_tags` enforces this visibility in-SQL by adding `t.id IN (visible_tags subquery)` to its builder for non-admin callers. That single subquery subsumes the narrower hidden-tag-group `EXCEPT` block the method used to build, so the whole filter is now one trip to the database instead of being post-filtered in Ruby.
`Tags::Search` steps all flow through the new visibility universe. `search_tags` inherits the guarantee through `filter_allowed_tags`. `append_disabled_tags` and `detect_forbidden_tag` both start from `DiscourseTagging.filter_visible(Tag.all, guardian)` via a private `visible_tags` helper, so neither can surface a tag outside the user's scope. `fetch_category` intersects the requested id with `guardian.allowed_category_ids` and returns `nil` otherwise, so categories the caller cannot see are treated the same as categories that do not exist.
`explain_exclusion` gates its two name-surfacing branches on visibility: the synonym-exclusion reason is only emitted when the target tag is visible, and the missing-parent-tag reason is only emitted when the parent tag is visible. When neither of those reasons (nor any category-based reason) applies, the fallback now picks between `tags.forbidden.in_this_category` (when `params.categoryId` is present) and a new `tags.forbidden.not_allowed` wording (when it is not), so the message matches the actual context of the request.
`TagsController.tag_counts_json` filters the `target_tags` lookup through `DiscourseTagging.filter_visible`, so synonyms whose targets are invisible no longer carry a `target_tag` field in the payload. This benefits every caller of `tag_counts_json` (search, tag index, tag show, the hashtag data source, and the detailed tag serializer), not just the tag search endpoint.
The accompanying specs cover each path both positively and negatively: admins and authorized users still see their tags, partial matches still return disabled entries with the right reason, exact matches still produce `forbidden_message` for tags the user can see; unauthorized users, anonymous users, invisible-category probes, synonym payloads, and parent-tag reasons are all verified not to surface anything outside the user's scope.
Ref - t/364105