[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."""
fixtures = ['location', 'category', 'part', 'stock']
roles = ['part.view']
token = None
auto_login = False
@ -132,6 +133,7 @@ class APITests(InvenTreeAPITestCase):
# Now log in!
self.basicAuth()
self.assignRole('part.view')
response = self.get(url)
@ -147,12 +149,17 @@ class APITests(InvenTreeAPITestCase):
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])
@ -297,6 +304,7 @@ class SearchTests(InvenTreeAPITestCase):
'order',
'sales_order',
]
roles = ['build.view', 'part.view']
def test_empty(self):
"""Test empty request."""
@ -331,6 +339,19 @@ class SearchTests(InvenTreeAPITestCase):
{'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)

View File

@ -71,6 +71,7 @@ class MiddlewareTests(InvenTreeTestCase):
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

View File

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

View File

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

View File

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

View File

@ -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."""
@ -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."""
@ -2642,6 +2642,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
superuser = False
is_staff = False
roles = ['stocktake.view']
fixtures = ['category', 'part', 'location', 'stock']

View File

@ -162,7 +162,24 @@ 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()
@ -170,7 +187,7 @@ class GroupDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated]
class GroupList(ListCreateAPI):
class GroupList(GroupMixin, ListCreateAPI):
"""List endpoint for all auth groups."""
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(
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(

View File

@ -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
@ -31,7 +32,25 @@ class GroupSerializer(InvenTreeModelSerializer):
"""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):
@ -83,14 +102,19 @@ class RoleSerializer(InvenTreeModelSerializer):
Q(user=user) | Q(group__user=user)
).distinct()
perms = {}
return generate_permission_dict(permissions)
for permission in permissions:
perm, model = permission.codename.split('_')
if model not in perms:
perms[model] = []
def generate_permission_dict(permissions):
"""Generate a dictionary of permissions for a given set of permissions."""
perms = {}
perms[model].append(perm)
for permission in permissions:
perm, model = permission.codename.split('_')
return perms
if model not in perms:
perms[model] = []
perms[model].append(perm)
return perms

View File

@ -123,8 +123,8 @@ class RuleSetModelTest(TestCase):
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:

View File

@ -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;
@ -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,

View File

@ -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`,

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',
owner = 'owner',
user = 'user',
group = 'group',
reporttemplate = 'reporttemplate',
labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig'

View File

@ -1,13 +1,23 @@
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 { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import AdminButton from '../../components/buttons/AdminButton';
import { EditApiForm } from '../../components/forms/ApiForm';
import { PlaceholderPill } from '../../components/items/Placeholder';
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import {
useCreateApiFormModal,
useDeleteApiFormModal
@ -32,14 +42,42 @@ export function GroupDrawer({
refreshTable: () => void;
}) {
const {
instance,
refreshInstance,
instanceQuery: { isFetching, error }
} = useInstance({
endpoint: ApiEndpoints.group_list,
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) {
return <LoadingOverlay visible={true} />;
}
@ -72,13 +110,13 @@ export function GroupDrawer({
}}
id={`group-detail-drawer-${id}`}
/>
<Title order={5}>
<Trans>Permission set</Trans>
</Title>
<Group>
<PlaceholderPill />
<Group justify="space-between">
<Title order={5}>
<Trans>Permission set</Trans>
</Title>
<AdminButton model={ModelType.group} pk={instance.pk} />
</Group>
<Group>{permissionsAccordion}</Group>
</Stack>
);
}