Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refact!: define a "scopeable model" base class + modelviewset scoping #536

Merged
merged 79 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 69 commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
09f4cfc
refact: define a "scopeable model" base class + modelviewset scoping
davidlougheed Sep 10, 2024
5cac2ff
refact: define common top-level (data type model) scope filters
davidlougheed Sep 10, 2024
1fd3ea6
Merge remote-tracking branch 'origin/develop' into refact/scopeable-m…
davidlougheed Sep 11, 2024
8bcff91
refact(discovery): constant for instance ValidatedDiscoveryScope
davidlougheed Sep 16, 2024
843cabf
chore(discovery): allow specifying data type for authz repr of scope
davidlougheed Sep 16, 2024
a60a418
refact: start working on auth systems for model viewsets
davidlougheed Sep 17, 2024
827bd4f
chore: remove authorized dataset filters (CanDIG legacy code)
davidlougheed Sep 17, 2024
c811bbd
fix: proper permissions for schema endpoints
davidlougheed Sep 17, 2024
65cc1f7
more work on scoped models
davidlougheed Sep 27, 2024
275524a
Merge remote-tracking branch 'origin/develop' into refact/scopeable-m…
davidlougheed Oct 31, 2024
c07b471
test: work on tests
davidlougheed Nov 1, 2024
cc1235e
fix(phenopackets): issues with biosample batch / csv rendering
davidlougheed Nov 1, 2024
43f0c3f
chore(patients): enable authz for individuals API
davidlougheed Nov 1, 2024
0368ad7
more authz functionality impl
davidlougheed Nov 1, 2024
a21afde
fix!: only enable GET/POST list endpoints (biosample batch viewset)
davidlougheed Nov 1, 2024
1d884dd
test: individual phenopackets responses
davidlougheed Nov 1, 2024
8af3867
refact: authz fn name change
davidlougheed Nov 1, 2024
d435acd
refact(restapi): create base class for Bento CSV renderers
davidlougheed Nov 1, 2024
3b3b991
chore: allow override of checked permission for POST-query-data reqs
davidlougheed Nov 1, 2024
7068623
chore: cache scope for repeated request scope gets
davidlougheed Nov 1, 2024
f5dc770
test: fix forbidden phenopackets attachment test
davidlougheed Nov 4, 2024
a90b779
refact: create authz base modelviewset
davidlougheed Nov 11, 2024
44f1031
add TODOs
davidlougheed Nov 11, 2024
44427c1
Merge remote-tracking branch 'origin/develop' into refact/scopeable-m…
davidlougheed Nov 27, 2024
721c9c9
lint
davidlougheed Nov 27, 2024
6a0c306
test(experiments): fix wrong method for experiment batch tests
davidlougheed Nov 27, 2024
e04f552
test: fix test helper
davidlougheed Nov 27, 2024
91044a7
Merge remote-tracking branch 'origin/develop' into refact/scopeable-m…
davidlougheed Dec 13, 2024
c6d2741
chore: rm old candig stuff
davidlougheed Dec 13, 2024
d9c2c53
cleanup
davidlougheed Dec 13, 2024
24f1f70
cleanup
davidlougheed Dec 13, 2024
0b0534e
refact!: move rest of app to authz + rm more unused
davidlougheed Dec 16, 2024
5b814c8
test: search overview forbidden
davidlougheed Dec 16, 2024
389bb9e
test(chord): project JSON schema update/delete tests
davidlougheed Dec 19, 2024
412573c
chore: remove unused CHORD_PERMISSIONS env var
davidlougheed Dec 19, 2024
439f680
refact(discovery): rm unused ScopeableModel method + docstrings
davidlougheed Dec 19, 2024
1410f7f
chore(deps): update lockfile
davidlougheed Dec 19, 2024
71974d9
chore(chord): clean up useless project view override
davidlougheed Dec 19, 2024
3a319e8
fix(chord): search view missing mark authz done
davidlougheed Dec 19, 2024
a31966c
test: search API tests + project list test
davidlougheed Dec 19, 2024
084fccf
fix: individuals scoping
davidlougheed Dec 20, 2024
bfac5fb
test: individuals list scoping
davidlougheed Dec 20, 2024
5945ce5
fix(experiments): typos
davidlougheed Jan 7, 2025
916690a
chore(authz): comment purpose of BentoAuthzModelViewSet
davidlougheed Jan 7, 2025
fd7a59e
chore(discovery): implement scope filter OR system
davidlougheed Jan 7, 2025
6d6ad60
chore(phenopackets): rm unused scope filters for MetaData
davidlougheed Jan 7, 2025
d03722c
refact: scope all authz viewsets incl. resources
davidlougheed Jan 7, 2025
76a3cda
fix(resources): bad model scope filters
davidlougheed Jan 8, 2025
2526eff
fix(authz): handle scope 404 errors in viewset
davidlougheed Jan 8, 2025
c6c39c2
test(resources): add some resource list testing
davidlougheed Jan 8, 2025
e058fe9
test(chord): cbio export forbidden test
davidlougheed Jan 8, 2025
d30a9a3
chore(deps): update lockfile
davidlougheed Jan 8, 2025
eda7daa
fix(resources): bad prefetch_related for scoping
davidlougheed Jan 8, 2025
1df59cd
test(resources): resource list scoping
davidlougheed Jan 8, 2025
72b12cb
lint: clean up chord tests helpers
davidlougheed Jan 8, 2025
cdfc25d
fix(resources): more bad scope prefetches
davidlougheed Jan 8, 2025
bf45ce6
test(resources): more tests of resource scoping
davidlougheed Jan 8, 2025
ae27873
chore!: remove to-be-unused /overview endpoint
davidlougheed Jan 9, 2025
0f3c2d2
Merge pull request #557 from bento-platform/chore/rm-overview
davidlougheed Jan 9, 2025
42ad69a
test(experiments): scoping tests for /api/experiments
davidlougheed Jan 9, 2025
b3c1d46
fix(experiments): bad prefetch for exp result scoping
davidlougheed Jan 9, 2025
cd42615
test(experiments): experiment result scoping tests
davidlougheed Jan 9, 2025
b2f2dc1
refact(chord): async queryset dataset id grouping + comment
davidlougheed Jan 9, 2025
673ca39
chore(deps): update lockfile
davidlougheed Jan 9, 2025
4846699
chore(authz): finish docstring
davidlougheed Jan 9, 2025
bf223b8
refact: include list mixin with generic scoped model viewset
davidlougheed Jan 9, 2025
0a3eaa0
refact: rm unused candig code
davidlougheed Jan 9, 2025
4619d1f
chore: log public individuals search + queried fields
davidlougheed Jan 9, 2025
a764f8a
chore(authz): error log if permission_from_request is unimpl
davidlougheed Jan 10, 2025
ca27822
chore(deps): update lockfile
davidlougheed Jan 15, 2025
e428a90
chore(authz): add get_queryset not impl to override + test
davidlougheed Jan 15, 2025
637b40d
chore: commenting for batch API viewsets
davidlougheed Jan 15, 2025
4902a9c
chore: cleaner individual model scope filters + tests
davidlougheed Jan 15, 2025
68d4af9
chore: make permission_classes explicit for search overview
davidlougheed Jan 15, 2025
95e0b72
fix(patients): model scope filters
davidlougheed Jan 15, 2025
6fe09af
test(authz): test obj_is_in_request_scope exception branch
davidlougheed Jan 15, 2025
9c1afc2
test(authz): request_has_data_type_permissions tests
davidlougheed Jan 15, 2025
c91d084
refact!: handle scope exceptions with DRF exc handler
davidlougheed Jan 15, 2025
3488176
lint
davidlougheed Jan 15, 2025
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
10 changes: 0 additions & 10 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,4 @@ export POSTGRES_PORT=

