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

Template tags #3

Merged
merged 6 commits into from
Sep 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.vscode/
.pytest_cache/
.pyc
*.sqlite3
*.db
build/
dist/
django_cancan.egg-info/
7 changes: 6 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ django-environ = "*"
setuptools = "*"
wheel = "*"
twine = "*"
black = "*"
django-extensions = "*"

[requires]
python_version = "3.7"

[scripts]
build = "python setup.py sdist bdist_wheel"
publish = "twine upload --skip-existing dist/*"
publish = "twine upload --skip-existing dist/*"

[pipenv]
allow_prereleases = true
263 changes: 187 additions & 76 deletions Pipfile.lock

Large diffs are not rendered by default.

141 changes: 125 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,32 +43,43 @@ INSTALLED_APPS = [
]
```

2. Create a function that define user abilites. For example, in `abilities.py`:
2. Create a function that define the access rules for a given user. For example, create `abilities.py` in `myapp` module:

```python
def declare_abilities(user, ability):
def define_access_rules(user, rules):
# Anybody can view published articles
rules.allow('view', Article, published=True)

if not user.is_authenticated:
# Allow anonymous users to view published articles
return ability.can('view', Article, published=True)
return

# Allow logged in user to view his own articles, regardless of the `published` status
rules.allow('view', Article, author=user)

if user.has_perm('article.view_own_article'):
# Allow logged in user to change his articles
return ability.can('change', Article, author=user)
if user.has_perm('article.view_unpublished'):
# You can also check for custom model permissions (i.e. view_unpublished)
rules.allow('view', Article, published=False)


