Skip to content

OpenConceptLab/ocl_online#224 | Access-control fixes for content in repos the requester can't view - #943

Merged
snyaggarwal merged 36 commits into
masterfrom
ocl_online/224-access-gaps
Oct 15, 2026
Merged

snyaggarwal merged 36 commits into
masterfrom
ocl_online/224-access-gaps

Conversation

@paynejd

@paynejd paynejd commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Refs OpenConceptLab/ocl_online#224 (partial: the items still open are listed on that ticket).

To finish (reviewer): the steps after merging live in the checklist on OpenConceptLab/ocl_online#224:

Items this PR leaves open are tracked in OpenConceptLab/ocl_online#482 to OpenConceptLab/ocl_online#488.

Follow-up to #906 and #910: more places where a response depended on content in repos the requester can't view. Each commit makes one fix and adds a regression test that fails on master and passes here.

Fixes

Concepts and search

  • Concept lookup (concepts/lookup/) checks view access, and its page cache holds only public sources. This restores e1f3138, which was reverted in 7bfb6c9.
  • $match filters on exact-case owner and source names (owner_text.keyword, source_text.keyword). A target_repo is now searched by the stored names of the repos that were checked, so a version key or a lowercase owner_type works too.
  • /parents/ and /children/ list only concepts the requester can view, the same rule includeParentConcepts/includeChildConcepts already use. They aren't searchable now: a search there listed the repo's concepts instead.
  • Search in the requester's own scope (their user, an org they belong to, or all their orgs on /user/orgs/...) leaves out the private content of an owner whose name differs only in case. Concepts are filtered on the exact-case owner. Mapping, source, collection and repo indexes have no exact-case owner field, so when such an owner exists those searches show only public content.
  • New org names are unique regardless of case, as on GitHub, through the API and bulk import alike.
  • List cache: repo version concept lists with includeParentConcepts/includeChildConcepts aren't cached, since their content depends on the requester.
  • collection_versions (concept and mapping version detail, versions/?includeCollectionVersions=true) and the /collection-versions/ lists show only collection versions the requester can view.

