diff --git a/.secrets.baseline b/.secrets.baseline index 95289971..0986c586 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -292,21 +292,21 @@ "filename": "testapp/main/settings/shared.py", "hashed_secret": "8f2581750096043a1c68bedea8cfa6e13ad1a2e4", "is_verified": false, - "line_number": 40 + "line_number": 41 }, { "type": "Basic Auth Credentials", "filename": "testapp/main/settings/shared.py", "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "is_verified": false, - "line_number": 120 + "line_number": 123 }, { "type": "Secret Keyword", "filename": "testapp/main/settings/shared.py", "hashed_secret": "9bc34549d565d9505b287de0cd20ac77be1d3f2c", "is_verified": false, - "line_number": 200 + "line_number": 203 } ], "testapp/main/settings/test.py": [ @@ -319,5 +319,5 @@ } ] }, - "generated_at": "2025-02-26T21:06:50Z" + "generated_at": "2025-02-26T18:42:24Z" } diff --git a/pyproject.toml b/pyproject.toml index 2e3c4038..2e1aff73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "mitol-django-payment_gateway", "mitol-django-uvtestapp", "mitol-django-transcoding", + "mitol-django-scim", ] readme = "README.md" requires-python = ">= 3.10" @@ -33,11 +34,13 @@ build-backend = "hatchling.build" managed = true dev-dependencies = [ "GitPython", + "anys>=0.3.1", "bumpver", "click", "click-log", "cloup", "coverage<=7.6.1", + "deepmerge>=2.0", "dj-database-url", "django-stubs[compatible-mypy]", "factory-boy~=3.2", @@ -85,6 +88,7 @@ mitol-django-openedx = { workspace = true } mitol-django-payment_gateway = { workspace = true } mitol-django-uvtestapp = { workspace = true } mitol-django-transcoding = { workspace = true } +mitol-django-scim = { workspace = true } [tool.hatch.build.targets.sdist] include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"] @@ -216,6 +220,11 @@ convention = "pep257" [tool.ruff.lint.flake8-quotes] inline-quotes = "double" + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"django.contrib.auth.models.User".msg = "use get_user_model() or settings.AUTH_USER_MODEL" + + [tool.ruff.lint.per-file-ignores] "tests/**" = ["S101"] "test_*.py" = ["S101"] diff --git a/src/scim/CHANGELOG.md b/src/scim/CHANGELOG.md new file mode 100644 index 00000000..cf45d9ca --- /dev/null +++ b/src/scim/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project uses date-based versioning. + + diff --git a/src/scim/README.md b/src/scim/README.md new file mode 100644 index 00000000..017948ca --- /dev/null +++ b/src/scim/README.md @@ -0,0 +1,36 @@ +## SCIM + +## Prerequisites + +- 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). + - You should have custom user profile fields setup on your `olapps` realm: + - `fullName`: required, otherwise defaults + - `emailOptIn`: defaults + +## Install the scim-for-keycloak plugin + +Sign up for an account on https://scim-for-keycloak.de and follow the instructions here: https://scim-for-keycloak.de/documentation/installation/install + +## Configure SCIM + +In the SCIM admin console, do the following: + +### Configure Remote SCIM Provider + +- In django-admin, go to OAuth Toolkit and create a new access token +- Go to Remote SCIM Provider +- Click the `+` button +- Specify a base URL for your learn API backend: `http://:8063/scim/v2/` +- At the bottom of the page, click "Use default configuration" +- Add a new authentication method: + - Type: Long Life Bearer Token + - Bearer Token: the access token you created above +- On the Schemas tab, edit the User schema and add these custom attributes: + - Add a `fullName` attribute and set the Custom Attribute Name to `fullName` + - Add an attribute named `emailOptIn` with the following settings: + - Type: integer + - Custom Attribute Name: `emailOptIn` +- On the Realm Assignments tab, assign to the `olapps` realm +- Go to the Synchronization tab and perform one: + - Identifier attribute: email + - Synchronization strategy: Search and Bulk diff --git a/src/scim/changelog.d/scriv.ini b/src/scim/changelog.d/scriv.ini new file mode 100644 index 00000000..ccf3d587 --- /dev/null +++ b/src/scim/changelog.d/scriv.ini @@ -0,0 +1,5 @@ +[scriv] +format = md +md_header_level = 2 +entry_title_template = file: ../../scripts/scriv/entry_title.${config:format}.j2 +version = literal: __init__.py: __version__ diff --git a/src/scim/mitol/__init__.py b/src/scim/mitol/__init__.py new file mode 100644 index 00000000..5284146e --- /dev/null +++ b/src/scim/mitol/__init__.py @@ -0,0 +1 @@ +__import__("pkg_resources").declare_namespace(__name__) diff --git a/src/scim/mitol/scim/__init__.py b/src/scim/mitol/scim/__init__.py new file mode 100644 index 00000000..4b9733ec --- /dev/null +++ b/src/scim/mitol/scim/__init__.py @@ -0,0 +1,6 @@ +"""mitol.scim""" + +default_app_config = "mitol.scim.apps.ScimApp" + +__version__ = "0.0.0" +__distributionname__ = "mitol-django-scim" diff --git a/src/scim/mitol/scim/adapters.py b/src/scim/mitol/scim/adapters.py new file mode 100644 index 00000000..39b4ac2e --- /dev/null +++ b/src/scim/mitol/scim/adapters.py @@ -0,0 +1,259 @@ +import json +import logging +from typing import Optional, Union + +from django.contrib.auth import get_user_model +from django.db import transaction +from django_scim import constants +from django_scim.adapters import SCIMUser +from scim2_filter_parser.attr_paths import AttrPath + +User = get_user_model() + + +logger = logging.getLogger(__name__) + + +def get_user_model_for_scim(): + """ + Get function for the django_scim library configuration (USER_MODEL_GETTER). + + Returns: + model: User model. + """ + return User + + +class LearnSCIMUser(SCIMUser): + """ + Custom adapter to extend django_scim library. This is required in order + to extend the profiles.models.Profile model to work with the + django_scim library. + """ + + password_changed = False + activity_changed = False + + resource_type = "User" + + id_field = "scim_id" + + ATTR_MAP = { + ("active", None, None): "is_active", + ("name", "givenName", None): "first_name", + ("name", "familyName", None): "last_name", + ("userName", None, None): "username", + } + + IGNORED_PATHS = { + ("schemas", None, None), + } + + @property + def is_new_user(self): + """_summary_ + + Returns: + bool: True is the user does not currently exist, + False if the user already exists. + """ + return not bool(self.obj.id) + + @property + def id(self): + """ + Return the SCIM id + """ + return self.obj.scim_id + + @property + def emails(self): + """ + Return the email of the user per the SCIM spec. + """ + return [{"value": self.obj.email, "primary": True}] + + @property + def display_name(self): + """ + Return the displayName of the user per the SCIM spec. + """ + return self.obj.profile.name + + @property + def meta(self): + """ + Return the meta object of the user per the SCIM spec. + """ + return { + "resourceType": self.resource_type, + "created": self.obj.created_on.isoformat(timespec="milliseconds"), + "lastModified": self.obj.updated_on.isoformat(timespec="milliseconds"), + "location": self.location, + } + + def to_dict(self): + """ + Return a ``dict`` conforming to the SCIM User Schema, + ready for conversion to a JSON object. + """ + return { + "id": self.id, + "externalId": self.obj.scim_external_id, + "schemas": [constants.SchemaURI.USER], + "userName": self.obj.username, + "name": { + "givenName": self.obj.first_name, + "familyName": self.obj.last_name, + }, + "displayName": self.display_name, + "emails": self.emails, + "active": self.obj.is_active, + "groups": [], + "meta": self.meta, + } + + def from_dict(self, d): + """ + Consume a ``dict`` conforming to the SCIM User Schema, updating the + internal user object with data from the ``dict``. + + Please note, the user object is not saved within this method. To + persist the changes made by this method, please call ``.save()`` on the + adapter. Eg:: + + scim_user.from_dict(d) + scim_user.save() + """ + self.parse_emails(d.get("emails")) + + self.obj.is_active = d.get("active", True) + 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") + + def _save(self): + self.obj.save() + + def _save_related(self): + pass + + def save(self): + """ + Save instances of the Profile and User models. + """ + with transaction.atomic(): + self._save() + self._save_related() + logger.info("User saved. User id %i", self.obj.id) + + def delete(self): + """ + Update User's is_active to False. + """ + self.obj.is_active = False + self.obj.save() + logger.info("Deactivated user id %i", self.obj.id) + + def handle_add( + self, + path: Optional[AttrPath], + value: Union[str, list, dict], + operation: dict, # noqa: ARG002 + ): + """ + Handle add operations per: + https://tools.ietf.org/html/rfc7644#section-3.5.2.1 + + Args: + path (AttrPath) + value (Union[str, list, dict]) + """ + if path is None: + return + + if path.first_path == ("externalId", None, None): + self.obj.scim_external_id = value + self.obj.save() + + def parse_scim_for_keycloak_payload(self, payload: str) -> dict: + """ + Parse the payload sent from scim-for-keycloak and normalize it + """ + result = {} + + for key, value in json.loads(payload).items(): + if key == "schema": + continue + + if isinstance(value, dict): + for nested_key, nested_value in value.items(): + result[self.split_path(f"{key}.{nested_key}")] = nested_value + else: + result[key] = value + + return result + + def parse_path_and_values( + self, path: Optional[str], value: Union[str, list, dict] + ) -> list: + """Parse the incoming value(s)""" + if isinstance(value, str): + # scim-for-keycloak sends this as a noncompliant JSON-encoded string + if path is None: + val = json.loads(value) + else: + msg = "Called with a non-null path and a str value" + raise ValueError(msg) + else: + val = value + + results = [] + + for attr_path, attr_value in val.items(): + if isinstance(attr_value, dict): + # nested object, we want to recursively flatten it to `first.second` + results.extend(self.parse_path_and_values(attr_path, attr_value)) + else: + flattened_path = ( + f"{path}.{attr_path}" if path is not None else attr_path + ) + new_path = self.split_path(flattened_path) + new_value = attr_value + results.append((new_path, new_value)) + + return results + + def handle_replace( + self, + path: Optional[AttrPath], + value: Union[str, list, dict], + operation: dict, # noqa: ARG002 + ): + """ + Handle the replace operations. + + All operations happen within an atomic transaction. + """ + + if not isinstance(value, dict): + # Restructure for use in loop below. + value = {path: value} + + for nested_path, nested_value in (value or {}).items(): + if nested_path.first_path in self.ATTR_MAP: + setattr(self.obj, self.ATTR_MAP[nested_path.first_path], nested_value) + elif nested_path.first_path == ("fullName", None, None): + self.obj.profile.name = nested_value + elif nested_path.first_path == ("emailOptIn", None, None): + self.obj.profile.email_optin = nested_value == 1 + elif nested_path.first_path == ("emails", None, None): + self.parse_emails(nested_value) + elif nested_path.first_path not in self.IGNORED_PATHS: + logger.debug( + "Ignoring SCIM update for path: %s", nested_path.first_path + ) + + self.save() diff --git a/src/scim/mitol/scim/apps.py b/src/scim/mitol/scim/apps.py new file mode 100644 index 00000000..2f734c98 --- /dev/null +++ b/src/scim/mitol/scim/apps.py @@ -0,0 +1,12 @@ +import os + +from django.apps import AppConfig + + +class ScimApp(AppConfig): + name = "mitol.scim" + label = "scim" + verbose_name = "Scim" + + # necessary because this is a namespaced app + path = os.path.dirname(os.path.abspath(__file__)) # noqa: PTH100, PTH120 diff --git a/src/scim/mitol/scim/config.py b/src/scim/mitol/scim/config.py new file mode 100644 index 00000000..49da7264 --- /dev/null +++ b/src/scim/mitol/scim/config.py @@ -0,0 +1,13 @@ +from django_scim.models import SCIMServiceProviderConfig + + +class LearnSCIMServiceProviderConfig(SCIMServiceProviderConfig): + """Custom provider config""" + + def to_dict(self): + result = super().to_dict() + + result["bulk"]["supported"] = True + result["filter"]["supported"] = True + + return result diff --git a/src/scim/mitol/scim/constants.py b/src/scim/mitol/scim/constants.py new file mode 100644 index 00000000..c51546da --- /dev/null +++ b/src/scim/mitol/scim/constants.py @@ -0,0 +1,7 @@ +"""SCIM constants""" + + +class SchemaURI: + BULK_REQUEST = "urn:ietf:params:scim:api:messages:2.0:BulkRequest" + + BULK_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:BulkResponse" diff --git a/src/scim/mitol/scim/filters.py b/src/scim/mitol/scim/filters.py new file mode 100644 index 00000000..e774b742 --- /dev/null +++ b/src/scim/mitol/scim/filters.py @@ -0,0 +1,126 @@ +import operator +from collections.abc import Callable +from typing import Optional + +from django.contrib.auth import get_user_model +from django.db.models import Model, Q +from pyparsing import ParseResults + +from mitol.scim.parser import Filters, TermType + + +class FilterQuery: + """Filters for users""" + + model_cls: type[Model] + + attr_map: dict[tuple[str, Optional[str]], tuple[str, ...]] + + related_selects: list[str] = [] + + dj_op_mapping = { + "eq": "exact", + "ne": "exact", + "gt": "gt", + "ge": "gte", + "lt": "lt", + "le": "lte", + "pr": "isnull", + "co": "contains", + "sw": "startswith", + "ew": "endswith", + } + + dj_negated_ops = ("ne", "pr") + + @classmethod + def _filter_expr(cls, parsed: ParseResults) -> Q: + if parsed is None: + msg = "Expected a filter, got: None" + raise ValueError(msg) + + if parsed.term_type == TermType.attr_expr: + return cls._attr_expr(parsed) + + msg = f"Unsupported term type: {parsed.term_type}" + raise ValueError(msg) + + @classmethod + def _attr_expr(cls, parsed: ParseResults) -> Q: + dj_op = cls.dj_op_mapping[parsed.comparison_operator.lower()] + + scim_keys = (parsed.attr_name, parsed.sub_attr) + + path_parts = list( + filter( + lambda part: part is not None, + ( + *cls.attr_map.get(scim_keys, scim_keys), + dj_op, + ), + ) + ) + path = "__".join(path_parts) + + q = Q(**{path: parsed.value}) + + if parsed.comparison_operator in cls.dj_negated_ops: + q = ~q + + return q + + @classmethod + def _filters(cls, parsed: ParseResults) -> Q: + parsed_iter = iter(parsed) + q = cls._filter_expr(next(parsed_iter)) + + try: + while operator := cls._logical_op(next(parsed_iter)): + filter_q = cls._filter_expr(next(parsed_iter)) + + # combine the previous and next Q() objects using the bitwise operator + q = operator(q, filter_q) + except StopIteration: + pass + + return q + + @classmethod + def _logical_op(cls, parsed: ParseResults) -> Callable[[Q, Q], Q] | None: + """Convert a defined operator to the corresponding bitwise operator""" + if parsed is None: + return None + + if parsed.logical_operator.lower() == "and": + return operator.and_ + elif parsed.logical_operator.lower() == "or": + return operator.or_ + else: + msg = f"Unexpected operator: {parsed.operator}" + raise ValueError(msg) + + @classmethod + def search(cls, filter_query, request=None): # noqa: ARG003 + """Create a search query""" + parsed = Filters.parse_string(filter_query, parse_all=True) + + return cls.model_cls.objects.select_related(*cls.related_selects).filter( + cls._filters(parsed) + ) + + +class UserFilterQuery(FilterQuery): + """FilterQuery for User""" + + attr_map: dict[tuple[str, Optional[str]], tuple[str, ...]] = { + ("userName", None): ("username",), + ("emails", "value"): ("email",), + ("active", None): ("is_active",), + ("fullName", None): ("profile", "name"), + ("name", "givenName"): ("first_name",), + ("name", "familyName"): ("last_name",), + } + + related_selects = ["profile"] + + model_cls = get_user_model() diff --git a/src/scim/mitol/scim/forms.py b/src/scim/mitol/scim/forms.py new file mode 100644 index 00000000..e69de29b diff --git a/src/scim/mitol/scim/parser.py b/src/scim/mitol/scim/parser.py new file mode 100644 index 00000000..15b149e1 --- /dev/null +++ b/src/scim/mitol/scim/parser.py @@ -0,0 +1,195 @@ +""" +SCIM filter parsers + + _tag_term_type(TermType.attr_name) + +This module aims to compliantly parse SCIM filter queries per the spec: +https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2 + +Note that this implementation defines things slightly differently +because a naive implementation exactly matching the filter grammar will +result in hitting Python's recursion limit because the grammar defines +logical lists (AND/OR chains) as a recursive relationship. + +This implementation avoids that by defining separately FilterExpr and +Filter. As a result of this, some definitions are collapsed and removed +(e.g. valFilter => FilterExpr). +""" + +from enum import auto + +try: + from enum import StrEnum +except ImportError: + # for python < 3.11 + from strenum import StrEnum + +from pyparsing import ( + CaselessKeyword, + Char, + Combine, + DelimitedList, + FollowedBy, + Forward, + Group, + Literal, + Suppress, + Tag, + alphanums, + alphas, + common, + dbl_quoted_string, + nested_expr, + one_of, + remove_quotes, + ungroup, +) + + +class TagName(StrEnum): + """Tag names""" + + term_type = auto() + value_type = auto() + + +class TermType(StrEnum): + """Tag term type""" + + urn = auto() + attr_name = auto() + attr_path = auto() + attr_expr = auto() + value_path = auto() + presence = auto() + + logical_op = auto() + compare_op = auto() + negation_op = auto() + + filter_expr = auto() + filters = auto() + + +class ValueType(StrEnum): + """Tag value_type""" + + boolean = auto() + number = auto() + string = auto() + null = auto() + + +def _tag_term_type(term_type: TermType) -> Tag: + return Tag(TagName.term_type.name, term_type) + + +def _tag_value_type(value_type: ValueType) -> Tag: + return Tag(TagName.value_type.name, value_type) + + +NameChar = Char(alphanums + "_-") +AttrName = Combine( + Char(alphas) + + NameChar[...] + # ensure we're not somehow parsing an URN + + ~FollowedBy(":") +).set_results_name("attr_name") + _tag_term_type(TermType.attr_name) + +# Example URN-qualifed attr: +# urn:ietf:params:scim:schemas:core:2.0:User:userName +# |--------------- URN --------------------|:| attr | +UrnAttr = Combine( + Combine( + Literal("urn:") + + DelimitedList( + # characters ONLY if followed by colon + Char(alphanums + ".-_")[1, ...] + FollowedBy(":"), + # separator + Literal(":"), + # combine everything back into a singular token + combine=True, + )[1, ...] + ).set_results_name("urn") + # separator between URN and attribute name + + Literal(":") + + AttrName + + _tag_term_type(TermType.urn) +) + + +SubAttr = ungroup(Combine(Suppress(".") + AttrName)).set_results_name("sub_attr") ^ ( + Tag("sub_attr", None) +) + +AttrPath = ( + ( + # match on UrnAttr first + UrnAttr ^ AttrName + ) + + SubAttr + + _tag_term_type(TermType.attr_path) +) + +ComparisonOperator = one_of( + ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le"], + caseless=True, + as_keyword=True, +).set_results_name("comparison_operator") + _tag_term_type(TermType.compare_op) + +LogicalOperator = Group( + one_of(["or", "and"], caseless=True).set_results_name("logical_operator") + + _tag_term_type(TermType.logical_op) +) + +NegationOperator = Group( + ( + CaselessKeyword("not") + + _tag_term_type(TermType.negation_op) + + Tag("negated", True) # noqa: FBT003 + )[..., 1] + ^ Tag("negated", False) # noqa: FBT003 +) + +ValueTrue = Literal("true").set_parse_action(lambda: True) + _tag_value_type( + ValueType.boolean +) +ValueFalse = Literal("false").set_parse_action(lambda: False) + _tag_value_type( + ValueType.boolean +) +ValueNull = Literal("null").set_parse_action(lambda: None) + _tag_value_type( + ValueType.null +) +ValueNumber = (common.integer | common.fnumber) + _tag_value_type(ValueType.number) +ValueString = dbl_quoted_string.set_parse_action(remove_quotes) + _tag_value_type( + ValueType.string +) + +ComparisonValue = ungroup( + ValueTrue | ValueFalse | ValueNull | ValueNumber | ValueString +).set_results_name("value") + +AttrPresence = Group( + AttrPath + Literal("pr").set_results_name("presence").set_parse_action(lambda: True) +) + _tag_term_type(TermType.presence) +AttrExpression = AttrPresence | Group( + AttrPath + ComparisonOperator + ComparisonValue + _tag_term_type(TermType.attr_expr) +) + +# these are forward references, so that we can have +# parsers circularly reference themselves +FilterExpr = Forward() +Filters = Forward() + +ValuePath = Group(AttrPath + nested_expr("[", "]", Filters)).set_results_name( + "value_path" +) + _tag_term_type(TermType.value_path) + +FilterExpr <<= ( + AttrExpression | ValuePath | (NegationOperator + nested_expr("(", ")", Filters)) +) + _tag_term_type(TermType.filter_expr) + +Filters <<= ( + # comment to force it to wrap the below for operator precedence + (FilterExpr + (LogicalOperator + FilterExpr)[...]) + + _tag_term_type(TermType.filters) +) diff --git a/src/scim/mitol/scim/py.typed b/src/scim/mitol/scim/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/scim/mitol/scim/settings/__init__.py b/src/scim/mitol/scim/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/scim/mitol/scim/settings/scim.py b/src/scim/mitol/scim/settings/scim.py new file mode 100644 index 00000000..f65cbf98 --- /dev/null +++ b/src/scim/mitol/scim/settings/scim.py @@ -0,0 +1,17 @@ +SCIM_SERVICE_PROVIDER = { + "AUTHENTICATION_SCHEMES": [ + { + "type": "oauth2", + "name": "OAuth 2", + "description": "Oauth 2 implemented with bearer token", + "specUri": "", + "documentationUri": "", + }, + ], + "BASE_LOCATION_GETTER": "mitol.scim.utils.base_scim_location_getter", + "SERVICE_PROVIDER_CONFIG_MODEL": "mitol.scim.config.LearnSCIMServiceProviderConfig", + "USER_ADAPTER": "mitol.scim.adapters.LearnSCIMUser", + "USER_MODEL_GETTER": "mitol.scim.adapters.get_user_model_for_scim", + "USER_FILTER_PARSER": "mitol.scim.filters.UserFilterQuery", + "GET_IS_AUTHENTICATED_PREDICATE": "mitol.scim.utils.is_authenticated_predicate", +} diff --git a/src/scim/mitol/scim/urls.py b/src/scim/mitol/scim/urls.py new file mode 100644 index 00000000..9ac226cf --- /dev/null +++ b/src/scim/mitol/scim/urls.py @@ -0,0 +1,18 @@ +"""URL configurations for SCIM""" + +from django.urls import include, re_path + +from mitol.scim import views + +ol_scim_urls = ( + [ + re_path(r"^Bulk$", views.BulkView.as_view(), name="bulk"), + re_path(r"^\.search$", views.SearchView.as_view(), name="users-search"), + ], + "ol-scim", +) + +urlpatterns = [ + re_path(r"^scim/v2/", include(ol_scim_urls)), + re_path(r"^scim/v2/", include("django_scim.urls", namespace="scim")), +] diff --git a/src/scim/mitol/scim/utils.py b/src/scim/mitol/scim/utils.py new file mode 100644 index 00000000..dcb870e3 --- /dev/null +++ b/src/scim/mitol/scim/utils.py @@ -0,0 +1,13 @@ +"""Utils""" + +from django.conf import settings + + +def is_authenticated_predicate(user): + """Verify that the user is active and staff""" + return user.is_authenticated and user.is_active and user.is_staff + + +def base_scim_location_getter(request=None, *args, **kwargs): # noqa: ARG001 + """Return the base url for SCIM location urls""" + return settings.SITE_BASE_URL diff --git a/src/scim/mitol/scim/views.py b/src/scim/mitol/scim/views.py new file mode 100644 index 00000000..3fc86c13 --- /dev/null +++ b/src/scim/mitol/scim/views.py @@ -0,0 +1,212 @@ +"""SCIM view customizations""" + +import copy +import json +import logging +from http import HTTPStatus +from urllib.parse import urljoin, urlparse + +from django.http import HttpRequest, HttpResponse +from django.urls import Resolver404, resolve, reverse +from django_scim import constants as djs_constants +from django_scim import exceptions +from django_scim import views as djs_views +from django_scim.utils import get_base_scim_location_getter + +from mitol.scim import constants + +log = logging.getLogger() + + +class InMemoryHttpRequest(HttpRequest): + """ + A spoofed HttpRequest that only exists in-memory. + It does not implement all features of HttpRequest and is only used + for the bulk SCIM operations here so we can reuse view implementations. + """ + + def __init__(self, request, path, method, body): + super().__init__() + + self.META = copy.deepcopy( + { + key: value + for key, value in request.META.items() + if not key.startswith(("wsgi", "uwsgi")) + } + ) + self.path = path + self.method = method + self.content_type = djs_constants.SCIM_CONTENT_TYPE + + # normally HttpRequest would read this in, but we already have the value + self._body = body + + +class BulkView(djs_views.SCIMView): + http_method_names = ["post"] + + def post(self, request, *args, **kwargs): # noqa: ARG002 + body = self.load_body(request.body) + + if body.get("schemas") != [constants.SchemaURI.BULK_REQUEST]: + msg = "Invalid schema uri. Must be SearchRequest." + raise exceptions.BadRequestError(msg) + + fail_on_errors = body.get("failOnErrors", None) + + if fail_on_errors is not None and isinstance(int, fail_on_errors): + msg = "Invalid failOnErrors. Must be an integer." + raise exceptions.BaseRequestError(msg) + + operations = body.get("Operations") + + results = self._attempt_operations(request, operations, fail_on_errors) + + response = { + "schemas": [constants.SchemaURI.BULK_RESPONSE], + "Operations": results, + } + + content = json.dumps(response) + + return HttpResponse( + content=content, + content_type=djs_constants.SCIM_CONTENT_TYPE, + status=HTTPStatus.OK, + ) + + def _attempt_operations(self, request, operations, fail_on_errors): + """Attempt to run the operations that were passed""" + responses = [] + num_errors = 0 + + for operation in operations: + # per-spec,if we've hit the error threshold stop processing and return + if fail_on_errors is not None and num_errors >= fail_on_errors: + break + + op_response = self._attempt_operation(request, operation) + + # if the operation returned a non-2xx status code, record it as a failure + if int(op_response.get("status")) >= HTTPStatus.MULTIPLE_CHOICES: + num_errors += 1 + + responses.append(op_response) + + return responses + + def _attempt_operation(self, bulk_request, operation): + """Attempt an operation as part of a bulk request""" + + method = operation.get("method") + bulk_id = operation.get("bulkId") + path = operation.get("path") + data = operation.get("data") + + try: + url_match = resolve(path, urlconf="django_scim.urls") + except Resolver404: + return self._operation_error( + bulk_id, + HTTPStatus.NOT_IMPLEMENTED, + "Endpoint is not supported for /Bulk", + ) + + # this is an ephemeral request not tied to the real request directly + op_request = InMemoryHttpRequest( + bulk_request, path, method, json.dumps(data).encode(djs_constants.ENCODING) + ) + + op_response = url_match.func(op_request, *url_match.args, **url_match.kwargs) + result = { + "method": method, + "bulkId": bulk_id, + "status": str(op_response.status_code), + } + + location = None + + if op_response.status_code >= HTTPStatus.BAD_REQUEST and op_response.content: + result["response"] = json.loads(op_response.content.decode("utf-8")) + + location = op_response.headers.get("Location", None) + + if location is not None: + result["location"] = location + # this is a custom field that the scim-for-keycloak plugin requires + try: + path = urlparse(location).path + location_match = resolve(path) + # this URL will be something like /scim/v2/Users/12345 + # resolving it gives the uuid + result["id"] = location_match.kwargs["uuid"] + except Resolver404: + log.exception("Unable to resolve resource url: %s", location) + + return result + + def _operation_error(self, method, bulk_id, status_code, detail): + """Return a failure response""" + status_code = str(status_code) + return { + "method": method, + "status": status_code, + "bulkId": bulk_id, + "response": { + "schemas": [djs_constants.SchemaURI.ERROR], + "status": status_code, + "detail": detail, + }, + } + + +class SearchView(djs_views.UserSearchView): + """ + View for /.search endpoint + """ + + def post(self, request, *args, **kwargs): # noqa: ARG002 + body = self.load_body(request.body) + if body.get("schemas") != [djs_constants.SchemaURI.SERACH_REQUEST]: + msg = "Invalid schema uri. Must be SearchRequest." + raise exceptions.BadRequestError(msg) + + start = body.get("startIndex", 1) + count = body.get("count", 50) + sort_by = body.get("sortBy", None) + sort_order = body.get("sortOrder", "ascending") + query = body.get("filter", None) + + if sort_by is not None and sort_by not in ("email", "username"): + msg = "Sorting only supports email or username" + raise exceptions.BadRequestError(msg) + + if sort_order is not None and sort_order not in ("ascending", "descending"): + msg = "Sorting only supports ascending or descending" + raise exceptions.BadRequestError(msg) + + if not query: + msg = "No filter query specified" + raise exceptions.BadRequestError(msg) + + try: + qs = self.__class__.parser_getter().search(query, request) + except ValueError as e: + msg = "Invalid filter/search query: " + str(e) + raise exceptions.BadRequestError(msg) from e + + if sort_by is not None: + qs = qs.order_by(sort_by) + + if sort_order == "descending": + qs = qs.reverse() + + response = self._build_response(request, qs, start, count) + + path = reverse(self.scim_adapter.url_name) + url = urljoin(get_base_scim_location_getter()(request=request), path).rstrip( + "/" + ) + response["Location"] = url + "/.search" + return response diff --git a/src/scim/pyproject.toml b/src/scim/pyproject.toml new file mode 100644 index 00000000..2d34f646 --- /dev/null +++ b/src/scim/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "mitol-django-scim" +version = "0.0.0" +description = "Django application for SCIM integrations" +dependencies = [ + "django-stubs>=1.13.1", + "django>=3.0", + "django_scim2>=0.19.1", + "mitol-django-common", + # Remove this why py310 support is dropped + "StrEnum>=0.4.15 ; python_version < '3.11'", +] +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.10" + +[tool.bumpver] +current_version = "0.0.0" +version_pattern = "YYYY.MM.DD[.INC0]" + +[tool.bumpver.file_patterns] +"pyproject.toml" = [ + 'version = "{version}"', +] +"mitol/scim/__init__.py" = [ + '__version__ = "{version}"', +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"] +exclude = ["BUILD", "pyproject.toml"] + +[tool.hatch.build.targets.wheel] +include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"] +exclude = ["BUILD", "pyproject.toml"] diff --git a/testapp/main/settings/shared.py b/testapp/main/settings/shared.py index e9d9e34a..a2421c07 100644 --- a/testapp/main/settings/shared.py +++ b/testapp/main/settings/shared.py @@ -28,6 +28,7 @@ "mitol.google_sheets_deferrals.settings.google_sheets_deferrals", "mitol.hubspot_api.settings.hubspot_api", "mitol.transcoding.settings.job", + "mitol.scim.settings.scim", ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -43,6 +44,7 @@ DEBUG = True ALLOWED_HOSTS = ["*"] +AUTH_USER_MODEL = "testapp.User" # Application definition @@ -72,6 +74,7 @@ "mitol.geoip.apps.GeoIPApp", "mitol.olposthog.apps.OlPosthog", "mitol.transcoding.apps.Transcoding", + "mitol.scim.apps.ScimApp", # test app, integrates the reusable apps "main", ] diff --git a/testapp/main/urls.py b/testapp/main/urls.py index f25b48fc..27514082 100644 --- a/testapp/main/urls.py +++ b/testapp/main/urls.py @@ -24,4 +24,5 @@ ), path("admin/", admin.site.urls), path("api/", include("mitol.transcoding.urls")), + path("api/", include("mitol.scim.urls")), ] diff --git a/tests/mitol/scim/__init__.py b/tests/mitol/scim/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mitol/scim/test_parser.py b/tests/mitol/scim/test_parser.py new file mode 100644 index 00000000..b688141d --- /dev/null +++ b/tests/mitol/scim/test_parser.py @@ -0,0 +1,63 @@ +import pytest +from faker import Faker +from mitol.scim.parser import Filters + +faker = Faker() + + +def test_scim_filter_parser(): + """Runer the parser tests""" + success, results = Filters.run_tests( + """\ + userName eq "bjensen" + + name.familyName co "O'Malley" + + userName sw "J" + + urn:ietf:params:scim:schemas:core:2.0:User:userName sw "J" + + title pr + + meta.lastModified gt "2011-05-13T04:42:34Z" + + meta.lastModified ge "2011-05-13T04:42:34Z" + + meta.lastModified lt "2011-05-13T04:42:34Z" + + meta.lastModified le "2011-05-13T04:42:34Z" + + title pr and userType eq "Employee" + + title pr or userType eq "Intern" + + schemas eq "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + + userType eq "Employee" and (emails co "example.com" or emails.value co "example.org") + + userType ne "Employee" and not (emails co "example.com" or emails.value co "example.org") + + userType eq "Employee" and (emails.type eq "work") + + userType eq "Employee" and emails[type eq "work" and value co "@example.com"] + + emails[type eq "work" and value co "@example.com"] or ims[type eq "xmpp" and value co "@foo.com"] + """ # noqa: E501 + ) + + # run_tests will output error messages + assert success + + +@pytest.mark.parametrize("count", [10, 100, 1000, 5000]) +def test_large_filter(count): + """Test that the parser can handle large filters""" + + filter_str = " OR ".join( + [f'email.value eq "{faker.email()}"' for _ in range(count)] + ) + + success, _ = Filters.run_tests(filter_str) + + # run_tests will output error messages + assert success diff --git a/tests/mitol/scim/test_views.py b/tests/mitol/scim/test_views.py new file mode 100644 index 00000000..2055ec82 --- /dev/null +++ b/tests/mitol/scim/test_views.py @@ -0,0 +1,501 @@ +import itertools +import json +import operator +import random +from collections.abc import Callable +from functools import reduce +from http import HTTPStatus +from types import SimpleNamespace + +import pytest +from anys import ANY_STR +from deepmerge import always_merger +from django.contrib.auth import get_user_model +from django.test import Client +from django.urls import reverse +from django_scim import constants as djs_constants +from mitol.common.factories import UserFactory +from mitol.scim import constants + +User = get_user_model() + + +@pytest.fixture() +def scim_client(staff_user): + """Test client for scim""" + client = Client() + client.force_login(staff_user) + return client + + +def test_scim_user_post(scim_client): + """Test that we can create a user via SCIM API""" + user_q = User.objects.filter(scim_external_id="1") + assert not user_q.exists() + + resp = scim_client.post( + reverse("scim:users"), + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [djs_constants.SchemaURI.USER], + "emails": [{"value": "jdoe@example.com", "primary": True}], + "active": True, + "userName": "jdoe", + "externalId": "1", + "name": { + "familyName": "Doe", + "givenName": "John", + }, + "fullName": "John Smith Doe", + "emailOptIn": 1, + } + ), + ) + + assert resp.status_code == HTTPStatus.CREATED, f"Error response: {resp.content}" + + user = user_q.first() + + assert user is not None + assert user.email == "jdoe@example.com" + assert user.username == "jdoe" + assert user.first_name == "John" + assert user.last_name == "Doe" + assert user.profile.name == "John Smith Doe" + assert user.profile.email_optin is True + + +def test_scim_user_put(scim_client): + """Test that a user can be updated via PUT""" + user = UserFactory.create() + + resp = scim_client.put( + f"{reverse('scim:users')}/{user.profile.scim_id}", + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [djs_constants.SchemaURI.USER], + "emails": [{"value": "jsmith@example.com", "primary": True}], + "active": True, + "userName": "jsmith", + "externalId": "1", + "name": { + "familyName": "Smith", + "givenName": "Jimmy", + }, + "fullName": "Jimmy Smith", + "emailOptIn": 0, + } + ), + ) + + assert resp.status_code == HTTPStatus.OK, f"Error response: {resp.content}" + + user.refresh_from_db() + + assert user.email == "jsmith@example.com" + assert user.username == "jsmith" + assert user.first_name == "Jimmy" + assert user.last_name == "Smith" + assert user.profile.name == "Jimmy Smith" + assert user.profile.email_optin is False + + +def test_scim_user_patch(scim_client): + """Test that a user can be updated via PATCH""" + user = UserFactory.create() + + resp = scim_client.patch( + f"{reverse('scim:users')}/{user.profile.scim_id}", + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [djs_constants.SchemaURI.PATCH_OP], + "Operations": [ + { + "op": "replace", + # yes, the value we get from scim-for-keycloak is + # a JSON encoded string...inside JSON... + "value": json.dumps( + { + "schemas": [djs_constants.SchemaURI.USER], + "emailOptIn": 1, + "fullName": "Billy Bob", + "name": { + "givenName": "Billy", + "familyName": "Bob", + }, + } + ), + } + ], + } + ), + ) + + assert resp.status_code == HTTPStatus.OK, f"Error response: {resp.content}" + + user_updated = User.objects.get(pk=user.id) + + assert user_updated.email == user.email + assert user_updated.username == user.username + assert user_updated.first_name == "Billy" + assert user_updated.last_name == "Bob" + assert user_updated.profile.name == "Billy Bob" + assert user_updated.profile.email_optin is True + + +def _user_to_scim_payload(user): + """Test util to serialize a user to a SCIM representation""" + return { + "schemas": [djs_constants.SchemaURI.USER], + "emails": [{"value": user.email, "primary": True}], + "userName": user.username, + "emailOptIn": 1 if user.profile.email_optin else 0, + "fullName": user.profile.name, + "name": { + "givenName": user.first_name, + "familyName": user.last_name, + }, + } + + +USER_FIELD_TYPES: dict[str, type] = { + "username": str, + "email": str, + "first_name": str, + "last_name": str, + "profile.email_optin": bool, + "profile.name": str, +} + +USER_FIELDS_TO_SCIM: dict[str, Callable] = { + "username": lambda value: {"userName": value}, + "email": lambda value: {"emails": [{"value": value, "primary": True}]}, + "first_name": lambda value: {"name": {"givenName": value}}, + "last_name": lambda value: {"name": {"familyName": value}}, + "profile.email_optin": lambda value: {"emailOptIn": 1 if value else 0}, + "profile.name": lambda value: {"fullName": value}, +} + + +def _post_operation(data, bulk_id_gen): + """Operation for a bulk POST""" + bulk_id = str(next(bulk_id_gen)) + return SimpleNamespace( + payload={ + "method": "post", + "bulkId": bulk_id, + "path": "/Users", + "data": _user_to_scim_payload(data), + }, + user=None, + expected_user_state=data, + expected_response={ + "method": "post", + "location": ANY_STR, + "bulkId": bulk_id, + "status": "201", + "id": ANY_STR, + }, + ) + + +def _put_operation(user, data, bulk_id_gen): + """Operation for a bulk PUT""" + bulk_id = str(next(bulk_id_gen)) + return SimpleNamespace( + payload={ + "method": "put", + "bulkId": bulk_id, + "path": f"/Users/{user.profile.scim_id}", + "data": _user_to_scim_payload(data), + }, + user=user, + expected_user_state=data, + expected_response={ + "method": "put", + "location": ANY_STR, + "bulkId": bulk_id, + "status": "200", + "id": str(user.profile.scim_id), + }, + ) + + +def _patch_operation(user, data, fields_to_patch, bulk_id_gen): + """Operation for a bulk PUT""" + + def _expected_patch_value(field): + field_getter = operator.attrgetter(field) + return field_getter(data if field in fields_to_patch else user) + + bulk_id = str(next(bulk_id_gen)) + field_updates = [ + mk_scim_value(operator.attrgetter(user_path)(data)) + for user_path, mk_scim_value in USER_FIELDS_TO_SCIM.items() + if user_path in fields_to_patch + ] + + return SimpleNamespace( + payload={ + "method": "patch", + "bulkId": bulk_id, + "path": f"/Users/{user.profile.scim_id}", + "data": { + "schemas": [djs_constants.SchemaURI.PATCH_OP], + "Operations": [ + { + "op": "replace", + "value": reduce(always_merger.merge, field_updates, {}), + } + ], + }, + }, + user=user, + expected_user_state=SimpleNamespace( + email=_expected_patch_value("email"), + username=_expected_patch_value("username"), + first_name=_expected_patch_value("first_name"), + last_name=_expected_patch_value("last_name"), + profile=SimpleNamespace( + name=_expected_patch_value("profile.name"), + email_optin=_expected_patch_value("profile.email_optin"), + ), + ), + expected_response={ + "method": "patch", + "location": ANY_STR, + "bulkId": bulk_id, + "status": "200", + "id": str(user.profile.scim_id), + }, + ) + + +def _delete_operation(user, bulk_id_gen): + """Operation for a bulk DELETE""" + bulk_id = str(next(bulk_id_gen)) + return SimpleNamespace( + payload={ + "method": "delete", + "bulkId": bulk_id, + "path": f"/Users/{user.profile.scim_id}", + }, + user=user, + expected_user_state=None, + expected_response={ + "method": "delete", + "bulkId": bulk_id, + "status": "204", + }, + ) + + +@pytest.fixture() +def bulk_test_data(): + """Test data for the /Bulk API tests""" + existing_users = UserFactory.create_batch(500) + remaining_users = set(existing_users) + + users_to_put = random.sample(sorted(remaining_users, key=lambda user: user.id), 100) + remaining_users = remaining_users - set(users_to_put) + + users_to_patch = random.sample( + sorted(remaining_users, key=lambda user: user.id), 100 + ) + remaining_users = remaining_users - set(users_to_patch) + + users_to_delete = random.sample( + sorted(remaining_users, key=lambda user: user.id), 100 + ) + remaining_users = remaining_users - set(users_to_delete) + + user_post_data = UserFactory.build_batch(100) + user_put_data = UserFactory.build_batch(len(users_to_put)) + user_patch_data = UserFactory.build_batch(len(users_to_patch)) + + bulk_id_gen = itertools.count() + + post_operations = [_post_operation(data, bulk_id_gen) for data in user_post_data] + put_operations = [ + _put_operation(user, data, bulk_id_gen) + for user, data in zip(users_to_put, user_put_data) + ] + patch_operations = [ + _patch_operation(user, patch_data, fields_to_patch, bulk_id_gen) + for user, patch_data, fields_to_patch in [ + ( + user, + patch_data, + # random number of field updates + list( + random.sample( + list(USER_FIELDS_TO_SCIM.keys()), + random.randint(1, len(USER_FIELDS_TO_SCIM.keys())), # noqa: S311 + ) + ), + ) + for user, patch_data in zip(users_to_patch, user_patch_data) + ] + ] + delete_operations = [ + _delete_operation(user, bulk_id_gen) for user in users_to_delete + ] + + operations = [ + *post_operations, + *patch_operations, + *put_operations, + *delete_operations, + ] + random.shuffle(operations) + + return SimpleNamespace( + existing_users=existing_users, + remaining_users=remaining_users, + post_operations=post_operations, + patch_operations=patch_operations, + put_operations=put_operations, + delete_operations=delete_operations, + operations=operations, + ) + + +def test_bulk_post(scim_client, bulk_test_data): + """Verify that bulk operations work as expected""" + user_count = User.objects.count() + + resp = scim_client.post( + reverse("ol-scim:bulk"), + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [constants.SchemaURI.BULK_REQUEST], + "Operations": [ + operation.payload for operation in bulk_test_data.operations + ], + } + ), + ) + + assert resp.status_code == HTTPStatus.OK + + # singular user is the staff user + assert User.objects.count() == user_count + len(bulk_test_data.post_operations) + + results_by_bulk_id = { + op_result["bulkId"]: op_result for op_result in resp.json()["Operations"] + } + + for operation in bulk_test_data.operations: + assert ( + results_by_bulk_id[operation.payload["bulkId"]] + == operation.expected_response + ) + + if operation in bulk_test_data.delete_operations: + user = User.objects.get(id=operation.user.id) + assert not user.is_active + else: + if operation in bulk_test_data.post_operations: + user = User.objects.get(username=operation.expected_user_state.username) + else: + user = User.objects.get(id=operation.user.id) + + for key, key_type in USER_FIELD_TYPES.items(): + attr_getter = operator.attrgetter(key) + + actual_value = attr_getter(user) + expected_value = attr_getter(operation.expected_user_state) + + if key_type is bool or key_type is None: + assert actual_value is expected_value + else: + assert actual_value == expected_value + + +@pytest.mark.parametrize( + ("sort_by", "sort_order"), + [ + (None, None), + ("email", None), + ("email", "ascending"), + ("email", "descending"), + ("username", None), + ("username", "ascending"), + ("username", "descending"), + ], +) +@pytest.mark.parametrize("count", [None, 100, 500]) +def test_user_search(scim_client, sort_by, sort_order, count): + """Test the user search endpoint""" + large_user_set = UserFactory.create_batch(1100) + search_users = large_user_set[:1000] + emails = [user.email for user in search_users] + + expected = search_users + + effective_count = count or 50 + effective_sort_order = sort_order or "ascending" + + if sort_by is not None: + expected = sorted( + expected, + # postgres sort is case-insensitive + key=lambda user: getattr(user, sort_by).lower(), + reverse=effective_sort_order == "descending", + ) + + for page in range(int(len(emails) / effective_count)): + start_index = page * effective_count # zero based index + resp = scim_client.post( + reverse("ol-scim:users-search"), + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [djs_constants.SchemaURI.SERACH_REQUEST], + "filter": " OR ".join([f'email EQ "{email}"' for email in emails]), + "startIndex": start_index + 1, # SCIM API is 1-based index + **({"sortBy": sort_by} if sort_by is not None else {}), + **({"sortOrder": sort_order} if sort_order is not None else {}), + **({"count": count} if count is not None else {}), + } + ), + ) + + expected_in_resp = expected[start_index : start_index + effective_count] + + assert resp.status_code == HTTPStatus.OK, f"Got error: {resp.content}" + assert resp.json() == { + "totalResults": len(emails), + "itemsPerPage": effective_count, + "startIndex": start_index + 1, + "schemas": [djs_constants.SchemaURI.LIST_RESPONSE], + "Resources": [ + { + "id": user.profile.scim_id, + "active": user.is_active, + "userName": user.username, + "displayName": user.profile.name, + "emails": [{"value": user.email, "primary": True}], + "externalId": str(user.profile.scim_external_id), + "name": { + "givenName": user.first_name, + "familyName": user.last_name, + }, + "meta": { + "resourceType": "User", + "location": f"https://localhost/scim/v2/Users/{user.profile.scim_id}", + "lastModified": user.profile.updated_at.isoformat( + timespec="milliseconds" + ), + "created": user.date_joined.isoformat(timespec="milliseconds"), + }, + "groups": [], + "schemas": [djs_constants.SchemaURI.USER], + } + for user in expected_in_resp + ], + } diff --git a/tests/testapp/models/__init__.py b/tests/testapp/models/__init__.py new file mode 100644 index 00000000..b99a161b --- /dev/null +++ b/tests/testapp/models/__init__.py @@ -0,0 +1,6 @@ +# ruff: noqa: F403 +from testapp.models.auditable import * +from testapp.models.courseware import * +from testapp.models.related_tree import * +from testapp.models.updateable import * +from testapp.models.user import * diff --git a/tests/testapp/models/auditable.py b/tests/testapp/models/auditable.py new file mode 100644 index 00000000..d522a357 --- /dev/null +++ b/tests/testapp/models/auditable.py @@ -0,0 +1,35 @@ +"""Testapp auditable models""" + +from django.db import models +from mitol.common.models import ( + AuditableModel, + AuditModel, +) +from mitol.common.utils.serializers import serialize_model_object + + +class AuditableTestModel(AuditableModel): + """Test-only model""" + + @classmethod + def get_audit_class(cls): + return AuditableTestModelAudit + + def to_dict(self): + """ + Get a serialized representation of the AuditableTestModel + """ + data = serialize_model_object(self) + return data # noqa: RET504 + + +class AuditableTestModelAudit(AuditModel): + """Test-only model""" + + auditable_test_model = models.ForeignKey( + AuditableTestModel, null=True, on_delete=models.SET_NULL + ) + + @classmethod + def get_related_field_name(cls): + return "auditable_test_model" diff --git a/tests/testapp/models/courseware.py b/tests/testapp/models/courseware.py new file mode 100644 index 00000000..10d15b51 --- /dev/null +++ b/tests/testapp/models/courseware.py @@ -0,0 +1,13 @@ +"""Testapp courseware models""" + +from django.conf import settings +from django.db import models + + +class DemoCourseware(models.Model): + """Testapp courseware""" + + title = models.CharField(max_length=100) + description = models.TextField() + + learner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) diff --git a/tests/testapp/models/models.py b/tests/testapp/models/models.py new file mode 100644 index 00000000..0134f1b3 --- /dev/null +++ b/tests/testapp/models/models.py @@ -0,0 +1,87 @@ +"""Testapp models""" + +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from mitol.common.models import ( + AuditableModel, + AuditModel, + PrefetchGenericQuerySet, + TimestampedModel, +) +from mitol.common.utils.serializers import serialize_model_object + + +class SecondLevel1(models.Model): + """Test-only model""" + + +class SecondLevel2(models.Model): + """Test-only model""" + + +class FirstLevel1(models.Model): + """Test-only model""" + + second_level = models.ForeignKey(SecondLevel1, on_delete=models.CASCADE) + + +class FirstLevel2(models.Model): + """Test-only model""" + + second_levels = models.ManyToManyField(SecondLevel2) + + +class TestModelQuerySet(PrefetchGenericQuerySet): + """Test-only QuerySet""" + + +class Root(models.Model): + """Test-only model""" + + objects = TestModelQuerySet.as_manager() + + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + + +class Updateable(TimestampedModel): + """Test-only model""" + + +class AuditableTestModel(AuditableModel): + """Test-only model""" + + @classmethod + def get_audit_class(cls): + return AuditableTestModelAudit + + def to_dict(self): + """ + Get a serialized representation of the AuditableTestModel + """ + data = serialize_model_object(self) + return data # noqa: RET504 + + +class AuditableTestModelAudit(AuditModel): + """Test-only model""" + + auditable_test_model = models.ForeignKey( + AuditableTestModel, null=True, on_delete=models.SET_NULL + ) + + @classmethod + def get_related_field_name(cls): + return "auditable_test_model" + + +class DemoCourseware(models.Model): + """Testapp courseware""" + + title = models.CharField(max_length=100) + description = models.TextField() + + learner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) diff --git a/tests/testapp/models/related_tree.py b/tests/testapp/models/related_tree.py new file mode 100644 index 00000000..ba128d6c --- /dev/null +++ b/tests/testapp/models/related_tree.py @@ -0,0 +1,40 @@ +"""Testapp models""" + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from mitol.common.models import PrefetchGenericQuerySet + + +class SecondLevel1(models.Model): + """Test-only model""" + + +class SecondLevel2(models.Model): + """Test-only model""" + + +class FirstLevel1(models.Model): + """Test-only model""" + + second_level = models.ForeignKey(SecondLevel1, on_delete=models.CASCADE) + + +class FirstLevel2(models.Model): + """Test-only model""" + + second_levels = models.ManyToManyField(SecondLevel2) + + +class TestModelQuerySet(PrefetchGenericQuerySet): + """Test-only QuerySet""" + + +class Root(models.Model): + """Test-only model""" + + objects = TestModelQuerySet.as_manager() + + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") diff --git a/tests/testapp/models/updateable.py b/tests/testapp/models/updateable.py new file mode 100644 index 00000000..8075f608 --- /dev/null +++ b/tests/testapp/models/updateable.py @@ -0,0 +1,7 @@ +"""Testapp updateable models""" + +from mitol.common.models import TimestampedModel + + +class Updateable(TimestampedModel): + """Test-only model""" diff --git a/tests/testapp/models/user.py b/tests/testapp/models/user.py new file mode 100644 index 00000000..302b6e8e --- /dev/null +++ b/tests/testapp/models/user.py @@ -0,0 +1,8 @@ +"""Users models""" + +from django.contrib.auth.models import AbstractUser +from django_scim.models import AbstractSCIMUserMixin + + +class User(AbstractUser, AbstractSCIMUserMixin): + """Custom model for users""" diff --git a/uv.lock b/uv.lock index 9887b33f..c2a83c90 100644 --- a/uv.lock +++ b/uv.lock @@ -21,11 +21,21 @@ members = [ "mitol-django-olposthog", "mitol-django-openedx", "mitol-django-payment-gateway", + "mitol-django-scim", "mitol-django-transcoding", "mitol-django-uvtestapp", "ol-django", ] +[[package]] +name = "anys" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/f2/bdeab37937c956b15ec2c5bdd297c5ebb70094d34e16b26c4ab368228895/anys-0.3.1.tar.gz", hash = "sha256:5bcda88fcc490eda840a247c8c18b7a350051f530c288ad530e94c8d139602de", size = 14712 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/0e/46dd264dabc38f7ac05944d4a359cb262bf09126e5e23e5d915b45639b44/anys-0.3.1-py3-none-any.whl", hash = "sha256:a7f5353eb061e97cdbbfc41249a397d13fc7e558310ec23cece891d6f10a0a6d", size = 8906 }, +] + [[package]] name = "appnope" version = "0.1.4" @@ -497,6 +507,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, ] +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475 }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -575,6 +594,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/b8/1d02e873ebca6ecb3d42fc55e4130daa7c839df79b9ad5c454f6e3361827/django_redis-5.0.0-py3-none-any.whl", hash = "sha256:97739ca9de3f964c51412d1d7d8aecdfd86737bb197fce6e1ff12620c63c97ee", size = 29882 }, ] +[[package]] +name = "django-scim2" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "scim2-filter-parser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/59/da3061607de7be41d7e5bf484acb13510d631f8fcfcecd9f5e02dc5bf4f1/django_scim2-0.19.1.tar.gz", hash = "sha256:8126111160e76a880f6699babc5259f0345c9316c8018ce5dcc3f7579ccb9e89", size = 26988 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/24/ca2cab008049239585434f7451a42757d7d258bf741316fd16587e564b74/django_scim2-0.19.1-py3-none-any.whl", hash = "sha256:845eaa64e72e4ddfe2aa54d21865721f8c1d59ecd9c06e72341eeb03ed6ba94e", size = 32072 }, +] + [[package]] name = "django-stubs" version = "5.1.0" @@ -1471,6 +1503,27 @@ requires-dist = [ { name = "mitol-django-common", editable = "src/common" }, ] +[[package]] +name = "mitol-django-scim" +version = "0.0.0" +source = { editable = "src/scim" } +dependencies = [ + { name = "django" }, + { name = "django-scim2" }, + { name = "django-stubs" }, + { name = "mitol-django-common" }, + { name = "strenum", marker = "python_full_version < '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=3.0" }, + { name = "django-scim2", specifier = ">=0.19.1" }, + { name = "django-stubs", specifier = ">=1.13.1" }, + { name = "mitol-django-common", editable = "src/common" }, + { name = "strenum", marker = "python_full_version < '3.11'", specifier = ">=0.4.15" }, +] + [[package]] name = "mitol-django-transcoding" version = "2025.2.25" @@ -1590,17 +1643,20 @@ dependencies = [ { name = "mitol-django-olposthog" }, { name = "mitol-django-openedx" }, { name = "mitol-django-payment-gateway" }, + { name = "mitol-django-scim" }, { name = "mitol-django-transcoding" }, { name = "mitol-django-uvtestapp" }, ] [package.dev-dependencies] dev = [ + { name = "anys" }, { name = "bumpver" }, { name = "click" }, { name = "click-log" }, { name = "cloup" }, { name = "coverage" }, + { name = "deepmerge" }, { name = "dj-database-url" }, { name = "django-stubs", extra = ["compatible-mypy"] }, { name = "factory-boy" }, @@ -1639,17 +1695,20 @@ requires-dist = [ { name = "mitol-django-olposthog", editable = "src/olposthog" }, { name = "mitol-django-openedx", editable = "src/openedx" }, { name = "mitol-django-payment-gateway", editable = "src/payment_gateway" }, + { name = "mitol-django-scim", editable = "src/scim" }, { name = "mitol-django-transcoding", editable = "src/transcoding" }, { name = "mitol-django-uvtestapp", editable = "src/uvtestapp" }, ] [package.metadata.requires-dev] dev = [ + { name = "anys", specifier = ">=0.3.1" }, { name = "bumpver" }, { name = "click" }, { name = "click-log" }, { name = "cloup" }, { name = "coverage", specifier = "<=7.6.1" }, + { name = "deepmerge", specifier = ">=2.0" }, { name = "dj-database-url" }, { name = "django-stubs", extras = ["compatible-mypy"] }, { name = "factory-boy", specifier = "~=3.2" }, @@ -2230,6 +2289,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/ac/e7dc469e49048dc57f62e0c555d2ee3117fa30813d2a1a2962cce3a2a82a/s3transfer-0.11.2-py3-none-any.whl", hash = "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc", size = 84151 }, ] +[[package]] +name = "scim2-filter-parser" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/c0/2f4d5caee8faa8f0d0979584c4aab95fa69d626d6f9d211dd6bb7089bc2f/scim2_filter_parser-0.7.0.tar.gz", hash = "sha256:1e11dbe2e186fc1be6d93732b467a3bbaa9deff272dfeb3a0540394cfab7030c", size = 21358 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/54/b54961bfc5018fa593758c439fe0d4a22fbadfabff49a7559850af9a79e1/scim2_filter_parser-0.7.0-py3-none-any.whl", hash = "sha256:a74f90a2d52a77e0f1bc4d77e84b79f88749469f6f7192d64a4f92e4fe50ab69", size = 23409 }, +] + [[package]] name = "scriv" version = "1.5.1" @@ -2279,6 +2350,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, ] +[[package]] +name = "sly" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/8a/59e943f7b27904c7756a7b565ffbd55f3841f5cd3d2da2b2b0713c49e488/sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024", size = 66702 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/4d/c96d807295183f2360329cd8d8bf5e8072c53d664125b3858c04153f026e/sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133", size = 28864 }, +] + [[package]] name = "smmap" version = "5.0.1" @@ -2363,6 +2443,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/50/70762bdb23f6c2b746b90661f461d33c4913a22a46bb5265b10947e85ffb/stevedore-5.3.0-py3-none-any.whl", hash = "sha256:1efd34ca08f474dad08d9b19e934a22c68bb6fe416926479ba29e5013bcc8f78", size = 49661 }, ] +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851 }, +] + [[package]] name = "toml" version = "0.10.2"