Skip to content

Commit

Permalink
[PUI] Add permissions to groups (#7621)
Browse files Browse the repository at this point in the history
* Add permissions to group API

* factor out permission formatting

* add group permission details to UI

* add nicer accordions with permissions

* add group to models

* Add Admin button to change permissions

* add missing instance renderer

* turn off default view permission to everything

* add  migration

* fix rule assigment

* Add now missing view permissions

* Adjust test for the now new default permission count

* add missing view permission

* fix permissions for search test

* adjust search testing to also account for missing permissions

* adjust to new defaults

* expand role testing

---------

Co-authored-by: Oliver <[email protected]>
  • Loading branch information
matmair and SchrodingersGat authored Jul 22, 2024
1 parent 16e535f commit ffd55cf
Show file tree
Hide file tree
Showing 16 changed files with 170 additions and 30 deletions.
25 changes: 23 additions & 2 deletions src/backend/InvenTree/InvenTree/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class APITests(InvenTreeAPITestCase):
"""Tests for the InvenTree API."""

fixtures = ['location', 'category', 'part', 'stock']
roles = ['part.view']
token = None
auto_login = False

Expand Down Expand Up @@ -132,6 +133,7 @@ def test_role_view(self):

# Now log in!
self.basicAuth()
self.assignRole('part.view')

response = self.get(url)

Expand All @@ -147,12 +149,17 @@ def test_role_view(self):

role_names = roles.keys()

# By default, 'view' permissions are provided
# By default, no permissions are provided
for rule in RuleSet.RULESET_NAMES:
self.assertIn(rule, role_names)

self.assertIn('view', roles[rule])
if roles[rule] is None:
continue

if rule == 'part':
self.assertIn('view', roles[rule])
else:
self.assertNotIn('view', roles[rule])
self.assertNotIn('add', roles[rule])
self.assertNotIn('change', roles[rule])
self.assertNotIn('delete', roles[rule])
Expand Down Expand Up @@ -297,6 +304,7 @@ class SearchTests(InvenTreeAPITestCase):
'order',
'sales_order',
]
roles = ['build.view', 'part.view']

def test_empty(self):
"""Test empty request."""
Expand Down Expand Up @@ -331,6 +339,19 @@ def test_results(self):
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
expected_code=200,
)
self.assertEqual(
response.data['purchaseorder'],
{'error': 'User does not have permission to view this model'},
)

# Add permissions and try again
self.assignRole('purchase_order.view')
self.assignRole('sales_order.view')
response = self.post(
reverse('api-search'),
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
expected_code=200,
)

self.assertEqual(response.data['purchaseorder']['count'], 1)
self.assertEqual(response.data['salesorder']['count'], 0)
Expand Down
1 change: 1 addition & 0 deletions src/backend/InvenTree/InvenTree/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def test_token_auth(self):

def test_error_exceptions(self):
"""Test that ignored errors are not logged."""
self.assignRole('part.view')

def check(excpected_nbr=0):
# Check that errors are empty
Expand Down
3 changes: 2 additions & 1 deletion src/backend/InvenTree/InvenTree/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ def assignRole(cls, role=None, assign_all: bool = False, group=None):
ruleset.can_add = True

ruleset.save()
break
if not assign_all:
break


class PluginMixin:
Expand Down
4 changes: 2 additions & 2 deletions src/backend/InvenTree/company/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def test_company_active(self):
class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models."""

roles = []
roles = ['purchase_order.view']

@classmethod
def setUpTestData(cls):
Expand Down Expand Up @@ -266,7 +266,7 @@ def test_delete(self):
class AddressTest(InvenTreeAPITestCase):
"""Test cases for Address API endpoints."""

roles = []
roles = ['purchase_order.view']

@classmethod
def setUpTestData(cls):
Expand Down
1 change: 1 addition & 0 deletions src/backend/InvenTree/order/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2010,6 +2010,7 @@ class ReturnOrderTests(InvenTreeAPITestCase):
'supplier_part',
'stock',
]
roles = ['return_order.view']

def test_options(self):
"""Test the OPTIONS endpoint."""
Expand Down
5 changes: 3 additions & 2 deletions src/backend/InvenTree/part/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
Ensure that the required field details are provided!
"""

roles = ['part.add']
roles = ['part.add', 'part_category.view']

def test_part(self):
"""Test the Part API OPTIONS."""
Expand Down Expand Up @@ -2149,7 +2149,7 @@ class BomItemTest(InvenTreeAPITestCase):

fixtures = ['category', 'part', 'location', 'stock', 'bom', 'company']

roles = ['part.add', 'part.change', 'part.delete']
roles = ['part.add', 'part.change', 'part.delete', 'stock.view']

def setUp(self):
"""Set up the test case."""
Expand Down Expand Up @@ -2642,6 +2642,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):

superuser = False
is_staff = False
roles = ['stocktake.view']

fixtures = ['category', 'part', 'location', 'stock']

Expand Down
21 changes: 19 additions & 2 deletions src/backend/InvenTree/users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,32 @@ class UserList(ListCreateAPI):
filterset_fields = ['is_staff', 'is_active', 'is_superuser']


class GroupDetail(RetrieveUpdateDestroyAPI):
class GroupMixin:
"""Mixin for Group API endpoints to add permissions filter."""

def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint."""
# Do we wish to include extra detail?
try:
params = self.request.query_params
kwargs['permission_detail'] = InvenTree.helpers.str2bool(
params.get('permission_detail', None)
)
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)


class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a particular auth group."""

queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [permissions.IsAuthenticated]


class GroupList(ListCreateAPI):
class GroupList(GroupMixin, ListCreateAPI):
"""List endpoint for all auth groups."""

queryset = Group.objects.all()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2.12 on 2024-07-18 21:19

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("users", "0011_auto_20240523_1640"),
]

operations = [
migrations.AlterField(
model_name="ruleset",
name="can_view",
field=models.BooleanField(
default=False, help_text="Permission to view items", verbose_name="View"
),
),
]
2 changes: 1 addition & 1 deletion src/backend/InvenTree/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ class Meta:
)

can_view = models.BooleanField(
verbose_name=_('View'), default=True, help_text=_('Permission to view items')
verbose_name=_('View'), default=False, help_text=_('Permission to view items')
)

can_add = models.BooleanField(
Expand Down
40 changes: 32 additions & 8 deletions src/backend/InvenTree/users/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""DRF API serializers for the 'users' app."""

from django.contrib.auth.models import Group, Permission, User
from django.core.exceptions import AppRegistryNotReady
from django.db.models import Q

from rest_framework import serializers
Expand Down Expand Up @@ -31,7 +32,25 @@ class Meta:
"""Metaclass defines serializer fields."""

model = Group
fields = ['pk', 'name']
fields = ['pk', 'name', 'permissions']

def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra fields as required."""
permission_detail = kwargs.pop('permission_detail', False)

super().__init__(*args, **kwargs)

try:
if not permission_detail:
self.fields.pop('permissions', None)
except AppRegistryNotReady:
pass

permissions = serializers.SerializerMethodField()

def get_permissions(self, group: Group):
"""Return a list of permissions associated with the group."""
return generate_permission_dict(group.permissions.all())


class RoleSerializer(InvenTreeModelSerializer):
Expand Down Expand Up @@ -83,14 +102,19 @@ def get_permissions(self, user: User) -> dict:
Q(user=user) | Q(group__user=user)
).distinct()

perms = {}
return generate_permission_dict(permissions)


def generate_permission_dict(permissions):
"""Generate a dictionary of permissions for a given set of permissions."""
perms = {}

for permission in permissions:
perm, model = permission.codename.split('_')
for permission in permissions:
perm, model = permission.codename.split('_')

if model not in perms:
perms[model] = []
if model not in perms:
perms[model] = []

perms[model].append(perm)
perms[model].append(perm)

return perms
return perms
4 changes: 2 additions & 2 deletions src/backend/InvenTree/users/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ def test_permission_assign(self):
for model in models:
permission_set.add(model)

# Every ruleset by default sets one permission, the "view" permission set
self.assertEqual(group.permissions.count(), len(permission_set))
# By default no permissions should be assigned
self.assertEqual(group.permissions.count(), 0)

# Add some more rules
for rule in rulesets:
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/src/components/render/Instance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
RenderStockLocation,
RenderStockLocationType
} from './Stock';
import { RenderOwner, RenderUser } from './User';
import { RenderGroup, RenderOwner, RenderUser } from './User';

type EnumDictionary<T extends string | symbol | number, U> = {
[K in T]: U;
Expand Down Expand Up @@ -81,6 +81,7 @@ const RendererLookup: EnumDictionary<
[ModelType.stockhistory]: RenderStockItem,
[ModelType.supplierpart]: RenderSupplierPart,
[ModelType.user]: RenderUser,
[ModelType.group]: RenderGroup,
[ModelType.importsession]: RenderImportSession,
[ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate,
Expand Down
8 changes: 8 additions & 0 deletions src/frontend/src/components/render/ModelType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/user/:pk/',
api_endpoint: ApiEndpoints.user_list
},
group: {
label: t`Group`,
label_multiple: t`Groups`,
url_overview: '/user/group',
url_detail: '/user/group-:pk',
api_endpoint: ApiEndpoints.group_list,
admin_url: '/auth/group/'
},
importsession: {
label: t`Import Session`,
label_multiple: t`Import Sessions`,
Expand Down
6 changes: 6 additions & 0 deletions src/frontend/src/components/render/User.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ export function RenderUser({
)
);
}

export function RenderGroup({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return instance && <RenderInlineModel primary={instance.name} />;
}
1 change: 1 addition & 0 deletions src/frontend/src/enums/ModelType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export enum ModelType {
contact = 'contact',
owner = 'owner',
user = 'user',
group = 'group',
reporttemplate = 'reporttemplate',
labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig'
Expand Down
Loading

0 comments on commit ffd55cf

Please sign in to comment.