Skip to content

fix(permissions): stop errors and search revealing private records - #966

Draft
bencap wants to merge 4 commits into
release-2026.3.3from
bugfix/bencap/832/private-record-leaks
Draft

bencap wants to merge 4 commits into
release-2026.3.3from
bugfix/bencap/832/private-record-leaks

Conversation

@bencap

@bencap bencap commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes several places where the Python permission layer let callers learn about private records through search results, status codes or error details. The RLS inventory in #832 turned these up. RLS (#808) keeps the Python checks as a second line of defense, so they need fixing either way.

  • Experiment search: the publicationIdentifiers filter used an uncorrelated ScoreSet.publication_identifiers.any(...), which cross-joined scoresets. As a result, every experiment matched whenever any score set, private ones included, cited the publication. The filter now matches through the experiment's own score sets, counting only those the caller can see: published ones, plus the caller's own in /me/experiments/search.
  • Collection member routes: adding or removing a score set or experiment now checks READ on the collection, then READ on the member, then the edit permission, and only then whether the association exists. Before, a caller with no access could tell a missing collection from a private one (different 404 details), and could tell whether a private record was linked (409 vs 403/404).
  • Score set creation: READ on the experiment, then ADD_SCORE_SET, are now checked before the meta-analysis 409, so another user's private experiment gives the same 404 as a missing one. The supersede 409 names the superseding score set only when the caller can read it, because it may be another contributor's private draft.
  • Publication search by database: /publication-identifiers/search/{db_name}/{identifier} used a Python and between two clauses, which silently dropped the db_name filter.

The #832 inventory also lists the identifier list and autocomplete routes, which return identifiers linked only to private records. Those are left to the identifier-table RLS policies that #949 and #960 prepare for, not filtered here.

Behavior changes a reviewer may notice:

  • A missing experiment on POST /score-sets/ now returns experiment with URN '<urn>' not found, matching the private case, in place of "The requested experiment does not exist".
  • A collection editor can no longer remove a member they can't read. The collection view already hides those members, so the editor never sees them.
  • Removing an unlinked member as a non-editor now returns 403, not 409.

Part of #832

Tests

Added regression tests in tests/routers/ for each fix. Each one fails on the old code and passes on this branch:

  • test_experiments.py: publication filter, public search and /me search.
  • test_collections.py: collection and member probes, add and remove, score sets and experiments.
  • test_score_set.py: private meta-analysis experiment; supersede conflict.
  • test_publication_identifiers.py: db_name filter.

Ran bin/check and the PR CI set: bin/t tests/ -n 6 -m "not network and not slow" gave 5450 passed, 22 skipped.

Checklist

  • Targets the open patch branch if this is a small, non-breaking change, or the cycle's release-<year>.<n>.0
    branch otherwise; never main
  • API schema change? A matching mavedb-ui PR regenerates src/schema/openapi.d.ts: no schema change
  • Docs and instruction files touched by this change are updated: none affected

The publication filter in experiment search referenced ScoreSet without
tying it to the experiment, so SQLAlchemy cross-joined scoresets: every
experiment matched as soon as any score set cited the publication,
private ones included. Match through the experiment's own score sets,
counting only those the caller may see: published ones, plus the
caller's own in /me search. The visibility predicate ignores admin and
mapper roles, which search has never widened results for.

The meta-analysis filter also reads private score sets but is left as
is. It exposes only whether an experiment is a meta-analysis experiment,
which is uniform across its score sets and already disclosed by the
create route's 409 to anyone who may add to it.
The add and remove member routes looked up the collection and member
and could answer 404 or 409 before any permission check. A caller with
no access could tell a missing collection from a private one by the 404
detail, and tell whether a private record was linked to a collection by
409 against 403 or 404. Check READ on the collection, then on the
member, reporting either as missing, then the edit permission, and only
then the association.

An editor can no longer remove a member they cannot read; the collection
view already hides those members from them. A non-editor removing an
unlinked member now gets 403 rather than 409.
Creating a score set in another user's private meta-analysis experiment
returned the meta-analysis 409, and any other private experiment a 404
worded differently from a missing one, so both revealed that the
experiment exists. Report an unreadable experiment exactly like a
missing one, with the URN wording an existing test already expected,
and check ADD_SCORE_SET before the 409.

The supersede 409 named the superseding score set, which can be another
contributor's private draft. Name it only to a caller who may read it.
The two conditions were joined with Python's `and`, which evaluates the
first SQLAlchemy clause for truthiness and returns it, so only the
identifier filter reached SQL and a search under one database returned
matching identifiers from every database.
@bencap bencap added the app: backend Task implementation touches the backend label Oct 28, 2026
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 40747011804

Coverage increased (+0.04%) to 90.441%

Details

  • Coverage increased (+0.04%) from the base build.
  • Patch coverage: 23 of 23 lines across 3 files are fully covered (100%).
  • 12 coverage regressions across 3 files.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

12 previously-covered lines in 3 files lost coverage.

File Lines Losing Coverage Coverage
src/mavedb/lib/clinvar/utils.py 6 90.91%
src/mavedb/worker/jobs/external_services/clinvar.py 4 96.97%
src/mavedb/lib/mondo_ols.py 2 96.92%

Coverage Stats

Coverage Status
Relevant Lines: 19227
Covered Lines: 17389
Line Coverage: 90.44%
Coverage Strength: 0.9 hits per line

💛 - Coveralls

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

Labels

app: backend Task implementation touches the backend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants