Skip to content

Commit 73ec984

Browse files
authored
Added SCIM /Bulk API endpoint (#1985)
1 parent eaa4b60 commit 73ec984

25 files changed

+775
-138
lines changed

main/factories.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import ulid
66
from django.conf import settings
7-
from factory import LazyFunction, RelatedFactory, SubFactory, Trait
7+
from factory import Faker, LazyFunction, RelatedFactory, SubFactory, Trait
88
from factory.django import DjangoModelFactory
99
from factory.fuzzy import FuzzyText
1010
from social_django.models import UserSocialAuth
@@ -15,8 +15,8 @@ class UserFactory(DjangoModelFactory):
1515

1616
username = LazyFunction(lambda: ulid.new().str)
1717
email = FuzzyText(suffix="@example.com")
18-
first_name = FuzzyText()
19-
last_name = FuzzyText()
18+
first_name = Faker("first_name")
19+
last_name = Faker("last_name")
2020

2121
profile = RelatedFactory("profiles.factories.ProfileFactory", "user")
2222

main/settings.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
"data_fixtures",
123123
"vector_search",
124124
"ai_chat",
125+
"scim",
125126
)
126127

127128
if not get_bool("RUN_DATA_MIGRATIONS", default=False):
@@ -141,9 +142,11 @@
141142
"documentationUri": "",
142143
},
143144
],
144-
"USER_ADAPTER": "profiles.scim.adapters.LearnSCIMUser",
145-
"USER_MODEL_GETTER": "profiles.scim.adapters.get_user_model_for_scim",
146-
"USER_FILTER_PARSER": "profiles.scim.filters.LearnUserFilterQuery",
145+
"SERVICE_PROVIDER_CONFIG_MODEL": "scim.config.LearnSCIMServiceProviderConfig",
146+
"USER_ADAPTER": "scim.adapters.LearnSCIMUser",
147+
"USER_MODEL_GETTER": "scim.adapters.get_user_model_for_scim",
148+
"USER_FILTER_PARSER": "scim.filters.LearnUserFilterQuery",
149+
"GET_IS_AUTHENTICATED_PREDICATE": "scim.utils.is_authenticated_predicate",
147150
}
148151

149152

main/settings_celery.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@
131131
"schedule": crontab(minute=30, hour=18), # 2:30pm EST
132132
"kwargs": {"period": "daily", "subscription_type": "channel_subscription_type"},
133133
},
134+
"daily_embed_new_learning_resources": {
135+
"task": "vector_search.tasks.embed_new_learning_resources",
136+
"schedule": get_int(
137+
"EMBED_NEW_RESOURCES_SCHEDULE_SECONDS", 60 * 30
138+
), # default is every 30 minutes
139+
},
134140
"send-search-subscription-emails-every-1-days": {
135141
"task": "learning_resources_search.tasks.send_subscription_emails",
136142
"schedule": crontab(minute=0, hour=19), # 3:00pm EST

main/urls.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from django.conf import settings
1818
from django.conf.urls.static import static
1919
from django.contrib import admin
20-
from django.urls import include, path, re_path
20+
from django.urls import include, re_path
2121
from django.views.generic.base import RedirectView
2222
from rest_framework.routers import DefaultRouter
2323

@@ -41,7 +41,6 @@
4141

4242
urlpatterns = (
4343
[ # noqa: RUF005
44-
path("scim/v2/", include("django_scim.urls")),
4544
re_path(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
4645
re_path(r"^admin/", admin.site.urls),
4746
re_path(r"", include("authentication.urls")),
@@ -58,6 +57,7 @@
5857
re_path(r"", include("articles.urls")),
5958
re_path(r"", include("testimonials.urls")),
6059
re_path(r"", include("news_events.urls")),
60+
re_path(r"", include("scim.urls")),
6161
re_path(r"", include(features_router.urls)),
6262
re_path(r"^app", RedirectView.as_view(url=settings.APP_BASE_URL)),
6363
# Hijack

poetry.lock

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

profiles/factories.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Factories for making test data"""
22

3-
from factory import Faker, Sequence, SubFactory
3+
import uuid
4+
5+
from factory import Faker, LazyFunction, SelfAttribute, Sequence, SubFactory
46
from factory.django import DjangoModelFactory
57
from factory.fuzzy import FuzzyChoice
68
from faker.providers import BaseProvider
@@ -49,6 +51,9 @@ class ProfileFactory(DjangoModelFactory):
4951
[Profile.CertificateDesired.YES.value, Profile.CertificateDesired.NO.value]
5052
)
5153

54+
scim_external_id = LazyFunction(uuid.uuid4)
55+
scim_username = SelfAttribute("user.email")
56+
5257
class Meta:
5358
model = Profile
5459

profiles/scim/views_test.py

Lines changed: 0 additions & 117 deletions
This file was deleted.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ llama-index-llms-openai = "^0.3.12"
9191
llama-index-agent-openai = "^0.4.1"
9292
langchain-experimental = "^0.3.4"
9393
langchain-openai = "^0.3.2"
94+
deepmerge = "^2.0"
9495

9596

9697
[tool.poetry.group.dev.dependencies]

scim/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
## SCIM
2+
3+
## Prerequisites
4+
5+
- You need the following a local [Keycloak](https://www.keycloak.org/) instance running. Note which major version you are running (should be at least 26.x).
6+
- You should have custom user profile fields setup on your `olapps` realm:
7+
- `fullName`: required, otherwise defaults
8+
- `emailOptIn`: defaults
9+
10+
## Install the scim-for-keycloak plugin
11+
12+
Sign up for an account on https://scim-for-keycloak.de and follow the instructions here: https://scim-for-keycloak.de/documentation/installation/install
13+
14+
## Configure SCIM
15+
16+
In the SCIM admin console, do the following:
17+
18+
### Configure Remote SCIM Provider
19+
20+
- In django-admin, go to OAuth Toolkit and create a new access token
21+
- Go to Remote SCIM Provider
22+
- Click the `+` button
23+
- Specify a base URL for your learn API backend: `http://<IP_OR_HOSTNAME>:8063/scim/v2/`
24+
- At the bottom of the page, click "Use default configuration"
25+
- Add a new authentication method:
26+
- Type: Long Life Bearer Token
27+
- Bearer Token: the access token you created above
28+
- On the Schemas tab, edit the User schema and add these custom attributes:
29+
- Add a `fullName` attribute and set the Custom Attribute Name to `fullName`
30+
- Add an attribute named `emailOptIn` with the following settings:
31+
- Type: integer
32+
- Custom Attribute Name: `emailOptIn`
33+
- On the Realm Assignments tab, assign to the `olapps` realm
34+
- Go to the Synchronization tab and perform one:
35+
- Identifier attribute: email
36+
- Synchronization strategy: Search and Bulk
File renamed without changes.

profiles/scim/adapters.py renamed to scim/adapters.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class LearnSCIMUser(SCIMUser):
4444
("active", None, None): "is_active",
4545
("name", "givenName", None): "first_name",
4646
("name", "familyName", None): "last_name",
47+
("userName", None, None): "username",
4748
}
4849

4950
IGNORED_PATHS = {
@@ -158,7 +159,7 @@ def delete(self):
158159
"""
159160
self.obj.is_active = False
160161
self.obj.save()
161-
logger.info("Deactivated user id %i", self.obj.user.id)
162+
logger.info("Deactivated user id %i", self.obj.id)
162163

163164
def handle_add(
164165
self,
@@ -193,7 +194,7 @@ def parse_scim_for_keycloak_payload(self, payload: str) -> dict:
193194

194195
if isinstance(value, dict):
195196
for nested_key, nested_value in value.items():
196-
result[f"{key}.{nested_key}"] = nested_value
197+
result[self.split_path(f"{key}.{nested_key}")] = nested_value
197198
else:
198199
result[key] = value
199200

@@ -202,11 +203,32 @@ def parse_scim_for_keycloak_payload(self, payload: str) -> dict:
202203
def parse_path_and_values(
203204
self, path: Optional[str], value: Union[str, list, dict]
204205
) -> list:
205-
if not path and isinstance(value, str):
206+
"""Parse the incoming value(s)"""
207+
if isinstance(value, str):
206208
# scim-for-keycloak sends this as a noncompliant JSON-encoded string
207-
value = self.parse_scim_for_keycloak_payload(value)
209+
if path is None:
210+
val = json.loads(value)
211+
else:
212+
msg = "Called with a non-null path and a str value"
213+
raise ValueError(msg)
214+
else:
215+
val = value
216+
217+
results = []
218+
219+
for attr_path, attr_value in val.items():
220+
if isinstance(attr_value, dict):
221+
# nested object, we want to recursively flatten it to `first.second`
222+
results.extend(self.parse_path_and_values(attr_path, attr_value))
223+
else:
224+
flattened_path = (
225+
f"{path}.{attr_path}" if path is not None else attr_path
226+
)
227+
new_path = self.split_path(flattened_path)
228+
new_value = attr_value
229+
results.append((new_path, new_value))
208230

209-
return super().parse_path_and_values(path, value)
231+
return results
210232

211233
def handle_replace(
212234
self,
@@ -219,22 +241,20 @@ def handle_replace(
219241
220242
All operations happen within an atomic transaction.
221243
"""
244+
222245
if not isinstance(value, dict):
223246
# Restructure for use in loop below.
224247
value = {path: value}
225248

226249
for nested_path, nested_value in (value or {}).items():
227250
if nested_path.first_path in self.ATTR_MAP:
228-
setattr(
229-
self.obj, self.ATTR_MAP.get(nested_path.first_path), nested_value
230-
)
231-
251+
setattr(self.obj, self.ATTR_MAP[nested_path.first_path], nested_value)
232252
elif nested_path.first_path == ("fullName", None, None):
233253
self.obj.profile.name = nested_value
234254
elif nested_path.first_path == ("emailOptIn", None, None):
235255
self.obj.profile.email_optin = nested_value == 1
236256
elif nested_path.first_path == ("emails", None, None):
237-
self.parse_emails(value)
257+
self.parse_emails(nested_value)
238258
elif nested_path.first_path not in self.IGNORED_PATHS:
239259
logger.debug(
240260
"Ignoring SCIM update for path: %s", nested_path.first_path

scim/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
4+
class ScimConfig(AppConfig):
5+
name = "scim"

scim/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django_scim.models import SCIMServiceProviderConfig
2+
3+
4+
class LearnSCIMServiceProviderConfig(SCIMServiceProviderConfig):
5+
"""Custom provider config"""
6+
7+
def to_dict(self):
8+
result = super().to_dict()
9+
10+
result["bulk"]["supported"] = True
11+
result["filter"]["supported"] = True
12+
13+
return result

0 commit comments

Comments
 (0)