diff --git a/mypy.ini b/mypy.ini --- a/mypy.ini +++ b/mypy.ini @@ -13,3 +13,6 @@ [mypy-keycloak.*] ignore_missing_imports = True + +[mypy-django.*] +ignore_missing_imports = True diff --git a/requirements-django.txt b/requirements-django.txt new file mode 100644 --- /dev/null +++ b/requirements-django.txt @@ -0,0 +1 @@ +Django<3 diff --git a/requirements-test.txt b/requirements-test.txt --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,3 @@ pytest requests_mock +pytest-django diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -50,7 +50,10 @@ tests_require=parse_requirements("test"), setup_requires=["setuptools-scm"], use_scm_version=True, - extras_require={"testing": parse_requirements("test")}, + extras_require={ + "django": parse_requirements("django"), + "testing": parse_requirements("test"), + }, include_package_data=True, # entry_points=""" # [swh.cli.subcommands] diff --git a/swh/auth/django.py b/swh/auth/django.py new file mode 100644 --- /dev/null +++ b/swh/auth/django.py @@ -0,0 +1,84 @@ +# Copyright (C) 2020-2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from datetime import datetime +from typing import Optional, Set + +from django.contrib.auth.models import User + + +class OIDCUser(User): + """ + Custom User proxy model for remote users storing OpenID Connect + related data: profile containing authentication tokens. + + The model is also not saved to database as all users are already stored + in the Keycloak one. + """ + + # OIDC subject identifier + sub: str = "" + + # OIDC tokens and session related data, only relevant when a user + # authenticates from a web browser + access_token: Optional[str] = None + expires_at: Optional[datetime] = None + id_token: Optional[str] = None + refresh_token: Optional[str] = None + refresh_expires_at: Optional[datetime] = None + scope: Optional[str] = None + session_state: Optional[str] = None + + # User permissions + permissions: Set[str] + + class Meta: + # TODO: To redefine in subclass of this class + # Forced to empty otherwise, django complains about it + # "Model class swh.auth.django.OIDCUser doesn't declare an explicit app_label + # and isn't in an application in INSTALLED_APPS" + app_label = "" + proxy = True + + def save(self, **kwargs): + """ + Override django.db.models.Model.save to avoid saving the remote + users to web application database. + """ + pass + + def get_group_permissions(self, obj=None) -> Set[str]: + """ + Override django.contrib.auth.models.PermissionsMixin.get_group_permissions + to get permissions from OIDC + """ + return self.get_all_permissions(obj) + + def get_all_permissions(self, obj=None) -> Set[str]: + """ + Override django.contrib.auth.models.PermissionsMixin.get_all_permissions + to get permissions from OIDC + """ + return self.permissions + + def has_perm(self, perm, obj=None) -> bool: + """ + Override django.contrib.auth.models.PermissionsMixin.has_perm + to check permission from OIDC + """ + if self.is_active and self.is_superuser: + return True + + return perm in self.permissions + + def has_module_perms(self, app_label) -> bool: + """ + Override django.contrib.auth.models.PermissionsMixin.has_module_perms + to check permissions from OIDC. + """ + if self.is_active and self.is_superuser: + return True + + return any(perm.startswith(app_label) for perm in self.permissions) diff --git a/swh/auth/tests/apptest/__init__.py b/swh/auth/tests/apptest/__init__.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/apptest/__init__.py @@ -0,0 +1 @@ +default_app_config = "swh.auth.tests.apptest.apps.TestApp" diff --git a/swh/auth/tests/apptest/apps.py b/swh/auth/tests/apptest/apps.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/apptest/apps.py @@ -0,0 +1,10 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from django.apps import AppConfig + + +class TestApp(AppConfig): + name = "swh.auth.tests.apptest" diff --git a/swh/auth/tests/apptest/models.py b/swh/auth/tests/apptest/models.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/apptest/models.py @@ -0,0 +1,19 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from django.db import models + +from swh.auth.django import OIDCUser + + +class MyUser(OIDCUser): + """MyUser class to demonstrate the use of the OIDCUser which adds some attributes to + serialize in db.""" + + url = models.TextField(null=False) + + class meta: + db_table = "user_client" + app_label = "app-label" diff --git a/swh/auth/tests/apptest/urls.py b/swh/auth/tests/apptest/urls.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/apptest/urls.py @@ -0,0 +1,17 @@ +"""apptest URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/2.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +urlpatterns = [] # type: ignore diff --git a/swh/auth/tests/conftest.py b/swh/auth/tests/conftest.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/conftest.py @@ -0,0 +1,18 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from django.conf import settings + + +def pytest_configure(): + # Basic settings to avoid having to define a django module settings all over the + # place so django tests pass fine + settings.configure( + INSTALLED_APPS=[ + "django.contrib.auth", + "django.contrib.contenttypes", + "swh.auth.tests.apptest", + ], + ) diff --git a/swh/auth/tests/test_models.py b/swh/auth/tests/test_models.py new file mode 100644 diff --git a/tox.ini b/tox.ini --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ deps = pytest-cov dev: pdbpp + -r requirements-django.txt commands = pytest --doctest-modules \ {envsitepackagesdir}/swh/auth \