From 3cdba9458fef956dccfd29005569262796997d49 Mon Sep 17 00:00:00 2001 From: nstylo Date: Tue, 14 Sep 2021 12:18:19 +0200 Subject: [PATCH] Sketch: implement scoping on columns Sometimes we only would like to expose only select fields, annotations and properties to the client depending on their permissions. This commit represents a sketch of how this could be done. ref. T33048 --- binder/permissions/views.py | 81 +++++++++++++++++++++++++++++++++++++ binder/views.py | 23 +++++++++-- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/binder/permissions/views.py b/binder/permissions/views.py index 94bf76b5..d452d1d7 100644 --- a/binder/permissions/views.py +++ b/binder/permissions/views.py @@ -52,6 +52,21 @@ 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 None: + fields = list(self.model._meta.fields) + else: + fields = list(map(self.model._meta.get_field, fields)) + + properties = properties if properties is not None else self.shown_properties + + # TODO: At this point we would like to not use None anymore but instead + # use collections. Annotations are might still be None at this point. + return fields, annotations, properties + + def _require_model_perm(self, perm_type, request, pk=None): """ @@ -266,6 +281,65 @@ def scope_change_list(self, request, objects, values): + def scope_columns(self, request): + """ + Each view scope may optionally declare which columns (fields, annotations, properties) + ought to be exposed to client. 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 there is scoping 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 + + # TODO: This could be more DRY, but its readable + columns = result[1] + new_fields = columns.get('fields') + new_annotations = columns.get('annotations') + new_properties = columns.get('properties') + + fields = append_columns(fields, new_fields) + annotations = append_columns(annotations, new_annotations) + properties = append_columns(properties, new_properties) + + return fields, annotations, properties + + + def scope_view(self, request, queryset): """ Performs the scopes for a get request @@ -280,6 +354,13 @@ 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): + 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 0072f3f4..c738cd37 100644 --- a/binder/views.py +++ b/binder/views.py @@ -473,14 +473,24 @@ def _get_objs(self, queryset, request, annotations=None): 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) + # 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. TODO: perhaps accessing disallowed + # annotations should throw 403 much alike row scoping. + if annotations_scoped is not None: + annotations &= annotations_scoped + if self.shown_annotations is None: annotations -= set(self.hidden_annotations) else: @@ -518,7 +528,7 @@ def _get_objs(self, queryset, request, annotations=None): for a in annotations: data[a] = getattr(obj, a) - for prop in self.shown_properties: + for prop in properties_scoped: data[prop] = getattr(obj, prop) if self.model._meta.pk.name in data: @@ -1191,6 +1201,13 @@ def get_queryset(self, request): return self.model.objects.all() + def get_columns(self, request): + # TODO: annotations are currently just None here which is not very + # expressive. But annotations are a little more complicated than + # fields and properties. + return list(self.model._meta.fields), None, self.shown_properties + + def order_by(self, queryset, request): #### order_by