Repository navigation
Delete remaining reached-path filters; resolve the publish-time chain walk #813
Description
Everything #811 and #812 do not cover, plus the one item in this epic whose failure mode is a silent wrong answer rather than an absence.
Loop filters — delete
| Site | Form |
|---|---|
lib/score_sets.py:356 |
for score_set in score_sets: if not has_permission(...): continue |
routers/score_sets.py:726 |
same, over score sets with mapping metadata |
routers/score_sets.py:800 |
same, in the experiment score-set listing |
routers/score_calibrations.py:140, 180 |
list comprehension over calibrations |
routers/mapped_variant.py:246 |
comprehension over item.variant.score_set |
routers/experiments.py:100 |
comprehension over experiments |
Narrowing assignments — delete
These become unnecessary rather than merely redundant.
| Site | Form | Why RLS handles it |
|---|---|---|
routers/score_sets.py:604-605 |
item.superseding_score_set = None |
An invisible related row does not load; the attribute is already None |
routers/score_sets.py:804-805 |
same, in the listing loop | as above |
routers/score_sets.py:607 |
item.score_calibrations = [...] |
The collection arrives filtered |
routers/experiment_sets.py:68 |
item.experiments[:] = [...] |
as above |
lazy="raise" does not block scalar assignment, so nothing structurally prevented the scalar form. RLS removes the need for it entirely.
Boundary checks — move to #816
routers/target_genes.py:132 — if not item or not has_permission(...) is a named-path 404, not a filter.
The supersession chain walk
lib/score_sets.py:508, 515, 518, 538, 549 are not read filters. They are the supersession chain walkers, where permission acts as the chain terminator and the action is parameterized rather than always READ.
find_publish_or_private_superseded_score_set_tail runs at publish time and must see private score sets. Under RLS a filtered walk terminates early and publishes against the wrong tail. This is a silent wrong answer, not a missing row, which makes it the highest-risk change in the epic.
Resolve in this order:
- Establish whether the acting user always owns every score set in the chain. If so, the owner disjunct already admits them and no special handling is needed. This is the likely answer and the cheapest outcome, but it must be established, not assumed.
- Otherwise route the walk through a
SECURITY DEFINERhelper or the owner role.
Either way this needs a test that publishes over a private tail. Do not resolve it by inspection.
Carve-out, not a deletion
routers/permissions.py:80 — permission = has_permission(user_data, item, action).permitted — is the endpoint that reports permission. Under RLS a filtered lookup would make it answer "no" for every entity the caller cannot see, which is both wrong and a behavior change. It needs the owner role or a SECURITY DEFINER lookup.
Also fold in: the two primary-calibration checks (one walking item.score_set.score_calibrations in the router, one querying directly in the lib) collapse into a single SECURITY DEFINER helper. The business invariant must see rows the caller cannot.
Do not begin until the policy-gap counter in #835 has read zero for a full release cycle.
Acceptance criteria
- Every loop filter and narrowing assignment listed above is removed.
- The codebase contains no
has_permissioncall used as a read filter. - Test: publishing a superseding score set over a private tail resolves to the correct tail.
- Test:
GET /permissions/...still reports correctly for entities the caller cannot read. - Primary-calibration detection has exactly one call site.
- The chain-walk resolution is written down here, with whichever of the two options was taken and why.
Activity
[-]Convert remaining reached-path filter sites to visibility clauses[/-][+]Delete remaining reached-path filters; resolve the publish-time chain walk[/+]bencap commentedon Sep 28, 2026
Merged into #811, which now covers every read-filter deletion.