Repository: netbox-community/netbox

Issue:
Devices > Edit: (get() returned more than one Device) when using multi custom permissions by tags
### NetBox version

v3.1.8 (netbox-docker)

### Python version

3.9

### Steps to Reproduce

1. create 2 tags: "tag1" and "tag2"
2. add both tags to any device
3. add to test user permission to read devices
4. create permission: name - can_edit_device_with_tag1, action - 'can_change', object types - 'dcim > devices', constraints: '{"tags__name":"tag1"}'
5. create permission: name - can_edit_device_with_tag2, action - 'can_change', object types - 'dcim > devices', constraints: '{"tags__name":"tag2"}'
6. assign both permissions to the test user directly or to the groups they belong to
7. try to edit device


### Expected Behavior

The form for editing the device will open.

### Observed Behavior
Exception Type: MultipleObjectsReturned at /dcim/devices/1/edit/
Exception Value: get() returned more than one Device -- it returned 2!
Detail:
```
Environment:


Request Method: GET
Request URL: http://127.0.0.1:8000/dcim/devices/1/edit/

Django Version: 3.2.12
Python Version: 3.9.5
Installed Applications:
['django.contrib.admin',
 'django.contrib.auth',
 'django.contrib.contenttypes',
 'django.contrib.sessions',
 'django.contrib.messages',
 'django.contrib.staticfiles',
 'django.contrib.humanize',
 'corsheaders',
 'debug_toolbar',
 'graphiql_debug_toolbar',
 'django_filters',
 'django_tables2',
 'django_prometheus',
 'graphene_django',
 'mptt',
 'rest_framework',
 'social_django',
 'taggit',
 'timezone_field',
 'circuits',
 'dcim',
 'ipam',
 'extras',
 'tenancy',
 'users',
 'utilities',
 'virtualization',
 'wireless',
 'django_rq',
 'drf_yasg']
Installed Middleware:
['graphiql_debug_toolbar.middleware.DebugToolbarMiddleware',
 'django_prometheus.middleware.PrometheusBeforeMiddleware',
 'corsheaders.middleware.CorsMiddleware',
 'django.contrib.sessions.middleware.SessionMiddleware',
 'django.middleware.common.CommonMiddleware',
 'django.middleware.csrf.CsrfViewMiddleware',
 'django.contrib.auth.middleware.AuthenticationMiddleware',
 'django.contrib.messages.middleware.MessageMiddleware',
 'django.middleware.clickjacking.XFrameOptionsMiddleware',
 'django.middleware.security.SecurityMiddleware',
 'netbox.middleware.ExceptionHandlingMiddleware',
 'netbox.middleware.RemoteUserMiddleware',
 'netbox.middleware.LoginRequiredMiddleware',
 'netbox.middleware.DynamicConfigMiddleware',
 'netbox.middleware.APIVersionMiddleware',
 'netbox.middleware.ObjectChangeMiddleware',
 'django_prometheus.middleware.PrometheusAfterMiddleware']



Traceback (most recent call last):
  File "/opt/netbox/venv/lib/python3.9/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/opt/netbox/venv/lib/python3.9/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/opt/netbox/venv/lib/python3.9/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/opt/netbox/netbox/netbox/views/generic.py", line 322, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/opt/netbox/netbox/utilities/views.py", line 93, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/opt/netbox/venv/lib/python3.9/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/opt/netbox/netbox/netbox/views/generic.py", line 325, in get
    obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
  File "/opt/netbox/netbox/netbox/views/generic.py", line 302, in get_object
    obj = get_object_or_404(self.queryset, pk=kwargs['pk'])
  File "/opt/netbox/venv/lib/python3.9/site-packages/django/shortcuts.py", line 76, in get_object_or_404
    return queryset.get(*args, **kwargs)
  File "/opt/netbox/venv/lib/python3.9/site-packages/django/db/models/query.py", line 439, in get
    raise self.model.MultipleObjectsReturned(

Exception Type: MultipleObjectsReturned at /dcim/devices/1/edit/
Exception Value: get() returned more than one Device -- it returned 2!
```


Discussion:
See also #8351
if added to the call 
https://github.com/netbox-community/netbox/blob/90ee689d5ae61e829042e35824b4ff4201476e44/netbox/utilities/querysets.py#L42
`.distinct()`
the error disappears, but I'm not sure if this is the right way to solve it and it won't break anything.
Seems like a bug with the `tags` filter:

```
>>> Site.objects.filter(tags__name='Golf').filter(tags__name='Lima').get(pk=24)
<Site: Butler Communications>
>>> Site.objects.filter(tags__name__in=['Golf', 'Lima']).get(pk=24)
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/models/query.py", line 439, in get
    raise self.model.MultipleObjectsReturned(
dcim.models.sites.Site.MultipleObjectsReturned: get() returned more than one Site -- it returned 2!
```

From: https://django-taggit.readthedocs.io/en/latest/api.html#filtering:

If you’re filtering on multiple tags, it’s very common to get duplicate results, because of the way relational databases work. Often you’ll want to make use of the distinct() method on QuerySets:
```
>>> Food.objects.filter(tags__name__in=["delicious", "red"])
[<Food: apple>, <Food: apple>]
>>> Food.objects.filter(tags__name__in=["delicious", "red"]).distinct()
[<Food: apple>]
```


Resolving patch:
diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py
--- a/netbox/utilities/querysets.py
+++ b/netbox/utilities/querysets.py
@@ -39,6 +39,12 @@ def restrict(self, user, action='view'):
                     # Any permission with null constraints grants access to _all_ instances
                     attrs = Q()
                     break
+            else:
+                # for else, when no break
+                # avoid duplicates when JOIN on many-to-many fields without using DISTINCT.
+                # DISTINCT acts globally on the entire request, which may not be desirable.
+                allowed_objects = self.model.objects.filter(attrs)
+                attrs = Q(pk__in=allowed_objects)
             qs = self.filter(attrs)
 
         return qs


Test patch: