Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
eafb985
OpenConceptLab/ocl_online#224 | Concept lookup: check view access and…
paynejd Oct 14, 2026
0a69bdd
OpenConceptLab/ocl_online#224 | $match: filter on exact-case owner an…
paynejd Oct 14, 2026
5fe8543
OpenConceptLab/ocl_online#224 | /parents/ and /children/ list only co…
paynejd Oct 14, 2026
9c906eb
OpenConceptLab/ocl_online#224 | Don't cache repo version concept list…
paynejd Oct 14, 2026
349e19c
OpenConceptLab/ocl_online#224 | List only collection versions the req…
paynejd Oct 14, 2026
c76e54d
OpenConceptLab/ocl_online#224 | Link a new concept or source only to …
paynejd Oct 14, 2026
16ed5e5
OpenConceptLab/ocl_online#224 | omitIfExistsIn ignores a repo the use…
paynejd Oct 14, 2026
d7b6201
OpenConceptLab/ocl_online#224 | Read a concept's linked parents only …
paynejd Oct 14, 2026
cdc989d
OpenConceptLab/ocl_online#224 | GraphQL concepts query returns only c…
paynejd Oct 14, 2026
04f17e6
OpenConceptLab/ocl_online#224 | CodeSystem $lookup and $validate-code…
paynejd Oct 14, 2026
9c37bb5
OpenConceptLab/ocl_online#224 | ValueSet $validate-code reads only va…
paynejd Oct 14, 2026
83af49b
OpenConceptLab/ocl_online#224 | Following feed: a followed user's pri…
paynejd Oct 14, 2026
873fe73
OpenConceptLab/ocl_online#224 | A user's following list shows only fo…
paynejd Oct 14, 2026
db55cb1
OpenConceptLab/ocl_online#224 | Follow events of objects the public c…
paynejd Oct 14, 2026
80efbce
OpenConceptLab/ocl_online#224 | User list: lastLoginSince/lastLoginBe…
paynejd Oct 14, 2026
e1986bb
OpenConceptLab/ocl_online#224 | extras.* filters match only the named…
paynejd Oct 14, 2026
287593b
OpenConceptLab/ocl_online#224 | User detail: subscribed_orgs lists on…
paynejd Oct 14, 2026
5f6b1d8
OpenConceptLab/ocl_online#224 | Pins: list and read only the pins who…
paynejd Oct 14, 2026
85de1b0
OpenConceptLab/ocl_online#224 | CSV exports: build each export for it…
paynejd Oct 14, 2026
59fcc6d
OpenConceptLab/ocl_online#224 | Pin updates use the same visibility f…
paynejd Oct 14, 2026
a359a08
OpenConceptLab/ocl_online#224 | events/0011: a followed repo version …
paynejd Oct 14, 2026
4864683
OpenConceptLab/ocl_online#224 | CodeSystem $validate-code: a url the …
paynejd Oct 14, 2026
fe5badc
OpenConceptLab/ocl_online#224 | $cascade entries list the collection …
paynejd Oct 14, 2026
5be274f
OpenConceptLab/ocl_online#224 | A user's own following list also hide…
paynejd Oct 14, 2026
d1a4b83
OpenConceptLab/ocl_online#224 | Events show their objects only to req…
paynejd Oct 14, 2026
1fae404
OpenConceptLab/ocl_online#224 | /parents/ and /children/ aren't searc…
paynejd Oct 14, 2026
85fcd75
OpenConceptLab/ocl_online#224 | Repo search in the requester's own sc…
paynejd Oct 14, 2026
2b727ce
OpenConceptLab/ocl_online#224 | Review cleanups: one collection_versi…
paynejd Oct 14, 2026
1de3d3e
OpenConceptLab/ocl_online#224 | $cascade and $clone check view access…
paynejd Oct 14, 2026
c3c77cb
OpenConceptLab/ocl_online#224 | Repo list search applies the case rul…
paynejd Oct 14, 2026
66090dc
OpenConceptLab/ocl_online#224 | Event descriptions follow the same li…
paynejd Oct 14, 2026
06da5d8
OpenConceptLab/ocl_online#224 | A collection's mappings for a concept…
paynejd Oct 14, 2026
bc5fdeb
OpenConceptLab/ocl_online#224 | The case rule covers /user/orgs/ repo…
paynejd Oct 14, 2026
cf7d9a0
OpenConceptLab/ocl_online#224 | A collection's concept mappings throu…
paynejd Oct 14, 2026
fb7f854
OpenConceptLab/ocl_online#224 | Bulk import doesn't create an org who…
paynejd Oct 14, 2026
2f184ea
Merge master into ocl_online/224-access-gaps
snyaggarwal Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions core/bundles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@


class Bundle:
def __init__(self, root, repo_version, params=None, verbose=False, requested_url=None): # pylint: disable=too-many-arguments
def __init__( # pylint: disable=too-many-arguments
self, root, repo_version, params=None, verbose=False, requested_url=None, user=None):
params = params or {}
self.user = user
self.repo_version = repo_version
self.params = params
self.verbose = verbose
Expand Down Expand Up @@ -96,7 +98,14 @@ def set_cascade_direction(self):
self.reverse = self.params[CASCADE_DIRECTION_PARAM] in TRUTHY

def set_omit_if_exists_in(self):
self.omit_if_exists_in = self.params.get(OMIT_IF_EXISTS_IN, None) or None
"""A repo the user can't view is ignored, as if the expression didn't resolve."""
omit_if_exists_in = self.params.get(OMIT_IF_EXISTS_IN, None) or None
if omit_if_exists_in:
from core.common.models import ConceptContainerModel
repo, _ = ConceptContainerModel.resolve_expression_to_version(omit_if_exists_in)
if get(repo, 'id') and not repo.has_view_access(self.user):
omit_if_exists_in = None
self.omit_if_exists_in = omit_if_exists_in

def set_include_self(self):
if INCLUDE_SELF in self.params:
Expand Down Expand Up @@ -183,8 +192,9 @@ def cascade_as_hierarchy(self):
self.root, context={'request': {'query_params': self.params}}).data

def set_entries(self):
self.entries += self.get_concept_serializer()(self.concepts, many=True).data
self.entries += self.get_mapping_serializer()(self.mappings, many=True).data
context = {'user': self.user}
self.entries += self.get_concept_serializer()(self.concepts, many=True, context=context).data
self.entries += self.get_mapping_serializer()(self.mappings, many=True, context=context).data

def get_mapping_serializer(self):
from core.mappings.models import Mapping
Expand All @@ -210,7 +220,8 @@ def clone( # pylint: disable=too-many-arguments
params=parameters,
verbose=is_verbose,
repo_version=clone_from_source,
requested_url=requested_url
requested_url=requested_url,
user=user
)
bundle.set_cascade_parameters()
_parameters = {}
Expand Down
62 changes: 62 additions & 0 deletions core/code_systems/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from core.code_systems.converter import CodeSystemConverter
from core.code_systems.serializers import CodeSystemDetailSerializer
from core.code_systems.views import CodeSystemLookupNotFoundError
from core.common.constants import ACCESS_TYPE_NONE
from core.common.tests import OCLTestCase
from core.concepts.models import Concept
from core.concepts.tests.factories import ConceptFactory
Expand Down Expand Up @@ -259,6 +260,67 @@ def test_lookup_for_code_system_with_new_canonical(self):
{'name': 'version', 'valueString': self.org_source_v2.version},
{'name': 'display', 'valueString': self.concept_1.display_name}]}))

@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
def test_private_code_system_answers_as_missing_for_non_viewers(self): # pylint: disable=too-many-locals
private_org = OrganizationFactory()
private_source = OrganizationSourceFactory(
organization=private_org, canonical_url='/private/url', public_access=ACCESS_TYPE_NONE)
concept = ConceptFactory(parent=private_source, names=1, names__name='private_concept_name')
Source.persist_new_version(OrganizationSourceFactory.build(
version='v1', mnemonic=private_source.mnemonic, organization=private_org,
public_access=ACCESS_TYPE_NONE), private_source.created_by)
member = UserProfileFactory()
private_org.members.add(member)
outsider = {'HTTP_AUTHORIZATION': 'Token ' + self.user_token}
validate_url = f'/fhir/CodeSystem/$validate-code/?url=/private/url&code={concept.mnemonic}'
lookup_url = '/fhir/CodeSystem/$lookup/?system={}&code={}'
not_found = self.client.get(lookup_url.format('/unknown/url', concept.mnemonic))
self.assertEqual(not_found.status_code, 400)

public_concept = ConceptFactory()
selector = (f'&org={public_concept.parent.organization.mnemonic}&source={public_concept.parent.mnemonic}'
f'&concept={public_concept.mnemonic}')
for headers in [{}, outsider]:
# without a code, a code system the requester can't view still answers as one that doesn't exist
for system_url in ['/private/url', '/unknown/url']:
response = self.client.get(f'/fhir/CodeSystem/$validate-code/?url={system_url}{selector}', **headers)
self.assertFalse(response.data['parameter'][0]['valueBoolean'], (system_url, headers))
for url in [validate_url, f'{validate_url}&display={concept.display_name}']:
response = self.client.get(url, **headers)
self.assertEqual(response.status_code, 200, (url, headers))
self.assertFalse(response.data['parameter'][0]['valueBoolean'], (url, headers))
for code in [concept.mnemonic, 'missing']:
response = self.client.get(lookup_url.format('/private/url', code), **headers)
self.assertEqual(response.status_code, 400, (code, headers))
self.assertEqual(response.content, not_found.content, (code, headers))

member_headers = {'HTTP_AUTHORIZATION': 'Token ' + member.get_token()}
response = self.client.get(validate_url, **member_headers)
self.assertTrue(response.data['parameter'][0]['valueBoolean'])
response = self.client.get(lookup_url.format('/private/url', concept.mnemonic), **member_headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['parameter'][2]['valueString'], concept.display_name)

