Extend ObjectPermission constraints to OR multiple JSON objects

This commit is contained in:
Jeremy Stretch 2020-08-06 15:53:23 -04:00
parent b1ec332a56
commit 4a516103a6
5 changed files with 49 additions and 16 deletions

View File

@ -6,13 +6,15 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's
### Example Constraint Definitions ### Example Constraint Definitions
| Query Filter | Permission Constraints | | Constraints | Description |
| ------------ | --------------------- | | ----------- | ----------- |
| `filter(status='active')` | `{"status": "active"}` | | `{"status": "active"}` | Status is active |
| `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` | | `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved |
| `filter(status__in=['planned', 'reserved'])` | `{"status__in": ["planned", "reserved"]}` | | `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing |
| `filter(name__startswith('Foo')` | `{"name__startswith": "Foo"}` | | `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
| `filter(vid__gte=100, vid__lt=200)` | `{"vid__gte": 100, "vid__lt": 200}` | | `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
| `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |
| `[{"vid__lt": 200}, {"status": "reserved"}]` | VLAN ID is less than 200 **OR** status is reserved |
## Permissions Enforcement ## Permissions Enforcement

View File

@ -25,9 +25,9 @@ In addition to these, permissions can also grant custom actions that may be requ
## Constraints ## Constraints
Constraints are expressed as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. Constraints are expressed as a JSON object or list representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
All constraints defined on a permission are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints. All attributes defined within a single JSON object are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints.
```json ```json
{ {
@ -36,4 +36,20 @@ All constraints defined on a permission are applied with a logical AND. For exam
} }
``` ```
The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group. The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region.
To achieve a logical OR with a different set of constraints, define multiple objects within a list. For example, if you want to constrain the permission to VLANs with an ID between 100 and 199 _or_ a status of "reserved," do the following:
```json
[
{
"vid__gte": 100,
"vid__lt": 200
},
{
"status": "reserved"
}
]
```
Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation.

View File

@ -75,7 +75,10 @@ class ObjectPermissionBackend(ModelBackend):
obj_perm_constraints = self.get_all_permissions(user_obj)[perm] obj_perm_constraints = self.get_all_permissions(user_obj)[perm]
constraints = Q() constraints = Q()
for perm_constraints in obj_perm_constraints: for perm_constraints in obj_perm_constraints:
if perm_constraints: if type(perm_constraints) is list:
for c in obj_perm_constraints:
constraints |= Q(**c)
elif perm_constraints:
constraints |= Q(**perm_constraints) constraints |= Q(**perm_constraints)
else: else:
# Found ObjectPermission with null constraints; allow model-level access # Found ObjectPermission with null constraints; allow model-level access

View File

@ -4,6 +4,7 @@ from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.db.models import Q
from extras.admin import order_content_types from extras.admin import order_content_types
from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig
@ -136,7 +137,8 @@ class ObjectPermissionForm(forms.ModelForm):
help_texts = { help_texts = {
'actions': 'Actions granted in addition to those listed above', 'actions': 'Actions granted in addition to those listed above',
'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null '
'to match all objects of this type.' 'to match all objects of this type. A list of multiple objects will result in a logical OR '
'operation.'
} }
labels = { labels = {
'actions': 'Additional actions' 'actions': 'Additional actions'
@ -167,8 +169,8 @@ class ObjectPermissionForm(forms.ModelForm):
self.instance.actions.remove(action) self.instance.actions.remove(action)
def clean(self): def clean(self):
object_types = self.cleaned_data['object_types'] object_types = self.cleaned_data.get('object_types')
constraints = self.cleaned_data['constraints'] constraints = self.cleaned_data.get('constraints')
# Append any of the selected CRUD checkboxes to the actions list # Append any of the selected CRUD checkboxes to the actions list
if not self.cleaned_data.get('actions'): if not self.cleaned_data.get('actions'):
@ -184,10 +186,13 @@ class ObjectPermissionForm(forms.ModelForm):
# Validate the specified model constraints by attempting to execute a query. We don't care whether the query # Validate the specified model constraints by attempting to execute a query. We don't care whether the query
# returns anything; we just want to make sure the specified constraints are valid. # returns anything; we just want to make sure the specified constraints are valid.
if constraints: if constraints:
# Normalize the constraints to a list of dicts
if type(constraints) is not list:
constraints = [constraints]
for ct in object_types: for ct in object_types:
model = ct.model_class() model = ct.model_class()
try: try:
model.objects.filter(**constraints).exists() model.objects.filter(*[Q(**c) for c in constraints]).exists()
except FieldError as e: except FieldError as e:
raise ValidationError({ raise ValidationError({
'constraints': f'Invalid filter for {model}: {e}' 'constraints': f'Invalid filter for {model}: {e}'

View File

@ -44,8 +44,15 @@ class RestrictedQuerySet(QuerySet):
else: else:
attrs = Q() attrs = Q()
for perm_attrs in user._object_perm_cache[permission_required]: for perm_attrs in user._object_perm_cache[permission_required]:
if perm_attrs: if type(perm_attrs) is list:
for p in perm_attrs:
attrs |= Q(**p)
elif perm_attrs:
attrs |= Q(**perm_attrs) attrs |= Q(**perm_attrs)
else:
# Any permission with null constraints grants access to _all_ instances
attrs = Q()
break
qs = self.filter(attrs) qs = self.filter(attrs)
return qs return qs