Skip to content

Commit c23a2ca

Browse files
Merge pull request #536 from bento-platform/refact/scopeable-model-base-class
refact!: define a "scopeable model" base class + modelviewset scoping
2 parents 1d34b81 + 3488176 commit c23a2ca

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+2558
-1955
lines changed

.env-sample

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,4 @@ export POSTGRES_PORT=
1313

1414
# CHORD-specific
1515
export CHORD_URL=
16-
export CHORD_PERMISSIONS=
1716
export SERVICE_ID=
18-
19-
# CanDIG-specific
20-
export INSIDE_CANDIG=true
21-
export CANDIG_AUTHORIZATION=OPA
22-
export CANDIG_OPA_URL=http://0.0.0.0:8181
23-
export CACHE_TIME=0
24-
export ROOT_CA=
25-
export CANDIG_OPA_VERSION=
26-
export PERMISSIONS_SECRET=

README.md

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ A Phenopackets-based clinical and phenotypic metadata service for the Bento plat
2323
- [Standalone PostGres db and AdMiner](#standalone-postgres-db-and-adminer)
2424
- [Authentication](#authentication)
2525
- [Note on Permissions](#note-on-permissions)
26-
- [Authorization inside CanDIG](#authorization-inside-candig)
2726
- [Developing](#developing)
2827
- [Branching](#branching)
2928
- [Tests](#tests)
@@ -153,16 +152,6 @@ POSTGRES_PORT=5432
153152
# CHORD/Bento-specific variables:
154153
# - If set, used for setting an allowed host & other API-calling purposes
155154
CHORD_URL=
156-
# - If true, will enforce permissions. Do not run with this not set to true in production!
157-
# Defaults to (not DEBUG)
158-
CHORD_PERMISSIONS=
159-
160-
# CanDIG-specific variables:
161-
CANDIG_AUTHORIZATION=
162-
CANDIG_OPA_URL=
163-
CANDIG_OPA_SECRET=
164-
CANDIG_OPA_SITE_ADMIN_KEY=
165-
INSIDE_CANDIG=
166155
```
167156

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

226-
### Authorization inside CanDIG
227-
228-
When ran inside the CanDIG context, to properly implement authorization you'll
229-
have to do the following:
230-
231-
1. Make sure the CHORD_PERMISSIONS is set to "false".
232-
2. Set CANDIG_AUTHORIZATION to "OPA".
233-
3. Configure CANDIG_OPA_URL and CANDIG_OPA_SECRET.
234-
235215

236216
## Developing
237217

chord_metadata_service/authz/middleware.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import re
2-
31
from bento_lib.auth.middleware.django import DjangoAuthMiddleware
42
from django.conf import settings
53

@@ -10,32 +8,10 @@
108
"AuthzMiddleware",
119
]
1210

13-
pattern_get = re.compile(r"^GET$")
14-
15-
# --- List of patterns to apply authz middleware to --------------------------------------------------------------------
16-
# - Note: as we gradually roll out authz across Katus, this list will expand. Anything not covered here is assumed to
17-
# be protected by the gateway.
18-
include_pattern_public = (
19-
re.compile(r"^(GET|POST|PUT|DELETE)$"),
20-
re.compile(r"^/api/(projects|datasets|public|public_overview|public_search_fields|public_rules)$"),
21-
)
22-
include_pattern_workflows = (pattern_get, re.compile(r"^(/workflows$|/workflows/)"))
23-
include_pattern_si = (pattern_get, re.compile(r"^/service-info"))
24-
include_pattern_schemas = (pattern_get, re.compile(r"^/schemas/.+$"))
25-
include_pattern_schema_types = (pattern_get, re.compile(r"^/extra_properties_schema_types$"))
26-
# ----------------------------------------------------------------------------------------------------------------------
27-
2811
authz_middleware = DjangoAuthMiddleware(
2912
bento_authz_service_url=settings.BENTO_AUTHZ_SERVICE_URL,
3013
debug_mode=settings.DEBUG,
3114
enabled=settings.BENTO_AUTHZ_ENABLED,
32-
include_request_patterns=(
33-
include_pattern_public,
34-
include_pattern_workflows,
35-
include_pattern_si,
36-
include_pattern_schemas,
37-
include_pattern_schema_types,
38-
),
3915
logger=logger,
4016
)
4117

chord_metadata_service/authz/permissions.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
from django.conf import settings
1+
from asgiref.sync import async_to_sync
22
from rest_framework.permissions import BasePermission, SAFE_METHODS
3+
from rest_framework.request import Request as DrfRequest
4+
5+
from chord_metadata_service.discovery.scopeable_model import BaseScopeableModel
6+
37
from .middleware import authz_middleware
48

59

610
__all__ = [
711
"BentoAllowAny",
812
"BentoAllowAnyReadOnly",
913
"BentoDeferToHandler",
10-
"ReadOnly",
11-
"OverrideOrSuperUserOnly",
14+
"BentoDataTypePermission",
1215
]
1316

1417

@@ -36,13 +39,17 @@ def has_permission(self, _request, _view):
3639
return True # we return true, like AllowAny, but we don't mark authz as done - so we defer it to the handler
3740

3841

39-
class ReadOnly(BasePermission):
40-
def has_permission(self, request, view):
41-
return request.method in SAFE_METHODS
42-
43-
44-
class OverrideOrSuperUserOnly(BasePermission):
45-
def has_permission(self, request, view):
46-
# If in CHORD production, is_superuser will be set by remote user headers.
47-
# TODO: Configuration: Allow configurable read-only APIs or other external access
48-
return settings.AUTH_OVERRIDE or request.user.is_superuser
42+
class BentoDataTypePermission(BasePermission):
43+
@async_to_sync
44+
async def has_permission(self, request: DrfRequest, view) -> bool:
45+
# view: BentoAuthzScopedModelViewSet (cannot annotate due to circular import)
46+
if view.data_type is None:
47+
raise NotImplementedError("BentoAuthzScopedModelViewSet DATA_TYPE must be set")
48+
return await view.request_has_data_type_permissions(request)
49+
50+
@async_to_sync
51+
async def has_object_permission(self, request: DrfRequest, view, obj: BaseScopeableModel):
52+
# view: BentoAuthzScopedModelViewSet (cannot annotate due to circular import)
53+
# if this is called, has_data_type_permission has already been called and handled the overall action type
54+
# TODO: eliminate duplicate scope check somehow without enabling permissions on objects outside of scope
55+
return await view.obj_is_in_request_scope(request, obj)

chord_metadata_service/authz/tests/helpers.py

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,24 @@
1+
import json
2+
13
from aioresponses import aioresponses
24
from bento_lib.auth.types import EvaluationResultMatrix
3-
from rest_framework.test import APITestCase
5+
from rest_framework.test import APITransactionTestCase
46
from typing import Literal
57

68
from ..types import DataPermissionsDict
79

810

911
__all__ = [
10-
"mock_authz_eval_one_result",
11-
"mock_authz_eval_result",
1212
"DTAccessLevel",
1313
"AuthzAPITestCase",
1414
"PermissionsTestCaseMixin",
1515
]
1616

1717

18-
def mock_authz_eval_one_result(m: aioresponses, result: bool):
19-
m.post("http://authz.local/policy/evaluate", payload={"result": [[result]]})
20-
21-
22-
def mock_authz_eval_result(m: aioresponses, result: EvaluationResultMatrix | list[list[bool]]):
23-
m.post("http://authz.local/policy/evaluate", payload={"result": result})
24-
25-
2618
DTAccessLevel = Literal["none", "bool", "counts", "full"]
2719

2820

29-
class AuthzAPITestCase(APITestCase):
21+
class AuthzAPITestCase(APITransactionTestCase):
3022
# data type permissions: bool, counts, data
3123
dt_none_eval_res = [[False, False, False]]
3224
dt_bool_eval_res = [[True, False, False]]
@@ -42,44 +34,65 @@ class AuthzAPITestCase(APITestCase):
4234

4335
# ------------------------------------------------------------------------------------------------------------------
4436

45-
def _one_authz_post(self, authz_res: bool, url: str, *args, **kwargs):
37+
@staticmethod
38+
def mock_authz_eval_one_result(m: aioresponses, result: bool):
39+
m.post("http://authz.local/policy/evaluate", payload={"result": [[result]]})
40+
41+
@staticmethod
42+
def mock_authz_eval_result(m: aioresponses, result: EvaluationResultMatrix | list[list[bool]]):
43+
m.post("http://authz.local/policy/evaluate", payload={"result": result})
44+
45+
# ------------------------------------------------------------------------------------------------------------------
46+
47+
def _one_authz_generic(
48+
self, method: Literal["get", "post", "put", "patch", "delete"], authz_res: bool, url: str, *args, **kwargs
49+
):
50+
if "json" in kwargs:
51+
kwargs["data"] = json.dumps(kwargs["json"])
52+
del kwargs["json"]
53+
54+
if method in ("post", "put", "patch") and "format" not in kwargs:
55+
kwargs["content_type"] = "application/json"
56+
4657
with aioresponses() as m:
47-
mock_authz_eval_one_result(m, authz_res)
48-
return self.client.post(url, *args, content_type="application/json", **kwargs)
58+
self.mock_authz_eval_one_result(m, authz_res)
59+
return getattr(self.client, method)(url, *args, **kwargs)
60+
61+
def _one_authz_get(self, authz_res: bool, url: str, *args, **kwargs):
62+
return self._one_authz_generic("get", authz_res, url, *args, **kwargs)
63+
64+
def one_authz_get(self, url: str, *args, **kwargs):
65+
"""Mocks a single True response from the authorization service and executes a GET request."""
66+
return self._one_authz_get(True, url, *args, **kwargs)
67+
68+
def one_no_authz_get(self, url: str, *args, **kwargs):
69+
"""Mocks a single False response from the authorization service and executes a GET request."""
70+
return self._one_authz_get(False, url, *args, **kwargs)
71+
72+
def _one_authz_post(self, authz_res: bool, url: str, *args, **kwargs):
73+
return self._one_authz_generic("post", authz_res, url, *args, **kwargs)
4974

5075
def one_authz_post(self, url: str, *args, **kwargs):
51-
"""
52-
Mocks a single True response from the authorization service and executes a JSON POST request.
53-
"""
76+
"""Mocks a single True response from the authorization service and executes a JSON POST request."""
5477
return self._one_authz_post(True, url, *args, **kwargs)
5578

5679
def one_no_authz_post(self, url: str, *args, **kwargs):
57-
"""
58-
Mocks a single False response from the authorization service and executes a JSON POST request.
59-
"""
80+
"""Mocks a single False response from the authorization service and executes a JSON POST request."""
6081
return self._one_authz_post(False, url, *args, **kwargs)
6182

6283
def _one_authz_put(self, authz_res: bool, url: str, *args, **kwargs):
63-
with aioresponses() as m:
64-
mock_authz_eval_one_result(m, authz_res)
65-
return self.client.put(url, *args, content_type="application/json", **kwargs)
84+
return self._one_authz_generic("put", authz_res, url, *args, **kwargs)
6685

6786
def one_authz_put(self, url: str, *args, **kwargs):
68-
"""
69-
Mocks a single True response from the authorization service and executes a JSON PUT request.
70-
"""
87+
"""Mocks a single True response from the authorization service and executes a JSON PUT request."""
7188
return self._one_authz_put(True, url, *args, **kwargs)
7289

7390
def one_no_authz_put(self, url: str, *args, **kwargs):
74-
"""
75-
Mocks a single False response from the authorization service and executes a JSON PUT request.
76-
"""
91+
"""Mocks a single False response from the authorization service and executes a JSON PUT request."""
7792
return self._one_authz_put(False, url, *args, **kwargs)
7893

7994
def _one_authz_patch(self, authz_res: bool, url: str, *args, **kwargs):
80-
with aioresponses() as m:
81-
mock_authz_eval_one_result(m, authz_res)
82-
return self.client.patch(url, *args, content_type="application/json", **kwargs)
95+
return self._one_authz_generic("patch", authz_res, url, *args, **kwargs)
8396

8497
def one_authz_patch(self, url: str, *args, **kwargs):
8598
"""
@@ -89,12 +102,12 @@ def one_authz_patch(self, url: str, *args, **kwargs):
89102

90103
def _one_authz_delete(self, authz_res: bool, url: str, *args, **kwargs):
91104
with aioresponses() as m:
92-
mock_authz_eval_one_result(m, authz_res)
105+
self.mock_authz_eval_one_result(m, authz_res)
93106
return self.client.delete(url, *args, **kwargs)
94107

95108
async def _async_one_authz_delete(self, authz_res: bool, url: str, *args, **kwargs):
96109
with aioresponses() as m:
97-
mock_authz_eval_one_result(m, authz_res)
110+
self.mock_authz_eval_one_result(m, authz_res)
98111
return await self.async_client.delete(url, *args, **kwargs)
99112

100113
def one_authz_delete(self, url: str, *args, **kwargs):
@@ -119,12 +132,12 @@ def one_no_authz_delete(self, url: str, *args, **kwargs):
119132

120133
def dt_get(self, level: Literal["none", "bool", "counts", "full"], url: str, *args, **kwargs):
121134
with aioresponses() as m:
122-
mock_authz_eval_result(m, self.dt_levels[level]) # data type permissions: bool, counts, data
135+
self.mock_authz_eval_result(m, self.dt_levels[level]) # data type permissions: bool, counts, data
123136
return self.client.get(url, *args, **kwargs)
124137

125138
def dt_post(self, level: Literal["none", "bool", "counts", "full"], url: str, *args, **kwargs):
126139
with aioresponses() as m:
127-
mock_authz_eval_result(m, self.dt_levels[level]) # data type permissions: bool, counts, data
140+
self.mock_authz_eval_result(m, self.dt_levels[level]) # data type permissions: bool, counts, data
128141
return self.client.post(url, *args, **kwargs)
129142

130143
def dt_authz_none_get(self, url: str, *args, **kwargs):
@@ -136,6 +149,9 @@ def dt_authz_bool_get(self, url: str, *args, **kwargs):
136149
def dt_authz_counts_get(self, url: str, *args, **kwargs):
137150
return self.dt_get("counts", url, *args, **kwargs)
138151

152+
def dt_authz_counts_post(self, url: str, *args, **kwargs):
153+
return self.dt_post("counts", url, *args, **kwargs)
154+
139155
def dt_authz_full_get(self, url: str, *args, **kwargs):
140156
return self.dt_get("full", url, *args, **kwargs)
141157

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import uuid
2+
3+
from aioresponses import aioresponses
4+
from django.http.request import HttpRequest
5+
from rest_framework.request import Request as DrfRequest
6+
7+
from chord_metadata_service.authz.tests.helpers import AuthzAPITestCase
8+
from chord_metadata_service.authz.viewset import BentoAuthzScopedModelGenericListViewSet
9+
from chord_metadata_service.chord.tests.helpers import ProjectTestCase
10+
from chord_metadata_service.discovery.exceptions import DiscoveryScopeException
11+
from chord_metadata_service.phenopackets import models as ph_m
12+
from chord_metadata_service.phenopackets.tests import constants as ph_c
13+
14+
15+
class TestNotImplViewSet(BentoAuthzScopedModelGenericListViewSet):
16+
pass
17+
18+
19+
class AuthzBaseViewsetTest(AuthzAPITestCase, ProjectTestCase):
20+
21+
def setUp(self):
22+
super().setUp()
23+
self.individual = ph_m.Individual.objects.create(**ph_c.VALID_INDIVIDUAL_1)
24+
25+
self.mock_project_req = HttpRequest()
26+
self.mock_project_req.GET["project"] = str(self.project.identifier)
27+
self.mock_project_drf_req = DrfRequest(self.mock_project_req)
28+
29+
def test_get_queryset_not_impl(self):
30+
with self.assertRaises(NotImplementedError):
31+
TestNotImplViewSet().get_queryset()
32+
33+
def test_permission_from_request_none(self):
34+
vs = TestNotImplViewSet()
35+
vs.action = "fubar"
36+
mock_drf_req = DrfRequest(HttpRequest())
37+
self.assertIsNone(vs.permission_from_request(mock_drf_req))
38+
39+
async def test_obj_is_in_request_scope(self):
40+
mock_req = HttpRequest()
41+
mock_req.GET["project"] = "does-not-exist"
42+
mock_drf_req = DrfRequest(mock_req)
43+
44+
with self.assertRaises(DiscoveryScopeException):
45+
await TestNotImplViewSet.obj_is_in_request_scope(mock_drf_req, self.individual)
46+
47+
mock_req_2 = HttpRequest()
48+
mock_req_2.GET["project"] = str(uuid.uuid4())
49+
mock_drf_req_2 = DrfRequest(mock_req_2)
50+
51+
with self.assertRaises(DiscoveryScopeException):
52+
await TestNotImplViewSet.obj_is_in_request_scope(mock_drf_req_2, self.individual)
53+
54+
async def test_request_has_data_type_permissions(self):
55+
vs = TestNotImplViewSet()
56+
vs.action = "list"
57+
with aioresponses() as m:
58+
self.mock_authz_eval_one_result(m, True)
59+
self.assertTrue(await vs.request_has_data_type_permissions(self.mock_project_drf_req, None))
60+
61+
async def test_request_has_data_type_permissions_false(self):
62+
vs = TestNotImplViewSet()
63+
vs.action = "list"
64+
with aioresponses() as m:
65+
self.mock_authz_eval_one_result(m, False)
66+
self.assertFalse(await vs.request_has_data_type_permissions(self.mock_project_drf_req, None))
67+
68+
async def test_request_has_data_type_permissions_action_dne(self):
69+
vs = TestNotImplViewSet()
70+
vs.action = "does-not-exist" # no permissions implemented for this action
71+
self.assertFalse(await vs.request_has_data_type_permissions(self.mock_project_drf_req, None))
72+
73+
async def test_request_has_data_type_permissions_scope_dne(self):
74+
mock_req = HttpRequest()
75+
mock_req.GET["project"] = "does-not-exist"
76+
mock_drf_req = DrfRequest(mock_req)
77+
78+
vs = TestNotImplViewSet()
79+
80+
with self.assertRaises(DiscoveryScopeException):
81+
await vs.request_has_data_type_permissions(mock_drf_req, None)
82+
83+
mock_req_2 = HttpRequest()
84+
mock_req_2.GET["project"] = str(uuid.uuid4())
85+
mock_drf_req_2 = DrfRequest(mock_req_2)
86+
87+
with self.assertRaises(DiscoveryScopeException):
88+
await vs.request_has_data_type_permissions(mock_drf_req_2, None)

0 commit comments

Comments
 (0)