@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
def test_private_code_system_does_not_hide_public_one_with_same_url(self):
private_source = UserSourceFactory(user=self.user, public_access=ACCESS_TYPE_NONE, canonical_url='/shared/url')
Source.persist_new_version(UserSourceFactory.build(
version='v1', mnemonic=private_source.mnemonic, user=self.user), private_source.created_by)
public_source = OrganizationSourceFactory(canonical_url='/shared/url')
concept = ConceptFactory(parent=public_source)
Source.persist_new_version(OrganizationSourceFactory.build(
version='v1', mnemonic=public_source.mnemonic, organization=public_source.organization),
public_source.created_by)

response = self.client.get(f'/fhir/CodeSystem/$lookup/?system=/shared/url&code={concept.mnemonic}')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['parameter'][0]['valueString'], public_source.mnemonic)

response = self.client.get(f'/fhir/CodeSystem/$validate-code/?url=/shared/url&code={concept.mnemonic}')
self.assertTrue(response.data['parameter'][0]['valueBoolean'])

@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
def test_post_code_system_without_concepts(self):
Expand Down
32 changes: 25 additions & 7 deletions core/code_systems/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging

from django.db.models import F
from rest_framework.exceptions import ValidationError, NotAuthenticated
from rest_framework.exceptions import ValidationError, NotAuthenticated, PermissionDenied

from core.bundles.serializers import FHIRBundleSerializer
from core.code_systems.serializers import CodeSystemDetailSerializer, \
Expand All @@ -18,6 +18,16 @@
logger = logging.getLogger('oclapi')


def get_viewable_repo(repos, user):
"""The first of these repo versions the user can view, skipping the rest as if they didn't exist. Its HEAD,
which governs access to its concepts, must be viewable too."""
for repo in repos.order_by('id'):
head = repo.head
if head and repo.has_view_access(user) and head.has_view_access(user):
return repo
return None


class CodeSystemListView(SourceListView):
serializer_class = CodeSystemDetailSerializer

Expand Down Expand Up @@ -96,14 +106,22 @@ def get_queryset(self):
else:
source = source.filter(is_latest_version=True).exclude(version=HEAD)

source = get_viewable_repo(source, self.request.user)
if source:
queryset = queryset.filter(sources=source.first(), mnemonic=code)
queryset = queryset.filter(sources=source, mnemonic=code)
if not queryset.exists():
raise CodeSystemLookupNotFoundError(code)
return queryset

raise CodeSystemLookupNotFoundError()

def get_object(self, queryset=None):
"""A code the requester can't view gives the same answer as a code system that doesn't exist."""
try:
return super().get_object(queryset)
except (NotAuthenticated, PermissionDenied) as ex:
raise CodeSystemLookupNotFoundError() from ex

def get_serializer(self, instance=None): # pylint: disable=arguments-differ
if instance:
return ParametersSerializer.from_concept(instance)
Expand Down Expand Up @@ -147,19 +165,19 @@ def get_queryset(self):
url = IdentifierSerializer.convert_fhir_url_to_ocl_uri(url, 'sources')
source = Source.objects.filter(uri=url)

if not source:
return queryset.none()

if version:
source = source.filter(version=version)
else:
source = source.filter(is_latest_version=True).exclude(version=HEAD)
source = get_viewable_repo(source, self.request.user)
if not source:
return queryset.none()

if code:
if not source:
return queryset.none()

queryset = queryset.filter(sources=source.first(), mnemonic=code)
queryset = queryset.filter(sources=source, mnemonic=code)

if display and queryset:
instance = queryset.first()
Expand Down Expand Up @@ -187,7 +205,7 @@ def get_object(self, queryset=None):
if instance:
try:
self.check_object_permissions(self.request, instance)
except NotAuthenticated:
except (NotAuthenticated, PermissionDenied):
return {
'parameter': [
{'name': 'result', 'valueBoolean': False}
Expand Down
6 changes: 6 additions & 0 deletions core/collections/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,12 @@ def get_concept_cascade_params(self, system_version=None):
'reverse', 'omit_if_exists_in']:
if attr in self.cascade:
cascade_params[attr] = get(self.cascade, attr)
omit_if_exists_in = cascade_params.get('omit_if_exists_in')
if omit_if_exists_in:
# a repo the reference's creator can't view is ignored, as if the expression didn't resolve
repo, _ = ConceptContainerModel.resolve_expression_to_version(omit_if_exists_in)
if get(repo, 'id') and not repo.has_view_access(self.created_by):
cascade_params.pop('omit_if_exists_in')
map_types = get(self.cascade, 'map_types', None)
exclude_map_types = get(self.cascade, 'exclude_map_types', None)
return_map_types = get(self.cascade, 'return_map_types', None)
Expand Down
Loading
Loading