Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 18 additions & 27 deletions apps/accounts/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions apps/accounts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
162 changes: 106 additions & 56 deletions apps/accounts/tests/views/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()}",
Expand All @@ -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()}",
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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])
12 changes: 7 additions & 5 deletions apps/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
)
Expand Down
1 change: 1 addition & 0 deletions apps/organizations/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 2 additions & 0 deletions apps/organizations/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion keycloak-lpi-theme
4 changes: 2 additions & 2 deletions locale/ca/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
Expand Down Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions locale/de/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
Expand Down Expand Up @@ -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"

Expand Down
Loading