Skip to content

Commit

Permalink
feat(permissions): introduce dgap
Browse files Browse the repository at this point in the history
Introduce Django-Generic-API-Permissions: We now use the permissions/visibility framework
that was originally derived from Emeis' code. This simplifies stuff a lot, and removes
tons of code that isn't required anymore.

And, as a bonus, we now also get the ability to provide custom validations, free of charge!
  • Loading branch information
winged committed Aug 27, 2021
1 parent a115f6e commit ff5aa2f
Show file tree
Hide file tree
Showing 15 changed files with 168 additions and 694 deletions.
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Most of the settings below are documented in it's [respective documentation](htt
* `OIDC_RP_CLIENT_ID`: ID of the client (optionally needed for Client Credentials Grant)
* `OIDC_RP_CLIENT_SECRET`: Secret of the client (optionally needed for Client Credentials Grant)
* `EMEIS_OIDC_USER_FACTORY`: Optional, factory function (or class) that defines
an OIDC user object. See also [docs/extending_emeis.md]
an OIDC user object. See also here: [Extending Emeis](docs/extending_emeis.md)

##### Cache

Expand Down Expand Up @@ -117,8 +117,7 @@ creates a user, scope, role and and ACL for administration based on the settings

You can now access the api at [http://localhost:8000/api/v1/](http://localhost:8000/api/v1/).

Read more about extending and configuring Emeis in
[docs/extending_emeis.md].
Read more about extending and configuring Emeis here: [Extending Emeis](docs/extending_emeis.md).

## Contributing

Expand Down
107 changes: 62 additions & 45 deletions docs/extending_emeis.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,68 @@

Emeis is used ideally as a stand-alone microservice. However, it also can be used
as a Django app. When using Emeis as a standalone app, you can mount your own
files into the container and point to them via environment variables.
configuration / customisation files into the container and point to them via environment variables.

For customization some clear extension points are defined. In case a
customization is needed where no extension point is defined, best
[open an issue](https://github.com/projectcaluma/caluma/issues/new) for discussion.

## Extension points

For customization some clear extension points are defined. In case a customization is needed
where no extension point is defined, best [open an issue](https://github.com/projectcaluma/caluma/issues/new) for discussion.
Emeis uses
[Django-Generic-API-Permissions (DGAP)](https://github.com/adfinis-sygroup/django-generic-api-permissions)
to provide customizable visibility, permission, and validation.

### Visibility classes
each of those aspects is configured the same way: A settings points to one or
more classes that you need to write. Each of those classes contains one or more
methods to configure the given aspect.

The visibility part defines what you can see at all. Anything you cannot see, you're implicitly also not allowed to modify. The visibility classes define what you see depending on your roles, permissions, etc. Building on top of this follow the permission classes (see below) that define what you can do with the data you see.
Here, we only explain this in a very short version. Read the DGAP documentation linked above
for details.

Visibility classes are configured as `VISIBILITY_CLASSES`.

Following pre-defined classes are available:
* `emeis.core.visibilities.Any`: Allow any user without any filtering
* `emeis.core.visibilities.Union`: Union result of a list of configured visibility classes. May only be used as base class.
* `emeis.user.visibilities.OwnAndAdmin`: Only show data that belongs to the current user. For admin show all data
### Visibility

In case this default classes do not cover your use case, it is also possible to create your custom
visibility class defining per node how to filter.
Visibility defines what a user can see.

Example:
```python
from emeis.core.visibilities import BaseVisibility, filter_queryset_for
from emeis.core.models import BaseModel, Scope


class CustomVisibility(BaseVisibility):
# Put the fully qualified name of the class into the settings
# under `EMEIS_VISIBILITY_CLASSES`.
# For example, if you mount this under /app/emeis/custom/visibilities.py,
# set EMEIS_VISIBILITY_CLASSES to 'emeis.custom.visibilities.MyCustomVisibilities'

from generic_permissions.visibilities import filter_queryset_for
from emeis.core.models import BaseModel
class MyCustomVisibilities:
"""Custom visibilities:
* Scopes are visible to everyone
* All other objects are only visible to their respective creators
"""
@filter_queryset_for(BaseModel)
def filter_queryset_for_all(self, queryset, request):
def show_only_mine(self, queryset, request):
return queryset.filter(created_by_user=request.user.username)
@filter_queryset_for(Scope)
def filter_queryset_for_scope(self, queryset, request):
return queryset.exclude(slug='protected-scope')
```

Arguments:
* `queryset`: [Queryset](https://docs.djangoproject.com/en/2.1/ref/models/querysets/) of specific node type
* `request`: holds the [http request](https://docs.djangoproject.com/en/1.11/ref/request-response/#httprequest-objects)

Save your visibility module as `visibilities.py` and inject it as Docker volume to path `/app/caluma/extensions/visibilities.py`,

Afterwards you can configure it in `VISIBILITY_CLASSES` as `emeis.extensions.visibilities.CustomVisibility`.
@filter_queryset_for(Scope)
def filter_scopes(self, queryset, request):
return queryset

### Permission classes
```

Permission classes define who may perform which data mutation. Such can be configured as `PERMISSION_CLASSES`.

Following pre-defined classes are available:
* `emeis.core.permissions.AllowAny`: allow any users to perform any mutation.
### Permissions

In case this default classes do not cover your use case, it is also possible to create your custom
permission class defining per mutation and mutation object what is allowed.
Permissions define what a user can do with the data that the visibilities allow
them to see.

Example:
```python
from emeis.core.permissions import BasePermission, object_permission_for, permission_for
from emeis.core.models import BaseModel, User
# Put the fully qualified name of the class into the settings
# under `EMEIS_PERMISSION_CLASSES`.
# For example, if you mount this under /app/emeis/custom/permissions.py,
# set EMEIS_PERMISSION_CLASSES to 'emeis.custom.permissions.CustomPermission'
from generic_permissions.permissions import permission_for, object_permission_for

class CustomPermission(BasePermission):
class CustomPermission:
@permission_for(BaseModel)
def has_permission_default(self, request):
# change default permission to False when no more specific
Expand All @@ -78,13 +79,29 @@ class CustomPermission(BasePermission):
return request.user.username == 'admin'
```

Arguments:
* `request`: holds the [http request](https://docs.djangoproject.com/en/1.11/ref/request-response/#httprequest-objects)
* `instance`: instance being edited by specific request
### Validation

You can add your custom validation methods to Emeis in a similar manner.

Save your permission module as `permissions.py` and inject it as Docker volume to path `/app/caluma/extensions/permissions.py`,
For this, you can use the `EMEIS_VALIDATION_CLASSES` setting. The settings is a
list of strings that you can fill in via environment variable (comma separated
list of class names).

Afterwards you can configure it in `PERMISSION_CLASSES` as `emeis.extensions.permissions.CustomPermission`.
Here's an example validator class that ensures the username is lower case.

```python
# Put the fully qualified name of the class into the settings
# under `EMEIS_VALIDATION_CLASSES`.
# For example, if you mount this under /app/emeis/custom/validation.py,
# set EMEIS_VALIDATION_CLASSES to 'emeis.custom.validation.LowercaseUsername'
from emeis.core.models import User
from emeis.core.validation import EmeisBaseValidator, validator_for
class LowercaseUsername:
@validator_for(User)
def lowercase_username(self, data, context):
data["username"] = data["username"].lower()
return data
```


### OIDC User factory
Expand Down
14 changes: 14 additions & 0 deletions emeis/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import inspect

import pytest
from django.apps import apps
from django.core.cache import cache
from django.utils.module_loading import import_string
from factory.base import FactoryMetaClass
Expand All @@ -11,6 +12,19 @@
from emeis.core.models import ACL, Role, Scope, User


@pytest.fixture(autouse=True)
def reset_config_classes(settings):
"""Reset the config classes to clean state after test.
The config classes need to be reset after running tests that
use them. Otherwise, unrelated tests may get affected.
"""

# First, set config to original value
core_config = apps.get_app_config("generic_permissions")
core_config.ready()


def register_module(module):
for _, obj in inspect.getmembers(module):
if isinstance(obj, FactoryMetaClass) and not obj._meta.abstract:
Expand Down
14 changes: 0 additions & 14 deletions emeis/core/apps.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,6 @@
from django.apps import AppConfig
from django.conf import settings
from django.utils.module_loading import import_string


class DefaultConfig(AppConfig):
name = "emeis.core"
label = "emeis_core"

def ready(self):
from .models import PermissionMixin, VisibilityMixin

# to avoid recursive import error, load extension classes
# only once the app is ready
PermissionMixin.permission_classes = [
import_string(cls) for cls in settings.PERMISSION_CLASSES
]
VisibilityMixin.visibility_classes = [
import_string(cls) for cls in settings.VISIBILITY_CLASSES
]
6 changes: 0 additions & 6 deletions emeis/core/collections.py

This file was deleted.

30 changes: 5 additions & 25 deletions emeis/core/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,7 @@ class Migration(migrations.Migration):
),
],
options={"verbose_name": "user", "verbose_name_plural": "users"},
bases=(
emeis.core.models.VisibilityMixin,
emeis.core.models.PermissionMixin,
models.Model,
),
bases=(models.Model,),
),
migrations.CreateModel(
name="Permission",
Expand Down Expand Up @@ -178,11 +174,7 @@ class Migration(migrations.Migration):
),
],
options={"abstract": False},
bases=(
emeis.core.models.VisibilityMixin,
emeis.core.models.PermissionMixin,
models.Model,
),
bases=(models.Model,),
),
migrations.CreateModel(
name="Scope",
Expand Down Expand Up @@ -243,11 +235,7 @@ class Migration(migrations.Migration):
),
],
options={"abstract": False},
bases=(
emeis.core.models.VisibilityMixin,
emeis.core.models.PermissionMixin,
models.Model,
),
bases=(models.Model,),
),
migrations.CreateModel(
name="Role",
Expand Down Expand Up @@ -295,11 +283,7 @@ class Migration(migrations.Migration):
),
],
options={"ordering": ["slug"]},
bases=(
emeis.core.models.VisibilityMixin,
emeis.core.models.PermissionMixin,
models.Model,
),
bases=(models.Model,),
),
migrations.CreateModel(
name="ACL",
Expand Down Expand Up @@ -355,10 +339,6 @@ class Migration(migrations.Migration):
),
],
options={"unique_together": {("user", "scope", "role")}},
bases=(
emeis.core.models.VisibilityMixin,
emeis.core.models.PermissionMixin,
models.Model,
),
bases=(models.Model,),
),
]
42 changes: 1 addition & 41 deletions emeis/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@
from django.contrib.auth.models import AbstractBaseUser, UserManager
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from localized_fields.fields import LocalizedCharField, LocalizedTextField
from mptt.models import MPTTModel, TreeForeignKey
from rest_framework import exceptions


def make_uuid():
Expand All @@ -30,45 +28,7 @@ def get_language_code():
return settings.LANGUAGE_CODE


class VisibilityMixin:
visibility_classes = None

@classmethod
def visibility_queryset_filter(cls, queryset, request, **kwargs):
if cls.visibility_classes is None:
raise ImproperlyConfigured(
"check that app `emeis.core.DefaultConfig` is part of your `INSTALLED_APPS`."
)

for visibility_class in cls.visibility_classes:
queryset = visibility_class().filter_queryset(cls, queryset, request)

return queryset


class PermissionMixin:
permission_classes = None

@classmethod
def check_permissions(cls, request, **kwargs):
if cls.permission_classes is None:
raise ImproperlyConfigured(
"check that app `emeis.core.DefaultConfig` is part of your `INSTALLED_APPS`."
)

for permission_class in cls.permission_classes:
if not permission_class().has_permission(cls, request):
raise exceptions.PermissionDenied()

def check_object_permissions(self, request):
for permission_class in self.permission_classes:
if not permission_class().has_object_permission(
self.__class__, request, self
):
raise exceptions.PermissionDenied()


class BaseModel(VisibilityMixin, PermissionMixin, models.Model):
class BaseModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
modified_at = models.DateTimeField(auto_now=True, db_index=True)
created_by_user = models.ForeignKey("User", null=True, on_delete=models.SET_NULL)
Expand Down
Loading

0 comments on commit ff5aa2f

Please sign in to comment.