Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor/jsonfield #1

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions django_prbac/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
__version__ = '1.0.1'
default_app_config = 'django_prbac.apps.DjangoprbacConfig'
8 changes: 8 additions & 0 deletions django_prbac/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.apps import AppConfig


class DjangoprbacConfig(AppConfig):
name = 'django_prbac'

def ready(self):
import django_prbac.signals
4 changes: 3 additions & 1 deletion django_prbac/arbitrary.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import uuid
from random import choice

from django.contrib.auth.models import User
from django.contrib.auth import get_user_model

User = get_user_model()

from django_prbac.models import *

Expand Down
10 changes: 10 additions & 0 deletions django_prbac/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.core.cache import cache


class RoleCache:

def __init__(self, timeout:int = 30) -> None:
self.timeout = timeout

def clear(self):
cache.clear()
7 changes: 5 additions & 2 deletions django_prbac/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
from django.db import models, migrations
import django_prbac.fields
from django.conf import settings
import jsonfield.fields
try:
from django.db.models import JSONField
except (ImportError, AttributeError):
from django_jsonfield_backport.models import JSONField
import django_prbac.models


Expand All @@ -20,7 +23,7 @@ class Migration(migrations.Migration):
name='Grant',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('assignment', jsonfield.fields.JSONField(default=dict, help_text='Assignment from parameters (strings) to values (any JSON-compatible value)', blank=True)),
('assignment', JSONField(default=dict, help_text='Assignment from parameters (strings) to values (any JSON-compatible value)', blank=True)),
],
bases=(django_prbac.models.ValidatingModel, models.Model),
),
Expand Down
22 changes: 22 additions & 0 deletions django_prbac/migrations/0002_auto_20210930_1858.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 2.2.24 on 2021-09-30 18:58

from django.db import migrations
try:
from django.db.models import JSONField
except (ImportError, AttributeError):
from django_jsonfield_backport.models import JSONField


class Migration(migrations.Migration):

dependencies = [
('django_prbac', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='grant',
name='assignment',
field=JSONField(blank=True, default=dict, help_text='Assignment from parameters (strings) to values (any JSON-compatible value)'),
),
]
15 changes: 15 additions & 0 deletions django_prbac/migrations/0003_auto_20211004_1133.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Generated by Django 2.2.24 on 2021-10-04 11:33

from django.db import migrations
from django.contrib.postgres.operations import BtreeGinExtension


class Migration(migrations.Migration):

dependencies = [
('django_prbac', '0002_auto_20210930_1858'),
]

operations = [
BtreeGinExtension()
]
18 changes: 18 additions & 0 deletions django_prbac/migrations/0004_auto_20211004_1139.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.24 on 2021-10-04 11:39

import django.contrib.postgres.indexes
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('django_prbac', '0003_auto_20211004_1133'),
]

operations = [
migrations.AddIndex(
model_name='grant',
index=django.contrib.postgres.indexes.GinIndex(fields=['assignment'], name='django_prba_assignm_471ffc_gin'),
),
]
11 changes: 9 additions & 2 deletions django_prbac/mock_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'django-prbac.db',
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'django-prbac',
'USER': 'postgres',
'PASSWORD': 'postgres',
'HOST': 'localhost',
'PORT': 5432,
'TEST': {
'NAME': 'django-prbac-test'
}
}
}

Expand Down
110 changes: 76 additions & 34 deletions django_prbac/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,28 @@
import weakref

from django import VERSION
from django.contrib.postgres.indexes import GinIndex
from django.core.cache import cache
from django.db import models
from django.conf import settings
import json, hashlib
from django_prbac.cache import RoleCache

if VERSION[0] < 3:
from django.utils.encoding import python_2_unicode_compatible
else:
def python_2_unicode_compatible(fn):
return fn

import jsonfield

from django_prbac.fields import StringSetField

try:
from django.db.models import JSONField
except (ImportError, AttributeError):
from django_jsonfield_backport.models import JSONField

CACHE_TIMEOUT = getattr(settings, 'DJANGO_PRBAC_CACHE_TIMEOUT', 60)


