Repository: netbox-community/netbox

Issue:
It should be possible to express an OR case in a permissions constraint
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for proposing specific new features or enhancements.
    If you have a general idea or question, please post to our mailing list
    instead of opening an issue:

        https://groups.google.com/forum/#!forum/netbox-discuss

    NOTE: Due to an excessive backlog of feature requests, we are not currently
    accepting any proposals which significantly extend NetBox's feature scope.

    Please describe the environment in which you are running NetBox. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report.
-->
### Environment
* Python version:  3.6.8
* NetBox version:  2.9-beta1

<!--
    Describe in detail the new functionality you are proposing. Include any
    specific changes to work flows, data models, or the user interface.
-->
### Proposed Functionality
Some method of expressing an OR clause in a permissions constraint should be possible.

<!--
    Convey an exa mple use case for your proposed feature. Write from the
    perspective of a NetBox user who would benefit from the proposed
    functionality and describe how.
--->
### Use Case
In a dev instance I've created a couple of devices. 'Device 001' has a tenant 'Tenant A' assigned to it, 'Device Two' does not. I have a group called Group A and a set of permissions that grant this group access to devices assigned to Tenant A using the following constraint:  { "tenant__slug": "tenant-a" }. I have granted permissions to view the device interfaces with a constraint as follows: { "device__tenant__slug": "tenant-a" }. I have two cables between 'Device One' and 'Device Two' and by virtue of this permission, when viewing Device One, I can see that they are both connected. One connection was created from Device One, the other from Device Two so both have different 'A' ends.

I've been able to grant permission for the member of Group A to view the details of one of the connections using a constraint on dcim > cables as follows: { "_termination_a_device__tenant__slug": "tenant-a" } (incidentally, should I be able to do this? the preceding underscore makes it feel like this relationship should be private in some way). Despite being able to see the connection from the device page for 'Device 001', one of the cables cannot be traced as the A end of the cable is 'Device 002' which the member does not have access to and it does not match the above constraint. The cables view shows only one of the two cables.

I could resolve this, I guess, by adding a constraint similar to the above but for _termination_b_device__tenant__slug, but needing a whole new permission rule for this feels clunky.

Supporting OR clauses in the permissions model would resolve this.

To incite discussion, how about the constraint clause requiring something like the following?

OR Clause:
{ "ANY": [ "_termination_a_device__tenant__slug": "tenant-a", "_termination_b_device__tenant__slug": "tenant-a" ] }

AND Clause:
{ "ALL": [ "_termination_a_device__tenant__slug": "tenant-a", "_termination_a_device__device_role__slug: "management" ] }

Even better would be some way of making this recursive such that nested clauses would work.

<!--
    Note any changes to the database schema necessary to support the new
    feature. For example, does the proposal require adding a new model or
    field? (Not all new features require database changes.)
--->
### Database Changes
Not sure whether any changes would be required to the permissions model to support this.

<!--
    List any new dependencies on external libraries or services that this new
    feature would introduce. For example, does the proposal require the
    installation of a new Python package? (Not all new features introduce new
    dependencies.)
-->
### External Dependencies
None


Discussion:
The docs currently say:

> To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group.

IMO this is the optimal approach, because whereas ANDing multiple attributes results in _finer_ scope, ORing them tends to greatly increase the scope of granted permissions, and it's more manageable and more efficient to document each one as a separate entity.

As for your particular use case, it's probably preferable to introduce a more robust filtering mechanism for the cable model. (As you've noted, `_termination_a_device` and `_termination_b_device` aren't intended to be referenced directly.) Something like `termination__device=device`, which would match either end of the cable. #3592 may be relevant here.
A generic termination__device filter would be perfect and avoid the need for two separate rules for the A end and the B end. How does the device page obtain its list of connections? One assumes not by running the same filters as I defined above as presumably one connection would have remained invisible? Would I be better filtering my cables some other way?
Looking at this further, I don't think my suggestion above is going to be feasible with the current cabling model. However, we could extend ObjectPermission to accept a list of JSON objects, rather than a single object, to be ORed using Django's [Q objects](https://docs.djangoproject.com/en/3.0/ref/models/querysets/#q-objects). Using your case as an example, you'd end up with something like this for the constraints definition:

```json
[
    {"_termination_a_device__tenant__slug": "tenant-a"},
    {"_termination_b_device__tenant__slug": "tenant-a"}
]
```

This would result in the queryset being constructed as:

```
Cable.objects.filter(
    Q(_termination_a_device__tenant__slug='tenant-a') |
    Q(_termination_b_device__tenant__slug='tenant-b')
)
```

(I'll also note that the AND condition is already supported by declaring a single JSON object specifying both parameters.)
Perfect!

> On 6 Aug 2020, at 19:03, Jeremy Stretch <notifications@github.com> wrote:
> 
> ﻿
> Looking at this further, I don't think my suggestion above is going to be feasible with the current cabling model. However, we could extend ObjectPermission to accept a list of JSON objects, rather than a single object, to be ORed using Django's Q objects. Using your case as an example, you'd end up with something like this for the constraints definition:
> 
> [
>     {"_termination_a_device__tenant__slug": "tenant-a"},
>     {"_termination_b_device__tenant__slug": "tenant-a"}
> ]
> This would result in the queryset being constructed as:
> 
> Cable.objects.filter(
>     Q(_termination_a_device__tenant__slug='tenant-a') |
>     Q(_termination_b_device__tenant__slug='tenant-b')
> )
> (I'll also note that the AND condition is already supported by declaring a single JSON object specifying both parameters.)
> 
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub, or unsubscribe.


Resolving patch:
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -75,7 +75,10 @@ def has_perm(self, user_obj, perm, obj=None):
         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
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -4,6 +4,7 @@
 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 Meta:
         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 @@ def __init__(self, *args, **kwargs):
                     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 @@ def clean(self):
         # 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
--- a/netbox/utilities/querysets.py
+++ b/netbox/utilities/querysets.py
@@ -44,8 +44,15 @@ def restrict(self, user, action='view'):
         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


Test patch: