diff --git a/mypy.ini b/mypy.ini index fb4140a..1d827b2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,15 +1,18 @@ [mypy] namespace_packages = True warn_unused_ignores = True # 3rd party libraries without stubs (yet) [mypy-pkg_resources.*] ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True [mypy-keycloak.*] ignore_missing_imports = True + +[mypy-django.*] +ignore_missing_imports = True diff --git a/pytest.ini b/pytest.ini index b712d00..81fe35e 100644 --- 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 index 0000000..9d46956 --- /dev/null +++ b/requirements-django.txt @@ -0,0 +1 @@ +Django<3 diff --git a/requirements-test.txt b/requirements-test.txt index 5821059..af0b52a 100644 --- 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 index 9e6ac78..f418e2e 100755 --- a/setup.py +++ b/setup.py @@ -1,72 +1,75 @@ #!/usr/bin/env python3 # Copyright (C) 2019-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 io import open from os import path from setuptools import find_packages, setup here = path.abspath(path.dirname(__file__)) # Get the long description from the README file with open(path.join(here, "README.md"), encoding="utf-8") as f: long_description = f.read() def parse_requirements(*names): requirements = [] for name in names: if name: reqf = "requirements-%s.txt" % name else: reqf = "requirements.txt" if not path.exists(reqf): return requirements with open(reqf) as f: for line in f.readlines(): line = line.strip() if not line or line.startswith("#"): continue requirements.append(line) return requirements setup( name="swh.auth", description="Software Heritage Authentication Utilities", long_description=long_description, long_description_content_type="text/markdown", python_requires=">=3.7", author="Software Heritage developers", author_email="swh-devel@inria.fr", url="https://forge.softwareheritage.org/source/swh-auth/", packages=find_packages(), # packages's modules install_requires=parse_requirements(None, "swh"), 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] # =swh..cli # """, classifiers=[ "Programming Language :: Python :: 3", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Development Status :: 3 - Alpha", ], project_urls={ "Bug Reports": "https://forge.softwareheritage.org/maniphest", "Funding": "https://www.softwareheritage.org/donate", "Source": "https://forge.softwareheritage.org/source/swh-", "Documentation": "https://docs.softwareheritage.org/devel/swh-/", }, ) diff --git a/swh/auth/django/__init__.py b/swh/auth/django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/auth/django/models.py b/swh/auth/django/models.py new file mode 100644 index 0000000..3863c33 --- /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 index 0000000..e69de29 diff --git a/swh/auth/tests/app/apptest/__init__.py b/swh/auth/tests/app/apptest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/auth/tests/app/apptest/apps.py b/swh/auth/tests/app/apptest/apps.py new file mode 100644 index 0000000..9ebdc06 --- /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 index 0000000..a0610e3 --- /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 index 0000000..163c790 --- /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 index 0000000..541ee7b --- /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 index 0000000..57db14f --- /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 index 0000000..e0ec95a --- /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 index de9dbf7..214b632 100644 --- a/tox.ini +++ b/tox.ini @@ -1,36 +1,37 @@ [tox] envlist=black,flake8,mypy,py3 [testenv] extras = testing + django deps = pytest-cov dev: pdbpp commands = pytest --doctest-modules \ {envsitepackagesdir}/swh/auth \ --cov={envsitepackagesdir}/swh/auth \ --cov-branch {posargs} [testenv:black] skip_install = true deps = black==19.10b0 commands = {envpython} -m black --check swh [testenv:flake8] skip_install = true deps = flake8 commands = {envpython} -m flake8 [testenv:mypy] extras = testing deps = mypy commands = mypy swh