__all__ = [
'Role',
Expand Down Expand Up @@ -74,31 +84,67 @@ class Meta:
# Methods
# -------

def get_privilege_cache_key(self, slug: str, assignment: dict) -> str:
"""
Generate a cache key to determine the result of a permission check from this role
to the given role + assignment
"""
encoded = json.dumps(assignment, sort_keys=True).encode()
dhash = hashlib.md5(encoded)
return f'prbac-grant:{self.slug}->{slug}:{dhash.hexdigest()}'

def check_privilege(self, slug: str, assignment: dict):
"""
Refactored to try out a recursive version using JSONField lookups
:param privilege:
The role we wish to check whether we have a grant to
:param assignment:
Specific assigments to check in the grant
:return:
"""

# We can cache individual permission checks instead of the entire lookup table
# We also cache results in a shared cache instead of in-memory
key = self.get_privilege_cache_key(slug, assignment)
res = cache.get(key, None)
if res is not None:
return res

# default is permission-denied
res = False

# recurse through all granted memberships that include any part of the assignment
grants = self.memberships_granted.\
select_related().filter(assignment__contained_by=assignment)

for g in grants:

# If this grant exactly matches, break out and return True
if g.to_role.slug == slug and g.assignment == assignment:
res = True
break
else:

# recurse down the tree of grants
res = g.to_role.check_privilege(slug, assignment)
if res is True:
break

# cache the result for future checks
cache.set(key, res, CACHE_TIMEOUT)
return res

@classmethod
def get_cache(cls):
try:
cache = cls.cache
except AttributeError:
timeout = getattr(settings, 'DJANGO_PRBAC_CACHE_TIMEOUT', 60)
cache = cls.cache = DictCache(timeout)
cache = cls.cache = RoleCache(CACHE_TIMEOUT)
return cache

@classmethod
def update_cache(cls):
roles = cls.objects.prefetch_related('memberships_granted').all()
roles = {role.id: role for role in roles}
for role in roles.values():
role._granted_privileges = privileges = []
# Prevent extra queries by manually linking grants and roles
# because Django 1.6 isn't smart enough to do this for us
for membership in role.memberships_granted.all():
membership.to_role = roles[membership.to_role_id]
membership.from_role = roles[membership.from_role_id]
privileges.append(membership.instantiated_to_role({}))
cache = cls.get_cache()
cache.set(cls.ROLES_BY_ID, roles)
cache.set(cls.PRIVILEGES_BY_SLUG,
{role.slug: role.instantiate({}) for role in roles.values()})
pass

@classmethod
def get_privilege(cls, slug, assignment=None):
Expand All @@ -108,12 +154,12 @@ def get_privilege(cls, slug, assignment=None):
This optimization is specifically geared toward cases where
`assignments` is empty.
"""
cache = cls.get_cache()
if cache.disabled:
roles = Role.objects.filter(slug=slug)
if roles:
return roles[0].instantiate(assignment or {})
return None
# cache = cls.get_cache()
# if cache.disabled:
roles = Role.objects.filter(slug=slug)
if roles:
return roles[0].instantiate(assignment or {})
return None
privileges = cache.get(cls.PRIVILEGES_BY_SLUG)
if privileges is None:
cls.update_cache()
Expand All @@ -129,14 +175,7 @@ def get_cached_role(self):
"""
Optimized lookup of role by id
"""
cache = self.get_cache()
if cache.disabled:
return self
roles = cache.get(self.ROLES_BY_ID)
if roles is None or self.id not in roles:
self.update_cache()
roles = cache.get(self.ROLES_BY_ID)
return roles.get(self.id, self)
return self

def get_privileges(self, assignment):
if not assignment:
Expand All @@ -145,7 +184,7 @@ def get_privileges(self, assignment):
except AttributeError:
pass
return [membership.instantiated_to_role(assignment)
for membership in self.memberships_granted.all()]
for membership in self.memberships_granted.filter(assignment__contains=assignment)]

def instantiate(self, assignment):
"""
Expand Down Expand Up @@ -208,14 +247,17 @@ class Grant(ValidatingModel, models.Model):
on_delete=models.CASCADE,
)

assignment = jsonfield.JSONField(
assignment = JSONField(
help_text='Assignment from parameters (strings) to values (any JSON-compatible value)',
blank=True,
default=dict,
)

class Meta:
app_label = 'django_prbac'
indexes = [
GinIndex(fields=['assignment'])
]

# Methods
# -------
Expand Down
18 changes: 18 additions & 0 deletions django_prbac/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django_prbac.models import Grant
from django.core.cache import cache


@receiver(post_save, sender=Grant, dispatch_uid='clear-grant-cache')
def clear_grant_cache(sender, instance, created, **kwargs):
if isinstance(instance, Grant):
key = instance.from_role.get_privilege_cache_key(instance.to_role.slug, instance.assignment)
cache.delete(key)


@receiver(pre_delete, sender=Grant, dispatch_uid='clear-grant-cache-delete')
def clear_grant_cache_delete(sender, instance, **kwargs):
if isinstance(instance, Grant):
key = instance.from_role.get_privilege_cache_key(instance.to_role.slug, instance.assignment)
cache.delete(key)
14 changes: 13 additions & 1 deletion django_prbac/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.test import TestCase # https://code.djangoproject.com/ticket/20913

from django_prbac.models import Role
from django_prbac.models import Role, Grant
from django_prbac import arbitrary


Expand Down Expand Up @@ -95,6 +95,18 @@ def test_instantiated_to_role_smoke_test(self):
grant = arbitrary.grant(to_role=superrole, assignment={'two': 'goodbye'})
self.assertEqual(grant.instantiated_to_role({}).assignment, {})

def test_query_grant_assignments(self):
"""
Test we can search grants using features in newer JSONField
"""

user = arbitrary.role()
privilege = arbitrary.role(parameters={'thing'})
for thing in ['thingone', 'thingtwo']:
arbitrary.grant(to_role=privilege, from_role=user, assignment={'thing': thing})

assert Grant.objects.filter(from_role=user, assignment__thing='thingone').count() == 1


class TestUserRole(TestCase):

Expand Down
Empty file added django_prbac_example/README.md
Empty file.
18 changes: 18 additions & 0 deletions django_prbac_example/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import uuid
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, User
from django_prbac.models import Role

@pytest.fixture()
def user() -> get_user_model():
UserModel: User = get_user_model()
return UserModel.objects.create_user(
username=str(uuid.uuid4()),
email=f'{uuid.uuid4()}@example.com',
password='1234'
)

@pytest.fixture()
def user_role() -> Role:
return Role.objects.create(name=str(uuid.uuid4()), slug=str(uuid.uuid4()), description='User Role')
Binary file added django_prbac_example/db.sqlite3
Binary file not shown.
Empty file.
16 changes: 16 additions & 0 deletions django_prbac_example/django_prbac_example/asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
ASGI config for django_prbac_example project.

It exposes the ASGI callable as a module-level variable named ``application``.

For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""

import os

from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_prbac_example.settings')

application = get_asgi_application()
Loading