1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Closes #13038: Establish DEFAULT_PERMISSIONS config parameter (#13308)

* Introduce the DEFAULT_PERMISSIONS config parameter

* Establish default permissions for user token management
This commit is contained in:
Jeremy Stretch
2023-07-30 15:04:58 -04:00
committed by GitHub
parent ca634be7ad
commit 07f68ae579
10 changed files with 99 additions and 108 deletions

View File

@ -68,8 +68,13 @@ When defining a permission constraint, administrators may use the special token
The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes.
### Default Permissions
#### Example Constraint Definitions
!!! info "This feature was introduced in NetBox v3.6."
While permissions are typically assigned to specific groups and/or users, it is also possible to define a set of default permissions that are applied to _all_ authenticated users. This is done using the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. Note that statically configuring permissions for specific users or groups is **not** supported.
### Example Constraint Definitions
| Constraints | Description |
| ----------- | ----------- |

View File

@ -90,6 +90,38 @@ CSRF_TRUSTED_ORIGINS = (
---
## DEFAULT_PERMISSIONS
!!! info "This parameter was introduced in NetBox v3.6."
Default:
```python
{
'users.view_token': ({'user': '$user'},),
'users.add_token': ({'user': '$user'},),
'users.change_token': ({'user': '$user'},),
'users.delete_token': ({'user': '$user'},),
}
```
This parameter defines object permissions that are applied automatically to _any_ authenticated user, regardless of what permissions have been defined in the database. By default, this parameter is defined to allow all users to manage their own API tokens, however it can be overriden for any purpose.
For example, to allow all users to create a device role beginning with the word "temp," you could configure the following:
```python
DEFAULT_PERMISSIONS = {
'dcim.add_devicerole': (
{'name__startswith': 'temp'},
)
}
```
!!! warning
Setting a custom value for this parameter will overwrite the default permission mapping shown above. If you want to retain the default mapping, be sure to reproduce it in your custom configuration.
---
## EXEMPT_VIEW_PERMISSIONS
Default: Empty list

View File

@ -76,6 +76,18 @@ class ObjectPermissionMixin:
"""
Return all permissions granted to the user by an ObjectPermission.
"""
# Initialize a dictionary mapping permission names to sets of constraints
perms = defaultdict(list)
# Collect any configured default permissions
for perm_name, constraints in settings.DEFAULT_PERMISSIONS.items():
constraints = constraints or tuple()
if type(constraints) not in (list, tuple):
raise ImproperlyConfigured(
f"Constraints for default permission {perm_name} must be defined as a list or tuple."
)
perms[perm_name].extend(constraints)
# Retrieve all assigned and enabled ObjectPermissions
object_permissions = ObjectPermission.objects.filter(
self.get_permission_filter(user_obj),
@ -83,7 +95,6 @@ class ObjectPermissionMixin:
).order_by('id').distinct('id').prefetch_related('object_types')
# Create a dictionary mapping permissions to their constraints
perms = defaultdict(list)
for obj_perm in object_permissions:
for object_type in obj_perm.object_types.all():
for action in obj_perm.actions:

View File

@ -39,6 +39,8 @@ REDIS = {
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
DEFAULT_PERMISSIONS = {}
LOGGING = {
'version': 1,
'disable_existing_loggers': True

View File

@ -99,6 +99,13 @@ DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
DEBUG = getattr(configuration, 'DEBUG', False)
DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None)
DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
# Permit users to manage their own API tokens
'users.view_token': ({'user': '$user'},),
'users.add_token': ({'user': '$user'},),
'users.change_token': ({'user': '$user'},),
'users.delete_token': ({'user': '$user'},),
})
DEVELOPER = getattr(configuration, 'DEVELOPER', False)
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
EMAIL = getattr(configuration, 'EMAIL', {})

View File

@ -1,6 +1,5 @@
from django.urls import include, path
from django.urls import path
from utilities.urls import get_model_urls
from . import views
app_name = 'account'
@ -13,6 +12,8 @@ urlpatterns = [
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
path('api-tokens/<int:pk>/', include(get_model_urls('users', 'usertoken'))),
path('api-tokens/<int:pk>/', views.UserTokenView.as_view(), name='usertoken'),
path('api-tokens/<int:pk>/edit/', views.UserTokenEditView.as_view(), name='usertoken_edit'),
path('api-tokens/<int:pk>/delete/', views.UserTokenDeleteView.as_view(), name='usertoken_delete'),
]

View File