if user.is_superuser:
# Allow superuser change all articles
return ability.can('change', Article)
# Superuser gets unlimited access to all articles
rules.allow('add', Article)
rules.allow('view', Article)
rules.allow('change', Article)
rules.allow('delete', Article)
```

3. Configure `cancan` by adding `CANCAN` section in `settings.py`:
3. In `settings.py` add `CANCAN` section, so that `cancan` library will know where to search for `define_access_rules` function from the previous step:

```python
CANCAN = {
'ABILITIES': 'myapp.abilities.declare_abilities'
'ABILITIES': 'myapp.abilities.define_access_rules'
}
```

Next, add `cancan` middleware after `AuthenticationMiddleware`:
The `define_access_rules` function will be executed automatically per each request by the `cancan` middleware. The middleware will call the function to determine the abilities of a current user.

Let's add `cancan` middleware, just after `AuthenticationMiddleware`:

```python
MIDDLEWARE = [
Expand All @@ -79,18 +90,21 @@ MIDDLEWARE = [
]
```

Adding the middleware adds `request.ability` instance which you can use
to check for: model permissions, object permissions and model querysets.
By adding the middleware you will also get an access to `request.ability` instance which you can use
to:
- check model permissions,
- check object permissions,
- generate model querysets (i.e. in case of `ListView`s

4. Check abilities in views:
4. Check for abilities in views:

```python

class ArticleListView(ListView):
model = Article

def get_queryset():
# this is how you can retrieve all objects a user can access
# this is how you can retrieve all objects that current user can access
qs = self.request.ability.queryset_for('view', Article)
return qs

Expand All @@ -104,6 +118,101 @@ class ArticleDetailView(PermissionRequiredMixin, DetailView):
return self.request.ability.can('view', article)
```

5. Check for abilities in templates

You can also check for abilities in template files, i. e. to show/hide/disable buttons or links.

First you need to add `cancan` processor to `context_processors` in `TEMPLATES` section of `settings.py`:

```python
TEMPLATES = [
{
...,
"OPTIONS": {
"context_processors": [
...,
"cancan.context_processors.abilities",
],
},
},
]
```

This will give you access to `ability` object in a template. You also need add `{% load cancan_tags %}` at the beginning
of the template file.

Next you can check for object permissions:

```
{% if ability|can:"change"|subject:article %}
<a href="{% url 'article_edit' pk=article.id %}">Edit article</a>
{% endif %}
```

or model permissions:

```
{% load cancan_tags %}

...

{% if ability|can:"add"|"myapp.Article" %}
<a href="{% url 'article_new' %}">Create new article</a>
{% endif %}
```

You can also use `can` template tag to create a reusable variable:

```
{% can "add" "core.Project" as can_add_project %}
...
{% if can_add_project %}
...
{% endif %}
```

## Checking for abilities in Django Rest Framework

Let's start by creating a pemission class:

```python
from rest_framework import permissions

def set_aliases_for_drf_actions(ability):
"""
map DRF actions to default Django permissions
"""
ability.access_rules.set_alias("list", "view")
ability.access_rules.set_alias("retrieve", "view")
ability.access_rules.set_alias("create", "add")
ability.access_rules.set_alias("update", "change")
ability.access_rules.set_alias("partial_update", "change")
ability.access_rules.set_alias("destroy", "delete")


class AbilityPermission(permissions.BasePermission):
def has_permission(self, request, view=None):
ability = request.ability
set_aliases_for_drf_actions(ability)
return ability.can(view.action, view.get_queryset().model)

def has_object_permission(self, request, view, obj):
ability = request.ability
set_aliases_for_drf_actions(ability)
return ability.can(view.action, obj)
```

Next, secure the ViewSet with `AbilityPermission` and override `get_queryset` method to list objects based on the access rights.

```python
class ArticleViewset(ModelViewSet):
permission_classes = [AbilityPermission]

def get_queryset(self):
return self.request.ability.queryset_for(self.action, Article).distinct()
```


## Sponsors

<a href="https://ermlab.com/" target="_blank">
Expand Down
93 changes: 36 additions & 57 deletions cancan/ability.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,23 @@
import inspect
from django.apps import apps
from .access_rules import AccessRules, normalize_subject


class Ability:
def __init__(self, user):
self.user = user
self.abilities = []
self.aliases = {}

def can(self, action, model, **kwargs):
if type(model) is str:
model = apps.get_model(model)
self.abilities.append({
'type': 'can',
'action': action,
'model': model,
'conditions': kwargs,
})

def cannot(self, action, model, **kwargs):
if type(model) is str:
model = apps.get_model(model)

self.abilities.append({
'type': 'cannot',
'action': action,
'model': model,
'conditions': kwargs,
})

def set_alias(self, alias, action):
self.aliases[alias] = action

def alias_to_action(self, alias):
return self.aliases.get(alias, alias)


class AbilityValidator:
def __init__(self, ability: Ability):
self.ability = ability
def __init__(self, access_rules: AccessRules):
self.access_rules = access_rules

def validate_model(self, action, model):
can_count = 0
cannot_count = 0
model_abilities = filter(
lambda c: c['model'] == model and c['action'] == action, self.ability.abilities)
lambda c: c["subject"] == model and c["action"] == action,
self.access_rules.rules,
)
for c in model_abilities:
if c['type'] == 'can':
if c["type"] == "can":
can_count += 1
if c['type'] == 'cannot':
if c["type"] == "cannot":
cannot_count += 1

if cannot_count > 0:
Expand All @@ -60,16 +29,19 @@ def validate_model(self, action, model):
def validate_instance(self, action, instance):
model = instance._meta.model
model_abilities = filter(
lambda c: c['model'] == model and c['action'] == action, self.ability.abilities)
lambda c: c["subject"] == model and c["action"] == action,
self.access_rules.rules,
)

query_sets = []
for c in model_abilities:
if c['type'] == 'can':
qs = model.objects.all().filter(pk=instance.id, **c.get('conditions', {}))
if c["type"] == "can":
qs = model.objects.all().filter(
pk=instance.id, **c.get("conditions", {})
)

if c['type'] == 'cannot':
raise NotImplementedError(
'cannot-type rules are not yet implemented')
if c["type"] == "cannot":
raise NotImplementedError("cannot-type rules are not yet implemented")

query_sets.append(qs)

Expand All @@ -82,27 +54,30 @@ def validate_instance(self, action, instance):

return can_query_set.count() > 0

def can(self, action, model_or_instance) -> bool:
action = self.ability.alias_to_action(action)
if inspect.isclass(model_or_instance):
return self.validate_model(action, model_or_instance)
def can(self, action, subject) -> bool:
subject = normalize_subject(subject)
action = self.access_rules.alias_to_action(action)
if inspect.isclass(subject):
return self.validate_model(action, subject)
else:
return self.validate_instance(action, model_or_instance)
return self.validate_instance(action, subject)

def queryset_for(self, action, model):
action = self.ability.alias_to_action(action)
model = normalize_subject(model)
action = self.access_rules.alias_to_action(action)

model_abilities = filter(
lambda c: c['model'] == model and c['action'] == action, self.ability.abilities)
lambda c: c["subject"] == model and c["action"] == action,
self.access_rules.rules,
)

query_sets = []
for c in model_abilities:
if c['type'] == 'can' and 'conditions' in c:
qs = model.objects.all().filter(**c.get('conditions', {}))
if c["type"] == "can" and "conditions" in c:
qs = model.objects.all().filter(**c.get("conditions", {}))

if c['type'] == 'cannot':
raise NotImplementedError(
'cannot-type rules are not yet implemented')
if c["type"] == "cannot":
raise NotImplementedError("cannot-type rules are not yet implemented")

query_sets.append(qs)

Expand All @@ -114,3 +89,7 @@ def queryset_for(self, action, model):
can_query_set |= qs

return can_query_set

def __contains__(self, item):
action, subject = item
return self.can(action, subject)
34 changes: 34 additions & 0 deletions cancan/access_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.apps import apps


def normalize_subject(subject):
if isinstance(subject, str):
try:
app_label, model_name = subject.split(".")
return apps.get_model(app_label, model_name)
except Exception as e:
pass
return subject


class AccessRules:
def __init__(self, user):
self.user = user
self.rules = []
self.action_aliases = {}

def allow(self, action, subject, **kwargs):
rule = {
"type": "can",
"action": action,
"subject": normalize_subject(subject),
"conditions": kwargs,
}
self.rules.append(rule)
return rule

def alias_action(self, action, alias):
self.action_aliases[alias] = action

def alias_to_action(self, alias):
return self.action_aliases.get(alias, alias)
6 changes: 1 addition & 5 deletions cancan/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,4 @@


class CanCanConfig(AppConfig):
name = 'cancan'

def ready(self):
aaa()
pass
name = "cancan"
Loading