# CHORD-specific
export CHORD_URL=
export CHORD_PERMISSIONS=
export SERVICE_ID=

# CanDIG-specific
export INSIDE_CANDIG=true
export CANDIG_AUTHORIZATION=OPA
export CANDIG_OPA_URL=http://0.0.0.0:8181
export CACHE_TIME=0
export ROOT_CA=
export CANDIG_OPA_VERSION=
export PERMISSIONS_SECRET=
20 changes: 0 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ A Phenopackets-based clinical and phenotypic metadata service for the Bento plat
- [Standalone PostGres db and AdMiner](#standalone-postgres-db-and-adminer)
- [Authentication](#authentication)
- [Note on Permissions](#note-on-permissions)
- [Authorization inside CanDIG](#authorization-inside-candig)
- [Developing](#developing)
- [Branching](#branching)
- [Tests](#tests)
Expand Down Expand Up @@ -153,16 +152,6 @@ POSTGRES_PORT=5432
# CHORD/Bento-specific variables:
# - If set, used for setting an allowed host & other API-calling purposes
CHORD_URL=
# - If true, will enforce permissions. Do not run with this not set to true in production!
# Defaults to (not DEBUG)
CHORD_PERMISSIONS=

# CanDIG-specific variables:
CANDIG_AUTHORIZATION=
CANDIG_OPA_URL=
CANDIG_OPA_SECRET=
CANDIG_OPA_SITE_ADMIN_KEY=
INSIDE_CANDIG=
```

## Standalone Postgres db and Adminer
Expand Down Expand Up @@ -223,15 +212,6 @@ functions as follows:
This can be turned off with the `CHORD_PERMISSIONS` environment variable and/or
Django setting, or with the `AUTH_OVERRIDE` Django setting.

### Authorization inside CanDIG

When ran inside the CanDIG context, to properly implement authorization you'll
have to do the following:

1. Make sure the CHORD_PERMISSIONS is set to "false".
2. Set CANDIG_AUTHORIZATION to "OPA".
3. Configure CANDIG_OPA_URL and CANDIG_OPA_SECRET.


## Developing

Expand Down
24 changes: 0 additions & 24 deletions chord_metadata_service/authz/middleware.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import re

from bento_lib.auth.middleware.django import DjangoAuthMiddleware
from django.conf import settings

Expand All @@ -10,32 +8,10 @@
"AuthzMiddleware",
]

pattern_get = re.compile(r"^GET$")

# --- List of patterns to apply authz middleware to --------------------------------------------------------------------
# - Note: as we gradually roll out authz across Katus, this list will expand. Anything not covered here is assumed to
# be protected by the gateway.
include_pattern_public = (
re.compile(r"^(GET|POST|PUT|DELETE)$"),
re.compile(r"^/api/(projects|datasets|public|public_overview|public_search_fields|public_rules)$"),
)
include_pattern_workflows = (pattern_get, re.compile(r"^(/workflows$|/workflows/)"))
include_pattern_si = (pattern_get, re.compile(r"^/service-info"))
include_pattern_schemas = (pattern_get, re.compile(r"^/schemas/.+$"))
include_pattern_schema_types = (pattern_get, re.compile(r"^/extra_properties_schema_types$"))
# ----------------------------------------------------------------------------------------------------------------------

authz_middleware = DjangoAuthMiddleware(
bento_authz_service_url=settings.BENTO_AUTHZ_SERVICE_URL,
debug_mode=settings.DEBUG,
enabled=settings.BENTO_AUTHZ_ENABLED,
include_request_patterns=(
include_pattern_public,
include_pattern_workflows,
include_pattern_si,
include_pattern_schemas,
include_pattern_schema_types,
),
logger=logger,
)

Expand Down
33 changes: 20 additions & 13 deletions chord_metadata_service/authz/permissions.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from django.conf import settings
from asgiref.sync import async_to_sync
from rest_framework.permissions import BasePermission, SAFE_METHODS
from rest_framework.request import Request as DrfRequest

from chord_metadata_service.discovery.scopeable_model import BaseScopeableModel

from .middleware import authz_middleware


__all__ = [
"BentoAllowAny",
"BentoAllowAnyReadOnly",
"BentoDeferToHandler",
"ReadOnly",
"OverrideOrSuperUserOnly",
"BentoDataTypePermission",
]


Expand Down Expand Up @@ -36,13 +39,17 @@
return True # we return true, like AllowAny, but we don't mark authz as done - so we defer it to the handler


class ReadOnly(BasePermission):
def has_permission(self, request, view):
return request.method in SAFE_METHODS


class OverrideOrSuperUserOnly(BasePermission):
def has_permission(self, request, view):
# If in CHORD production, is_superuser will be set by remote user headers.
# TODO: Configuration: Allow configurable read-only APIs or other external access
return settings.AUTH_OVERRIDE or request.user.is_superuser
class BentoDataTypePermission(BasePermission):
@async_to_sync
async def has_permission(self, request: DrfRequest, view) -> bool:
# view: BentoAuthzScopedModelViewSet (cannot annotate due to circular import)
if view.data_type is None:
raise NotImplementedError("BentoAuthzScopedModelViewSet DATA_TYPE must be set")

Check warning on line 47 in chord_metadata_service/authz/permissions.py

View check run for this annotation

Codecov / codecov/patch

chord_metadata_service/authz/permissions.py#L47

Added line #L47 was not covered by tests
return await view.request_has_data_type_permissions(request)

@async_to_sync
async def has_object_permission(self, request: DrfRequest, view, obj: BaseScopeableModel):
# view: BentoAuthzScopedModelViewSet (cannot annotate due to circular import)
# if this is called, has_data_type_permission has already been called and handled the overall action type
# TODO: eliminate duplicate scope check somehow without enabling permissions on objects outside of scope
return await view.obj_is_in_request_scope(request, obj)
60 changes: 38 additions & 22 deletions chord_metadata_service/authz/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json

from aioresponses import aioresponses
from bento_lib.auth.types import EvaluationResultMatrix
from rest_framework.test import APITestCase
from rest_framework.test import APITransactionTestCase
from typing import Literal

from ..types import DataPermissionsDict
Expand All @@ -26,7 +28,7 @@ def mock_authz_eval_result(m: aioresponses, result: EvaluationResultMatrix | lis
DTAccessLevel = Literal["none", "bool", "counts", "full"]


class AuthzAPITestCase(APITestCase):
class AuthzAPITestCase(APITransactionTestCase):
# data type permissions: bool, counts, data
dt_none_eval_res = [[False, False, False]]
dt_bool_eval_res = [[True, False, False]]
Expand All @@ -42,44 +44,55 @@ class AuthzAPITestCase(APITestCase):

# ------------------------------------------------------------------------------------------------------------------

def _one_authz_post(self, authz_res: bool, url: str, *args, **kwargs):
def _one_authz_generic(
self, method: Literal["get", "post", "put", "patch", "delete"], authz_res: bool, url: str, *args, **kwargs
):
if "json" in kwargs:
kwargs["data"] = json.dumps(kwargs["json"])
del kwargs["json"]

if method in ("post", "put", "patch") and "format" not in kwargs:
kwargs["content_type"] = "application/json"

with aioresponses() as m:
mock_authz_eval_one_result(m, authz_res)
return self.client.post(url, *args, content_type="application/json", **kwargs)
return getattr(self.client, method)(url, *args, **kwargs)

def _one_authz_get(self, authz_res: bool, url: str, *args, **kwargs):
return self._one_authz_generic("get", authz_res, url, *args, **kwargs)

def one_authz_get(self, url: str, *args, **kwargs):
"""Mocks a single True response from the authorization service and executes a GET request."""
return self._one_authz_get(True, url, *args, **kwargs)

def one_no_authz_get(self, url: str, *args, **kwargs):
"""Mocks a single False response from the authorization service and executes a GET request."""
return self._one_authz_get(False, url, *args, **kwargs)

def _one_authz_post(self, authz_res: bool, url: str, *args, **kwargs):
return self._one_authz_generic("post", authz_res, url, *args, **kwargs)

def one_authz_post(self, url: str, *args, **kwargs):
"""
Mocks a single True response from the authorization service and executes a JSON POST request.
"""
"""Mocks a single True response from the authorization service and executes a JSON POST request."""
return self._one_authz_post(True, url, *args, **kwargs)

def one_no_authz_post(self, url: str, *args, **kwargs):
"""
Mocks a single False response from the authorization service and executes a JSON POST request.
"""
"""Mocks a single False response from the authorization service and executes a JSON POST request."""
return self._one_authz_post(False, url, *args, **kwargs)

def _one_authz_put(self, authz_res: bool, url: str, *args, **kwargs):
with aioresponses() as m:
mock_authz_eval_one_result(m, authz_res)
return self.client.put(url, *args, content_type="application/json", **kwargs)
return self._one_authz_generic("put", authz_res, url, *args, **kwargs)

def one_authz_put(self, url: str, *args, **kwargs):
"""
Mocks a single True response from the authorization service and executes a JSON PUT request.
"""
"""Mocks a single True response from the authorization service and executes a JSON PUT request."""
return self._one_authz_put(True, url, *args, **kwargs)

def one_no_authz_put(self, url: str, *args, **kwargs):
"""
Mocks a single False response from the authorization service and executes a JSON PUT request.
"""
"""Mocks a single False response from the authorization service and executes a JSON PUT request."""
return self._one_authz_put(False, url, *args, **kwargs)

def _one_authz_patch(self, authz_res: bool, url: str, *args, **kwargs):
with aioresponses() as m:
mock_authz_eval_one_result(m, authz_res)
return self.client.patch(url, *args, content_type="application/json", **kwargs)
return self._one_authz_generic("patch", authz_res, url, *args, **kwargs)

def one_authz_patch(self, url: str, *args, **kwargs):
"""
Expand Down Expand Up @@ -136,6 +149,9 @@ def dt_authz_bool_get(self, url: str, *args, **kwargs):
def dt_authz_counts_get(self, url: str, *args, **kwargs):
return self.dt_get("counts", url, *args, **kwargs)

def dt_authz_counts_post(self, url: str, *args, **kwargs):
return self.dt_post("counts", url, *args, **kwargs)

def dt_authz_full_get(self, url: str, *args, **kwargs):
return self.dt_get("full", url, *args, **kwargs)

Expand Down
79 changes: 79 additions & 0 deletions chord_metadata_service/authz/viewset.py
davidlougheed marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from bento_lib.auth.permissions import P_QUERY_DATA, Permission, P_INGEST_DATA, P_DELETE_DATA
from rest_framework import mixins, viewsets
from rest_framework.request import Request as DrfRequest

from chord_metadata_service.discovery.exceptions import DiscoveryScopeException
from chord_metadata_service.discovery.scope import get_request_discovery_scope, ValidatedDiscoveryScope
from chord_metadata_service.discovery.scopeable_model import BaseScopeableModel
from chord_metadata_service.logger import logger

from .middleware import authz_middleware
from .permissions import BentoDataTypePermission

__all__ = [
"BentoAuthzScopedModelGenericListViewSet",
"BentoAuthzScopedModelViewSet",
]


class BentoAuthzScopedModelGenericListViewSet(viewsets.GenericViewSet, mixins.ListModelMixin):
"""
An extension of the DRF generic viewset which adds utility functions for Bento Django permissions classes.
These work together to properly implement scoped Bento permissions based on the request being made.

<!!!>
Security note: Subclasses MUST implement a get_queryset(...) which returns a model-scoped, request-based queryset!
</!!!>
davidlougheed marked this conversation as resolved.
Show resolved Hide resolved
"""

data_type: str | None = None
permission_classes = (BentoDataTypePermission,)

@staticmethod
async def obj_is_in_request_scope(request: DrfRequest, obj: BaseScopeableModel) -> bool:
try:
return await obj.scope_contains_object(await get_request_discovery_scope(request))
except DiscoveryScopeException: # project/dataset does not exist, or non-UUID request for a project/dataset
return False

Check warning on line 37 in chord_metadata_service/authz/viewset.py

View check run for this annotation

Codecov / codecov/patch

chord_metadata_service/authz/viewset.py#L36-L37

Added lines #L36 - L37 were not covered by tests

def permission_from_request(self, request: DrfRequest) -> Permission | None:
if self.action in ("list", "retrieve"):
return P_QUERY_DATA
elif self.action in ("create", "update"):
return P_INGEST_DATA
elif self.action == "destroy":
return P_DELETE_DATA
else:
logger.error("viewset permission_from_request(...) is not implemented for action: %s", self.action)
return None

Check warning on line 48 in chord_metadata_service/authz/viewset.py

View check run for this annotation

Codecov / codecov/patch

chord_metadata_service/authz/viewset.py#L47-L48

Added lines #L47 - L48 were not covered by tests

async def request_has_data_type_permissions(
self, request: DrfRequest, scope: ValidatedDiscoveryScope | None = None
):
try:
_scope: ValidatedDiscoveryScope = scope or await get_request_discovery_scope(request)
except DiscoveryScopeException: # project/dataset does not exist, or non-UUID request for a project/dataset
return False

p: Permission | None = self.permission_from_request(request)
if p is None:
return False

Check warning on line 60 in chord_metadata_service/authz/viewset.py

View check run for this annotation

Codecov / codecov/patch

chord_metadata_service/authz/viewset.py#L60

Added line #L60 was not covered by tests

return await authz_middleware.async_evaluate_one(
request, _scope.as_authz_resource(data_type=self.data_type), p, mark_authz_done=True
)


class BentoAuthzScopedModelViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
BentoAuthzScopedModelGenericListViewSet
):
"""
This class is equivalent to the DRF viewsets.ModelViewSet class, except with our BentoAuthzModelGenericViewSet
replacing the base viewsets.GenericViewSet. In this way, we get all the scoping / permissions helper functions.
Security note: Subclasses MUST implement a get_queryset(...) which returns a model-scoped queryset!
"""
pass
Loading
Loading