Skip to content

Commit 39e6944

Browse files
committed
T50391: add field scoping
1 parent 0c2f669 commit 39e6944

File tree

6 files changed

+175
-5
lines changed

6 files changed

+175
-5
lines changed

binder/permissions/views.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,65 @@ def get_queryset(self, request):
190190
return self.scope_view(request, queryset)
191191

192192

193+
def get_columns(self, request):
194+
fields, annotations, properties = self.scope_columns(request)
195+
196+
if fields is not None:
197+
fields = list(map(self.model._meta.get_field, fields))
198+
199+
return fields, annotations, properties
200+
201+
202+
def scope_columns(self, request):
203+
"""
204+
Each view scope may optionally declare which columns (fields, annotations, properties)
205+
ought to be exposed to the user. So view scope functions may return a tuple of (rows, columns)
206+
instead of rows only. Columns are specified like so:
207+
{
208+
'fields': ['id', 'name', ...] | None,
209+
'annotations': ['derived_name', 'bsn', ...] | None,
210+
'properties': ['amount', ...] | None,
211+
}
212+
Where 'None' means, that no scoping is being performed on that column type.
213+
If multiple functions with scoped columns exist, we take the set union.
214+
"""
215+
216+
# helper function to take the set union of columns
217+
def append_columns(columns, new_columns):
218+
if new_columns is None:
219+
return columns
220+
if columns is None:
221+
columns = set()
222+
return columns | set(new_columns)
223+
224+
scopes = self._require_model_perm('view', request)
225+
226+
fields = None # this is equivalent to all fields
227+
annotations = None # this is equivalent to all annotations
228+
properties = None # this is equivalent to all properties
229+
230+
for s in scopes:
231+
scope_name = '_scope_view_{}'.format(s)
232+
scope_func = getattr(self, scope_name, None)
233+
if scope_func is None:
234+
raise UnexpectedScopeException(
235+
'Scope {} is not implemented for model {}'.format(scope_name, self.model))
236+
237+
result = scope_func(request)
238+
239+
# ignore scope functions which do not scope columns
240+
# i.e. they do not return a tuple of length two
241+
if isinstance(result, tuple):
242+
if len(result) < 2:
243+
continue
244+
245+
columns = result[1]
246+
fields = append_columns(fields, columns['fields'])
247+
annotations = append_columns(annotations, columns['annotations'])
248+
properties = append_columns(properties, columns['properties'])
249+
250+
return fields, annotations, properties
251+
193252