Mappings, cascade and imports

  • Linking stored targets: when a concept or source is created, mappings that already pointed at it are linked only if the mapping's last saver can view it, the same rule as on save. This also fixes the re-index of from-side mappings, which filtered on to_source.
  • $cascade and $clone check view access on the repo they run over, as well as on the root concept's repo.
  • omitIfExistsIn ($cascade, and a collection reference's omit_if_exists_in) is ignored when the requester, or the reference's creator, can't view the repo.
  • A collection's mappings for a concept (includeMappings in collection scope) match the concept and collection URLs by exact prefix, not by case-insensitive substring.
  • Bulk updates: a concept update reads its previous version's parents only when a new parent isn't viewable. Before, it did so on every update, at 2 queries per concept.

FHIR

  • CodeSystem $lookup / $validate-code: a code system the requester can't view is handled exactly like one that doesn't exist, with or without a code. A public code system that shares a canonical URL with a private one is no longer hidden by it.
  • ValueSet $validate-code reads only the value sets the request names: the url value set and the route's collection. Other query params can't pick another owner or collection. A display with a code that isn't in the value set now returns false instead of a 500.

GraphQL

  • Scoped queries: a source the requester can't view returns the same error as a missing source.
  • Global queries (conceptIds or text), their mappings and the ES totalCount use the REST visibility rules. Staff are unaffected.

Users, pins, follows and events

  • lastLoginSince/lastLoginBefore apply only for staff.
  • extras.* filters match only the named extras field.
  • User detail: subscribed_orgs lists only orgs the requester can view, and pins shows only for the user themselves and staff.
  • Pins: reads and updates cover only pins whose org and resource the requester can view. The owner can still delete any of their pins.
  • Following lists (/following/ and includeFollowing) show only follows of objects the requester can view. That includes the user themselves once they lose access.
  • Following feeds show a followed user's private events to staff followers only. Org members still get them through scopes=orgs.
  • Followed/Unfollowed events of objects the public can't view are private.
  • Events: an event's objects are serialized live, so they're shown only to requesters who can view them now. Otherwise they're null, for example for a repo made private after the event, and the description is just the event type. Event lists can't be exported as CSV.
  • $cascade entries list the collection versions their requester can view.

CSV export

  • ?csv=true builds each export from the view's own queryset, so its access checks apply, and stores it under the requester. The response shape ({"url": ...}) is unchanged.

Behavior changes

  • Non-staff lastLoginSince/lastLoginBefore params are ignored.
  • extras.x= values containing : or \ are now matched literally.
  • Every CSV export is built fresh: one DB query (at most 1,000 rows) and one upload per request.
  • ValueSet $validate-code ignores org, user, collection and source query params. A version param only narrows the route's own collection.
  • /parents/ and /children/ ignore q.
  • Members of an org whose name matches another org's apart from letter case see only public mappings and repos when they search their own org; the same applies to a user whose username matches another's apart from case, searching their own repos. Production has 10 such org name groups and 16 username groups. Concept search is unaffected.
  • Creating an org whose name differs from an existing one only in case returns 400 from the API and FAILED from bulk import.
  • ?csv=true on event lists is ignored.
  • Requesters who can't view a repo stop seeing it in the places above. Owners, org members and staff see the same as before.

Migration

  • events/0011_private_follow_events (data only, reverse is a no-op) marks existing Followed/Unfollowed events of non-public orgs, repos and repo versions as private. On production it changes 2 rows and runs in seconds.

After deploy

Verification

  • Every fix commit carries a regression test that was run against the code before the fix (it fails there) and with the fix (it passes).
  • Full suite, as CI runs it (manage.py test --parallel=1 --keepdb, PYTHONHASHSEED=2, fresh ES indexes): 2,586 tests, OK.
  • pylint -j0 core/: 10.00/10.
  • Prod EXPLAIN (no ANALYZE) of the new mapping-link query: index scans on to_concept_code, to_source_url and the membership table.
  • The owner_text.keyword and source_text.keyword subfields exist in the production concepts index mapping.
  • Review: three Codex defect passes, a simplification review and an adversarial review, then Codex checks of the later fix commits (clean). Details are on OpenConceptLab/ocl_online#224.

🤖 Generated with Claude Code

paynejd and others added 30 commits October 14, 2026 01:26
… cache only public sources

Restores e1f3138 (reverted in 7bfb6c9). The lookup view accepts any
source, not only the public OCL lookup repos, so it needs the view check;
and its page cache is keyed on the URL, so only public sources are cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d source names

`owner` and `source` in the concepts index are lowercase-normalized, but
owner names that differ only in case belong to different users or orgs.
The visible-repo filter and the target repo filter now use the exact-case
owner_text.keyword and source_text.keyword fields, and a `target_repo` is
searched by the stored names of the repos that were checked, so a
`version` key or a lowercase `owner_type` now works too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ncepts the requester can view

The same filter the includeParentConcepts/includeChildConcepts fields use
(get_viewable_concepts), so a concept's parents or children in another
repo show only when the requester can view that repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s that include parent or child concepts

includeParentConcepts and includeChildConcepts show only concepts the
requester can view, so these responses differ per requester and can't come
from, or go into, the shared list cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uester can view

A concept or mapping version's collection_versions (version detail, and
versions lists with includeCollectionVersions) and the /collection-versions/
lists now show only public collection versions and those of the requester
and their orgs (staff: all), using the collection version's own access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mappings whose saver can view it

Mappings to a concept or source that didn't exist yet keep the target as an
external code and URL. When the concept or source is created, those mappings
are linked only if the user who last saved them can view it, the same rule
as when a mapping is saved. Also re-indexes the from-side mappings a new
source links (the query filtered on to_source).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r can't view

$cascade's omitIfExistsIn, and a collection reference's omit_if_exists_in,
now apply only when the requester (for a reference, its creator) can view
the repo; otherwise the expression is ignored, as if it didn't resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…when a new parent isn't viewable

Updating a concept with parent_concept_urls read its previous version's
parents (2 queries) on every update, even with no parents or with parents
the user can view, which bulk imports pay per concept. They're now read only
when some parent isn't viewable, to keep parents that are already linked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oncepts the requester can view

A source-scoped query checks view access on the source version, and one
the requester cannot view gets the same error as a source that does not
exist. A global query (conceptIds or text) and the mappings listed for
each concept use the same rule as the REST concept and mapping lists
(apply_user_criteria; staff see everything), and the Elasticsearch
search behind `query` applies get_visible_repo_criteria, so totalCount
counts only viewable hits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… treat code systems the requester can't view as missing

$lookup and $validate-code resolve `system`/`url` to the first code system
version the requester can view (with its HEAD, which governs its concepts),
skipping the rest as if they didn't exist. $lookup gives the same not-found
response for a code in a code system the requester can't view as for a code
system that doesn't exist, and $validate-code returns result false for
signed-in requesters too, not only anonymous ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lue sets the requester can view

$validate-code reads only the value sets the request names: the `url`
value set and the route's collection. Query params no longer pick another
owner or collection; a `version` param only narrows the route's collection.
Each value set, its HEAD and every version read must be viewable by the
requester; one that doesn't resolve or can't be viewed gives result false,
the same as a code that isn't in it. The owner-less route reads the
collections with that id the requester can view. Without `url`, the global
route still checks the code system alone.

A `display` with a code that isn't in the value set now gives result false
instead of a server error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vate events go to staff followers only

A user's private events are about orgs and repos the follower may not be
able to view, so the following scope now shows a followed user's public
events only, unless the follower is staff. Private events of a followed
org or repo still go to followers who can view it, and org members still
get their orgs' private events in the orgs scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…llows of objects the requester can view

/users/<user>/following/ and the includeFollowing field of user details
and verbose user lists now leave out follows of orgs, repos and repo
content the requester can't view. The user themselves and staff still
get every follow.

UserProfile.can_user_view(user, obj) answers can_view for any requester,
anonymous included, and can_view delegates to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an't view are private

Followed and Unfollowed events now get public=False when the followed
org, repo or repo content isn't publicly viewable, like Created and
Joined events already do. The user themselves, staff, and followers who
can view the object still see them.

Migration events/0011 makes existing Followed/Unfollowed events private
when they reference a non-public org, or a non-public HEAD source or
collection (or content under one). Reverse is a no-op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fore filters apply only for staff

Last login is staff-only account data, so the two filters are now
ignored for non-staff and anonymous requests, the same way the
staff-only isSuperuser/isStaff/isAdmin filters already are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… extras field

The extras.<key> filter value goes into an ES query_string, where a
`field:value` clause can address any indexed field. Escape backslash
and colon in the value so it only ever matches extras.<key>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly orgs the requester can view; pins only for self and staff

includeSubscribedOrgs now lists the user's public orgs plus the orgs the
requester belongs to (all for staff), using the same rule as the
/users/<user>/orgs/ list, now Organization.get_visible_to.
includePins is returned only to the user themselves and staff, like the
other private user fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se org and resource the requester can view

GET /users/<user>/pins/, /orgs/<org>/pins/ and the pin detail return only
pins whose pinned source, collection or org the requester can view, and,
for org pins, whose org they can view (the same check as pinning). The
owner can still change or remove every pin. includePins on the user
detail applies the same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s requester and store it under that requester

?csv=true no longer reuses an existing export file looked up by URL path
alone. Every export is built from the view's own queryset, so the
view's access checks and visibility filters apply, and is written to a
downloads folder of the requester's own (user id, or "anonymous"), so
one requester's file is never served to another. The ?csv&type= path
uses the same rule. Removes the unused export-reuse helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ilter as reads

An update returns the pinned resource, so updating a pin whose org or
resource the requester can't view is refused like reading it (404). The
pin's owner can still delete it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is judged by its own access

A Followed/Unfollowed event of a repo version that isn't public is made
private too, even when its HEAD is public; anything else under a repo is
still judged by the HEAD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…requester can't view gives false, with or without a code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…versions their requester can view

A $cascade bundle serializes its entries without the request, so it now
passes its requester, and verbose mapping entries show members and staff
the private collection versions they can view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s objects they can no longer view

Only staff get every follow; the user themselves sees a follow only while
they can view the followed object, like everyone else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uesters who can view them now

An event's public flag is set when it's recorded, but the objects it shows
are serialized live. An object the requester can't view now (for example a
repo made private after the event) is shown as null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hable

A search on them took the list search path, which lists the repo's
concepts, not the concept's parents or children, and skips the view's
own checks. They now always list the concept's hierarchy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ope excludes other owners' private content

A search of a repo the requester owns or is a member of includes private
content, but the owner and source filters match names in any case, and
owner names that differ only in case belong to other users or orgs. Their
private content is now excluded: concepts by exact-case owner name; for
mappings, which have no exact-case owner field, private content is left
out when such an owner exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons method, fewer one-caller helpers

- CollectionVersionsMixin replaces four copies of get_collection_versions;
  the unused collection_versions property goes, and the user is required.
- GraphQL's global queryset reuses get_viewable_concepts.
- get_viewable_parent_uris takes linked_uris as a callable only; its empty
  guard and CodeSystem $validate-code's earlier empty check were redundant.
- Organization.get_visible_to takes the queryset its callers always pass.
- The CSV export test's export service no longer stubs the removed lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on the repo they run over

The root concept is resolved from the merged URL kwargs and query params,
and its own repo is checked, but the repo the cascade or clone runs over
comes from the URL. That repo now needs view access too (404 otherwise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e too; new org names are unique regardless of case

Source, collection and repo list searches in the requester's own scope
match owner names in any case, like concept and mapping searches: when
another owner of the same type has the same name in a different case, only
public repos are searched. New orgs can't take a name that differs from an
existing org's only in case, so no new such owners can be created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
paynejd and others added 5 commits October 14, 2026 01:56
…ve-object check; no CSV export of events

An event's description names its referenced object, so it's only the event
type when the requester can't view that object now. Event lists can't be
exported as CSV, which would skip the serializer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… match exact URL prefixes

The concept and collection URLs were matched as case-insensitive
substrings, so a collection whose URL differed only in case (another
owner's) contributed its mappings. They now match by exact prefix, which
can also use the URL indexes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… lists, which search all the requester's orgs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gh /user/collections/ resolve the collection's owner

The /user/ routes carry no owner in the URL kwargs, so the collection URL
the mappings are matched against now names the requester, as the exact
prefix match needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se name differs from an existing one only in case

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@snyaggarwal snyaggarwal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed commit fb7f85494233c4c958a35e72439f1c2526333319.

No actionable defects found in this review. I checked the changed access-control paths, including exact-case search scoping, FHIR repository selection, GraphQL visibility, deferred mapping links, hierarchy and collection membership, CSV exports, pins/follows/events, and the data migration.

Verification in an isolated checkout inside the existing API container:

  • GraphQL, CodeSystem, ValueSet, pins, and events suites: 104 of 105 tests passed initially. test_fetch_concepts_global_search failed because the shared Elasticsearch index returned IDs outside the test database while the fixture concepts were absent from the results. A temporary diagnostic explicitly indexed/refreshed those fixture concepts, and the same assertion then passed. This is a local verification caveat, not a confirmed application defect.
  • 53 tests passed covering public-read access, cross-repository access, search resolution, CSV export access, events, and the refreshed GraphQL diagnostic.
  • Targeted pylint on the shared filtering/serialization code and affected GraphQL, concept, ValueSet, and event paths: 10.00/10.

I did not rerun the full project suite. No application files were changed.

@snyaggarwal

Copy link
Copy Markdown
Contributor

Approved but requires merge conflicts to be handled

@snyaggarwal snyaggarwal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second review after conflict resolution, at 2f184ea857b4339dc4c9cf42632d05817d5d859a (merging master b016e253 into the previously reviewed fb7f8549).

No actionable defects found in the merge resolutions.

I inspected Git's reconstructed conflict-resolution diff and compared the result against both parents, focusing on the interaction between master's batched list serialization and this PR's collection-version visibility checks:

  • SourceChildPageSerializer forwards the requester from both normal request context and cascade's user context.
  • Batched collection-version loading applies the visibility criteria through the correct expansion__ relation before populating each row's cached values.
  • Concept and mapping serializers retain both the collection-version visibility mixin and master's list/source-version serialization behavior.
  • The existing collection-version access regression covers anonymous users, outsiders, members, staff, version lists/details, and cascade; I checked that those paths now reach the merged implementation.

Verification: targeted pylint on all four resolution-related files passed at 10.00/10 in a temporary OCL image container; all 50 Python files changed against the current PR base parsed successfully; git diff --check passed and the resolved files contain no conflict markers.

Limitation: I did not rerun Django/integration tests for this commit. The OCL API, PostgreSQL, Redis, and Elasticsearch containers are stopped. The prior review's runtime results apply to the earlier commit, not this merged one. No application files were changed during this review.

@snyaggarwal
snyaggarwal merged commit 1850437 into master Oct 15, 2026
3 checks passed
@snyaggarwal
snyaggarwal deleted the ocl_online/224-access-gaps branch October 15, 2026 05:08
paynejd added a commit that referenced this pull request Oct 16, 2026
…r a code system the caller isn't shown exactly as one that doesn't exist

- An OCL path naming one version, with another version named (the version parameter, url|version, HEAD, a coding's
  version, valueSetVersion, systemVersion), names no version OCL holds and is not found. Before, the path's own
  version row was the starting point, so a private version in it was answered differently from a missing one.
- The shared resolver takes an optional head_shown check. FHIR's resolve() passes the caller's view of HEAD, so a
  repository the caller isn't shown resolves to nothing before any of its versions is read, and costs what one that
  doesn't exist does for an anonymous caller at an owner base. The version then reuses HEAD's check of the caller's
  rights, so it isn't asked twice. Every other caller, $resolveReference included, passes nothing and is unchanged.
- An owner instance route reads the newest public release in place of a private newest one only for a public
  repository, so a private repository costs what a missing one does there too.
- ValueSet membership reads a code's concepts only in code systems the caller is shown, so what a hidden code
  system holds doesn't change the work done or the answer.
- The #943 test compares each answer to what it would be, for the same requester and base, once the repository
  is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wdpwbn9tU8NmVL8oW2A75d
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.

2 participants