From 09bd7d6a40671fd820dcad644ca41acb104ff17a Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 28 Jan 2026 10:41:41 +0530 Subject: [PATCH 1/8] CH-220 - add organization and organization member models and its migrations --- .../0002_organization_organizationmember.py | 38 +++++++++++++++++++ .../cloudharness_django/models.py | 24 ++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/migrations/0002_organization_organizationmember.py diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/migrations/0002_organization_organizationmember.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/migrations/0002_organization_organizationmember.py new file mode 100644 index 000000000..a0f2fd8da --- /dev/null +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/migrations/0002_organization_organizationmember.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.11 on 2026-01-15 00:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cloudharness_django', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Organization', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256)), + ('kc_id', models.CharField(db_index=True, max_length=100)), + ], + options={ + 'verbose_name': 'CloudHarness Organization', + 'verbose_name_plural': 'CloudHarness Organizations', + }, + ), + migrations.CreateModel( + name='OrganizationMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='auth.user')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cloudharness_django.organization')), + ], + options={ + 'verbose_name': 'CloudHarness Organization Member', + 'verbose_name_plural': 'CloudHarness Organization Members', + }, + ), + ] diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/models.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/models.py index 99ea89cb6..779ccfc5e 100644 --- a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/models.py +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/models.py @@ -19,3 +19,27 @@ class Member(models.Model): def __str__(self): return f"{self.user.first_name} {self.user.last_name}" + + +class Organization(models.Model): + name = models.CharField(max_length=256) + kc_id = models.CharField(max_length=100, db_index=True) + + def __str__(self): + return f"{self.name}" + + class Meta: + verbose_name = "CloudHarness Organization" + verbose_name_plural = "CloudHarness Organizations" + + +class OrganizationMember(models.Model): + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + user = models.OneToOneField(User, on_delete=models.CASCADE) + + def __str__(self): + return f"{self.user} - {self.organization}" + + class Meta: + verbose_name = "CloudHarness Organization Member" + verbose_name_plural = "CloudHarness Organization Members" From d8145b0517b6e757d63c439fe73799c15d524022 Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 28 Jan 2026 10:49:58 +0530 Subject: [PATCH 2/8] CH-220 - Add organization admin and sync --- .../cloudharness_django/admin.py | 17 ++++++ .../cloudharness_django/services/user.py | 57 ++++++++++++++++++- .../cloudharness/auth/keycloak.py | 50 ++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py index 30635fe3b..b2c1a083f 100644 --- a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py @@ -55,5 +55,22 @@ def sync_keycloak(self, request): self.message_user(request, 'Keycloak users & groups synced.') +class CHOrganizationAdmin(ExtraButtonsMixin, admin.ModelAdmin): + + def has_add_permission(self, request): + return settings.DEBUG + + def has_change_permission(self, request, obj=None): + return settings.DEBUG + + def has_delete_permission(self, request, obj=None): + return settings.DEBUG + + @button() + def sync_keycloak(self, request): + get_user_service().sync_kc_organizations() + self.message_user(request, 'Keycloak organizations synced.') + + admin.site.register(User, CHUserAdmin) admin.site.register(Group, CHGroupAdmin) diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py index 0fb0d61cc..bc824c45c 100644 --- a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py @@ -1,7 +1,7 @@ from django.contrib.auth.models import User, Group from django.db import transaction -from cloudharness_django.models import Team, Member +from cloudharness_django.models import Team, Member, Organization, OrganizationMember from cloudharness_django.services.auth import AuthService, AuthorizationLevel from cloudharness import models as ch_models @@ -90,6 +90,24 @@ def sync_kc_group(self, kc_group: ch_models.UserGroup): team.save() group.save() + def sync_kc_organization(self, kc_organization): + # sync the kc organization to Organization model + organization, _ = Organization.objects.get_or_create(kc_id=kc_organization["id"]) + organization.name = kc_organization["name"] + organization.save() + + def sync_kc_organizations(self, kc_organizations=None): + if not kc_organizations: + kc_organizations = self.auth_client.get_organizations() + + kc_organization_ids = set() + for kc_organization in kc_organizations: + self.sync_kc_organization(kc_organization) + kc_organization_ids.add(kc_organization["id"]) + + # Delete organizations that no longer exist in Keycloak + Organization.objects.exclude(kc_id__in=kc_organization_ids).delete() + def sync_kc_groups(self, kc_groups=None): # sync all groups if not kc_groups: @@ -164,6 +182,36 @@ def sync_kc_user_groups(self, kc_user: ch_models.User): member = Member(user=user, kc_id=kc_user.id) member.save() + def sync_kc_user_organizations(self, kc_user): + # Ensure OrganizationMember records reflect Keycloak userOrganizations + try: + member = Member.objects.get(user__email=kc_user["email"]) + except Member.DoesNotExist: + # Member not available yet; nothing to sync + return + + desired_org_ids = set() + kc_user_organizations = self.auth_client.get_user_organizations(kc_user["id"]) + for kc_org in kc_user_organizations: + try: + org = Organization.objects.get(kc_id=kc_org["id"]) + except Organization.DoesNotExist: + # Organizations should already be synced; skip if missing + continue + desired_org_ids.add(org.id) + + existing_qs = OrganizationMember.objects.filter(member=member) + existing_org_ids = set(existing_qs.values_list("organization_id", flat=True)) + + to_add = desired_org_ids - existing_org_ids + to_remove = existing_org_ids - desired_org_ids + + for org_id in to_add: + OrganizationMember.objects.get_or_create(member=member, organization_id=org_id) + + if to_remove: + OrganizationMember.objects.filter(member=member, organization_id__in=to_remove).delete() + def sync_kc_users_groups(self): # cache all admin users to minimize KC rest api calls all_admin_users = self.auth_client.get_client_role_members( @@ -178,6 +226,13 @@ def sync_kc_users_groups(self): is_superuser = any([admin_user for admin_user in all_admin_users if admin_user["id"] == kc_user["id"]]) self.sync_kc_user(kc_user, is_superuser) + # sync the groups + self.sync_kc_groups() + + # sync the organizations + self.sync_kc_organizations() + # sync the user groups and memberships for kc_user in users: self.sync_kc_user_groups(kc_user) + self.sync_kc_user_organizations(kc_user) diff --git a/libraries/cloudharness-common/cloudharness/auth/keycloak.py b/libraries/cloudharness-common/cloudharness/auth/keycloak.py index f094c57cf..adeaec7a8 100644 --- a/libraries/cloudharness-common/cloudharness/auth/keycloak.py +++ b/libraries/cloudharness-common/cloudharness/auth/keycloak.py @@ -641,3 +641,53 @@ def user_delete_attribute(self, user_id, attribute_name): }) return True return False + + @with_refreshtoken + def get_organizations(self, with_members=False) -> List[dict]: + """ + Get all organizations in the realm + + :param with_members: If True, include organization members for each org. Defaults to False + :return: List of organization representations + """ + admin_client = self.get_admin_client() + try: + orgs = admin_client.get_organizations() + if with_members: + for org in orgs: + org['members'] = admin_client.get_organization_members(org['id']) + return orgs + except Exception as e: + log.error(f"Error getting organizations: {e}") + raise + + @with_refreshtoken + def get_organization_members(self, org_id: str) -> List[User]: + """ + Get all members of an organization + + :param org_id: Organization ID + :return: List of User objects + """ + admin_client = self.get_admin_client() + try: + members = admin_client.get_organization_members(org_id) + return [User.from_dict(member) for member in members] + except Exception as e: + log.error(f"Error getting members for organization {org_id}: {e}") + raise + + @with_refreshtoken + def get_user_organizations(self, user_id: str) -> List[dict]: + """ + Get all organizations a user belongs to + + :param user_id: User ID + :return: List of organization representations + """ + admin_client = self.get_admin_client() + try: + return admin_client.get_user_organizations(user_id) + except Exception as e: + log.error(f"Error getting organizations for user {user_id}: {e}") + raise From 3f3eb433860ba340c75088e69c1a09c9d43a6dd5 Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 28 Jan 2026 12:04:30 +0530 Subject: [PATCH 3/8] CH-220 - fix organizationmember query for user instead of member --- .../cloudharness_django/services/user.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py index bc824c45c..264590faa 100644 --- a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py @@ -166,7 +166,7 @@ def sync_kc_user_groups(self, kc_user: ch_models.User): raise ValueError(f"Django user not found for Keycloak user {kc_user.id}") user_groups = [] - for kc_group in [*kc_user.user_groups, *kc_user.organizations]: + for kc_group in [*kc_user.user_groups]: group, _ = Group.objects.get_or_create(name=kc_group.name) user_groups.append(group) user.groups.set(user_groups) @@ -200,17 +200,17 @@ def sync_kc_user_organizations(self, kc_user): continue desired_org_ids.add(org.id) - existing_qs = OrganizationMember.objects.filter(member=member) + existing_qs = OrganizationMember.objects.filter(user=member.user) existing_org_ids = set(existing_qs.values_list("organization_id", flat=True)) to_add = desired_org_ids - existing_org_ids to_remove = existing_org_ids - desired_org_ids for org_id in to_add: - OrganizationMember.objects.get_or_create(member=member, organization_id=org_id) + OrganizationMember.objects.get_or_create(user=member.user, organization_id=org_id) if to_remove: - OrganizationMember.objects.filter(member=member, organization_id__in=to_remove).delete() + OrganizationMember.objects.filter(user=member.user, organization_id__in=to_remove).delete() def sync_kc_users_groups(self): # cache all admin users to minimize KC rest api calls From dac753bdd6dd615c58c127715ec8ae368cad35a4 Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 28 Jan 2026 12:11:40 +0530 Subject: [PATCH 4/8] CH-220 - fix linting on models.py --- .../libraries/cloudharness-django/cloudharness_django/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/models.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/models.py index 779ccfc5e..c7ed946e7 100644 --- a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/models.py +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/models.py @@ -27,7 +27,7 @@ class Organization(models.Model): def __str__(self): return f"{self.name}" - + class Meta: verbose_name = "CloudHarness Organization" verbose_name_plural = "CloudHarness Organizations" From c9e4408ab7521b8148dcb0cdcec30fbffee00eac Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 28 Jan 2026 12:21:18 +0530 Subject: [PATCH 5/8] CH-220 - fix import get user service in ch organization admin --- .../libraries/cloudharness-django/cloudharness_django/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py index b2c1a083f..4b9af26f4 100644 --- a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py @@ -68,6 +68,7 @@ def has_delete_permission(self, request, obj=None): @button() def sync_keycloak(self, request): + from cloudharness_django.services import get_user_service get_user_service().sync_kc_organizations() self.message_user(request, 'Keycloak organizations synced.') From ed6064d3d3a4f88ce100f980646783f72b83a68e Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 28 Jan 2026 12:49:07 +0530 Subject: [PATCH 6/8] CH-220 - separate sync_kc_users_organizations method for organization sync and orgnaization member sync --- .../cloudharness_django/services/user.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py index 264590faa..405a6b1cd 100644 --- a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py @@ -229,10 +229,15 @@ def sync_kc_users_groups(self): # sync the groups self.sync_kc_groups() + # sync the user groups and memberships + for kc_user in users: + self.sync_kc_user_groups(kc_user) + + def sync_kc_users_organizations(self): + # sync the users organizations + users = self.auth_client.get_users() # sync the organizations self.sync_kc_organizations() - # sync the user groups and memberships for kc_user in users: - self.sync_kc_user_groups(kc_user) self.sync_kc_user_organizations(kc_user) From 1544f6406da44439c0e8709fc2f1ae41b7994637 Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 28 Jan 2026 13:30:09 +0530 Subject: [PATCH 7/8] CH-220 - sync keycloak groups for group sync button --- .../libraries/cloudharness-django/cloudharness_django/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py index 4b9af26f4..2a50ffa97 100644 --- a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py @@ -51,7 +51,7 @@ def has_delete_permission(self, request, obj=None): @button() def sync_keycloak(self, request): from cloudharness_django.services import get_user_service - get_user_service().sync_kc_users_groups() + get_user_service().sync_kc_groups() self.message_user(request, 'Keycloak users & groups synced.') From 0b01b324382b4e5f1f592e809545eb28cda0866e Mon Sep 17 00:00:00 2001 From: "D. Gopal Krishna" Date: Wed, 28 Jan 2026 17:47:07 +0530 Subject: [PATCH 8/8] CH-220 - remove old sync_kc_groups - which was already removed --- .../cloudharness-django/cloudharness_django/services/user.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py index 405a6b1cd..7501c3089 100644 --- a/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py +++ b/infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py @@ -226,8 +226,6 @@ def sync_kc_users_groups(self): is_superuser = any([admin_user for admin_user in all_admin_users if admin_user["id"] == kc_user["id"]]) self.sync_kc_user(kc_user, is_superuser) - # sync the groups - self.sync_kc_groups() # sync the user groups and memberships for kc_user in users: