Skip to content

Commit

Permalink
Added SCIM fields to User and populate (#2062)
Browse files Browse the repository at this point in the history
  • Loading branch information
rhysyngsun authored Feb 25, 2025
1 parent e394d1d commit 6c9a8d2
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 25 deletions.
3 changes: 0 additions & 3 deletions profiles/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ class ProfileAdmin(admin.ModelAdmin):
"image_small_file",
"image_medium_file",
"updated_at",
"scim_id",
"scim_username",
"scim_external_id",
)


Expand Down
3 changes: 0 additions & 3 deletions profiles/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,4 @@ class Meta:
"current_education",
"time_commitment",
"delivery",
"scim_id",
"scim_username",
"scim_external_id",
]
18 changes: 8 additions & 10 deletions scim/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class LearnSCIMUser(SCIMUser):

resource_type = "User"

id_field = "profile__scim_id"
id_field = "scim_id"

ATTR_MAP = {
("active", None, None): "is_active",
Expand Down Expand Up @@ -66,7 +66,7 @@ def id(self):
"""
Return the SCIM id
"""
return self.obj.profile.scim_id
return self.obj.scim_id

@property
def emails(self):
Expand All @@ -89,10 +89,8 @@ def meta(self):
"""
return {
"resourceType": self.resource_type,
"created": self.obj.date_joined.isoformat(timespec="milliseconds"),
"lastModified": self.obj.profile.updated_at.isoformat(
timespec="milliseconds"
),
"created": self.obj.created_on.isoformat(timespec="milliseconds"),
"lastModified": self.obj.updated_on.isoformat(timespec="milliseconds"),
"location": self.location,
}

Expand All @@ -103,7 +101,7 @@ def to_dict(self):
"""
return {
"id": self.id,
"externalId": self.obj.profile.scim_external_id,
"externalId": self.obj.scim_external_id,
"schemas": [constants.SchemaURI.USER],
"userName": self.obj.username,
"name": {
Expand Down Expand Up @@ -135,10 +133,10 @@ def from_dict(self, d):
self.obj.username = d.get("userName")
self.obj.first_name = d.get("name", {}).get("givenName", "")
self.obj.last_name = d.get("name", {}).get("familyName", "")
self.obj.scim_username = d.get("userName")
self.obj.scim_external_id = d.get("externalId")

self.obj.profile = getattr(self.obj, "profile", Profile())
self.obj.profile.scim_username = d.get("userName")
self.obj.profile.scim_external_id = d.get("externalId")
self.obj.profile.name = d.get("fullName", "")
self.obj.profile.email_optin = d.get("emailOptIn", 1) == 1

Expand Down Expand Up @@ -179,7 +177,7 @@ def handle_add(
return

if path.first_path == ("externalId", None, None):
self.obj.profile.scim_external_id = value
self.obj.scim_external_id = value
self.obj.save()

def parse_scim_for_keycloak_payload(self, payload: str) -> dict:
Expand Down
16 changes: 8 additions & 8 deletions scim/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def scim_client(staff_user):

def test_scim_user_post(scim_client):
"""Test that we can create a user via SCIM API"""
user_q = User.objects.filter(profile__scim_external_id="1")
user_q = User.objects.filter(scim_external_id="1")
assert not user_q.exists()

resp = scim_client.post(
Expand Down Expand Up @@ -71,7 +71,7 @@ def test_scim_user_put(scim_client):
user = UserFactory.create()

resp = scim_client.put(
f"{reverse('scim:users')}/{user.profile.scim_id}",
f"{reverse('scim:users')}/{user.scim_id}",
content_type="application/scim+json",
data=json.dumps(
{
Expand Down Expand Up @@ -107,7 +107,7 @@ def test_scim_user_patch(scim_client):
user = UserFactory.create()

resp = scim_client.patch(
f"{reverse('scim:users')}/{user.profile.scim_id}",
f"{reverse('scim:users')}/{user.scim_id}",
content_type="application/scim+json",
data=json.dumps(
{
Expand Down Expand Up @@ -208,7 +208,7 @@ def _put_operation(user, data, bulk_id_gen):
payload={
"method": "put",
"bulkId": bulk_id,
"path": f"/Users/{user.profile.scim_id}",
"path": f"/Users/{user.scim_id}",
"data": _user_to_scim_payload(data),
},
user=user,
Expand All @@ -218,7 +218,7 @@ def _put_operation(user, data, bulk_id_gen):
"location": ANY_STR,
"bulkId": bulk_id,
"status": "200",
"id": str(user.profile.scim_id),
"id": str(user.scim_id),
},
)

Expand All @@ -241,7 +241,7 @@ def _expected_patch_value(field):
payload={
"method": "patch",
"bulkId": bulk_id,
"path": f"/Users/{user.profile.scim_id}",
"path": f"/Users/{user.scim_id}",
"data": {
"schemas": [djs_constants.SchemaURI.PATCH_OP],
"Operations": [
Expand All @@ -268,7 +268,7 @@ def _expected_patch_value(field):
"location": ANY_STR,
"bulkId": bulk_id,
"status": "200",
"id": str(user.profile.scim_id),
"id": str(user.scim_id),
},
)

Expand All @@ -280,7 +280,7 @@ def _delete_operation(user, bulk_id_gen):
payload={
"method": "delete",
"bulkId": bulk_id,
"path": f"/Users/{user.profile.scim_id}",
"path": f"/Users/{user.scim_id}",
},
user=user,
expected_user_state=None,
Expand Down
24 changes: 24 additions & 0 deletions users/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Users admin"""

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as ContribUserAdmin
from hijack.contrib.admin import HijackUserAdminMixin

from users.models import User


@admin.register(User)
class UserAdmin(ContribUserAdmin, HijackUserAdminMixin):
"""Admin for User"""

readonly_fields = (
*ContribUserAdmin.readonly_fields,
"scim_id",
"scim_username",
"scim_external_id",
)

fieldsets = (
*ContribUserAdmin.fieldsets,
("SCIM", {"fields": ("scim_id", "scim_username", "scim_external_id")}),
)
65 changes: 65 additions & 0 deletions users/migrations/0004_add_scim_and_timestamp_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Generated by Django 4.2.19 on 2025-02-19 18:01

import django.utils.timezone
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0003_rename_user_table"),
]

operations = [
migrations.AddField(
model_name="user",
name="created_on",
field=models.DateTimeField(
auto_now_add=True, db_index=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="user",
name="scim_external_id",
field=models.CharField(
blank=True,
db_index=True,
default=None,
help_text="A string that is an identifier for the resource as defined by the provisioning client.", # noqa: E501
max_length=254,
null=True,
verbose_name="SCIM External ID",
),
),
migrations.AddField(
model_name="user",
name="scim_id",
field=models.CharField(
blank=True,
default=None,
help_text="A unique identifier for a SCIM resource as defined by the service provider.", # noqa: E501
max_length=254,
null=True,
unique=True,
verbose_name="SCIM ID",
),
),
migrations.AddField(
model_name="user",
name="scim_username",
field=models.CharField(
blank=True,
db_index=True,
default=None,
help_text="A service provider's unique identifier for the user",
max_length=254,
null=True,
verbose_name="SCIM Username",
),
),
migrations.AddField(
model_name="user",
name="updated_on",
field=models.DateTimeField(auto_now=True),
),
]
62 changes: 62 additions & 0 deletions users/migrations/0005_set_user_scim_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Generated by Django 4.2.19 on 2025-02-19 16:16
import logging

from django.db import migrations, models

BATCH_SIZE = 10_000

log = logging.getLogger()


def _set_scim_and_timestamps(apps, schema_editor):
User = apps.get_model("users", "User")
Profile = apps.get_model("profiles", "Profile")

query = User.objects.filter(
id__in=User.objects.filter(scim_id__isnull=True).only("id")[:BATCH_SIZE]
)

while num_updates := query.update(
# this uses the user.id to avoid conflicts with new users
scim_id=models.F("id"),
scim_external_id=models.Subquery(
Profile.objects.filter(user_id=models.OuterRef("id")).values(
"scim_external_id"
)[:1]
),
scim_username=models.Subquery(
Profile.objects.filter(user_id=models.OuterRef("id")).values(
"scim_username"
)[:1]
),
# created_on previously got a default of timestamp.now
# so we update it with the correct date
created_on=models.F("date_joined"),
# this would've been null
updated_on=models.Subquery(
Profile.objects.filter(user_id=models.OuterRef("id")).values("updated_at")[
:1
]
),
):
log.info("Updated %s user records", num_updates)


class Migration(migrations.Migration):
"""
This is a separate migration from 0004 because for performance reasons
we don't want to update the entire table in a transaction but we DO
want the schema changes in 0004 in a transaction.
"""

atomic = False

dependencies = [
("users", "0004_add_scim_and_timestamp_fields"),
]

# we don't bother to undo these changes becaus eif we're rolling back the columns
# just get dropped in the previous migration
operations = [
migrations.RunPython(_set_scim_and_timestamps, migrations.RunPython.noop)
]
17 changes: 17 additions & 0 deletions users/migrations/0006_remove_auth_user_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.19 on 2025-02-24 20:23

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("users", "0005_set_user_scim_id"),
]

operations = [
migrations.RunSQL(
sql="DROP VIEW auth_user;",
reverse_sql="CREATE VIEW auth_user AS SELECT * FROM users_user;",
elidable=True,
),
]
5 changes: 4 additions & 1 deletion users/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Users models"""

from django.contrib.auth.models import AbstractUser
from django_scim.models import AbstractSCIMUserMixin

from main.models import TimestampedModel

class User(AbstractUser):

class User(AbstractUser, AbstractSCIMUserMixin, TimestampedModel):
"""Custom model for users"""

0 comments on commit 6c9a8d2

Please sign in to comment.