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/pytest.ini b/pytest.ini --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ [pytest] norecursedirs = docs .* +DJANGO_SETTINGS_MODULE = swh.auth.tests.app.apptest.settings 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/__init__.py b/swh/auth/django/__init__.py new file mode 100644 diff --git a/swh/auth/django/models.py b/swh/auth/django/models.py new file mode 100644 --- /dev/null +++ b/swh/auth/django/models.py @@ -0,0 +1,85 @@ +# 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/app/__init__.py b/swh/auth/tests/app/__init__.py new file mode 100644 diff --git a/swh/auth/tests/app/apptest/__init__.py b/swh/auth/tests/app/apptest/__init__.py new file mode 100644 diff --git a/swh/auth/tests/app/apptest/apps.py b/swh/auth/tests/app/apptest/apps.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/app/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.app" diff --git a/swh/auth/tests/app/apptest/models.py b/swh/auth/tests/app/apptest/models.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/app/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.models import OIDCUser + + +class AppUser(OIDCUser): + """AppUser class to demonstrate the use of the OIDCUser which adds some attributes. + + """ + + url = models.TextField(null=False) + + class meta: + app_label = "app-label" diff --git a/swh/auth/tests/app/apptest/settings.py b/swh/auth/tests/app/apptest/settings.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/app/apptest/settings.py @@ -0,0 +1,7 @@ +SECRET_KEY = "o+&ayiuk(y^wh4ijz5e=c2$$kjj7g^6r%z+8d*c0lbpfs##k#7" + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "swh.auth.tests.app.apptest.apps.TestApp", +] diff --git a/swh/auth/tests/app/apptest/urls.py b/swh/auth/tests/app/apptest/urls.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/app/apptest/urls.py @@ -0,0 +1,6 @@ +# 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 + +urlpatterns = [] # type: ignore diff --git a/swh/auth/tests/app/manage.py b/swh/auth/tests/app/manage.py new file mode 100755 --- /dev/null +++ b/swh/auth/tests/app/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "swh.auth.tests.app.apptest.settings" + ) + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/swh/auth/tests/test_models.py b/swh/auth/tests/test_models.py new file mode 100644 --- /dev/null +++ b/swh/auth/tests/test_models.py @@ -0,0 +1,71 @@ +# 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 copy import copy +from typing import Set + +import pytest + +from swh.auth.tests.app.apptest.models import AppUser + +PERMISSIONS: Set[str] = set(["api", "app-label-read"]) +NO_PERMISSION: Set[str] = set() + + +@pytest.fixture +def appuser(): + return copy( + AppUser( + id=666, + username="foo", + password="bar", + first_name="foobar", + last_name="", + email="foo@bar.org", + ) + ) + + +@pytest.fixture +def appuser_admin(appuser): + appuser_admin = appuser + appuser_admin.is_active = True + appuser_admin.is_superuser = True + return appuser_admin + + +def test_django_appuser(appuser): + appuser.permissions = PERMISSIONS + + assert appuser.get_group_permissions() == PERMISSIONS + assert appuser.get_group_permissions(appuser) == PERMISSIONS + assert appuser.get_all_permissions() == PERMISSIONS + assert appuser.get_all_permissions(appuser) == PERMISSIONS + + assert "api" in PERMISSIONS + assert appuser.has_perm("api") is True + assert appuser.has_perm("something") is False + + assert "app-label-read" in PERMISSIONS + assert appuser.has_module_perms("app-label") is True + assert appuser.has_module_perms("app-something") is False + + +def test_django_appuser_admin(appuser_admin): + appuser_admin.permissions = NO_PERMISSION + + assert appuser_admin.get_group_permissions() == NO_PERMISSION + assert appuser_admin.get_group_permissions(appuser_admin) == NO_PERMISSION + + assert appuser_admin.get_all_permissions() == NO_PERMISSION + assert appuser_admin.get_all_permissions(appuser) == NO_PERMISSION + + assert "foobar" not in PERMISSIONS + assert appuser_admin.has_perm("foobar") is True + assert "something" not in PERMISSIONS + assert appuser_admin.has_perm("something") is True + + assert appuser_admin.has_module_perms("app-label") is True + assert appuser_admin.has_module_perms("really-whatever-app") is True diff --git a/tox.ini b/tox.ini --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ [testenv] extras = testing + django deps = pytest-cov dev: pdbpp