Skip to content

Commit fdf232e

Browse files
committed
CH-220 separate groups and organizations from Keycloak
1 parent 8be7ea9 commit fdf232e

4 files changed

Lines changed: 188 additions & 5 deletions

File tree

infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/admin.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.contrib.auth.models import User, Group
55

66
from admin_extra_buttons.api import ExtraButtonsMixin, button
7-
from .models import Member
7+
from .models import Member, Organization, OrganizationMember
88

99

1010
# Register your models here.
@@ -13,6 +13,46 @@
1313
admin.site.unregister(Group)
1414

1515

16+
class OrganizationMemberInline(admin.TabularInline):
17+
model = OrganizationMember
18+
extra = 0
19+
autocomplete_fields = ['user']
20+
readonly_fields = []
21+
22+
23+
@admin.register(Organization)
24+
class OrganizationAdmin(ExtraButtonsMixin, admin.ModelAdmin):
25+
list_display = ['name', 'kc_id', 'members_count']
26+
search_fields = ['name', 'kc_id']
27+
list_filter = [('kc_id', admin.EmptyFieldListFilter)]
28+
readonly_fields = ['kc_id']
29+
inlines = [OrganizationMemberInline]
30+
31+
def get_readonly_fields(self, request, obj=None):
32+
"""Make kc_id readonly only for existing objects."""
33+
if obj:
34+
return ['kc_id']
35+
return []
36+
37+
@admin.display(description='Members')
38+
def members_count(self, obj):
39+
return obj.members.count()
40+
41+
@button()
42+
def sync_keycloak(self, request):
43+
from cloudharness_django.services import get_user_service
44+
get_user_service().sync_kc_users_groups()
45+
self.message_user(request, 'Keycloak organizations synced.')
46+
47+
48+
@admin.register(OrganizationMember)
49+
class OrganizationMemberAdmin(admin.ModelAdmin):
50+
list_display = ['user', 'organization']
51+
search_fields = ['user__username', 'user__email', 'organization__name']
52+
list_filter = ['organization']
53+
autocomplete_fields = ['user', 'organization']
54+
55+
1656
class MemberAdmin(admin.StackedInline):
1757
model = Member
1858

@@ -38,7 +78,6 @@ def sync_keycloak(self, request):
3878

3979

4080
class CHGroupAdmin(ExtraButtonsMixin, GroupAdmin):
41-
4281
def has_add_permission(self, request):
4382
return settings.DEBUG
4483

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Generated by Django 3.2.11 on 2026-01-28 00:00
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('auth', '0012_alter_user_first_name_max_length'),
11+
('cloudharness_django', '0001_initial'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='Organization',
17+
fields=[
18+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('name', models.CharField(max_length=256, unique=True)),
20+
('kc_id', models.CharField(db_index=True, max_length=100, null=True, blank=True, unique=True)),
21+
],
22+
options={
23+
'verbose_name': 'Organization',
24+
'verbose_name_plural': 'Organizations',
25+
},
26+
),
27+
migrations.CreateModel(
28+
name='OrganizationMember',
29+
fields=[
30+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
31+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_memberships', to='auth.user')),
32+
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='cloudharness_django.organization')),
33+
],
34+
options={
35+
'verbose_name': 'Organization Member',
36+
'verbose_name_plural': 'Organization Members',
37+
'unique_together': {('user', 'organization')},
38+
},
39+
),
40+
]

infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/models.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,39 @@ class Member(models.Model):
1919

2020
def __str__(self):
2121
return f"{self.user.first_name} {self.user.last_name}"
22+
23+
24+
class Organization(models.Model):
25+
"""
26+
Represents a Keycloak organization synced to Django.
27+
This is the base organization model - applications can extend it with
28+
additional fields by creating a OneToOne relationship.
29+
Organizations can be created without kc_id and will be synced by name
30+
when a matching Keycloak organization is found.
31+
"""
32+
name = models.CharField(max_length=256, unique=True)
33+
kc_id = models.CharField(max_length=100, db_index=True, null=True, blank=True, unique=True)
34+
35+
class Meta:
36+
verbose_name = "Organization"
37+
verbose_name_plural = "Organizations"
38+
39+
def __str__(self):
40+
return self.name
41+
42+
43+
class OrganizationMember(models.Model):
44+
"""
45+
Represents membership of a User in a Keycloak Organization.
46+
A user can belong to multiple organizations.
47+
"""
48+
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='organization_memberships')
49+
organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='members')
50+
51+
class Meta:
52+
verbose_name = "Organization Member"
53+
verbose_name_plural = "Organization Members"
54+
unique_together = ('user', 'organization')
55+
56+
def __str__(self):
57+
return f"{self.user.username} - {self.organization.name}"