194253
def _require_model_perm(self, perm_type, request, pk=None):
195254
"""
@@ -420,6 +479,13 @@ def scope_view(self, request, queryset):
420479
raise UnexpectedScopeException(
421480
'Scope {} is not implemented for model {}'.format(scope_name, self.model))
422481
query_or_q = scope_func(request)
482+
483+
# view scoping may describe scoping of columns. In this case
484+
# the return type is a tuple and we only have to consider the
485+
# first argument
486+
if isinstance(query_or_q, tuple):
487+
query_or_q = query_or_q[0]
488+
423489
# Allow either a ORM filter query manager or a Q object.
424490
# Q objects generate more efficient queries (so we don't
425491
# get an "id IN (subquery)"), but query managers allow

binder/views.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -615,14 +615,26 @@ def _get_objs(self, queryset, request, annotations=None, to_annotate={}):
615615
datas_by_id = {} # Save datas so we can annotate m2m fields later (avoiding a query)
616616
objs_by_id = {} # Same for original objects
617617

618+
# get scoped fields, properties and annotations
619+
fields_scoped, annotations_scoped, properties_scoped = self.get_columns(request)
620+
621+
if fields_scoped is None:
622+
fields_scoped = list(self.model._meta.fields)
623+
618624
# Serialize the objects!
619625
if self.shown_fields is None:
620-
fields = [f for f in self.model._meta.fields if f.name not in self.hidden_fields]
626+
fields = [f for f in fields_scoped if f.name not in self.hidden_fields]
621627
else:
622-
fields = [f for f in self.model._meta.fields if f.name in self.shown_fields]
628+
fields = [f for f in fields_scoped if f.name in self.shown_fields]
623629

624630
if annotations is None:
625631
annotations = set(self.annotations(request))
632+
633+
# from the set of annotations remove the ones which are
634+
# hidden by scoping.
635+
if annotations_scoped is not None:
636+
annotations &= annotations_scoped
637+
626638
if self.shown_annotations is None:
627639
annotations -= set(self.hidden_annotations)
628640
else:
@@ -706,7 +718,10 @@ def _get_objs(self, queryset, request, annotations=None, to_annotate={}):
706718
if a not in to_annotate:
707719
data[a] = getattr(obj, a)
708720

709-
for prop in self.shown_properties:
721+
if properties_scoped is None:
722+
properties_scoped = self.shown_properties
723+
724+
for prop in properties_scoped:
710725
data[prop] = getattr(obj, prop)
711726

712727
if self.model._meta.pk.name in data:
@@ -733,6 +748,10 @@ def _get_objs(self, queryset, request, annotations=None, to_annotate={}):
733748
return datas
734749

735750

751+
def get_columns(self, request):
752+
return None, None, None
753+
754+
736755
def _annotate_objs(self, datas_by_id, objs_by_id):
737756
pks = datas_by_id.keys()
738757

docs/permissions.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,32 @@ Option 3 mainly exists out of historic reasons, this will generate a subquery
5454
and thus often leads to performance issues. Thus it is advised to use option 1
5555
or 2 whenever possible.
5656

57+
#### Scoping individual fields
58+
It is also possible to return a **tuple** in a view scope.
59+
For instance, you could return `some_queryset, columns`.
60+
The first value of the tuple must be a `Q`, `FilterDescription`, or queryset,
61+
just like explained above.
62+
The *second* value should be a dict that looks like this:
63+
```
64+
{
65+
'fields': ['id', 'floor_plan'],
66+
'properties': ['another_animal_count'],
67+
'annotations': ['another_zoo_name'],
68+
}
69+
```
70+
This allows you to restrict the fields, properties, and/or annotations that
71+
a user can view. The user can see *only these* fields.
72+
You can also replace any of the arrays with `None`, which means that the
73+
user can see everything. Consider the following example dict:
74+
```
75+
{
76+
'fields': None,
77+
'properties': [],
78+
'annotations': [],
79+
}
80+
```
81+
If you use this, the user can see every field, but none of the properties or annotations.
82+
5783
### Add/Change/Delete scopes
5884
Add, change and delete scopes all work the same. They receive 3 arguments:
5985
`request`, `object` and `values`. And should return a boolean indicating if the
@@ -76,8 +102,8 @@ Change scoping:
76102
- view.store(obj, fields, request)
77103

78104
## @no_scoping_required()
79-
In some cases you might not need the automated scoping. An example might be when your endpoint does not make any
80-
changes to the data-model but simply triggers an event or if you have already implemented custom scoping. In that
105+
In some cases you might not need the automated scoping. An example might be when your endpoint does not make any
106+
changes to the data-model but simply triggers an event or if you have already implemented custom scoping. In that
81107
case there is the option of adding `@no_scoping_required()` before the endpoint, which will ignore the scoping checks for the endpoint.
82108

83109
```python

tests/test_permission_view.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,3 +861,45 @@ def test_for_update_bug_not_occurs_on_put(self):
861861
print(res.json())
862862

863863
self.assertEqual(200, res.status_code)
864+
865+
class TestColumnScoping(TestCase):
866+
def setUp(self):
867+
super().setUp()
868+
869+
u = User(username='testuser_for_not_all_fields', is_active=True, is_superuser=False)
870+
u.set_password('test')
871+
u.save()
872+
873+
self.client = Client()
874+
r = self.client.login(username='testuser_for_not_all_fields', password='test')
875+
self.assertTrue(r)
876+
877+
self.zoo: Zoo = Zoo.objects.create(name='Artis')
878+
879+
880+
def test_column_scoping_excludes_columns(self):
881+
res = self.client.get('/zoo/{}/'.format(self.zoo.id))
882+
self.assertEqual(res.status_code, 200)
883+
884+
columns = jsonloads(res.content)['data'].keys()
885+
886+
for field in ['name', 'founding_date', 'django_picture']:
887+
self.zoo._meta.get_field(field) # check if those fields exist, otherwise throw error
888+
self.assertFalse(field in columns)
889+
890+
self.assertFalse('zoo_name' in columns)
891+
self.assertFalse('animal_count' in columns)
892+
893+
894+
def test_column_scoping_includes_columns(self):
895+
res = self.client.get('/zoo/{}/'.format(self.zoo.id))
896+
self.assertEqual(res.status_code, 200)
897+
898+
columns = jsonloads(res.content)['data'].keys()
899+
900+
for field in ['id', 'floor_plan']:
901+
self.zoo._meta.get_field(field)
902+
self.assertTrue(field in columns)
903+
904+
self.assertTrue('another_zoo_name' in columns)
905+
self.assertTrue('another_animal_count' in columns)

tests/testapp/models/zoo.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ def __str__(self):
5252
def animal_count(self):
5353
return self.animals.count()
5454

55+
@property # simple alias for testing scoping on properties
56+
def another_animal_count(self):
57+
return self.animals.count()
5558

5659
def clean(self):
5760
if self.name == 'very_special_forbidden_zoo_name':
@@ -69,5 +72,8 @@ def clean(self):
6972
if errors:
7073
raise ValidationError(errors)
7174

75+
class Annotations:
76+
zoo_name = models.F('name') # simple alias for testing scoping on annotations
77+
another_zoo_name = models.F('name') # simple alias for testing scoping on annotations
7278

7379
post_delete.connect(delete_files, sender=Zoo)

tests/testapp/views/zoo.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ def _require_model_perm(self, perm_type, request, pk=None):
4040
return ['bad_q_filter']
4141
elif perm_type == 'view' and request.user.username == 'testuser_for_good_q_filter':
4242
return ['good_q_filter']
43+
elif perm_type == 'view' and request.user.username == 'testuser_for_not_all_fields':
44+
return ['not_all_fields']
4345
else:
4446
model = self.perms_via if hasattr(self, 'perms_via') else self.model
4547
perm = '{}.{}_{}'.format(model._meta.app_label, perm_type, model.__name__.lower())
@@ -57,3 +59,12 @@ def _scope_view_bad_q_filter(self, request):
5759

5860
def _scope_view_good_q_filter(self, request):
5961
return FilterDescription(Q(animals__id__in=Animal.objects.all()), True)
62+
63+
def _scope_view_not_all_fields(self, request):
64+
# expose only certain columns
65+
columns = {
66+
'fields': ['id', 'floor_plan'],
67+
'properties': ['another_animal_count'],
68+
'annotations': ['another_zoo_name'],
69+
}
70+
return Zoo.objects.all(), columns

0 commit comments

Comments
 (0)