Skip to content

fix: Permission narrowing within entities - #805

Open
bencap wants to merge 6 commits into
release-2026.2.7.1from
bugfix/bencap/calibration-permissions
Open

fix: Permission narrowing within entities#805
bencap wants to merge 6 commits into
release-2026.2.7.1from
bugfix/bencap/calibration-permissions

Conversation

@bencap

@bencap bencap commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

bencap added 6 commits August 6, 2026 13:48
…iewer

A calibration's READ rule is stricter than its score set's: publishing a
score set does not publish its calibrations, and reading one does not
entitle a caller to read the private calibrations hanging off it. Reads
that fanned out from a score set to its calibrations left the router's
assert_permission boundary behind and served every calibration they
found.

Introduce Principal and the generic Viewer, so "may this caller see
this?" can be asked at the point of fan-out rather than assumed to have
been asked upstream. Both default to anonymous, so a caller that
constructs one with no argument serves the public view rather than
everything in the database.

Apply it to the paths that fan out today: VA-Spec annotation building,
the recently-published listing, and the public data export. Annotations
are emitted per viewer rather than public-only, so an entitled caller
still receives evidence drawn from calibrations they may read. Because a
VA-Spec statement carries no stable identifier, every emitted annotation
now discloses its own scope through a mavedb_calibration_scope
extension, so a consumer can tell whether the record is the one anyone
would receive or one widened by the requester's access.
…core-set listing

GET /experiments/{urn}/score-sets returns the full score set view model,
which carries score_calibrations, but applied no calibration filter. A
published score set can hold an unpublished calibration, so an anonymous
caller listing an experiment's score sets received every private
calibration's baseline score, threshold ranges, oddsPath ratios and ACMG
criteria.

Filter through ScoreCalibrationViewer, the same rule the score set
detail and recently-published endpoints use.

The filter is applied to the serialized view rather than by reassigning
ScoreSet.score_calibrations. That relationship is mapped with
cascade="all, delete-orphan", so narrowing the ORM collection marks the
withheld rows as orphans and the next flush deletes them; read paths
avoid that today only because the session is built with autoflush=False
and no read handler commits.
The export narrowed its data by assigning filtered lists back onto
ExperimentSet.experiments and ScoreSet.score_calibrations. Both
relationships are mapped with cascade="all, delete-orphan", so those
assignments did not merely shape the dump -- they marked every withheld
row as an orphan. This script can flush: with_database_session commits
when the command is invoked with --commit, at which point the withheld
private calibrations are deleted, along with every published experiment
carrying no CC0 score sets and, by cascade, its score sets and variants.

Only --dry-run, the default, made this survivable.

Validate the experiment sets first, then narrow the resulting
ExperimentSetPublicDump views. Pydantic models are throwaway response
objects, so narrowing them stages nothing. The ORM graph is now only
read: visible calibration ids are collected in a read-only pass and the
views are filtered by id.

Score set ids for the per-score-set files are now taken from the
narrowed views rather than the ORM graph, so the csv/, mapped/ and va/
entries cover exactly what main.json describes.
A calibration's READ rule is stricter than its score set's in both
directions: publishing a score set does not publish its calibrations,
and owning a score set does not entitle its owner to a community
calibration -- one contributed by a non-contributor -- attached to it.
The owner-facing endpoints returned the score set wholesale, so
creating, updating, uploading to or publishing a score set handed its
owner calibrations they cannot fetch directly.

Route every ScoreSet response in this module through _score_set_response,
which narrows both sub-resources whose rules diverge from the score set's
own: calibrations, and the superseding score set. The search routes are
the documented exception -- they answer with ShortScoreSet, which carries
neither.

Centralizing this removes the last four ORM-mutation filters here.
ScoreSet.score_calibrations cascades delete-orphan, so narrowing it in
place marked the withheld rows as orphans; assigning superseding_score_set
= None nulls the other score set's replaces_id, but only once the
attribute has been read, which the permission check did immediately
before. fetch_score_set_by_urn now returns the score set as it is, which
is also what its non-response callers -- supersession lookup and
publication -- actually want.

_score_set_response is module-private deliberately. A shared response
constructor would not cover the CSV, VA-Spec NDJSON or public-dump
serializations of the same graph, so the durable fix belongs at the
session rather than the response layer.
…e ORM

fetch_experiment_set filtered its response by assigning into
item.experiments in place. ExperimentSet.experiments is mapped with
cascade="all, delete-orphan", so that assignment marked every experiment
the caller could not read as an orphan, and any subsequent flush would
have deleted those experiments along with their score sets and variants.
Verified: the assignment plus a commit drops the experiment count.

Nothing flushed on this path, so nothing was lost -- SessionLocal is
built autoflush=False and the handler never commits. Both are
coincidences rather than guarantees.

Build a local list instead, and sort that rather than the ORM collection.
@bencap
bencap changed the base branch from release-2026.2.8 to release-2026.2.7.1 August 6, 2026 23:11
@bencap
bencap marked this pull request as ready for review August 6, 2026 23:11
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 31130112892

Warning

No base build found for commit 20af8f3 on release-2026.2.7.1.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 89.531%

Details

  • Patch coverage: 117 of 117 lines across 10 files are fully covered (100%).

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 14385
Covered Lines: 12879
Line Coverage: 89.53%
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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Private score calibrations reach unentitled callers, and the narrowing idiom used to prevent it can delete rows

2 participants