infrastructure/common-images/cloudharness-django/libraries/cloudharness-django/cloudharness_django/services/user.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.contrib.auth.models import User, Group
22
from django.db import transaction
33

4-
from cloudharness_django.models import Team, Member
4+
from cloudharness_django.models import Team, Member, Organization, OrganizationMember
55
from cloudharness_django.services.auth import AuthService, AuthorizationLevel
66
from cloudharness import models as ch_models
77

@@ -141,19 +141,23 @@ def sync_kc_user(self, kc_user: ch_models.User, is_superuser=False, delete=False
141141
return user
142142

143143
def sync_kc_user_groups(self, kc_user: ch_models.User):
144-
# Sync the user usergroups and memberships using kc_id for reliable lookups
144+
# Sync the user usergroups (not organizations) using kc_id for reliable lookups
145145
user = get_user_by_kc_id(kc_user.id)
146146

147147
if user is None:
148148
raise ValueError(f"Django user not found for Keycloak user {kc_user.id}")
149149

150150
user_groups = []
151-
for kc_group in [*kc_user.user_groups, *kc_user.organizations]:
151+
# Only sync user_groups, not organizations (organizations are handled separately)
152+
for kc_group in kc_user.user_groups or []:
152153
group, _ = Group.objects.get_or_create(name=kc_group.name)
153154
user_groups.append(group)
154155
user.groups.set(user_groups)
155156
user.save()
156157

158+
# Sync organization memberships separately
159+
self.sync_kc_user_organizations(kc_user)
160+
157161
# Ensure the member relationship exists and is correct
158162
try:
159163
member = user.member
@@ -164,6 +168,70 @@ def sync_kc_user_groups(self, kc_user: ch_models.User):
164168
member = Member(user=user, kc_id=kc_user.id)
165169
member.save()
166170

171+
def sync_kc_organization(self, kc_org: ch_models.Organization) -> Organization:
172+
"""
173+
Sync a single Keycloak organization to Django.
174+
First tries to find by kc_id, then by name (for organizations created without kc_id).
175+
Updates the kc_id if found by name.
176+
177+
:param kc_org: Keycloak organization object
178+
:return: Django Organization instance
179+
"""
180+
org = None
181+
182+
# First try to find by kc_id
183+
try:
184+
org = Organization.objects.get(kc_id=kc_org.id)
185+
# Update name if changed
186+
if org.name != kc_org.name:
187+
org.name = kc_org.name
188+
org.save()
189+
except Organization.DoesNotExist:
190+
# Try to find by name (for organizations created without kc_id)
191+
try:
192+
org = Organization.objects.get(name=kc_org.name, kc_id__isnull=True)
193+
# Update with kc_id from Keycloak
194+
org.kc_id = kc_org.id
195+
org.save()
196+
except Organization.DoesNotExist:
197+
# Create new organization
198+
org = Organization.objects.create(
199+
kc_id=kc_org.id,
200+
name=kc_org.name
201+
)
202+
return org
203+
204+
def sync_kc_user_organizations(self, kc_user: ch_models.User):
205+
"""
206+
Sync the user's organization memberships from Keycloak to Django.
207+
Creates Organization records if they don't exist and manages OrganizationMember relationships.
208+
209+
:param kc_user: Keycloak user object with organizations attribute
210+
"""
211+
user = get_user_by_kc_id(kc_user.id)
212+
213+
if user is None:
214+
raise ValueError(f"Django user not found for Keycloak user {kc_user.id}")
215+
216+
# Get current organization memberships
217+
current_org_ids = set()
218+
219+
# Sync each organization the user belongs to
220+
for kc_org in kc_user.organizations or []:
221+
org = self.sync_kc_organization(kc_org)
222+
current_org_ids.add(org.id)
223+
224+
# Create membership if it doesn't exist
225+
OrganizationMember.objects.get_or_create(
226+
user=user,
227+
organization=org
228+
)
229+
230+
# Remove memberships that no longer exist in Keycloak
231+
OrganizationMember.objects.filter(user=user).exclude(
232+
organization_id__in=current_org_ids
233+
).delete()
234+
167235
def sync_kc_users_groups(self):
168236
# cache all admin users to minimize KC rest api calls
169237
all_admin_users = self.auth_client.get_client_role_members(

0 commit comments

Comments
 (0)