diff --git a/apps/accounts/models.py b/apps/accounts/models.py index dcd1aad9..76cab046 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -1,5 +1,6 @@ import math import uuid +from contextlib import suppress from datetime import date from functools import cached_property from typing import Any, Optional @@ -446,38 +447,28 @@ def import_from_keycloak(cls, keycloak_id: str) -> "ProjectUser": email=keycloak_user.get("email", ""), user=user, ) - if KeycloakService.is_superuser(keycloak_account): - user.groups.add(get_superadmins_group()) - # Users imported from an external IdP can be added to one or more organizations - organizations_codes = keycloak_user.get("attributes", {}).get( - "idp_organizations", [] - ) - organizations_codes = [code.split(",") for code in organizations_codes] - organizations_codes = [ - item for sublist in organizations_codes for item in sublist - ] - organizations = Organization.objects.filter(code__in=organizations_codes) - user.groups.add(get_default_group(), *[o.get_users() for o in organizations]) + KeycloakService.set_user_projects_groups(keycloak_account) + user.groups.add(get_default_group()) return user - def add_idp_organizations(self) -> "ProjectUser": - try: - keycloak_user = KeycloakService.get_user(self.keycloak_id) - organizations_codes = keycloak_user.get("attributes", {}).get( - "idp_organizations", [] + def sync_groups_from_keycloak(self) -> "ProjectUser": + with suppress(KeycloakGetError): + keycloak_account = KeycloakService.set_user_projects_groups( + self.keycloak_account ) - organizations_codes = [code.split(",") for code in organizations_codes] - organizations_codes = [ - item for sublist in organizations_codes for item in sublist - ] - organizations = Organization.objects.filter( - code__in=organizations_codes - ).exclude(groups__users=self) - self.groups.add(*[o.get_users() for o in organizations]) - except KeycloakGetError: # Needed for tests to pass - pass + return keycloak_account.user return self + def add_to_keycloak_group(self, organization: Organization): + return KeycloakService.add_user_to_organization_group( + self.keycloak_account, organization + ) + + def remove_from_keycloak_group(self, organization: Organization): + return KeycloakService.remove_user_from_organization_group( + self.keycloak_account, organization + ) + def is_owned_by(self, user: "ProjectUser") -> bool: """Whether the given user is the owner of the object.""" return self == user diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index c8fb1080..37d174bb 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -864,6 +864,8 @@ def validate_roles(self, groups: list[Group]) -> list[Group]: projects__in=group_projects, people_groups=instance ).distinct() additional_groups_to_add += list(group_projects_roles) + if self.instance and isinstance(instance, Organization): + self.instance.add_to_keycloak_group(instance) for group in groups_to_remove: instance = get_instance_from_group(group) self._validate_role(group, user, instance) @@ -873,6 +875,16 @@ def validate_roles(self, groups: list[Group]) -> list[Group]: projects__in=group_projects, people_groups=instance ).distinct() additional_groups_to_remove += list(group_projects_roles) + if ( + self.instance + and isinstance(instance, Organization) + and ( + not self.instance.groups.filter(organizations=instance) + .exclude(id=group.id) + .exists() + ) + ): + self.instance.remove_from_keycloak_group(instance) default_group = get_default_group() if default_group not in groups: groups.append(default_group) diff --git a/apps/accounts/tests/views/test_user.py b/apps/accounts/tests/views/test_user.py index b02e7c18..a9eb24d0 100644 --- a/apps/accounts/tests/views/test_user.py +++ b/apps/accounts/tests/views/test_user.py @@ -862,6 +862,11 @@ def test_filter_needs_mentor_on(self): class MiscUserTestCase(JwtAPITestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.superadmin = UserFactory(groups=[get_superadmins_group()]) + def test_notifications_count(self): project = ProjectFactory() user = UserFactory() @@ -878,8 +883,7 @@ def test_notifications_count(self): @patch("services.keycloak.interface.KeycloakService.send_email") def test_language_from_organization(self, mocked): mocked.return_value = {} - user = UserFactory(groups=[get_superadmins_group()]) - self.client.force_authenticate(user) + self.client.force_authenticate(self.superadmin) organization = OrganizationFactory(language="fr") payload = { "email": f"{faker.uuid4()}@{faker.domain_name()}", @@ -899,8 +903,7 @@ def test_language_from_organization(self, mocked): @patch("services.keycloak.interface.KeycloakService.send_email") def test_language_from_payload(self, mocked): mocked.return_value = {} - user = UserFactory(groups=[get_superadmins_group()]) - self.client.force_authenticate(user) + self.client.force_authenticate(self.superadmin) organization = OrganizationFactory(language="en") payload = { "email": f"{faker.uuid4()}@{faker.domain_name()}", @@ -919,7 +922,7 @@ def test_language_from_payload(self, mocked): self.assertEqual(keycloak_user["attributes"]["locale"], ["fr"]) def test_keycloak_attributes_updated(self): - self.client.force_authenticate(UserFactory(groups=[get_superadmins_group()])) + self.client.force_authenticate(self.superadmin) user = SeedUserFactory(language="en") KeycloakService._update_user( user.keycloak_id, @@ -941,56 +944,6 @@ def test_keycloak_attributes_updated(self): self.assertEqual(keycloak_user["attributes"]["locale"], ["fr"]) self.assertEqual(keycloak_user["attributes"]["attribute_1"], ["value_1"]) - def test_add_organization_from_keycloak_attributes(self): - organization = OrganizationFactory() - payload = { - "username": f"{faker.uuid4()}@{faker.domain_name()}", - "email": f"{faker.uuid4()}@{faker.domain_name()}", - "firstName": faker.first_name(), - "lastName": faker.last_name(), - "attributes": {"idp_organizations": [organization.code]}, - } - keycloak_id = KeycloakService._create_user(payload) - user = ProjectUser.import_from_keycloak(keycloak_id) - self.assertIsNotNone(user) - self.assertIn(user, organization.users.all()) - - def test_add_organization_from_keycloak_attributes_existing_user(self): - organization_1 = OrganizationFactory() - organization_2 = OrganizationFactory() - - user_1 = SeedUserFactory(groups=[organization_1.get_users()]) - payload_1 = { - "username": user_1.email, - "email": user_1.email, - "firstName": user_1.given_name, - "lastName": user_1.family_name, - "attributes": {"idp_organizations": [organization_2.code]}, - } - KeycloakService._update_user(user_1.keycloak_id, payload_1) - self.client.force_authenticate(user_1) - self.client.get(reverse("ProjectUser-detail", args=(user_1.id,))) - user_1.refresh_from_db() - self.assertIn(user_1, organization_1.users.all()) - self.assertIn(user_1, organization_2.users.all()) - - user_2 = SeedUserFactory( - groups=[organization_1.get_users(), organization_2.get_admins()] - ) - payload_2 = { - "username": user_2.email, - "email": user_2.email, - "firstName": user_2.given_name, - "lastName": user_2.family_name, - "attributes": {"idp_organizations": [organization_2.code]}, - } - KeycloakService._update_user(user_2.keycloak_id, payload_2) - self.client.force_authenticate(user_2) - self.client.get(reverse("ProjectUser-detail", args=(user_2.id,))) - user_2.refresh_from_db() - self.assertIn(user_2, organization_1.users.all()) - self.assertIn(user_2, organization_2.admins.all()) - def test_get_current_org_role(self): users = UserFactory.create_batch(3) admins = UserFactory.create_batch(3) @@ -1139,7 +1092,7 @@ def test_get_slug(self): def test_outdated_slug(self, mocked): mocked.return_value = {} organization = OrganizationFactory() - self.client.force_authenticate(UserFactory(groups=[get_superadmins_group()])) + self.client.force_authenticate(self.superadmin) given_name = faker.first_name() family_name_a = "name-a" family_name_b = "name-b" @@ -1217,3 +1170,100 @@ def test_uuid_slug(self): family_name = "" user = UserFactory(given_name=given_name, family_name=family_name) self.assertEqual(user.slug, f"user-{given_name}") + + def test_add_organization_from_keycloak_attributes(self): + organization = OrganizationFactory() + KeycloakService.create_organization_group(organization) + payload = { + "username": f"{faker.uuid4()}@{faker.domain_name()}", + "email": f"{faker.uuid4()}@{faker.domain_name()}", + "firstName": faker.first_name(), + "lastName": faker.last_name(), + "groups": [f"/organizations/{organization.code}"], + } + keycloak_id = KeycloakService._create_user(payload) + + user = ProjectUser.import_from_keycloak(keycloak_id) + self.assertIsNotNone(user) + self.assertIn(user, organization.users.all()) + + def test_add_organization_from_keycloak_attributes_existing_user(self): + organization_1 = OrganizationFactory() + organization_2 = OrganizationFactory() + + user_1 = SeedUserFactory(groups=[organization_1.get_users()]) + KeycloakService.add_user_to_organization_group( + user_1.keycloak_account, organization_2 + ) + self.client.force_authenticate(user_1) + self.client.get(reverse("ProjectUser-detail", args=(user_1.id,))) + user_1.refresh_from_db() + self.assertIn(user_1, organization_1.users.all()) + self.assertIn(user_1, organization_2.users.all()) + + user_2 = SeedUserFactory( + groups=[organization_1.get_users(), organization_2.get_admins()] + ) + KeycloakService.add_user_to_organization_group( + user_2.keycloak_account, organization_2 + ) + self.client.force_authenticate(user_2) + self.client.get(reverse("ProjectUser-detail", args=(user_2.id,))) + user_2.refresh_from_db() + self.assertIn(user_2, organization_1.users.all()) + self.assertIn(user_2, organization_2.admins.all()) + + def test_keycloak_sync_add_to_organization(self): + self.client.force_authenticate(self.superadmin) + organization = OrganizationFactory() + organization_group = KeycloakService.create_organization_group(organization) + user = SeedUserFactory() + keycloak_groups = KeycloakService.get_user_groups(user.keycloak_account) + self.assertNotIn(organization_group, [group["id"] for group in keycloak_groups]) + payload = {"roles_to_add": [organization.get_users().name]} + response = self.client.patch( + reverse("ProjectUser-detail", args=(user.id,)), data=payload + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + keycloak_groups = KeycloakService.get_user_groups(user.keycloak_account) + self.assertIn(organization_group, [group["id"] for group in keycloak_groups]) + + def test_keycloak_sync_remove_from_organization(self): + self.client.force_authenticate(self.superadmin) + organization = OrganizationFactory() + organization_group = KeycloakService.create_organization_group(organization) + user = SeedUserFactory(groups=[organization.get_users()]) + KeycloakService.add_user_to_organization_group( + user.keycloak_account, organization + ) + keycloak_groups = KeycloakService.get_user_groups(user.keycloak_account) + self.assertIn(organization_group, [group["id"] for group in keycloak_groups]) + payload = {"roles_to_remove": [organization.get_users().name]} + response = self.client.patch( + reverse("ProjectUser-detail", args=(user.id,)), data=payload + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + keycloak_groups = KeycloakService.get_user_groups(user.keycloak_account) + self.assertNotIn(organization_group, [group["id"] for group in keycloak_groups]) + + def test_keycloak_sync_stay_in_organization(self): + self.client.force_authenticate(self.superadmin) + organization = OrganizationFactory() + organization_group = KeycloakService.create_organization_group(organization) + user = SeedUserFactory( + groups=[organization.get_users(), organization.get_admins()] + ) + KeycloakService.add_user_to_organization_group( + user.keycloak_account, organization + ) + keycloak_groups = KeycloakService.get_user_groups(user.keycloak_account) + self.assertIn(organization_group, [group["id"] for group in keycloak_groups]) + payload = { + "roles_to_remove": [organization.get_users().name], + } + response = self.client.patch( + reverse("ProjectUser-detail", args=(user.id,)), data=payload + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + keycloak_groups = KeycloakService.get_user_groups(user.keycloak_account) + self.assertIn(organization_group, [group["id"] for group in keycloak_groups]) diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 17a7ac6c..887e0fe8 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -197,18 +197,19 @@ def get_object(self): """ Returns the object the view is displaying. - Overridden to add the organization role to the user if they have logged in - through an IdP for the first time. + Overridden to sync organizations if one was added through another app and sent + through the keycloak groups but not yet added to the user groups here. This would be better if it was done during authentication but it slows down every authenticated request instead of just this one. - This might cause some issues on the first login, because some other requests - might be made before this one and the organization role would not be added yet. + This might cause some issues on the first login after the role is given, because + some other requests might be made before this one and the organization role + would not be added yet. """ instance: ProjectUser = super().get_object() if self.request.user.is_authenticated and instance.id == self.request.user.id: - instance = instance.add_idp_organizations() + instance.sync_groups_from_keycloak() return instance def get_serializer_class(self): @@ -393,6 +394,7 @@ def perform_create(self, serializer): keycloak_account = KeycloakService.create_user( instance, self.request.data.get("password") ) + KeycloakService.set_user_keycloak_groups(keycloak_account) update_new_user_pending_access_requests.delay( instance.id, redirect_organization_code ) diff --git a/apps/organizations/admin.py b/apps/organizations/admin.py index e90b70df..f6454b7d 100644 --- a/apps/organizations/admin.py +++ b/apps/organizations/admin.py @@ -37,6 +37,7 @@ def save_model(self, request, obj, form, change): redirect_uris.append(f"{obj.website_url}/*") data["redirectUris"] = redirect_uris KeycloakService.update_client(client_id, data) + KeycloakService.create_organization_group(obj) super().save_model(request, obj, form, change) diff --git a/apps/organizations/serializers.py b/apps/organizations/serializers.py index e8c47dce..4b5118d7 100644 --- a/apps/organizations/serializers.py +++ b/apps/organizations/serializers.py @@ -131,6 +131,7 @@ def create(self, validated_data): for user in users: user.groups.remove(*organization.groups.filter(users=user)) user.groups.add(group) + user.add_to_keycloak_group(organization) return validated_data @@ -150,6 +151,7 @@ def create(self, validated_data): users = validated_data.get("users", []) for user in users: user.groups.remove(*organization.groups.filter(users=user)) + user.remove_from_keycloak_group(organization) return validated_data diff --git a/keycloak-lpi-theme b/keycloak-lpi-theme index d6a9594e..83173eb5 160000 --- a/keycloak-lpi-theme +++ b/keycloak-lpi-theme @@ -1 +1 @@ -Subproject commit d6a9594eff40286e05b58009b9ea42f44f4a63ff +Subproject commit 83173eb50a291373cce8b308d1156f07b675d19a diff --git a/locale/ca/LC_MESSAGES/django.po b/locale/ca/LC_MESSAGES/django.po index 7776a56b..6c863acb 100644 --- a/locale/ca/LC_MESSAGES/django.po +++ b/locale/ca/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-23 15:55+0100\n" +"POT-Creation-Date: 2026-02-27 14:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,7 +110,7 @@ msgstr "No pots assignar aquest rol a un usuari" msgid "You cannot assign this role to a user : {role}" msgstr "No pots assignar aquest rol a un usuari: {role}" -#: apps/accounts/models.py:157 apps/projects/models.py:164 +#: apps/accounts/models.py:158 apps/projects/models.py:164 msgid "visibility" msgstr "visibilitat" diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 47e02ce6..6118d36d 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-23 15:55+0100\n" +"POT-Creation-Date: 2026-02-27 14:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -112,7 +112,7 @@ msgstr "Sie können diese Rolle keinem Benutzer zuweisen" msgid "You cannot assign this role to a user : {role}" msgstr "Sie können diese Rolle keinem Benutzer zuweisen: {role}" -#: apps/accounts/models.py:157 apps/projects/models.py:164 +#: apps/accounts/models.py:158 apps/projects/models.py:164 msgid "visibility" msgstr "Sichtbarkeit" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index eb480f7a..fdbcd2a3 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-23 15:55+0100\n" +"POT-Creation-Date: 2026-02-27 14:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -108,7 +108,7 @@ msgstr "" msgid "You cannot assign this role to a user : {role}" msgstr "" -#: apps/accounts/models.py:157 apps/projects/models.py:164 +#: apps/accounts/models.py:158 apps/projects/models.py:164 msgid "visibility" msgstr "" diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index f4bffad6..008a7c2d 100644 --- a/locale/es/LC_MESSAGES/django.po +++ b/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-23 15:55+0100\n" +"POT-Creation-Date: 2026-02-27 14:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,7 +110,7 @@ msgstr "No puedes asignar este rol a un usuario" msgid "You cannot assign this role to a user : {role}" msgstr "No puedes asignar este rol a un usuario: {role}" -#: apps/accounts/models.py:157 apps/projects/models.py:164 +#: apps/accounts/models.py:158 apps/projects/models.py:164 msgid "visibility" msgstr "visibilidad" diff --git a/locale/et/LC_MESSAGES/django.po b/locale/et/LC_MESSAGES/django.po index 497a1134..fd507ad4 100644 --- a/locale/et/LC_MESSAGES/django.po +++ b/locale/et/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-23 15:55+0100\n" +"POT-Creation-Date: 2026-02-27 14:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -110,7 +110,7 @@ msgstr "Sa ei saa seda rolli kasutajale määrata" msgid "You cannot assign this role to a user : {role}" msgstr "Sa ei saa seda rolli kasutajale määrata: {role}" -#: apps/accounts/models.py:157 apps/projects/models.py:164 +#: apps/accounts/models.py:158 apps/projects/models.py:164 msgid "visibility" msgstr "nähtavus" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 2f1e9144..28306434 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-23 15:55+0100\n" +"POT-Creation-Date: 2026-02-27 14:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -112,7 +112,7 @@ msgstr "Vous ne pouvez pas assigner ce rôle à un·e utilisateur·ice" msgid "You cannot assign this role to a user : {role}" msgstr "Vous ne pouvez pas assigner ce rôle à un·e utilisateur·ice : {role}" -#: apps/accounts/models.py:157 apps/projects/models.py:164 +#: apps/accounts/models.py:158 apps/projects/models.py:164 msgid "visibility" msgstr "visibilité" diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index 305083ee..c0567a69 100644 --- a/locale/nl/LC_MESSAGES/django.po +++ b/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-23 15:55+0100\n" +"POT-Creation-Date: 2026-02-27 14:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -112,7 +112,7 @@ msgstr "Je kunt deze rol niet toewijzen aan een gebruiker" msgid "You cannot assign this role to a user : {role}" msgstr "Je kunt deze rol niet toewijzen aan een gebruiker: {role}" -#: apps/accounts/models.py:157 apps/projects/models.py:164 +#: apps/accounts/models.py:158 apps/projects/models.py:164 msgid "visibility" msgstr "zichtbaarheid" diff --git a/services/keycloak/interface.py b/services/keycloak/interface.py index 0097b337..a21ddd17 100644 --- a/services/keycloak/interface.py +++ b/services/keycloak/interface.py @@ -1,19 +1,22 @@ import logging +from contextlib import suppress from datetime import datetime from typing import TYPE_CHECKING from babel.dates import format_date, format_time from django.conf import settings -from django.contrib.auth.models import Group from django.db import models from django.http import Http404 from keycloak import KeycloakAdmin from keycloak.exceptions import ( KeycloakAuthenticationError, + KeycloakDeleteError, KeycloakGetError, + KeycloakPutError, raise_error_from_response, ) +from apps.accounts.utils import get_superadmins_group from apps.emailing.utils import render_message, send_email from apps.organizations.models import Organization @@ -298,44 +301,135 @@ def send_reset_password_email( send_email(subject, text, [keycloak_account.email], html_content=html) return True - @classmethod - def is_superuser(cls, keycloak_account: KeycloakAccount) -> list[Group]: - keycloak_groups = cls.get_user_groups(keycloak_account) - keycloak_groups = [group.get("path") for group in keycloak_groups] - return "/projects/administrators" in keycloak_groups - @classmethod def get_user_groups(cls, keycloak_account: KeycloakAccount) -> list[dict[str, str]]: return cls.service().get_user_groups(keycloak_account.keycloak_id) @classmethod - def get_superadmins(cls) -> list[dict]: - keycloak_admin = cls.service() - group = keycloak_admin.get_group_by_path( - path="/projects/administrators", search_in_subgroups=True - ) - if not group: - return [] - return keycloak_admin.get_group_members( - group.get("id", ""), {"briefRepresentation": True, "max": -1} - ) + def get_organization_group(cls, organization: Organization) -> dict[str, str]: + try: + return cls.service().get_group_by_path( + f"/organizations/{organization.code}" + ) + except KeycloakGetError: + return None @classmethod - def get_members_from_organization(cls, code: str, subgroup: str) -> list: - keycloak_admin = cls.service() - group = keycloak_admin.get_group_by_path( - path=f"/projects/portals/{code}/{subgroup}", - search_in_subgroups=True, - ) - if not group: - return [] - return keycloak_admin.get_group_members( - group.get("id", ""), {"briefRepresentation": True, "max": -1} + def create_organization_group(cls, organization: Organization) -> str: + service = cls.service() + try: + parent = service.get_group_by_path("/organizations") + except KeycloakGetError: + service.create_group({"name": "organizations", "path": "/organizations"}) + parent = service.get_group_by_path("/organizations") + path = f"/organizations/{organization.code}" + return service.create_group( + {"name": organization.code, "path": path}, + parent=parent["id"], + skip_exists=True, ) + @classmethod + def add_user_to_organization_group( + cls, keycloak_account: KeycloakAccount, organization: Organization + ) -> None: + service = cls.service() + try: + group = service.get_group_by_path(f"/organizations/{organization.code}") + except KeycloakGetError: + cls.create_organization_group(organization) + group = service.get_group_by_path(f"/organizations/{organization.code}") + with suppress(KeycloakPutError): + service.group_user_add( + user_id=keycloak_account.keycloak_id, group_id=group["id"] + ) + + @classmethod + def remove_user_from_organization_group( + cls, keycloak_account: KeycloakAccount, organization: Organization + ) -> None: + service = cls.service() + try: + group = service.get_group_by_path(f"/organizations/{organization.code}") + except KeycloakGetError: + cls.create_organization_group(organization) + group = service.get_group_by_path(f"/organizations/{organization.code}") + with suppress(KeycloakDeleteError): + service.group_user_remove( + user_id=keycloak_account.keycloak_id, group_id=group["id"] + ) + + @classmethod + def set_user_projects_groups( + cls, keycloak_account: KeycloakAccount + ) -> KeycloakAccount: + organizations = Organization.objects.filter( + groups__users__keycloak_account=keycloak_account + ).distinct() + with suppress(KeycloakGetError): + keycloak_groups = cls.get_user_groups(keycloak_account) + + # Handle superadmin group + if "/projects/administrators" in [ + group.get("path") for group in keycloak_groups + ]: + keycloak_groups.remove("/projects/administrators") + keycloak_account.user.groups.add(get_superadmins_group()) + + keycloak_organization_codes = { + group.get("name") + for group in keycloak_groups + if group.get("path", "").startswith("/organizations/") + } + # Remove extra groups + for organization in organizations: + if organization.code not in keycloak_organization_codes: + # At the moment we don't perform destructive actions using this system + # keycloak_account.user.groups.remove(*organization.groups.all()) # noqa: E800 + pass + # Add missing groups + for organization_code in keycloak_organization_codes: + if organization_code not in organizations.values_list( + "code", flat=True + ): + organization = Organization.objects.get(code=organization_code) + keycloak_account.user.groups.add(organization.get_users()) + return keycloak_account + + @classmethod + def set_user_keycloak_groups( + cls, keycloak_account: KeycloakAccount + ) -> KeycloakAccount: + organizations = Organization.objects.filter( + groups__users__keycloak_account=keycloak_account + ).distinct() + with suppress(KeycloakGetError): + keycloak_groups = cls.get_user_groups(keycloak_account) + keycloak_organization_codes = { + group.get("name") + for group in keycloak_groups + if group.get("path", "").startswith("/organizations/") + } + # Add missing groups + for organization in organizations: + if organization.code not in keycloak_organization_codes: + organization = Organization.objects.get(code=organization.code) + cls.add_user_to_organization_group(keycloak_account, organization) + # Remove extra groups + organizations_codes = organizations.values_list("code", flat=True) + for group in keycloak_groups: + if group.get("path", "").startswith("/organizations/"): + organization_code = group.get("name") + if organization_code not in organizations_codes: + organization = Organization.objects.get(code=organization_code) + cls.remove_user_from_organization_group( + keycloak_account, organization + ) + return keycloak_account + @classmethod def _update_user(cls, keycloak_id: str, payload: dict): - cls.service().update_user(user_id=keycloak_id, payload=payload) + return cls.service().update_user(user_id=keycloak_id, payload=payload) @classmethod def update_user(cls, keycloak_account: KeycloakAccount): diff --git a/services/keycloak/management/commands/store_groups_in_kc.py b/services/keycloak/management/commands/store_groups_in_kc.py new file mode 100644 index 00000000..31690ba8 --- /dev/null +++ b/services/keycloak/management/commands/store_groups_in_kc.py @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand + +from apps.accounts.models import ProjectUser +from apps.organizations.models import Organization +from services.keycloak.interface import KeycloakService + + +class Command(BaseCommand): + def handle(self, *args, **options): + for organization in Organization.objects.all(): + KeycloakService.create_organization_group(organization) + for user in ProjectUser.objects.filter(keycloak_account__isnull=False): + KeycloakService.set_user_keycloak_groups(user.keycloak_account)