Commit 4c13d473 authored by Eliot Berriot's avatar Eliot Berriot 💬

Resolve "Implement a Oauth provider in Funkwhale"

parent 1dc7304b
......@@ -75,6 +75,10 @@ v1_patterns += [
r"^users/",
include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
),
url(
r"^oauth/",
include(("funkwhale_api.users.oauth.urls", "oauth"), namespace="oauth"),
),
url(r"^token/$", jwt_views.obtain_jwt_token, name="token"),
url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"),
]
......
......@@ -121,6 +121,7 @@ THIRD_PARTY_APPS = (
"allauth.account", # registration
"allauth.socialaccount", # registration
"corsheaders",
"oauth2_provider",
"rest_framework",
"rest_framework.authtoken",
"taggit",
......@@ -152,6 +153,7 @@ LOCAL_APPS = (
"funkwhale_api.common.apps.CommonConfig",
"funkwhale_api.activity.apps.ActivityConfig",
"funkwhale_api.users", # custom users app
"funkwhale_api.users.oauth",
# Your stuff: custom apps go here
"funkwhale_api.instance",
"funkwhale_api.music",
......@@ -222,6 +224,14 @@ DATABASES = {
"default": env.db("DATABASE_URL")
}
DATABASES["default"]["ATOMIC_REQUESTS"] = True
MIGRATION_MODULES = {
# see https://github.com/jazzband/django-oauth-toolkit/issues/634
# swappable models are badly designed in oauth2_provider
# ignore migrations and provide our own models.
"oauth2_provider": None
}
#
# DATABASES = {
# 'default': {
......@@ -343,6 +353,22 @@ AUTH_USER_MODEL = "users.User"
LOGIN_REDIRECT_URL = "users:redirect"
LOGIN_URL = "account_login"
# OAuth configuration
from funkwhale_api.users.oauth import scopes # noqa
OAUTH2_PROVIDER = {
"SCOPES": {s.id: s.label for s in scopes.SCOPES_BY_ID.values()},
"ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https", "urn"],
# we keep expired tokens for 15 days, for tracability
"REFRESH_TOKEN_EXPIRE_SECONDS": 3600 * 24 * 15,
"AUTHORIZATION_CODE_EXPIRE_SECONDS": 5 * 60,
"ACCESS_TOKEN_EXPIRE_SECONDS": 60 * 60 * 10,
}
OAUTH2_PROVIDER_APPLICATION_MODEL = "users.Application"
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "users.AccessToken"
OAUTH2_PROVIDER_GRANT_MODEL = "users.Grant"
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "users.RefreshToken"
# LDAP AUTHENTICATION CONFIGURATION
# ------------------------------------------------------------------------------
AUTH_LDAP_ENABLED = env.bool("LDAP_ENABLED", default=False)
......@@ -450,14 +476,19 @@ CELERY_TASK_TIME_LIMIT = 300
CELERY_BEAT_SCHEDULE = {
"federation.clean_music_cache": {
"task": "federation.clean_music_cache",
"schedule": crontab(hour="*/2"),
"schedule": crontab(minute="0", hour="*/2"),
"options": {"expires": 60 * 2},
},
"music.clean_transcoding_cache": {
"task": "music.clean_transcoding_cache",
"schedule": crontab(hour="*"),
"schedule": crontab(minute="0", hour="*"),
"options": {"expires": 60 * 2},
},
"oauth.clear_expired_tokens": {
"task": "oauth.clear_expired_tokens",
"schedule": crontab(minute="0", hour="0"),
"options": {"expires": 60 * 60 * 24},
},
}
JWT_AUTH = {
......@@ -477,7 +508,6 @@ CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_CREDENTIALS = True
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_PAGINATION_CLASS": "funkwhale_api.common.pagination.FunkwhalePagination",
"PAGE_SIZE": 25,
"DEFAULT_PARSER_CLASSES": (
......@@ -487,12 +517,16 @@ REST_FRAMEWORK = {
"funkwhale_api.federation.parsers.ActivityParser",
),
"DEFAULT_AUTHENTICATION_CLASSES": (
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
"rest_framework.authentication.SessionAuthentication",
"funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS",
"funkwhale_api.common.authentication.BearerTokenHeaderAuth",
"funkwhale_api.common.authentication.JSONWebTokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"funkwhale_api.users.oauth.permissions.ScopePermission",
),
"DEFAULT_FILTER_BACKENDS": (
"rest_framework.filters.OrderingFilter",
"django_filters.rest_framework.DjangoFilterBackend",
......
......@@ -87,4 +87,6 @@ def mutations_route(types):
)
return response.Response(serializer.data, status=status.HTTP_201_CREATED)
return decorators.action(methods=["get", "post"], detail=True)(mutations)
return decorators.action(
methods=["get", "post"], detail=True, required_scope="edits"
)(mutations)
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from django.db.models import Prefetch
......@@ -9,6 +8,7 @@ from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track
from funkwhale_api.music import utils as music_utils
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers
......@@ -24,10 +24,11 @@ class TrackFavoriteViewSet(
serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all().select_related("user")
permission_classes = [
permissions.ConditionalAuthentication,
oauth_permissions.ScopePermission,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
required_scope = "favorites"
anonymous_policy = "setting"
owner_checks = ["write"]
def get_serializer_class(self):
......
......@@ -5,11 +5,11 @@ from django.db.models import Count
from rest_framework import decorators
from rest_framework import mixins
from rest_framework import permissions
from rest_framework import response
from rest_framework import viewsets
from funkwhale_api.music import models as music_models
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import activity
from . import api_serializers
......@@ -43,7 +43,8 @@ class LibraryFollowViewSet(
.select_related("actor", "target__actor")
)
serializer_class = api_serializers.LibraryFollowSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "follows"
filterset_class = filters.LibraryFollowFilter
ordering_fields = ("creation_date",)
......@@ -100,7 +101,8 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
.annotate(_uploads_count=Count("uploads"))
)
serializer_class = api_serializers.LibrarySerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
def get_queryset(self):
qs = super().get_queryset()
......@@ -169,7 +171,8 @@ class InboxItemViewSet(
.order_by("-activity__creation_date")
)
serializer_class = api_serializers.InboxItemSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "notifications"
filterset_class = filters.InboxItemFilter
ordering_fields = ("activity__creation_date",)
......
from rest_framework import mixins, viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from django.db.models import Prefetch
......@@ -9,6 +8,8 @@ from funkwhale_api.music.models import Track
from funkwhale_api.music import utils as music_utils
from . import filters, models, serializers
from funkwhale_api.users.oauth import permissions as oauth_permissions
class ListeningViewSet(
mixins.CreateModelMixin,
......@@ -19,11 +20,13 @@ class ListeningViewSet(
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related("user")
permission_classes = [
permissions.ConditionalAuthentication,
oauth_permissions.ScopePermission,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
required_scope = "listenings"
anonymous_policy = "setting"
owner_checks = ["write"]
filterset_class = filters.ListeningFilter
......
......@@ -5,7 +5,7 @@ from rest_framework import views
from rest_framework.response import Response
from funkwhale_api.common import preferences
from funkwhale_api.users.permissions import HasUserPermission
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import nodeinfo
......@@ -14,8 +14,8 @@ NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.so
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
pagination_class = None
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "instance:settings"
class InstanceSettings(views.APIView):
......
......@@ -8,7 +8,7 @@ from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.music import models as music_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.users import models as users_models
from funkwhale_api.users.permissions import HasUserPermission
from . import filters, serializers
......@@ -23,8 +23,7 @@ class ManageUploadViewSet(
)
serializer_class = serializers.ManageUploadSerializer
filterset_class = filters.ManageUploadFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["library"]
required_scope = "instance:libraries"
ordering_fields = [
"accessed_date",
"modification_date",
......@@ -55,8 +54,7 @@ class ManageUserViewSet(
queryset = users_models.User.objects.all().order_by("-id")
serializer_class = serializers.ManageUserSerializer
filterset_class = filters.ManageUserFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
required_scope = "instance:users"
ordering_fields = ["date_joined", "last_activity", "username"]
def get_serializer_context(self):
......@@ -80,8 +78,7 @@ class ManageInvitationViewSet(
)
serializer_class = serializers.ManageInvitationSerializer
filterset_class = filters.ManageInvitationFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
required_scope = "instance:invitations"
ordering_fields = ["creation_date", "expiration_date"]
def perform_create(self, serializer):
......@@ -114,8 +111,7 @@ class ManageDomainViewSet(
)
serializer_class = serializers.ManageDomainSerializer
filterset_class = filters.ManageDomainFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["moderation"]
required_scope = "instance:domains"
ordering_fields = [
"name",
"creation_date",
......@@ -153,7 +149,7 @@ class ManageActorViewSet(
)
serializer_class = serializers.ManageActorSerializer
filterset_class = filters.ManageActorFilterSet
permission_classes = (HasUserPermission,)
required_scope = "instance:accounts"
required_permissions = ["moderation"]
ordering_fields = [
"name",
......@@ -199,8 +195,7 @@ class ManageInstancePolicyViewSet(
)
serializer_class = serializers.ManageInstancePolicySerializer
filterset_class = filters.ManageInstancePolicyFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["moderation"]
required_scope = "instance:policies"
ordering_fields = ["id", "creation_date"]
def perform_create(self, serializer):
......
from django.db import IntegrityError
from rest_framework import mixins
from rest_framework import permissions
from rest_framework import response
from rest_framework import status
from rest_framework import viewsets
......@@ -24,7 +23,7 @@ class UserFilterViewSet(
.select_related("target_artist")
)
serializer_class = serializers.UserFilterSerializer
permission_classes = [permissions.IsAuthenticated]
required_scope = "filters"
ordering_fields = ("creation_date",)
def create(self, request, *args, **kwargs):
......
......@@ -8,7 +8,6 @@ from django.db.models.functions import Length
from django.utils import timezone
from rest_framework import mixins
from rest_framework import permissions
from rest_framework import settings as rest_settings
from rest_framework import views, viewsets
from rest_framework.decorators import action
......@@ -24,6 +23,7 @@ from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation import actors
from funkwhale_api.federation import api_serializers as federation_api_serializers
from funkwhale_api.federation import routes
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, licenses, models, serializers, tasks, utils
......@@ -64,7 +64,9 @@ class TagViewSetMixin(object):
class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
queryset = models.Artist.objects.all()
serializer_class = serializers.ArtistWithAlbumsSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
filterset_class = filters.ArtistFilter
ordering_fields = ("id", "name", "creation_date")
......@@ -90,7 +92,9 @@ class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi
models.Album.objects.all().order_by("artist", "release_date").select_related()
)
serializer_class = serializers.AlbumSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
ordering_fields = ("creation_date", "release_date", "title")
filterset_class = filters.AlbumFilter
......@@ -126,9 +130,11 @@ class LibraryViewSet(
)
serializer_class = serializers.LibraryForOwnerSerializer
permission_classes = [
permissions.IsAuthenticated,
oauth_permissions.ScopePermission,
common_permissions.OwnerPermission,
]
required_scope = "libraries"
anonymous_policy = "setting"
owner_field = "actor.user"
owner_checks = ["read", "write"]
......@@ -178,7 +184,9 @@ class TrackViewSet(
queryset = models.Track.objects.all().for_nested_serialization()
serializer_class = serializers.TrackSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
filterset_class = filters.TrackFilter
ordering_fields = (
"creation_date",
......@@ -350,7 +358,9 @@ class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES
+ [SignatureAuthentication]
)
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
......@@ -385,9 +395,11 @@ class UploadViewSet(
)
serializer_class = serializers.UploadForOwnerSerializer
permission_classes = [
permissions.IsAuthenticated,
oauth_permissions.ScopePermission,
common_permissions.OwnerPermission,
]
required_scope = "libraries"
anonymous_policy = "setting"
owner_field = "library.actor.user"
owner_checks = ["read", "write"]
filterset_class = filters.UploadFilter
......@@ -432,12 +444,16 @@ class UploadViewSet(
class TagViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Tag.objects.all().order_by("name")
serializer_class = serializers.TagSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
class Search(views.APIView):
max_results = 3
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
def get(self, request, *args, **kwargs):
query = request.GET["query"]
......@@ -502,7 +518,9 @@ class Search(views.APIView):
class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
serializer_class = serializers.LicenseSerializer
queryset = models.License.objects.all().order_by("code")
lookup_value_regex = ".*"
......@@ -527,7 +545,9 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
class OembedView(views.APIView):
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
def get(self, request, *args, **kwargs):
serializer = serializers.OembedSerializer(data=request.GET)
......
......@@ -2,11 +2,12 @@ from django.db import transaction
from django.db.models import Count
from rest_framework import exceptions, mixins, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from funkwhale_api.common import fields, permissions
from funkwhale_api.music import utils as music_utils
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers
......@@ -28,10 +29,11 @@ class PlaylistViewSet(
.with_duration()
)
permission_classes = [
permissions.ConditionalAuthentication,
oauth_permissions.ScopePermission,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
required_scope = "playlists"
anonymous_policy = "setting"
owner_checks = ["write"]
filterset_class = filters.PlaylistFilter
ordering_fields = ("id", "name", "creation_date", "modification_date")
......@@ -101,10 +103,11 @@ class PlaylistTrackViewSet(
serializer_class = serializers.PlaylistTrackSerializer
queryset = models.PlaylistTrack.objects.all()
permission_classes = [
permissions.ConditionalAuthentication,
oauth_permissions.ScopePermission,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
required_scope = "playlists"
anonymous_policy = "setting"
owner_field = "playlist.user"
owner_checks = ["write"]
......
......@@ -5,6 +5,7 @@ from rest_framework.response import Response
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.music.serializers import TrackSerializer
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, filtersets, models, serializers
......@@ -20,10 +21,11 @@ class RadioViewSet(
serializer_class = serializers.RadioSerializer
permission_classes = [
permissions.IsAuthenticated,
oauth_permissions.ScopePermission,
common_permissions.OwnerPermission,
]
filterset_class = filtersets.RadioFilter
required_scope = "radios"
owner_field = "user"
owner_checks = ["write"]
......
......@@ -92,7 +92,7 @@ def get_playlist_qs(request):
class SubsonicViewSet(viewsets.GenericViewSet):
content_negotiation_class = negotiation.SubsonicContentNegociation
authentication_classes = [authentication.SubsonicAuthentication]
permissions_classes = [rest_permissions.IsAuthenticated]
permission_classes = [rest_permissions.IsAuthenticated]
def dispatch(self, request, *args, **kwargs):
if not preferences.get("subsonic__enabled"):
......@@ -128,7 +128,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
detail=False,
methods=["get", "post"],
url_name="get_license",
permissions_classes=[],
permission_classes=[],
url_path="getLicense",
)
def get_license(self, request, *args, **kwargs):
......
import pytz
import factory
from django.contrib.auth.models import Permission
from django.utils import timezone
from funkwhale_api.factories import ManyToManyFromList, registry, NoUpdateOnCreate
from . import models
......@@ -87,3 +87,49 @@ class UserFactory(factory.django.DjangoModelFactory):
class SuperUserFactory(UserFactory):
is_staff = True
is_superuser = True
@registry.register
class ApplicationFactory(factory.django.DjangoModelFactory):
name = factory.Faker("name")
redirect_uris = factory.Faker("url")
client_type = models.Application.CLIENT_CONFIDENTIAL
authorization_grant_type = models.Application.GRANT_AUTHORIZATION_CODE
scope = "read"
class Meta:
model = "users.Application"
@registry.register
class GrantFactory(factory.django.DjangoModelFactory):
application = factory.SubFactory(ApplicationFactory)
scope = factory.SelfAttribute(".application.scope")
redirect_uri = factory.SelfAttribute(".application.redirect_uris")
user = factory.SubFactory(UserFactory)
expires = factory.Faker("future_datetime", end_date="+15m")
code = factory.Faker("uuid4")
class Meta:
model = "users.Grant"
@registry.register
class AccessTokenFactory(factory.django.DjangoModelFactory):
application = factory.SubFactory(ApplicationFactory)
user = factory.SubFactory(UserFactory)
expires = factory.Faker("future_datetime", tzinfo=pytz.UTC)
token = factory.Faker("uuid4")
class Meta:
model = "users.AccessToken"
@registry.register
class RefreshTokenFactory(factory.django.DjangoModelFactory):
application = factory.SubFactory(ApplicationFactory)
user = factory.SubFactory(UserFactory)
token = factory.Faker("uuid4")
class Meta:
model = "users.RefreshToken"
# Generated by Django 2.0.9 on 2018-12-06 10:08
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
import oauth2_provider.generators
class Migration(migrations.Migration):
dependencies = [
("users", "0013_auto_20181206_1008"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AccessToken",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("expires", models.DateTimeField()),
("scope", models.TextField(blank=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
("token", models.CharField(max_length=255, unique=True)),
],
options={"abstract": False},
),
migrations.CreateModel(
name="Application",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
(
"client_id",
models.CharField(
db_index=True,
default=oauth2_provider.generators.generate_client_id,
max_length=100,
unique=True,
),
),
(
"redirect_uris",
models.TextField(
blank=True, help_text="Allowed URIs list, space separated"
),
),
(
"client_type",
models.CharField(
choices=[
("confidential", "Confidential"),
("public", "Public"),
],
max_length=32,
),
),
(
"authorization_grant_type",
models.CharField(
choices=[
("authorization-code", "Authorization code"),
("implicit", "Implicit"),
("password", "Resource owner password-based"),
("client-credentials", "Client credentials"),
],
max_length=32,
),
),
(
"client_secret",
models.CharField(
blank=True,
db_index=True,
default=oauth2_provider.generators.generate_client_secret,
max_length=255,
),
),
("name", models.CharField(blank=True, max_length=255)),
("skip_authorization", models.BooleanField(default=False)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="users_application",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"abstract": False},
),
migrations.CreateModel(
name="Grant",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("code", models.CharField(max_length=255, unique=True)),
("expires", models.DateTimeField()),
("redirect_uri", models.CharField(max_length=255)),
("scope", models.TextField(blank=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"application",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="users.Application",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="users_grant",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"abstract": False},