Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this sync_kc_groups() - was removed from sync_kc_users_groups - so there's no way to sync groups if users are not already assigned to groups. This will ensure groups can be synced in the group admin panel

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to sync groups that are not assigned to users

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)
Original file line number Diff line number Diff line change
@@ -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',
},
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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)
50 changes: 50 additions & 0 deletions libraries/cloudharness-common/cloudharness/auth/keycloak.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,3 +641,53 @@ def user_delete_attribute(self, user_id, attribute_name):
})
return True
return False

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these additions are unnecessary, as the pattern is to lazily sync groups/organizations together with users (and organizations were coming already)

@with_refreshtoken
def get_organizations(self, with_members=False) -> List[dict]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no much use in typing when returning a List[dict] where a proper type can be used. CloudHarness defines a type for organizations

"""
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
Loading