To protect your data, the CISO officer has suggested users to enable GitLab 2FA as soon as possible.

Commit 022b3f0d authored by Lincoln Smith's avatar Lincoln Smith
Browse files

Made pfp-makeperms less chatty. Version bump.

- PFPModelForm now merges attribute/parameter disabled fields with
  those generated from a user parameter.
parent 4b8ad4b2
Implements permissions for model fields in Django
Requirements
============
## Requirements
* Python 3.4+
* Django 1.11+
Installation
============
Include the 'perfieldperms' directory in your Django project as an application.
OR
Use pip to install it as a package.
## Installation
`pip install django-perfieldperms`
Add ``perfieldperms.apps.PerfieldpermsConfig`` to Django's installed
applications.
Add perfieldperms to INSTALLED_APPS and AUTHENTICATION_BACKENDS:
```python
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
...
'perfieldperms.apps.PerfieldpermsConfig',
]
Add ``perfieldperms.backends.PFPBackend`` to ``AUTHENTICATION_BACKENDS`` in
your ``settings.py``
AUTHENTICATION_BACKENDS = [
'perfieldperms.backends.PFPBackend',
]
```
Run ``manage.py migrate``
Run `manage.py migrate`
Configuration
=============
## Configuration
perfieldperms is configurable via ``settings.py`` or via internal models.
settings.py
-----------
PFP_MODELS -- an iterable of two tuples [(app_label, model name)] of models you
Once configured you will need to run `./manage.py pfp-makeperms` to create
field permissions.
### settings.py
PFP_MODELS - an iterable of two tuples `[(app_label, model_name)]` of models you
want perfieldpermissions (pfps) created for.
PFP_IGNORE_PERMS -- a dict of dicts of iterables of permissions you want
ignored when creating pfps. Structured::
{app_label:
{model name: [<perm codename>, <perm codename>,..]}
PFP_IGNORE_PERMS - a dict of dicts of iterables of permissions you want
ignored when creating pfps. Structured:
```python
{app_label:
{model name: [<perm codename>, <perm codename>,..]}
}
```
PFP_IGNORE_DELETE -- By default perfieldperms doesn't create field level delete
PFP_IGNORE_DELETE - By default perfieldperms doesn't create field level delete
permissions as this doesn't necessarily make sense. Set to False if you want to
create delete pfps.
Internal models
---------------
PFPContentType -- Django ContentTypes you want to create pfps. This setting is
PFP_IGNORE_VIEW - By default perfieldperms doesn't create field level view
permissions as this doesn't necessarily make sense. Set to False if you want to
create view pfps.
### Internal models
PFPContentType - Django ContentTypes you want to create pfps for. This setting is
merged with PFP_MODELS from ``settings.py``.
Use
===
## Management commands
pfp-makeperms - Create field permissions for configured models.
## Forms
`perfieldperms.forms.PFPModelForm` extends `django.forms.ModelForm` to apply
field permissions to a ModelForm. Fields can be disabled or removed entirely
based on class attributes/parameters, or by passing in a user to check
permissions against at form creation.
## Use
After configuring which models you want to creats pfps for run the management
command ``pfp-makeperms``.
command `./manage.py pfp-makeperms`.
PerFieldPermission subclasses Permission so pfps can be accessed, allocated and
tested as normal Permission objects. PerFieldPermissions are linked to a parent
"model permission" to create the appropriate hierarchy. Depending on needs you
model permission to create the appropriate hierarchy. Depending on needs you
may not need to access the actual PerFieldPermission objects.
Perfieldperms tries to take a Principle of Least Astonishment approach to
......@@ -66,12 +84,11 @@ permission structures:
are a member of. The effect is to explicitly set the fields the user has
access to. This allows for the creation of limited exceptions within groups.
* Possession of a field permission implies access to the model, so testing
access to a model via e.g. ModelAdmin had_add_permission() will succeed if
access to a model via e.g. ModelAdmin `had_add_permission()` will succeed if
the user has any applicable field permission.
Admin interface
---------------
Two ModelAdmins ``PFPModelAdmin`` and ``PFPInlineAdmin`` are provided that
## Admin interface
Two ModelAdmins `PFPModelAdmin` and `PFPInlineAdmin` are provided that
extend the appropriate ModelAdmin, disabling fields in forms as appropriate.
These can be used as is or as Mixins to extend other ModelAdmin classes.
......@@ -82,8 +99,7 @@ permissions/roles (where a role is a user or group,) and generates a table
based form listing permissions against users. It is terrible and needs
replacing :)
Tests
=====
Use ``runtests.py`` to run tests. Test sub-modules and individual tests can be
## Tests
Use `runtests.py` to run tests. Test sub-modules and individual tests can be
targetted by supplying the appropriate python module address as an argument to
the script.
......@@ -114,7 +114,7 @@ class PFPContentTypesForm(forms.ModelForm):
# Base ModelForm Classes
class PFPModelForm(forms.ModelForm):
"""
Extension to ModelForm that modifies fields based on user permissions.
Extension to ModelForm that disables or removes fields based on user permissions.
Attributes
----------
......@@ -123,6 +123,8 @@ class PFPModelForm(forms.ModelForm):
disable_fields : bool, optional
If True, disable fields on the form, otherwise completely remove them from the
form
disabled_field_help_text : str, optional
A string to use as help text for disabled fields.
Parameters
----------
......@@ -136,33 +138,32 @@ class PFPModelForm(forms.ModelForm):
"""
disable_fields = True
disabled_fields = None
disabled_field_help_text = 'This field is disabled as you lack required permissions.'
def __init__(self, *args, **kwargs):
# Overwrite class settings with those passed as kwargs
# Process args and overwrite class settings with those passed as kwargs
if self.disabled_fields is None:
self.disabled_fields = []
self.disable_fields = kwargs.pop('disable_fields', self.disable_fields)
self.disabled_fields = kwargs.pop('disabled_fields', self.disabled_fields)
# If we were passed a user grab them
user = None
if 'user' in kwargs:
user = kwargs.pop('user')
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# If we have a user use them to generate disabled_fields
# If we have a user work out which fields they don't have access to and merge that list
# with any preconfigured fields.
if user:
# If instance.pk is None it's a new unsaved object so 'add' permissions apply,
# otherwise it's an existing object and we're changing.
if hasattr(self.instance, 'pk') and self.instance.pk is not None:
self.disabled_fields = get_unpermitted_fields(
user_disabled_fields = get_unpermitted_fields(
self._meta.model,
user,
obj=self.instance,
)
else:
self.disabled_fields = get_unpermitted_fields(self._meta.model, user)
user_disabled_fields = get_unpermitted_fields(self._meta.model, user)
self.disabled_fields = list(set(self.disabled_fields + user_disabled_fields))
self._filter_fields()
def _filter_fields(self):
......@@ -173,7 +174,6 @@ class PFPModelForm(forms.ModelForm):
if fname in form_fields:
if self.disable_fields:
self.fields[fname].disabled = True
self.fields[fname].help_text = ('This field is disabled as you lack required '
'permissions.')
self.fields[fname].help_text = self.disabled_field_help_text
else:
del self.fields[fname]
......@@ -61,15 +61,14 @@ class Command(BaseCommand):
return perms
def handle(self, *args, **options):
verbose = options['verbosity'] > 1
# Get all the models to create permissions for
models = set()
if hasattr(settings, 'PFP_MODELS'):
models.update(set(settings.PFP_MODELS))
model_ctypes = ContentType.objects.filter(
pfpcontenttype__isnull=False)
models.update(set(
[(ctype.app_label, ctype.model) for ctype in model_ctypes]
))
model_ctypes = ContentType.objects.filter(pfpcontenttype__isnull=False)
models.update(set([(ctype.app_label, ctype.model) for ctype in model_ctypes]))
deleted = 0
existing = 0
......@@ -82,36 +81,31 @@ class Command(BaseCommand):
ignore_perms = settings.PFP_IGNORE_PERMS[app_label][model]
ctype = ContentType.objects.get(app_label=app_label, model=model)
model_fields = set(
[field for field, name in list_fields_for_ctype(ctype)]
)
model_fields = set([field for field, name in list_fields_for_ctype(ctype)])
perms = self._get_perms(ctype)
# Remove perms for removed fields, add permissions for new fields
for perm in perms:
pfps = PerFieldPermission.objects.filter(
model_permission=perm)
with transaction.atomic():
for perm in perms:
pfps = PerFieldPermission.objects.filter(model_permission=perm)
if perm.codename in ignore_perms:
if settings.DEBUG:
for pfp in pfps:
self.stdout.write(
'Deleted permission {} for ignored permission {}.{} - '
'{}'.format(
pfp.codename,
app_label,
model,
pfp.field_name
)
)
if pfps:
# pfps are a multi-table subclass so divide by 2 to get
# 'actual' number deleted
deleted += pfps.delete()[0] / 2
else:
with transaction.atomic():
if perm.codename in ignore_perms:
if verbose:
for pfp in pfps:
self.stdout.write(
'Deleted permission {} for ignored permission {}.{} - '
'{}'.format(pfp.codename, app_label, model, pfp.field_name)
)
if pfps:
# pfps are a multi-table subclass so divide by 2 to get actual number
# deleted
deleted += pfps.delete()[0] / 2
else:
fields = model_fields.copy()
pfps = set(pfps)
existing += len(pfps)
# Delete any permissions for fields that don't exist anymore
while pfps:
pfp = pfps.pop()
if pfp.field_name in fields:
......@@ -119,16 +113,11 @@ class Command(BaseCommand):
else:
pfp.delete()
deleted += 1
if settings.DEBUG:
self.stdout.write(
'Deleted permission {} for removed field {}.{} - '
'{}'.format(
pfp.codename,
app_label,
model,
pfp.field_name
)
)
verbose and self.stdout.write(
'Deleted permission {} for removed field {}.{} - '
'{}'.format(pfp.codename, app_label, model, pfp.field_name)
)
# Add new permissions
for field in fields:
new_pfp = PerFieldPermission(
content_type=ctype,
......@@ -140,15 +129,14 @@ class Command(BaseCommand):
new_pfp.full_clean()
new_pfp.save()
added += 1
if settings.DEBUG:
self.stdout.write(
'Added new permission {} for field {}.{} - {}'.format(
new_pfp.codename,
app_label,
model,
new_pfp.field_name
)
)
self.stdout.write('Permissions added: {}\nPermissions deleted: {}\nExisting permissions '
'skipped: {}'.format(added, deleted, existing)
)
verbose and self.stdout.write(
'Added new permission {} for field {}.{} - {}'.format(
new_pfp.codename,
app_label,
model,
new_pfp.field_name
)
)
if verbose:
return ('Permissions added: {}\nPermissions deleted: {}\nExisting permissions '
'skipped: {}'.format(added, deleted, existing))
......@@ -8,7 +8,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__),
setup(
name='django-perfieldperms',
version='0.1.6',
version='0.1.7',
packages=find_packages(exclude=['tests*']),
include_package_data=True,
license='Apache-2.0',
......@@ -24,7 +24,8 @@ setup(
'Development Status :: 3 - Alpha',
'Environment :: Web Environment',
'Framework :: Django :: 1.11',
'Framework :: Django :: 2',
'Framework :: Django :: 2.0',
'Framework :: Django :: 2.1',
'Intended Audience :: Developers',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Software Development :: Libraries :: Python Modules',
......
from django.test import TestCase, override_settings
from django.contrib.auth.models import Permission, User, Group
from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.models import ContentType
from django.core.management import call_command
from django.forms.models import modelform_factory
from django.test import TestCase, override_settings
from perfieldperms.forms import PFPModelForm, PFPPermFilterForm, PFPRoleFilterForm
from perfieldperms.models import PFPFilterType, PFPRoleType
from perfieldperms.utils import get_non_pfp_perms, get_unpermitted_fields
from . import helpers
from .models import Pizza
......@@ -43,12 +43,12 @@ class PFPModelFormTest(TestCase):
form = PizzaForm(disabled_fields=['name'], disable_fields=False)
self.assertEqual(['name'], form.disabled_fields)
self.assertFalse(form.disable_fields)
# Set via passing in user overrides other config methods
# Set via passing in user merged with other config methods
user1 = User.objects.create_user(username='user1', password='sekret')
pfp = helpers.get_perm('tests', 'pizza', 'add_pizza__name')
user1.user_permissions.add(pfp)
form = PizzaForm(disabled_fields=['name'], user=user1)
self.assertSetEqual(set(['price', 'toppings', 'bases']), set(form.disabled_fields))
self.assertSetEqual({'name', 'price', 'toppings', 'bases'}, set(form.disabled_fields))
self.assertTrue(form.disable_fields)
def test_modify_fields(self):
......@@ -87,7 +87,7 @@ class PFPRoleFilterFormTest(TestCase):
user_ctype = ContentType.objects.get_for_model(User)
form = PFPRoleFilterForm()
self.assertSetEqual(
set([user_ctype]),
{user_ctype},
set(form.fields['role_type'].queryset),
)
......
......@@ -32,12 +32,9 @@ class PFPMakepermsTest(TestCase):
ctype = ContentType.objects.get(app_label='auth', model='user')
PFPContentType(content_type=ctype).save()
out = StringIO()
call_command('pfp-makeperms', stdout=out)
call_command('pfp-makeperms', stdout=out, verbosity=2)
pfps = PerFieldPermission.objects.all()
self.assertIn(
'Permissions added: {}'.format(len(pfps)),
out.getvalue()
)
self.assertIn('Permissions added: {}'.format(len(pfps)), out.getvalue())
for pfp in pfps:
with self.subTest(pfp=pfp):
self.assertIn(
......@@ -106,36 +103,32 @@ class PFPMakepermsTest(TestCase):
@override_settings(DEBUG=True)
def test_add_perms_delete_perm(self):
"""Deletes permissions for removed fields."""
pfp = helpers.create_pfp('auth', 'user', 'change_user', 'foo')
helpers.create_pfp('auth', 'user', 'change_user', 'foo')
ctype = ContentType.objects.get(app_label='auth', model='user')
PFPContentType.objects.create(content_type=ctype)
fields = list_fields_for_ctype(ctype)
num_pfps = len(fields) * 2
out = StringIO()
call_command('pfp-makeperms', stdout=out)
call_command('pfp-makeperms', stdout=out, verbosity=2)
pfps = PerFieldPermission.objects.count()
self.assertEqual(pfps, num_pfps)
self.assertIn('Permissions deleted: 1', out.getvalue())
self.assertIn(
'Deleted permission change_user__foo for removed field '
'auth.user - foo',
'Deleted permission change_user__foo for removed field auth.user - foo',
out.getvalue()
)
@override_settings(DEBUG=True)
def test_add_perms_skip_existing(self):
"""Doesn't try to duplicate already existing PFPs."""
pfp = helpers.create_pfp('auth', 'user', 'change_user', 'is_staff')
helpers.create_pfp('auth', 'user', 'change_user', 'is_staff')
ctype = ContentType.objects.get(app_label='auth', model='user')
PFPContentType.objects.create(content_type=ctype)
fields = list_fields_for_ctype(ctype)
num_created = len(fields) * 2 - 1
out = StringIO()
call_command('pfp-makeperms', stdout=out)
self.assertIn(
'Permissions added: {}'.format(num_created),
out.getvalue()
)
call_command('pfp-makeperms', stdout=out, verbosity=2)
self.assertIn('Permissions added: {}'.format(num_created), out.getvalue())
self.assertIn('Existing permissions skipped: 1', out.getvalue())
def test_existing_perms_removed(self):
......@@ -144,21 +137,19 @@ class PFPMakepermsTest(TestCase):
PFPContentType.objects.create(content_type=ctype)
out = StringIO()
call_command('pfp-makeperms', stdout=out)
with self.settings(PFP_IGNORE_PERMS={'auth': {'user': ['add_user'] }}):
with self.settings(PFP_IGNORE_PERMS={'auth': {'user': ['add_user']}}):
out = StringIO()
call_command('pfp-makeperms', stdout=out)
call_command('pfp-makeperms', stdout=out, verbosity=2)
fields = list_fields_for_ctype(ctype)
num_pfps = len(fields)
pfps = PerFieldPermission.objects.count()
self.assertEqual(pfps, num_pfps)
self.assertIn('Permissions deleted: {}'.format(len(fields)),
out.getvalue())
self.assertIn('Permissions deleted: {}'.format(len(fields)), out.getvalue())
def test_lots_of_skipping(self):
"""
Skip over multiple sets of PFPs to make sure loop variables are
being reset properly.
Skip over multiple sets of PFPs to make sure loop variables are being reset properly.
"""
ctype = ContentType.objects.get(app_label='auth', model='user')
PFPContentType.objects.create(content_type=ctype)
......@@ -166,10 +157,8 @@ class PFPMakepermsTest(TestCase):
Permission.objects.create(content_type=ctype, codename='foo_user')
fields = list_fields_for_ctype(ctype)
out = StringIO()
call_command('pfp-makeperms', stdout=out)
call_command('pfp-makeperms', stdout=out, verbosity=2)
pfps = PerFieldPermission.objects.count()
self.assertEqual(pfps, len(fields) * 3)
self.assertIn('Existing permissions skipped: {}'.format(len(fields) * 2),
out.getvalue())
self.assertIn('Permissions added: {}'.format(len(fields)),
out.getvalue())
self.assertIn('Existing permissions skipped: {}'.format(len(fields) * 2), out.getvalue())
self.assertIn('Permissions added: {}'.format(len(fields)), out.getvalue())
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment