diff --git a/binder/permissions/views.py b/binder/permissions/views.py index 9f0b8e6e..815733e9 100644 --- a/binder/permissions/views.py +++ b/binder/permissions/views.py @@ -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): """ @@ -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 diff --git a/binder/views.py b/binder/views.py index fea5de50..b0e4e283 100644 --- a/binder/views.py +++ b/binder/views.py @@ -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: @@ -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: @@ -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() diff --git a/docs/permissions.md b/docs/permissions.md index 56fa084c..5b0057c4 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -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 @@ -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 diff --git a/tests/test_permission_view.py b/tests/test_permission_view.py index edff733a..c6ba319a 100644 --- a/tests/test_permission_view.py +++ b/tests/test_permission_view.py @@ -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) diff --git a/tests/testapp/models/zoo.py b/tests/testapp/models/zoo.py index 0aff9c2a..4f923dd7 100644 --- a/tests/testapp/models/zoo.py +++ b/tests/testapp/models/zoo.py @@ -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': @@ -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) diff --git a/tests/testapp/views/zoo.py b/tests/testapp/views/zoo.py index 1f79917e..fad64378 100644 --- a/tests/testapp/views/zoo.py +++ b/tests/testapp/views/zoo.py @@ -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()) @@ -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