diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index c2942c8a9..c66c65543 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -6,13 +6,15 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's ### Example Constraint Definitions -| Query Filter | Permission Constraints | -| ------------ | --------------------- | -| `filter(status='active')` | `{"status": "active"}` | -| `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` | -| `filter(status__in=['planned', 'reserved'])` | `{"status__in": ["planned", "reserved"]}` | -| `filter(name__startswith('Foo')` | `{"name__startswith": "Foo"}` | -| `filter(vid__gte=100, vid__lt=200)` | `{"vid__gte": 100, "vid__lt": 200}` | +| Constraints | Description | +| ----------- | ----------- | +| `{"status": "active"}` | Status is active | +| `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved | +| `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing | +| `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) | +| `{"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 diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md index 4e3ef0e0b..48970dd05 100644 --- a/docs/models/users/objectpermission.md +++ b/docs/models/users/objectpermission.md @@ -25,9 +25,9 @@ In addition to these, permissions can also grant custom actions that may be requ ## 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 { @@ -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. diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 8971bdb52..92127fdae 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -75,7 +75,10 @@ class ObjectPermissionBackend(ModelBackend): obj_perm_constraints = self.get_all_permissions(user_obj)[perm] constraints = Q() 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) else: # Found ObjectPermission with null constraints; allow model-level access diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 134e9d026..1fac75899 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -4,6 +4,7 @@ from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError +from django.db.models import Q from extras.admin import order_content_types from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig @@ -136,7 +137,8 @@ class ObjectPermissionForm(forms.ModelForm): help_texts = { 'actions': 'Actions granted in addition to those listed above', '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 = { 'actions': 'Additional actions' @@ -167,8 +169,8 @@ class ObjectPermissionForm(forms.ModelForm): self.instance.actions.remove(action) def clean(self): - object_types = self.cleaned_data['object_types'] - constraints = self.cleaned_data['constraints'] + object_types = self.cleaned_data.get('object_types') + constraints = self.cleaned_data.get('constraints') # Append any of the selected CRUD checkboxes to the actions list 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 # returns anything; we just want to make sure the specified constraints are valid. if constraints: + # Normalize the constraints to a list of dicts + if type(constraints) is not list: + constraints = [constraints] for ct in object_types: model = ct.model_class() try: - model.objects.filter(**constraints).exists() + model.objects.filter(*[Q(**c) for c in constraints]).exists() except FieldError as e: raise ValidationError({ 'constraints': f'Invalid filter for {model}: {e}' diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index e748e7a23..40f221b10 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -44,8 +44,15 @@ class RestrictedQuerySet(QuerySet): else: attrs = Q() 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) + else: + # Any permission with null constraints grants access to _all_ instances + attrs = Q() + break qs = self.filter(attrs) return qs