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..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,9 +51,27 @@ 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.') +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): + from cloudharness_django.services import get_user_service + 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/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..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 @@ -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" 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..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 @@ -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: @@ -148,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) @@ -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(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(user=member.user, organization_id=org_id) + + if to_remove: + 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 all_admin_users = self.auth_client.get_client_role_members( @@ -178,6 +226,16 @@ 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 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() + + for kc_user in users: + 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