Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
68 changes: 68 additions & 0 deletions binder/permissions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,65 @@ def get_queryset(self, request):
return self.scope_view(request, queryset)


def get_columns(self, request):
fields, annotations, properties = self.scope_columns(request)

if fields is not None:
fields = list(map(self.model._meta.get_field, fields))

return fields, annotations, properties


def scope_columns(self, request):
"""
Each view scope may optionally declare which columns (fields, annotations, properties)
ought to be exposed to the user. So view scope functions may return a tuple of (rows, columns)
instead of rows only. Columns are specified like so:
{
'fields': ['id', 'name', ...] | None,
'annotations': ['derived_name', 'bsn', ...] | None,
'properties': ['amount', ...] | None,
}
Where 'None' means, that no scoping is being performed on that column type.
If multiple functions with scoped columns exist, we take the set union.
"""

# helper function to take the set union of columns
def append_columns(columns, new_columns):
if new_columns is None:
return columns
if columns is None:
columns = set()
return columns | set(new_columns)

scopes = self._require_model_perm('view', request)

fields = None # this is equivalent to all fields
annotations = None # this is equivalent to all annotations
properties = None # this is equivalent to all properties

for s in scopes:
scope_name = '_scope_view_{}'.format(s)
scope_func = getattr(self, scope_name, None)
if scope_func is None:
raise UnexpectedScopeException(
'Scope {} is not implemented for model {}'.format(scope_name, self.model))

result = scope_func(request)

# ignore scope functions which do not scope columns
# i.e. they do not return a tuple of length two
if isinstance(result, tuple):
if len(result) < 2:
continue

columns = result[1]
fields = append_columns(fields, columns['fields'])
annotations = append_columns(annotations, columns['annotations'])
properties = append_columns(properties, columns['properties'])

return fields, annotations, properties


def _require_model_perm(self, perm_type, request, pk=None):
"""
Expand Down Expand Up @@ -420,6 +479,15 @@ def scope_view(self, request, queryset):
raise UnexpectedScopeException(
'Scope {} is not implemented for model {}'.format(scope_name, self.model))
query_or_q = scope_func(request)

# view scoping may describe scoping of columns. In this case
# the return type is a tuple and we only have to consider the
# first argument
if isinstance(query_or_q, tuple) and isinstance(query_or_q[-1], dict):
potential_columns: dict = query_or_q[-1]
if 'fields' in potential_columns and 'properties' in potential_columns and 'annotations' in potential_columns:
query_or_q = query_or_q[0]

# Allow either a ORM filter query manager or a Q object.
# Q objects generate more efficient queries (so we don't
# get an "id IN (subquery)"), but query managers allow
Expand Down
25 changes: 22 additions & 3 deletions binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,14 +615,26 @@ def _get_objs(self, queryset, request, annotations=None, to_annotate={}):
datas_by_id = {} # Save datas so we can annotate m2m fields later (avoiding a query)
objs_by_id = {} # Same for original objects

# get scoped fields, properties and annotations
fields_scoped, annotations_scoped, properties_scoped = self.get_columns(request)

if fields_scoped is None:
fields_scoped = list(self.model._meta.fields)

# Serialize the objects!
if self.shown_fields is None:
fields = [f for f in self.model._meta.fields if f.name not in self.hidden_fields]
fields = [f for f in fields_scoped if f.name not in self.hidden_fields]
else:
fields = [f for f in self.model._meta.fields if f.name in self.shown_fields]
fields = [f for f in fields_scoped if f.name in self.shown_fields]

if annotations is None:
annotations = set(self.annotations(request))

# from the set of annotations remove the ones which are
# hidden by scoping.
if annotations_scoped is not None:
annotations &= annotations_scoped

if self.shown_annotations is None:
annotations -= set(self.hidden_annotations)
else:
Expand Down Expand Up @@ -706,7 +718,10 @@ def _get_objs(self, queryset, request, annotations=None, to_annotate={}):
if a not in to_annotate:
data[a] = getattr(obj, a)

for prop in self.shown_properties:
if properties_scoped is None:
properties_scoped = self.shown_properties

for prop in properties_scoped:
data[prop] = getattr(obj, prop)

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


def get_columns(self, request):
return None, None, None


def _annotate_objs(self, datas_by_id, objs_by_id):
pks = datas_by_id.keys()

Expand Down
30 changes: 28 additions & 2 deletions docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,32 @@ Option 3 mainly exists out of historic reasons, this will generate a subquery
and thus often leads to performance issues. Thus it is advised to use option 1
or 2 whenever possible.

#### Scoping individual fields
It is also possible to return a **tuple** in a view scope.
For instance, you could return `some_queryset, columns`.
The first value of the tuple must be a `Q`, `FilterDescription`, or queryset,
just like explained above.
The *second* value should be a dict that looks like this:
```
{
'fields': ['id', 'floor_plan'],
'properties': ['another_animal_count'],
'annotations': ['another_zoo_name'],
}
```
This allows you to restrict the fields, properties, and/or annotations that
a user can view. The user can see *only these* fields.
You can also replace any of the arrays with `None`, which means that the
user can see everything. Consider the following example dict:
```
{
'fields': None,
'properties': [],
'annotations': [],
}
```
If you use this, the user can see every field, but none of the properties or annotations.

### Add/Change/Delete scopes
Add, change and delete scopes all work the same. They receive 3 arguments:
`request`, `object` and `values`. And should return a boolean indicating if the
Expand All @@ -76,8 +102,8 @@ Change scoping:
- view.store(obj, fields, request)

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

```python
Expand Down
42 changes: 42 additions & 0 deletions tests/test_permission_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -861,3 +861,45 @@ def test_for_update_bug_not_occurs_on_put(self):
print(res.json())

self.assertEqual(200, res.status_code)

class TestColumnScoping(TestCase):
def setUp(self):
super().setUp()

u = User(username='testuser_for_not_all_fields', is_active=True, is_superuser=False)
u.set_password('test')
u.save()

self.client = Client()
r = self.client.login(username='testuser_for_not_all_fields', password='test')
self.assertTrue(r)

self.zoo: Zoo = Zoo.objects.create(name='Artis')


def test_column_scoping_excludes_columns(self):
res = self.client.get('/zoo/{}/'.format(self.zoo.id))
self.assertEqual(res.status_code, 200)

columns = jsonloads(res.content)['data'].keys()

for field in ['name', 'founding_date', 'django_picture']:
self.zoo._meta.get_field(field) # check if those fields exist, otherwise throw error
self.assertFalse(field in columns)

self.assertFalse('zoo_name' in columns)
self.assertFalse('animal_count' in columns)


def test_column_scoping_includes_columns(self):
res = self.client.get('/zoo/{}/'.format(self.zoo.id))
self.assertEqual(res.status_code, 200)

columns = jsonloads(res.content)['data'].keys()

for field in ['id', 'floor_plan']:
self.zoo._meta.get_field(field)
self.assertTrue(field in columns)

self.assertTrue('another_zoo_name' in columns)
self.assertTrue('another_animal_count' in columns)
6 changes: 6 additions & 0 deletions tests/testapp/models/zoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def __str__(self):
def animal_count(self):
return self.animals.count()

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

def clean(self):
if self.name == 'very_special_forbidden_zoo_name':
Expand All @@ -69,5 +72,8 @@ def clean(self):
if errors:
raise ValidationError(errors)

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

post_delete.connect(delete_files, sender=Zoo)
11 changes: 11 additions & 0 deletions tests/testapp/views/zoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ def _require_model_perm(self, perm_type, request, pk=None):
return ['bad_q_filter']
elif perm_type == 'view' and request.user.username == 'testuser_for_good_q_filter':
return ['good_q_filter']
elif perm_type == 'view' and request.user.username == 'testuser_for_not_all_fields':
return ['not_all_fields']
else:
model = self.perms_via if hasattr(self, 'perms_via') else self.model
perm = '{}.{}_{}'.format(model._meta.app_label, perm_type, model.__name__.lower())
Expand All @@ -57,3 +59,12 @@ def _scope_view_bad_q_filter(self, request):

def _scope_view_good_q_filter(self, request):
return FilterDescription(Q(animals__id__in=Animal.objects.all()), True)

def _scope_view_not_all_fields(self, request):
# expose only certain columns
columns = {
'fields': ['id', 'floor_plan'],
'properties': ['another_animal_count'],
'annotations': ['another_zoo_name'],
}
return Zoo.objects.all(), columns