[PUI] Add permissions to groups (#7621)

* 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 <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair 2024-07-22 05:21:59 +02:00 committed by GitHub
parent 16e535f45f
commit ffd55cf164
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 170 additions and 30 deletions

View File

@ -62,6 +62,7 @@ class APITests(InvenTreeAPITestCase):
"""Tests for the InvenTree API.""" """Tests for the InvenTree API."""
fixtures = ['location', 'category', 'part', 'stock'] fixtures = ['location', 'category', 'part', 'stock']
roles = ['part.view']
token = None token = None
auto_login = False auto_login = False
@ -132,6 +133,7 @@ class APITests(InvenTreeAPITestCase):
# Now log in! # Now log in!
self.basicAuth() self.basicAuth()
self.assignRole('part.view')
response = self.get(url) response = self.get(url)
@ -147,12 +149,17 @@ class APITests(InvenTreeAPITestCase):
role_names = roles.keys() role_names = roles.keys()
# By default, 'view' permissions are provided # By default, no permissions are provided
for rule in RuleSet.RULESET_NAMES: for rule in RuleSet.RULESET_NAMES:
self.assertIn(rule, role_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('add', roles[rule])
self.assertNotIn('change', roles[rule]) self.assertNotIn('change', roles[rule])
self.assertNotIn('delete', roles[rule]) self.assertNotIn('delete', roles[rule])
@ -297,6 +304,7 @@ class SearchTests(InvenTreeAPITestCase):
'order', 'order',
'sales_order', 'sales_order',
] ]
roles = ['build.view', 'part.view']
def test_empty(self): def test_empty(self):
"""Test empty request.""" """Test empty request."""
@ -331,6 +339,19 @@ class SearchTests(InvenTreeAPITestCase):
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}}, {'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
expected_code=200, 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['purchaseorder']['count'], 1)
self.assertEqual(response.data['salesorder']['count'], 0) self.assertEqual(response.data['salesorder']['count'], 0)

View File

@ -71,6 +71,7 @@ class MiddlewareTests(InvenTreeTestCase):
def test_error_exceptions(self): def test_error_exceptions(self):
"""Test that ignored errors are not logged.""" """Test that ignored errors are not logged."""
self.assignRole('part.view')
def check(excpected_nbr=0): def check(excpected_nbr=0):
# Check that errors are empty # Check that errors are empty

View File

@ -204,6 +204,7 @@ class UserMixin:
ruleset.can_add = True ruleset.can_add = True
ruleset.save() ruleset.save()
if not assign_all:
break break

View File

@ -160,7 +160,7 @@ class CompanyTest(InvenTreeAPITestCase):
class ContactTest(InvenTreeAPITestCase): class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models.""" """Tests for the Contact models."""
roles = [] roles = ['purchase_order.view']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -266,7 +266,7 @@ class ContactTest(InvenTreeAPITestCase):
class AddressTest(InvenTreeAPITestCase): class AddressTest(InvenTreeAPITestCase):
"""Test cases for Address API endpoints.""" """Test cases for Address API endpoints."""
roles = [] roles = ['purchase_order.view']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -2010,6 +2010,7 @@ class ReturnOrderTests(InvenTreeAPITestCase):
'supplier_part', 'supplier_part',
'stock', 'stock',
] ]
roles = ['return_order.view']
def test_options(self): def test_options(self):
"""Test the OPTIONS endpoint.""" """Test the OPTIONS endpoint."""

View File

@ -512,7 +512,7 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
Ensure that the required field details are provided! Ensure that the required field details are provided!
""" """
roles = ['part.add'] roles = ['part.add', 'part_category.view']
def test_part(self): def test_part(self):
"""Test the Part API OPTIONS.""" """Test the Part API OPTIONS."""
@ -2149,7 +2149,7 @@ class BomItemTest(InvenTreeAPITestCase):
fixtures = ['category', 'part', 'location', 'stock', 'bom', 'company'] 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): def setUp(self):
"""Set up the test case.""" """Set up the test case."""
@ -2642,6 +2642,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
superuser = False superuser = False
is_staff = False is_staff = False
roles = ['stocktake.view']
fixtures = ['category', 'part', 'location', 'stock'] fixtures = ['category', 'part', 'location', 'stock']

View File

@ -162,7 +162,24 @@ class UserList(ListCreateAPI):
filterset_fields = ['is_staff', 'is_active', 'is_superuser'] 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.""" """Detail endpoint for a particular auth group."""
queryset = Group.objects.all() queryset = Group.objects.all()
@ -170,7 +187,7 @@ class GroupDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
class GroupList(ListCreateAPI): class GroupList(GroupMixin, ListCreateAPI):
"""List endpoint for all auth groups.""" """List endpoint for all auth groups."""
queryset = Group.objects.all() queryset = Group.objects.all()

View File

@ -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"
),
),
]

View File

@ -389,7 +389,7 @@ class RuleSet(models.Model):
) )
can_view = models.BooleanField( 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( can_add = models.BooleanField(

View File

@ -1,6 +1,7 @@
"""DRF API serializers for the 'users' app.""" """DRF API serializers for the 'users' app."""
from django.contrib.auth.models import Group, Permission, User from django.contrib.auth.models import Group, Permission, User
from django.core.exceptions import AppRegistryNotReady
from django.db.models import Q from django.db.models import Q
from rest_framework import serializers from rest_framework import serializers
@ -31,7 +32,25 @@ class GroupSerializer(InvenTreeModelSerializer):
"""Metaclass defines serializer fields.""" """Metaclass defines serializer fields."""
model = Group 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): class RoleSerializer(InvenTreeModelSerializer):
@ -83,6 +102,11 @@ class RoleSerializer(InvenTreeModelSerializer):
Q(user=user) | Q(group__user=user) Q(user=user) | Q(group__user=user)
).distinct() ).distinct()
return generate_permission_dict(permissions)
def generate_permission_dict(permissions):
"""Generate a dictionary of permissions for a given set of permissions."""
perms = {} perms = {}
for permission in permissions: for permission in permissions:

View File

@ -123,8 +123,8 @@ class RuleSetModelTest(TestCase):
for model in models: for model in models:
permission_set.add(model) permission_set.add(model)
# Every ruleset by default sets one permission, the "view" permission set # By default no permissions should be assigned
self.assertEqual(group.permissions.count(), len(permission_set)) self.assertEqual(group.permissions.count(), 0)
# Add some more rules # Add some more rules
for rule in rulesets: for rule in rulesets:

View File

@ -37,7 +37,7 @@ import {
RenderStockLocation, RenderStockLocation,
RenderStockLocationType RenderStockLocationType
} from './Stock'; } from './Stock';
import { RenderOwner, RenderUser } from './User'; import { RenderGroup, RenderOwner, RenderUser } from './User';
type EnumDictionary<T extends string | symbol | number, U> = { type EnumDictionary<T extends string | symbol | number, U> = {
[K in T]: U; [K in T]: U;
@ -81,6 +81,7 @@ const RendererLookup: EnumDictionary<
[ModelType.stockhistory]: RenderStockItem, [ModelType.stockhistory]: RenderStockItem,
[ModelType.supplierpart]: RenderSupplierPart, [ModelType.supplierpart]: RenderSupplierPart,
[ModelType.user]: RenderUser, [ModelType.user]: RenderUser,
[ModelType.group]: RenderGroup,
[ModelType.importsession]: RenderImportSession, [ModelType.importsession]: RenderImportSession,
[ModelType.reporttemplate]: RenderReportTemplate, [ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate, [ModelType.labeltemplate]: RenderLabelTemplate,

View File

@ -201,6 +201,14 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/user/:pk/', url_detail: '/user/:pk/',
api_endpoint: ApiEndpoints.user_list 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: { importsession: {
label: t`Import Session`, label: t`Import Session`,
label_multiple: t`Import Sessions`, label_multiple: t`Import Sessions`,

View File

@ -28,3 +28,9 @@ export function RenderUser({
) )
); );
} }
export function RenderGroup({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return instance && <RenderInlineModel primary={instance.name} />;
}

View File

@ -27,6 +27,7 @@ export enum ModelType {
contact = 'contact', contact = 'contact',
owner = 'owner', owner = 'owner',
user = 'user', user = 'user',
group = 'group',
reporttemplate = 'reporttemplate', reporttemplate = 'reporttemplate',
labeltemplate = 'labeltemplate', labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig' pluginconfig = 'pluginconfig'

View File

@ -1,13 +1,23 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core'; import {
Accordion,
Group,
LoadingOverlay,
Pill,
PillGroup,
Stack,
Text,
Title
} from '@mantine/core';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import AdminButton from '../../components/buttons/AdminButton';
import { EditApiForm } from '../../components/forms/ApiForm'; import { EditApiForm } from '../../components/forms/ApiForm';
import { PlaceholderPill } from '../../components/items/Placeholder';
import { DetailDrawer } from '../../components/nav/DetailDrawer'; import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal useDeleteApiFormModal
@ -32,14 +42,42 @@ export function GroupDrawer({
refreshTable: () => void; refreshTable: () => void;
}) { }) {
const { const {
instance,
refreshInstance, refreshInstance,
instanceQuery: { isFetching, error } instanceQuery: { isFetching, error }
} = useInstance({ } = useInstance({
endpoint: ApiEndpoints.group_list, endpoint: ApiEndpoints.group_list,
pk: id, pk: id,
throwError: true throwError: true,
params: {
permission_detail: true
}
}); });
const permissionsAccordion = useMemo(() => {
if (!instance?.permissions) return null;
const data = instance.permissions;
return (
<Accordion w={'100%'}>
{Object.keys(data).map((key) => (
<Accordion.Item key={key} value={key}>
<Accordion.Control>
<Pill>{instance.permissions[key].length}</Pill> {key}
</Accordion.Control>
<Accordion.Panel>
<PillGroup>
{data[key].map((perm: string) => (
<Pill key={perm}>{perm}</Pill>
))}
</PillGroup>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
);
}, [instance]);
if (isFetching) { if (isFetching) {
return <LoadingOverlay visible={true} />; return <LoadingOverlay visible={true} />;
} }
@ -72,13 +110,13 @@ export function GroupDrawer({
}} }}
id={`group-detail-drawer-${id}`} id={`group-detail-drawer-${id}`}
/> />
<Group justify="space-between">
<Title order={5}> <Title order={5}>
<Trans>Permission set</Trans> <Trans>Permission set</Trans>
</Title> </Title>
<Group> <AdminButton model={ModelType.group} pk={instance.pk} />
<PlaceholderPill />
</Group> </Group>
<Group>{permissionsAccordion}</Group>
</Stack> </Stack>
); );
} }