diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index f9e3285781..21b8a0ead1 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -138,6 +138,7 @@ INSTALLED_APPS = [
     'part.apps.PartConfig',
     'report.apps.ReportConfig',
     'stock.apps.StockConfig',
+    'users.apps.UsersConfig',
 
     # Third part add-ons
     'django_filters',               # Extended filter functionality
diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py
index 0afb18f616..5f84ce507f 100644
--- a/InvenTree/company/apps.py
+++ b/InvenTree/company/apps.py
@@ -38,4 +38,5 @@ class CompanyConfig(AppConfig):
                             company.image = None
                             company.save()
         except (OperationalError, ProgrammingError):
-            print("Could not generate Company thumbnails")
+            # Getting here probably meant the database was in test mode
+            pass
diff --git a/InvenTree/company/migrations/0019_auto_20200413_0642.py b/InvenTree/company/migrations/0019_auto_20200413_0642.py
index c81dfd795a..c3c2f58ea0 100644
--- a/InvenTree/company/migrations/0019_auto_20200413_0642.py
+++ b/InvenTree/company/migrations/0019_auto_20200413_0642.py
@@ -24,7 +24,6 @@ def reverse_association(apps, schema_editor):
     # Exit if there are no SupplierPart objects
     # This crucial otherwise the unit test suite fails!
     if SupplierPart.objects.count() == 0:
-        print("No SupplierPart objects - skipping")
         return
 
     print("Reversing migration for manufacturer association")
@@ -105,7 +104,6 @@ def associate_manufacturers(apps, schema_editor):
     # Exit if there are no SupplierPart objects
     # This crucial otherwise the unit test suite fails!
     if SupplierPart.objects.count() == 0:
-        print("No SupplierPart objects - skipping")
         return
 
     # Link a 'manufacturer_name' to a 'Company'
diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py
index 2137ec5d89..198e58e337 100644
--- a/InvenTree/part/apps.py
+++ b/InvenTree/part/apps.py
@@ -37,4 +37,4 @@ class PartConfig(AppConfig):
                             part.image = None
                             part.save()
         except (OperationalError, ProgrammingError):
-            print("Could not generate Part thumbnails")
+            pass
diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index b12fa73f94..86d4bb1a86 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -1,3 +1,135 @@
 # -*- coding: utf-8 -*-
-# from __future__ import unicode_literals
-# from django.contrib import admin
+from __future__ import unicode_literals
+
+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)
diff --git a/InvenTree/users/apps.py b/InvenTree/users/apps.py
index 251989770b..07e303c1be 100644
--- a/InvenTree/users/apps.py
+++ b/InvenTree/users/apps.py
@@ -1,8 +1,33 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+from django.db.utils import OperationalError, ProgrammingError
+
 from django.apps import AppConfig
 
 
 class UsersConfig(AppConfig):
     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)
diff --git a/InvenTree/users/migrations/0001_initial.py b/InvenTree/users/migrations/0001_initial.py
new file mode 100644
index 0000000000..04071c5a63
--- /dev/null
+++ b/InvenTree/users/migrations/0001_initial.py
@@ -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')},
+            },
+        ),
+    ]
diff --git a/InvenTree/users/migrations/0002_auto_20201004_0158.py b/InvenTree/users/migrations/0002_auto_20201004_0158.py
new file mode 100644
index 0000000000..a0573a89be
--- /dev/null
+++ b/InvenTree/users/migrations/0002_auto_20201004_0158.py
@@ -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),
+        ),
+    ]
diff --git a/InvenTree/users/migrations/__init__.py b/InvenTree/users/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 40a96afc6f..09f2a046d1 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -1 +1,317 @@
 # -*- 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)
diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index 57c7c1fe6b..d14ffc4950 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -1,4 +1,157 @@
 # -*- 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)
diff --git a/tasks.py b/tasks.py
index 9948b470d1..df386633e9 100644
--- a/tasks.py
+++ b/tasks.py
@@ -22,7 +22,8 @@ def apps():
         'part',
         'report',
         'stock',
-        'InvenTree'
+        'InvenTree',
+        'users',
     ]
 
 def localDir():