@ -49,21 +49,10 @@ class GroupViewSet(NetBoxModelViewSet):
#
class TokenViewSet(NetBoxModelViewSet):
queryset = RestrictedQuerySet(model=Token).prefetch_related('user')
queryset = Token.objects.prefetch_related('user')
serializer_class = serializers.TokenSerializer
filterset_class = filtersets.TokenFilterSet
def get_queryset(self):
"""
Limit the non-superusers to their own Tokens.
"""
queryset = super().get_queryset()
if not self.request.user.is_authenticated:
return queryset.none()
if self.request.user.is_superuser:
return queryset
return queryset.filter(user=self.request.user)
class TokenProvisionView(APIView):
"""

View File

@ -23,14 +23,6 @@ COPY_BUTTON = """
"""
class TokenActionsColumn(columns.ActionsColumn):
# Subclass ActionsColumn to disregard permissions for edit & delete buttons
actions = {
'edit': columns.ActionsItem('Edit', 'pencil', None, 'warning'),
'delete': columns.ActionsItem('Delete', 'trash-can-outline', None, 'danger'),
}
class UserTokenTable(NetBoxTable):
"""
Table for users to manager their own API tokens under account views.
@ -55,7 +47,8 @@ class UserTokenTable(NetBoxTable):
verbose_name=_('Allowed IPs'),
template_code=ALLOWED_IPS
)
actions = TokenActionsColumn(
# TODO: Fix permissions evaluation & viewname resolution
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
extra_buttons=COPY_BUTTON
)

View File

@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from users.models import ObjectPermission, Token
from utilities.testing import APIViewTestCases, APITestCase
from utilities.testing import APIViewTestCases, APITestCase, create_test_user
from utilities.utils import deepmerge
@ -105,24 +105,35 @@ class TokenTest(
def setUp(self):
super().setUp()
# Apply grant_token permission to enable the creation of Tokens for other Users
self.add_permissions('users.grant_token')
@classmethod
def setUpTestData(cls):
users = (
create_test_user('User 1'),
create_test_user('User 2'),
create_test_user('User 3'),
)
tokens = (
# We already start with one Token, created by the test class
Token(user=self.user),
Token(user=self.user),
Token(user=users[0]),
Token(user=users[1]),
Token(user=users[2]),
)
# Use save() instead of bulk_create() to ensure keys get automatically generated
for token in tokens:
token.save()
self.create_data = [
cls.create_data = [
{
'user': self.user.pk,
'user': users[0].pk,
},
{
'user': self.user.pk,
'user': users[1].pk,
},
{
'user': self.user.pk,
'user': users[2].pk,
},
]
@ -161,6 +172,9 @@ class TokenTest(
"""
Test provisioning a Token for a different User with & without the grant_token permission.
"""
# Clear grant_token permission assigned by setUpTestData
ObjectPermission.objects.filter(users=self.user).delete()
self.add_permissions('users.add_token')
user2 = User.objects.create_user(username='testuser2')
data = {

View File

@ -279,83 +279,20 @@ class UserTokenView(LoginRequiredMixin, View):
})
@register_model_view(UserToken, 'edit')
class UserTokenEditView(LoginRequiredMixin, View):
class UserTokenEditView(generic.ObjectEditView):
queryset = UserToken.objects.all()
form = forms.UserTokenForm
default_return_url = 'account:usertoken_list'
def get(self, request, pk=None):
if pk:
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
else:
token = UserToken(user=request.user)
form = forms.UserTokenForm(instance=token)
return render(request, 'generic/object_edit.html', {
'object': token,
'form': form,
'return_url': reverse('account:usertoken_list'),
})
def post(self, request, pk=None):
if pk:
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
form = forms.UserTokenForm(request.POST, instance=token)
else:
token = UserToken(user=request.user)
form = forms.UserTokenForm(request.POST)
if form.is_valid():
token = form.save(commit=False)
token.user = request.user
token.save()
msg = f"Modified token {token}" if pk else f"Created token {token}"
messages.success(request, msg)
if not pk and not settings.ALLOW_TOKEN_RETRIEVAL:
return render(request, 'users/account/token.html', {
'object': token,
'key': token.key,
'return_url': reverse('users:token_list'),
})
elif '_addanother' in request.POST:
return redirect(request.path)
else:
return redirect('account:usertoken_list')
return render(request, 'generic/object_edit.html', {
'object': token,
'form': form,
'return_url': reverse('account:usertoken_list'),
'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL
})
def alter_object(self, obj, request, url_args, url_kwargs):
if not obj.pk:
obj.user = request.user
return obj
@register_model_view(UserToken, 'delete')
class UserTokenDeleteView(LoginRequiredMixin, View):
def get(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
return render(request, 'generic/object_delete.html', {
'object': token,
'form': ConfirmationForm(),
'return_url': reverse('account:usertoken_list'),
})
def post(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
form = ConfirmationForm(request.POST)
if form.is_valid():
token.delete()
messages.success(request, "Token deleted")
return redirect('account:usertoken_list')
return render(request, 'generic/object_delete.html', {
'object': token,
'form': form,
'return_url': reverse('account:usertoken_list'),
})
class UserTokenDeleteView(generic.ObjectDeleteView):
queryset = UserToken.objects.all()
default_return_url = 'account:usertoken_list'
#