Merge pull request #1018 from SchrodingersGat/group-roles

Roles and Permissions
This commit is contained in:
Oliver 2020-10-05 11:16:52 +11:00 committed by GitHub
commit dc41231fcc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 685 additions and 9 deletions

View File

@ -138,6 +138,7 @@ INSTALLED_APPS = [
'part.apps.PartConfig', 'part.apps.PartConfig',
'report.apps.ReportConfig', 'report.apps.ReportConfig',
'stock.apps.StockConfig', 'stock.apps.StockConfig',
'users.apps.UsersConfig',
# Third part add-ons # Third part add-ons
'django_filters', # Extended filter functionality 'django_filters', # Extended filter functionality

View File

@ -38,4 +38,5 @@ class CompanyConfig(AppConfig):
company.image = None company.image = None
company.save() company.save()
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
print("Could not generate Company thumbnails") # Getting here probably meant the database was in test mode
pass

View File

@ -24,7 +24,6 @@ def reverse_association(apps, schema_editor):
# Exit if there are no SupplierPart objects # Exit if there are no SupplierPart objects
# This crucial otherwise the unit test suite fails! # This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0: if SupplierPart.objects.count() == 0:
print("No SupplierPart objects - skipping")
return return
print("Reversing migration for manufacturer association") print("Reversing migration for manufacturer association")
@ -105,7 +104,6 @@ def associate_manufacturers(apps, schema_editor):
# Exit if there are no SupplierPart objects # Exit if there are no SupplierPart objects
# This crucial otherwise the unit test suite fails! # This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0: if SupplierPart.objects.count() == 0:
print("No SupplierPart objects - skipping")
return return
# Link a 'manufacturer_name' to a 'Company' # Link a 'manufacturer_name' to a 'Company'

View File

@ -37,4 +37,4 @@ class PartConfig(AppConfig):
part.image = None part.image = None
part.save() part.save()
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
print("Could not generate Part thumbnails") pass

View File

@ -1,3 +1,135 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# from __future__ import unicode_literals from __future__ import unicode_literals
# from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from django.contrib import admin
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.contrib.auth.models import Group
from django.contrib.auth.admin import UserAdmin
from users.models import RuleSet
User = get_user_model()
class RuleSetInline(admin.TabularInline):
"""
Class for displaying inline RuleSet data in the Group admin page.
"""
model = RuleSet
can_delete = False
verbose_name = 'Ruleset'
verbose_plural_name = 'Rulesets'
fields = ['name'] + [option for option in RuleSet.RULE_OPTIONS]
readonly_fields = ['name']
max_num = len(RuleSet.RULESET_CHOICES)
min_num = 1
extra = 0
class InvenTreeGroupAdminForm(forms.ModelForm):
"""
Custom admin form for the Group model.
Adds the ability for editing user membership directly in the group admin page.
"""
class Meta:
model = Group
exclude = []
fields = [
'name',
'users',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
# Populate the users field with the current Group users.
self.fields['users'].initial = self.instance.user_set.all()
# Add the users field.
users = forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
widget=FilteredSelectMultiple('users', False),
label=_('Users'),
help_text=_('Select which users are assigned to this group')
)
def save_m2m(self):
# Add the users to the Group.
self.instance.user_set.set(self.cleaned_data['users'])
def save(self, *args, **kwargs):
# Default save
instance = super().save()
# Save many-to-many data
self.save_m2m()
return instance
class RoleGroupAdmin(admin.ModelAdmin):
"""
Custom admin interface for the Group model
"""
form = InvenTreeGroupAdminForm
inlines = [
RuleSetInline,
]
def get_formsets_with_inlines(self, request, obj=None):
for inline in self.get_inline_instances(request, obj):
# Hide RuleSetInline in the 'Add role' view
if not isinstance(inline, RuleSetInline) or obj is not None:
yield inline.get_formset(request, obj), inline
filter_horizontal = ['permissions']
# Save inlines before model
# https://stackoverflow.com/a/14860703/12794913
def save_model(self, request, obj, form, change):
if obj is not None:
# Save model immediately only if in 'Add role' view
super().save_model(request, obj, form, change)
else:
pass # don't actually save the parent instance
def save_formset(self, request, form, formset, change):
formset.save() # this will save the children
form.instance.save() # form.instance is the parent
class InvenTreeUserAdmin(UserAdmin):
"""
Custom admin page for the User model.
Hides the "permissions" view as this is now handled
entirely by groups and RuleSets.
(And it's confusing!)
"""
fieldsets = (
(None, {'fields': ('username', 'password')}),
(_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
(_('Permissions'), {
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'),
}),
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
)
admin.site.unregister(Group)
admin.site.register(Group, RoleGroupAdmin)
admin.site.unregister(User)
admin.site.register(User, InvenTreeUserAdmin)

View File

@ -1,8 +1,33 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db.utils import OperationalError, ProgrammingError
from django.apps import AppConfig from django.apps import AppConfig
class UsersConfig(AppConfig): class UsersConfig(AppConfig):
name = 'users' name = 'users'
def ready(self):
try:
self.assign_permissions()
except (OperationalError, ProgrammingError):
pass
def assign_permissions(self):
from django.contrib.auth.models import Group
from users.models import RuleSet, update_group_roles
# First, delete any rule_set objects which have become outdated!
for rule in RuleSet.objects.all():
if rule.name not in RuleSet.RULESET_NAMES:
print("need to delete:", rule.name)
rule.delete()
# Update group permission assignments for all groups
for group in Group.objects.all():
update_group_roles(group)

View File

@ -0,0 +1,31 @@
# Generated by Django 3.0.7 on 2020-10-03 13:44
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0011_update_proxy_permissions'),
]
operations = [
migrations.CreateModel(
name='RuleSet',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(choices=[('general', 'General'), ('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('supplier', 'Suppliers'), ('purchase_order', 'Purchase Orders'), ('customer', 'Customers'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50)),
('can_view', models.BooleanField(default=True, help_text='Permission to view items', verbose_name='View')),
('can_add', models.BooleanField(default=False, help_text='Permission to add items', verbose_name='Create')),
('can_change', models.BooleanField(default=False, help_text='Permissions to edit items', verbose_name='Update')),
('can_delete', models.BooleanField(default=False, help_text='Permission to delete items', verbose_name='Delete')),
('group', models.ForeignKey(help_text='Group', on_delete=django.db.models.deletion.CASCADE, related_name='rule_sets', to='auth.Group')),
],
options={
'unique_together': {('name', 'group')},
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-10-04 01:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='ruleset',
name='name',
field=models.CharField(choices=[('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('purchase_order', 'Purchase Orders'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50),
),
]

View File

View File

@ -1 +1,317 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.dispatch import receiver
from django.db.models.signals import post_save
class RuleSet(models.Model):
"""
A RuleSet is somewhat like a superset of the django permission class,
in that in encapsulates a bunch of permissions.
There are *many* apps models used within InvenTree,
so it makes sense to group them into "roles".
These roles translate (roughly) to the menu options available.
Each role controls permissions for a number of database tables,
which are then handled using the normal django permissions approach.
"""
RULESET_CHOICES = [
('admin', _('Admin')),
('part', _('Parts')),
('stock', _('Stock')),
('build', _('Build Orders')),
('purchase_order', _('Purchase Orders')),
('sales_order', _('Sales Orders')),
]
RULESET_NAMES = [
choice[0] for choice in RULESET_CHOICES
]
RULESET_MODELS = {
'admin': [
'auth_group',
'auth_user',
'auth_permission',
'authtoken_token',
'users_ruleset',
],
'part': [
'part_part',
'part_bomitem',
'part_partcategory',
'part_partattachment',
'part_partsellpricebreak',
'part_parttesttemplate',
'part_partparametertemplate',
'part_partparameter',
],
'stock': [
'stock_stockitem',
'stock_stocklocation',
'stock_stockitemattachment',
'stock_stockitemtracking',
'stock_stockitemtestresult',
],
'build': [
'part_part',
'part_partcategory',
'part_bomitem',
'build_build',
'build_builditem',
'stock_stockitem',
'stock_stocklocation',
],
'purchase_order': [
'company_company',
'company_supplierpart',
'company_supplierpricebreak',
'order_purchaseorder',
'order_purchaseorderattachment',
'order_purchaseorderlineitem',
],
'sales_order': [
'company_company',
'order_salesorder',
'order_salesorderattachment',
'order_salesorderlineitem',
'order_salesorderallocation',
]
}
# Database models we ignore permission sets for
RULESET_IGNORE = [
# Core django models (not user configurable)
'admin_logentry',
'contenttypes_contenttype',
'sessions_session',
# Models which currently do not require permissions
'common_colortheme',
'common_currency',
'common_inventreesetting',
'company_contact',
'label_stockitemlabel',
'report_reportasset',
'report_testreport',
'part_partstar',
]
RULE_OPTIONS = [
'can_view',
'can_add',
'can_change',
'can_delete',
]
class Meta:
unique_together = (
('name', 'group'),
)
name = models.CharField(
max_length=50,
choices=RULESET_CHOICES,
blank=False,
help_text=_('Permission set')
)
group = models.ForeignKey(
Group,
related_name='rule_sets',
blank=False, null=False,
on_delete=models.CASCADE,
help_text=_('Group'),
)
can_view = models.BooleanField(verbose_name=_('View'), default=True, help_text=_('Permission to view items'))
can_add = models.BooleanField(verbose_name=_('Create'), default=False, help_text=_('Permission to add items'))
can_change = models.BooleanField(verbose_name=_('Update'), default=False, help_text=_('Permissions to edit items'))
can_delete = models.BooleanField(verbose_name=_('Delete'), default=False, help_text=_('Permission to delete items'))
@staticmethod
def get_model_permission_string(model, permission):
"""
Construct the correctly formatted permission string,
given the app_model name, and the permission type.
"""
app, model = model.split('_')
return "{app}.{perm}_{model}".format(
app=app,
perm=permission,
model=model
)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def get_models(self):
"""
Return the database tables / models that this ruleset covers.
"""
return self.RULESET_MODELS.get(self.name, [])
def update_group_roles(group, debug=False):
"""
Iterates through all of the RuleSets associated with the group,
and ensures that the correct permissions are either applied or removed from the group.
This function is called under the following conditions:
a) Whenever the InvenTree database is launched
b) Whenver the group object is updated
The RuleSet model has complete control over the permissions applied to any group.
"""
# List of permissions already associated with this group
group_permissions = set()
# Iterate through each permission already assigned to this group,
# and create a simplified permission key string
for p in group.permissions.all():
(permission, app, model) = p.natural_key()
permission_string = '{app}.{perm}'.format(
app=app,
perm=permission
)
group_permissions.add(permission_string)
# List of permissions which must be added to the group
permissions_to_add = set()
# List of permissions which must be removed from the group
permissions_to_delete = set()
def add_model(name, action, allowed):
"""
Add a new model to the pile:
args:
name - The name of the model e.g. part_part
action - The permission action e.g. view
allowed - Whether or not the action is allowed
"""
if action not in ['view', 'add', 'change', 'delete']:
raise ValueError("Action {a} is invalid".format(a=action))
permission_string = RuleSet.get_model_permission_string(model, action)
if allowed:
# An 'allowed' action is always preferenced over a 'forbidden' action
if permission_string in permissions_to_delete:
permissions_to_delete.remove(permission_string)
if permission_string not in group_permissions:
permissions_to_add.add(permission_string)
else:
# A forbidden action will be ignored if we have already allowed it
if permission_string not in permissions_to_add:
if permission_string in group_permissions:
permissions_to_delete.add(permission_string)
# Get all the rulesets associated with this group
for r in RuleSet.RULESET_CHOICES:
rulename = r[0]
try:
ruleset = RuleSet.objects.get(group=group, name=rulename)
except RuleSet.DoesNotExist:
# Create the ruleset with default values (if it does not exist)
ruleset = RuleSet.objects.create(group=group, name=rulename)
# Which database tables does this RuleSet touch?
models = ruleset.get_models()
for model in models:
# Keep track of the available permissions for each model
add_model(model, 'view', ruleset.can_view)
add_model(model, 'add', ruleset.can_add)
add_model(model, 'change', ruleset.can_change)
add_model(model, 'delete', ruleset.can_delete)
def get_permission_object(permission_string):
"""
Find the permission object in the database,
from the simplified permission string
Args:
permission_string - a simplified permission_string e.g. 'part.view_partcategory'
Returns the permission object in the database associated with the permission string
"""
(app, perm) = permission_string.split('.')
(permission_name, model) = perm.split('_')
try:
content_type = ContentType.objects.get(app_label=app, model=model)
permission = Permission.objects.get(content_type=content_type, codename=perm)
except ContentType.DoesNotExist:
print(f"Error: Could not find permission matching '{permission_string}'")
permission = None
return permission
# Add any required permissions to the group
for perm in permissions_to_add:
permission = get_permission_object(perm)
group.permissions.add(permission)
if debug:
print(f"Adding permission {perm} to group {group.name}")
# Remove any extra permissions from the group
for perm in permissions_to_delete:
permission = get_permission_object(perm)
group.permissions.remove(permission)
if debug:
print(f"Removing permission {perm} from group {group.name}")
@receiver(post_save, sender=Group)
def create_missing_rule_sets(sender, instance, **kwargs):
"""
Called *after* a Group object is saved.
As the linked RuleSet instances are saved *before* the Group,
then we can now use these RuleSet values to update the
group permissions.
"""
update_group_roles(instance)

View File

@ -1,4 +1,157 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# from __future__ import unicode_literals from __future__ import unicode_literals
# from django.test import TestCase from django.test import TestCase
from django.apps import apps
from django.contrib.auth.models import Group
from users.models import RuleSet
class RuleSetModelTest(TestCase):
"""
Some simplistic tests to ensure the RuleSet model is setup correctly.
"""
def test_ruleset_models(self):
keys = RuleSet.RULESET_MODELS.keys()
# Check if there are any rulesets which do not have models defined
missing = [name for name in RuleSet.RULESET_NAMES if name not in keys]
if len(missing) > 0:
print("The following rulesets do not have models assigned:")
for m in missing:
print("-", m)
# Check if models have been defined for a ruleset which is incorrect
extra = [name for name in keys if name not in RuleSet.RULESET_NAMES]
if len(extra) > 0:
print("The following rulesets have been improperly added to RULESET_MODELS:")
for e in extra:
print("-", e)
# Check that each ruleset has models assigned
empty = [key for key in keys if len(RuleSet.RULESET_MODELS[key]) == 0]
if len(empty) > 0:
print("The following rulesets have empty entries in RULESET_MODELS:")
for e in empty:
print("-", e)
self.assertEqual(len(missing), 0)
self.assertEqual(len(extra), 0)
self.assertEqual(len(empty), 0)
def test_model_names(self):
"""
Test that each model defined in the rulesets is valid,
based on the database schema!
"""
available_models = apps.get_models()
available_tables = set()
# Extract each available database model and construct a formatted string
for model in available_models:
label = model.objects.model._meta.label
label = label.replace('.', '_').lower()
available_tables.add(label)
assigned_models = set()
# Now check that each defined model is a valid table name
for key in RuleSet.RULESET_MODELS.keys():
models = RuleSet.RULESET_MODELS[key]
for m in models:
assigned_models.add(m)
missing_models = set()
for model in available_tables:
if model not in assigned_models and model not in RuleSet.RULESET_IGNORE:
missing_models.add(model)
if len(missing_models) > 0:
print("The following database models are not covered by the defined RuleSet permissions:")
for m in missing_models:
print("-", m)
extra_models = set()
defined_models = set()
for model in assigned_models:
defined_models.add(model)
for model in RuleSet.RULESET_IGNORE:
defined_models.add(model)
for model in defined_models:
if model not in available_tables:
extra_models.add(model)
if len(extra_models) > 0:
print("The following RuleSet permissions do not match a database model:")
for m in extra_models:
print("-", m)
self.assertEqual(len(missing_models), 0)
self.assertEqual(len(extra_models), 0)
def test_permission_assign(self):
"""
Test that the permission assigning works!
"""
# Create a new group
group = Group.objects.create(name="Test group")
rulesets = group.rule_sets.all()
# Rulesets should have been created automatically for this group
self.assertEqual(rulesets.count(), len(RuleSet.RULESET_CHOICES))
# Check that all permissions have been assigned permissions?
permission_set = set()
for models in RuleSet.RULESET_MODELS.values():
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))
# Add some more rules
for rule in rulesets:
rule.can_add = True
rule.can_change = True
rule.save()
group.save()
# There should now be three permissions for each rule set
self.assertEqual(group.permissions.count(), 3 * len(permission_set))
# Now remove *all* permissions
for rule in rulesets:
rule.can_view = False
rule.can_add = False
rule.can_change = False
rule.can_delete = False
rule.save()
group.save()
# There should now not be any permissions assigned to this group
self.assertEqual(group.permissions.count(), 0)

View File

@ -22,7 +22,8 @@ def apps():
'part', 'part',
'report', 'report',
'stock', 'stock',
'InvenTree' 'InvenTree',
'users',
] ]
def localDir(): def localDir():