diff --git a/swh/web/auth/backends.py b/swh/web/auth/backends.py --- a/swh/web/auth/backends.py +++ b/swh/web/auth/backends.py @@ -44,6 +44,11 @@ if "groups" in decoded_token: user.is_staff = "/staff" in decoded_token["groups"] + # extract user permissions if any + resource_access = decoded_token.get("resource_access", {}) + client_resource_access = resource_access.get(_oidc_client.client_id, {}) + user.permissions = set(client_resource_access.get("roles", [])) + # add user sub to custom User proxy model user.sub = decoded_token["sub"] diff --git a/swh/web/auth/models.py b/swh/web/auth/models.py --- a/swh/web/auth/models.py +++ b/swh/web/auth/models.py @@ -4,7 +4,7 @@ # See top-level LICENSE file for more information from datetime import datetime -from typing import Optional +from typing import Optional, Set from django.contrib.auth.models import User @@ -31,6 +31,9 @@ scope: Optional[str] = None session_state: Optional[str] = None + # User permissions + permissions: Set[str] + class Meta: app_label = "swh.web.auth" proxy = True @@ -41,3 +44,37 @@ 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/web/tests/auth/keycloak_mock.py b/swh/web/tests/auth/keycloak_mock.py --- a/swh/web/tests/auth/keycloak_mock.py +++ b/swh/web/tests/auth/keycloak_mock.py @@ -16,7 +16,9 @@ class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect): - def __init__(self, auth_success=True, exp=None): + def __init__( + self, auth_success=True, exp=None, user_groups=[], user_permissions=[] + ): swhweb_config = get_config() super().__init__( swhweb_config["keycloak"]["server_url"], @@ -25,6 +27,8 @@ ) self.auth_success = auth_success self.exp = exp + self.user_groups = user_groups + self.user_permissions = user_permissions self._keycloak.public_key = lambda: realm_public_key self._keycloak.well_know = lambda: { "issuer": f"{self.server_url}realms/{self.realm_name}", @@ -86,12 +90,20 @@ else: decoded["auth_time"] = int(timezone.now().timestamp()) decoded["exp"] = decoded["auth_time"] + expire_in - decoded["groups"] = ["/staff"] + decoded["groups"] = self.user_groups + if self.user_permissions: + decoded["resource_access"][self.client_id] = { + "roles": self.user_permissions + } return decoded -def mock_keycloak(mocker, auth_success=True, exp=None): - kc_oidc_mock = KeycloackOpenIDConnectMock(auth_success, exp) +def mock_keycloak( + mocker, auth_success=True, exp=None, user_groups=[], user_permissions=[] +): + kc_oidc_mock = KeycloackOpenIDConnectMock( + auth_success, exp, user_groups, user_permissions + ) mock_get_oidc_client = mocker.patch("swh.web.auth.views.get_oidc_client") mock_get_oidc_client.return_value = kc_oidc_mock mocker.patch("swh.web.auth.backends._oidc_client", kc_oidc_mock) diff --git a/swh/web/tests/auth/sample_data.py b/swh/web/tests/auth/sample_data.py --- a/swh/web/tests/auth/sample_data.py +++ b/swh/web/tests/auth/sample_data.py @@ -15,6 +15,33 @@ oidc_profile = { "access_token": ( + # decoded token: + # {'acr': '1', + # 'allowed-origins': ['*'], + # 'aud': ['swh-web', 'account'], + # 'auth_time': 1592395601, + # 'azp': 'swh-web', + # 'email': 'john.doe@example.com', + # 'email_verified': False, + # 'exp': 1592396202, + # 'family_name': 'Doe', + # 'given_name': 'John', + # 'groups': ['/staff'], + # 'iat': 1582723101, + # 'iss': 'http://localhost:8080/auth/realms/SoftwareHeritage', + # 'jti': '31fc50b7-bbe5-4f51-91ef-8e3eec51331e', + # 'name': 'John Doe', + # 'nbf': 0, + # 'preferred_username': 'johndoe', + # 'realm_access': {'roles': ['offline_access', 'uma_authorization']}, + # 'resource_access': {'account': {'roles': ['manage-account', + # 'manage-account-links', + # 'view-profile']}}, + # 'scope': 'openid email profile', + # 'session_state': 'd82b90d1-0a94-4e74-ad66-dd95341c7b6d', + # 'sub': 'feacd344-b468-4a65-a236-14f61e6b7200', + # 'typ': 'Bearer' + # } "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJPSnhV" "Q0p0TmJQT0NOUGFNNmc3ZU1zY2pqTXhoem9vNGxZaFhsa1c2TWhBIn0." "eyJqdGkiOiIzMWZjNTBiNy1iYmU1LTRmNTEtOTFlZi04ZTNlZWM1MTMz" diff --git a/swh/web/tests/auth/test_backends.py b/swh/web/tests/auth/test_backends.py --- a/swh/web/tests/auth/test_backends.py +++ b/swh/web/tests/auth/test_backends.py @@ -32,7 +32,7 @@ ) -def _check_authenticated_user(user, decoded_token): +def _check_authenticated_user(user, decoded_token, kc_oidc_mock): assert user is not None assert isinstance(user, OIDCUser) assert user.id != 0 @@ -43,16 +43,19 @@ assert user.email == decoded_token["email"] assert user.is_staff == ("/staff" in decoded_token["groups"]) assert user.sub == decoded_token["sub"] + resource_access = decoded_token.get("resource_access", {}) + resource_access_client = resource_access.get(kc_oidc_mock, {}) + assert user.permissions == set(resource_access_client.get("roles", [])) @pytest.mark.django_db def test_oidc_code_pkce_auth_backend_success(mocker, request_factory): - kc_oidc_mock = mock_keycloak(mocker) + kc_oidc_mock = mock_keycloak(mocker, user_groups=["/staff"]) oidc_profile = sample_data.oidc_profile user = _authenticate_user(request_factory) decoded_token = kc_oidc_mock.decode_token(user.access_token) - _check_authenticated_user(user, decoded_token) + _check_authenticated_user(user, decoded_token, kc_oidc_mock) auth_datetime = datetime.fromtimestamp(decoded_token["auth_time"]) exp_datetime = datetime.fromtimestamp(decoded_token["exp"]) @@ -83,6 +86,18 @@ assert user is None +@pytest.mark.django_db +def test_oidc_code_pkce_auth_backend_permissions(mocker, request_factory): + permission = "webapp.some-permission" + mock_keycloak(mocker, user_permissions=[permission]) + user = _authenticate_user(request_factory) + assert user.has_perm(permission) + assert user.get_all_permissions() == {permission} + assert user.get_group_permissions() == {permission} + assert user.has_module_perms("webapp") + assert not user.has_module_perms("foo") + + @pytest.mark.django_db def test_drf_oidc_bearer_token_auth_backend_success(mocker, api_request_factory): url = reverse("api-1-stat-counters") @@ -96,7 +111,7 @@ request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"Bearer {access_token}") user, _ = drf_auth_backend.authenticate(request) - _check_authenticated_user(user, decoded_token) + _check_authenticated_user(user, decoded_token, kc_oidc_mock) # oidc_profile is not filled when authenticating through bearer token assert hasattr(user, "access_token") and user.access_token is None @@ -146,3 +161,21 @@ with pytest.raises(AuthenticationFailed): drf_auth_backend.authenticate(request) + + +@pytest.mark.django_db +def test_drf_oidc_bearer_token_auth_backend_permissions(mocker, api_request_factory): + permission = "webapp.some-permission" + mock_keycloak(mocker, user_permissions=[permission]) + + drf_auth_backend = OIDCBearerTokenAuthentication() + access_token = sample_data.oidc_profile["access_token"] + url = reverse("api-1-stat-counters") + request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"Bearer {access_token}") + user, _ = drf_auth_backend.authenticate(request) + + assert user.has_perm(permission) + assert user.get_all_permissions() == {permission} + assert user.get_group_permissions() == {permission} + assert user.has_module_perms("webapp") + assert not user.has_module_perms("foo")