mirror of https://github.com/zulip/zulip.git
auth: Add support for using SCIM for account management.
This commit is contained in:
parent
7c0995b14b
commit
73a6f2a1a7
|
@ -63,6 +63,7 @@ module = [
|
|||
"django_auth_ldap.*",
|
||||
"django_cte.*",
|
||||
"django_otp.*",
|
||||
"django_scim.*",
|
||||
"django_sendfile.*",
|
||||
"django_statsd.*",
|
||||
"DNS.*",
|
||||
|
@ -84,6 +85,7 @@ module = [
|
|||
"pyuca.*",
|
||||
"re2.*",
|
||||
"requests_oauthlib.*",
|
||||
"scim2_filter_parser.attr_paths",
|
||||
"scrapy.*",
|
||||
"social_core.*",
|
||||
"social_django.*",
|
||||
|
|
|
@ -189,3 +189,6 @@ google-re2
|
|||
|
||||
# For querying recursive group membership
|
||||
django-cte
|
||||
|
||||
# SCIM integration
|
||||
django-scim2
|
||||
|
|
|
@ -377,8 +377,10 @@ django[argon2]==3.2.7 \
|
|||
# django-formtools
|
||||
# django-otp
|
||||
# django-phonenumber-field
|
||||
# django-scim2
|
||||
# django-sendfile2
|
||||
# django-two-factor-auth
|
||||
# scim2-filter-parser
|
||||
django-auth-ldap==3.0.0 \
|
||||
--hash=sha256:19ee19034f344d9efd07ed88d3187e256ec33ae39d6a47222083b89f7d35c5f6 \
|
||||
--hash=sha256:1f2d5c562d9ba9a5e9a64099ae9798e1a63840a11afe4d1c4a9c74121f066eaa
|
||||
|
@ -406,6 +408,9 @@ django-phonenumber-field==5.2.0 \
|
|||
--hash=sha256:52b2e5970133ec5ab701218b802f7ab237229854dc95fd239b7e9e77dc43731d \
|
||||
--hash=sha256:5547fb2b2cc690a306ba77a5038419afc8fa8298a486fb7895008e9067cc7e75
|
||||
# via django-two-factor-auth
|
||||
django-scim2==0.17.0 \
|
||||
--hash=sha256:dc3cb3c0d5b6ebf4ae8a28dd1dac1e0658fb543c8c178e72e3c19975816da092
|
||||
# via -r requirements/common.in
|
||||
django-sendfile2==0.6.0 \
|
||||
--hash=sha256:7f850040ddc29c9c42192ed85b915465a3ed7cced916c4fafdd5eda057dd06ec
|
||||
# via -r requirements/common.in
|
||||
|
@ -1344,6 +1349,7 @@ python-dateutil==2.8.2 \
|
|||
# via
|
||||
# -r requirements/common.in
|
||||
# botocore
|
||||
# django-scim2
|
||||
# moto
|
||||
python-debian==0.1.40 \
|
||||
--hash=sha256:385dfb965eca75164d256486c7cf9bae772d24144249fd18b9d15d3cffb70eea \
|
||||
|
@ -1558,6 +1564,9 @@ s3transfer==0.5.0 \
|
|||
--hash=sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c \
|
||||
--hash=sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803
|
||||
# via boto3
|
||||
scim2-filter-parser==0.3.5 \
|
||||
--hash=sha256:f46b6ffa01cdad6011d3d991bd167af1a9822ab917c225bdf49bc7a44ad4ae53
|
||||
# via django-scim2
|
||||
https://github.com/scrapy/scrapy/archive/c5b1ee810167266fcd259f263dbfc0fe0204761a.zip#egg=Scrapy==2.5.0+git \
|
||||
--hash=sha256:d12f88f2cfb31e487170ee4e68f6e59a2af100ee690add873831c368fac6e0a7
|
||||
# via -r requirements/dev.in
|
||||
|
@ -1599,6 +1608,9 @@ six==1.16.0 \
|
|||
# talon-core
|
||||
# traitlets
|
||||
# w3lib
|
||||
sly==0.3 \
|
||||
--hash=sha256:be6a3825b042a9e1b6f5730fc747e6d983c917f0f002d798d0b9f86ca5c05ad9
|
||||
# via scim2-filter-parser
|
||||
snakeviz==2.1.0 \
|
||||
--hash=sha256:8ce375b18ae4a749516d7e6c6fbbf8be6177c53974f53534d8eadb646cd279b1 \
|
||||
--hash=sha256:92ad876fb6a201a7e23a6b85ea96d9643a51e285667c253a8653643804f7cb68
|
||||
|
|
|
@ -231,8 +231,10 @@ django[argon2]==3.2.7 \
|
|||
# django-formtools
|
||||
# django-otp
|
||||
# django-phonenumber-field
|
||||
# django-scim2
|
||||
# django-sendfile2
|
||||
# django-two-factor-auth
|
||||
# scim2-filter-parser
|
||||
django-auth-ldap==3.0.0 \
|
||||
--hash=sha256:19ee19034f344d9efd07ed88d3187e256ec33ae39d6a47222083b89f7d35c5f6 \
|
||||
--hash=sha256:1f2d5c562d9ba9a5e9a64099ae9798e1a63840a11afe4d1c4a9c74121f066eaa
|
||||
|
@ -260,6 +262,9 @@ django-phonenumber-field==5.2.0 \
|
|||
--hash=sha256:52b2e5970133ec5ab701218b802f7ab237229854dc95fd239b7e9e77dc43731d \
|
||||
--hash=sha256:5547fb2b2cc690a306ba77a5038419afc8fa8298a486fb7895008e9067cc7e75
|
||||
# via django-two-factor-auth
|
||||
django-scim2==0.17.0 \
|
||||
--hash=sha256:dc3cb3c0d5b6ebf4ae8a28dd1dac1e0658fb543c8c178e72e3c19975816da092
|
||||
# via -r requirements/common.in
|
||||
django-sendfile2==0.6.0 \
|
||||
--hash=sha256:7f850040ddc29c9c42192ed85b915465a3ed7cced916c4fafdd5eda057dd06ec
|
||||
# via -r requirements/common.in
|
||||
|
@ -909,6 +914,7 @@ python-dateutil==2.8.2 \
|
|||
# via
|
||||
# -r requirements/common.in
|
||||
# botocore
|
||||
# django-scim2
|
||||
python-gcm==0.4 \
|
||||
--hash=sha256:511c35fc5ae829f7fc3cbdb45c4ec3fda02f85e4fae039864efe82682ccb9c18
|
||||
# via -r requirements/common.in
|
||||
|
@ -1059,6 +1065,9 @@ s3transfer==0.5.0 \
|
|||
--hash=sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c \
|
||||
--hash=sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803
|
||||
# via boto3
|
||||
scim2-filter-parser==0.3.5 \
|
||||
--hash=sha256:f46b6ffa01cdad6011d3d991bd167af1a9822ab917c225bdf49bc7a44ad4ae53
|
||||
# via django-scim2
|
||||
sentry-sdk==1.3.1 \
|
||||
--hash=sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c \
|
||||
--hash=sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52
|
||||
|
@ -1081,6 +1090,9 @@ six==1.16.0 \
|
|||
# qrcode
|
||||
# talon-core
|
||||
# traitlets
|
||||
sly==0.3 \
|
||||
--hash=sha256:be6a3825b042a9e1b6f5730fc747e6d983c917f0f002d798d0b9f86ca5c05ad9
|
||||
# via scim2-filter-parser
|
||||
social-auth-app-django==5.0.0 \
|
||||
--hash=sha256:52241a25445a010ab1c108bafff21fc5522d5c8cd0d48a92c39c7371824b065d \
|
||||
--hash=sha256:b6e3132ce087cdd6e1707aeb1b588be41d318408fcf6395435da0bc6fe9a9795
|
||||
|
|
|
@ -48,4 +48,4 @@ API_FEATURE_LEVEL = 105
|
|||
# historical commits sharing the same major version, in which case a
|
||||
# minor version bump suffices.
|
||||
|
||||
PROVISION_VERSION = "162.2"
|
||||
PROVISION_VERSION = "162.3"
|
||||
|
|
|
@ -150,6 +150,7 @@ ALL_ZULIP_TABLES = {
|
|||
"zerver_scheduledemail_users",
|
||||
"zerver_scheduledmessage",
|
||||
"zerver_scheduledmessagenotificationemail",
|
||||
"zerver_scimclient",
|
||||
"zerver_service",
|
||||
"zerver_stream",
|
||||
"zerver_submessage",
|
||||
|
@ -199,6 +200,8 @@ NON_EXPORTED_TABLES = {
|
|||
"zerver_scheduledemail",
|
||||
"zerver_scheduledemail_users",
|
||||
"zerver_scheduledmessage",
|
||||
# SCIMClient should be manually created for the new realm after importing.
|
||||
"zerver_scimclient",
|
||||
# These tables are related to a user's 2FA authentication
|
||||
# configuration, which will need to be set up again on the new
|
||||
# server.
|
||||
|
|
|
@ -0,0 +1,363 @@
|
|||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||
|
||||
import django_scim.constants as scim_constants
|
||||
import django_scim.exceptions as scim_exceptions
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.http import HttpRequest
|
||||
from django_scim.adapters import SCIMUser
|
||||
from scim2_filter_parser.attr_paths import AttrPath
|
||||
|
||||
from zerver.lib.actions import (
|
||||
check_change_full_name,
|
||||
do_change_user_delivery_email,
|
||||
do_create_user,
|
||||
do_deactivate_user,
|
||||
do_reactivate_user,
|
||||
)
|
||||
from zerver.lib.email_validation import email_allowed_for_realm, validate_email_not_already_in_realm
|
||||
from zerver.lib.request import RequestNotes
|
||||
from zerver.lib.subdomains import get_subdomain
|
||||
from zerver.models import (
|
||||
DisposableEmailError,
|
||||
DomainNotAllowedForRealmError,
|
||||
EmailContainsPlusError,
|
||||
UserProfile,
|
||||
)
|
||||
|
||||
|
||||
class ZulipSCIMUser(SCIMUser):
|
||||
"""With django-scim2, the core of a project's SCIM implementation is
|
||||
this user adapter class, which defines how to translate between the
|
||||
concepts of users in the SCIM specification and the Zulip users.
|
||||
"""
|
||||
|
||||
id_field = "id"
|
||||
|
||||
def __init__(self, obj: UserProfile, request: Optional[HttpRequest] = None) -> None:
|
||||
# We keep the function signature from the superclass, but this actually
|
||||
# shouldn't be called with request being None.
|
||||
assert request is not None
|
||||
|
||||
# self.obj is populated appropriately by django-scim2 views with
|
||||
# an instance of UserProfile - either fetched from the database
|
||||
# or constructed via UserProfile() if the request currently being
|
||||
# handled is a User creation request (POST).
|
||||
self.obj: UserProfile
|
||||
|
||||
super().__init__(obj, request)
|
||||
self.subdomain = get_subdomain(request)
|
||||
self.config = settings.SCIM_CONFIG[self.subdomain]
|
||||
|
||||
# These attributes are custom to this class and will be
|
||||
# populated with values in handle_replace and similar methods
|
||||
# in response to a request for the the corresponding
|
||||
# UserProfile fields to change. The .save() method inspects
|
||||
# these fields an executes the requested changes.
|
||||
self._email_new_value: Optional[str] = None
|
||||
self._is_active_new_value: Optional[bool] = None
|
||||
self._full_name_new_value: Optional[str] = None
|
||||
self._password_set_to: Optional[str] = None
|
||||
|
||||
def is_new_user(self) -> bool:
|
||||
return not bool(self.obj.id)
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""
|
||||
Return the displayName of the user per the SCIM spec.
|
||||
|
||||
Overridden because UserProfile uses the .full_name attribute,
|
||||
while the superclass expects .first_name and .last_name.
|
||||
"""
|
||||
return self.obj.full_name
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return a ``dict`` conforming to the SCIM User Schema,
|
||||
ready for conversion to a JSON object.
|
||||
|
||||
The attribute names appearing in the dict are those defined in the SCIM User Schema:
|
||||
https://datatracker.ietf.org/doc/html/rfc7643#section-4.1
|
||||
"""
|
||||
if self.config["name_formatted_included"]:
|
||||
name = {
|
||||
"formatted": self.obj.full_name,
|
||||
}
|
||||
else:
|
||||
# Some clients (e.g. Okta) operate with a first_name,
|
||||
# last_name model and don't support a full name field.
|
||||
# While we strive never to do this in the project because
|
||||
# not every culture has the first/last name structure,
|
||||
# Okta's design means we have to convert our full_name
|
||||
# into a first_name/last_name pair to provide to the
|
||||
# client. We do naive conversion with `split`.
|
||||
if " " in self.obj.full_name:
|
||||
first_name, last_name = self.obj.full_name.split(" ", 1)
|
||||
else:
|
||||
first_name, last_name = self.obj.full_name, ""
|
||||
name = {
|
||||
"givenName": first_name,
|
||||
"familyName": last_name,
|
||||
}
|
||||
d = dict(
|
||||
{
|
||||
"schemas": [scim_constants.SchemaURI.USER],
|
||||
"id": self.obj.id,
|
||||
"userName": self.obj.delivery_email,
|
||||
"name": name,
|
||||
"displayName": self.display_name,
|
||||
"active": self.obj.is_active,
|
||||
# meta is a property implemented in the superclass
|
||||
# TODO: The upstream implementation uses `user_profile.date_joined`
|
||||
# as the value of the lastModified meta attribute, which is not
|
||||
# a correct simplification. We should add proper tracking
|
||||
# of this value.
|
||||
"meta": self.meta,
|
||||
}
|
||||
)
|
||||
|
||||
return d
|
||||
|
||||
def from_dict(self, d: Dict[str, Any]) -> None:
|
||||
"""Consume a dictionary conforming to the SCIM User Schema. The
|
||||
dictionary was originally submitted as JSON by the client in
|
||||
PUT (update a user) and POST (create a new user) requests. A
|
||||
PUT request tells us to update User attributes to match those
|
||||
passed in the dict. A POST request tells us to create a new
|
||||
User with attributes as specified in the dict.
|
||||
|
||||
The superclass implements some very basic default behavior,
|
||||
that doesn't support changing attributes via our actions.py
|
||||
functions (which update audit logs, send events, etc.) or
|
||||
doing application-specific validation.
|
||||
|
||||
Thus, we've completely overriden the upstream implementation
|
||||
to store the values of the supported attributes that the
|
||||
request would like to change. Actually modifying the database
|
||||
is implemented in self.save().
|
||||
|
||||
Given that SCIMUser is an adapter class, this method is meant
|
||||
to be completely overridden, and we can expect it remain the
|
||||
case that no important django-scim2 logic relies on the
|
||||
superclass's implementation of this function.
|
||||
"""
|
||||
email = d.get("userName")
|
||||
assert isinstance(email, str)
|
||||
self.change_delivery_email(email)
|
||||
|
||||
name_attr_dict = d.get("name", {})
|
||||
if self.config["name_formatted_included"]:
|
||||
full_name = name_attr_dict.get("formatted", "")
|
||||
else:
|
||||
# Some providers (e.g. Okta) don't provide name.formatted.
|
||||
first_name = name_attr_dict.get("givenName", "")
|
||||
last_name = name_attr_dict.get("familyName", "")
|
||||
full_name = f"{first_name} {last_name}".strip()
|
||||
|
||||
if full_name:
|
||||
assert isinstance(full_name, str)
|
||||
self.change_full_name(full_name)
|
||||
|
||||
if self.is_new_user() and not full_name:
|
||||
raise scim_exceptions.BadRequestError(
|
||||
"Must specify name.formatted, name.givenName or name.familyName when creating a new user"
|
||||
)
|
||||
|
||||
active = d.get("active")
|
||||
if self.is_new_user() and not active:
|
||||
raise scim_exceptions.BadRequestError("New user must have active=True")
|
||||
|
||||
if active is not None:
|
||||
assert isinstance(active, bool)
|
||||
self.change_is_active(active)
|
||||
|
||||
def change_delivery_email(self, new_value: str) -> None:
|
||||
# Note that the email_allowed_for_realm check that usually
|
||||
# appears adjacent to validate_email is present in save().
|
||||
self.validate_email(new_value)
|
||||
if self.obj.delivery_email != new_value:
|
||||
self._email_new_value = new_value
|
||||
|
||||
def change_full_name(self, new_value: str) -> None:
|
||||
if new_value and self.obj.full_name != new_value:
|
||||
self._full_name_new_value = new_value
|
||||
|
||||
def change_is_active(self, new_value: bool) -> None:
|
||||
if new_value is not None and new_value != self.obj.is_active:
|
||||
self._is_active_new_value = new_value
|
||||
|
||||
def handle_replace(
|
||||
self,
|
||||
path: Optional[AttrPath],
|
||||
value: Union[str, List[object], Dict[AttrPath, object]],
|
||||
operation: Any,
|
||||
) -> None:
|
||||
"""
|
||||
PATCH requests specify a list of operations of types "add", "remove", "replace".
|
||||
So far we only implement "replace" as that should be sufficient.
|
||||
|
||||
This method is forked from the superclass and is called to handle "replace"
|
||||
PATCH operations. Such an operation tells us to change the values
|
||||
of a User's attributes as specified. The superclass implements a very basic
|
||||
behavior in this method and is meant to be overriden, since this is an adapter class.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
# Restructure for use in loop below. Taken from the
|
||||
# overriden upstream method.
|
||||
assert path is not None
|
||||
value = {path: value}
|
||||
|
||||
assert isinstance(value, dict)
|
||||
for path, val in (value or {}).items():
|
||||
if path.first_path == ("userName", None, None):
|
||||
assert isinstance(val, str)
|
||||
self.change_delivery_email(val)
|
||||
elif path.first_path == ("name", "formatted", None):
|
||||
# TODO: Add support name_formatted_included=False config like we do
|
||||
# for updates via PUT.
|
||||
assert isinstance(val, str)
|
||||
self.change_full_name(val)
|
||||
elif path.first_path == ("active", None, None):
|
||||
assert isinstance(val, bool)
|
||||
self.change_is_active(val)
|
||||
else:
|
||||
raise scim_exceptions.NotImplementedError("Not Implemented")
|
||||
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
"""
|
||||
This method is called at the end of operations modifying a user,
|
||||
and is responsible for actually applying the requested changes,
|
||||
writing them to the database.
|
||||
"""
|
||||
realm = RequestNotes.get_notes(self._request).realm
|
||||
assert realm is not None
|
||||
|
||||
email_new_value = getattr(self, "_email_new_value", None)
|
||||
is_active_new_value = getattr(self, "_is_active_new_value", None)
|
||||
full_name_new_value = getattr(self, "_full_name_new_value", None)
|
||||
password = getattr(self, "_password_set_to", None)
|
||||
|
||||
# Clean up the internal "pending change" state, now that we've
|
||||
# fetched the values:
|
||||
self._email_new_value = None
|
||||
self._is_active_new_value = None
|
||||
self._full_name_new_value = None
|
||||
self._password_set_to = None
|
||||
|
||||
if email_new_value:
|
||||
try:
|
||||
# Note that the validate_email check that usually
|
||||
# appears adjacent to email_allowed_for_realm is
|
||||
# present in save().
|
||||
email_allowed_for_realm(email_new_value, realm)
|
||||
except DomainNotAllowedForRealmError:
|
||||
raise scim_exceptions.BadRequestError(
|
||||
"This email domain isn't allowed in this organization."
|
||||
)
|
||||
except DisposableEmailError: # nocoverage
|
||||
raise scim_exceptions.BadRequestError(
|
||||
"Disposable email domains are not allowed for this realm."
|
||||
)
|
||||
except EmailContainsPlusError: # nocoverage
|
||||
raise scim_exceptions.BadRequestError("Email address can't contain + characters.")
|
||||
|
||||
try:
|
||||
validate_email_not_already_in_realm(realm, email_new_value)
|
||||
except ValidationError as e:
|
||||
raise ConflictError("Email address already in use: " + str(e))
|
||||
|
||||
if self.is_new_user():
|
||||
self.obj = do_create_user(
|
||||
email_new_value,
|
||||
password,
|
||||
realm,
|
||||
full_name_new_value,
|
||||
acting_user=None,
|
||||
)
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# We process full_name first here, since it's the only one that can fail.
|
||||
if full_name_new_value:
|
||||
check_change_full_name(self.obj, full_name_new_value, acting_user=None)
|
||||
|
||||
if email_new_value:
|
||||
do_change_user_delivery_email(self.obj, email_new_value)
|
||||
|
||||
if is_active_new_value is not None and is_active_new_value:
|
||||
do_reactivate_user(self.obj, acting_user=None)
|
||||
elif is_active_new_value is not None and not is_active_new_value:
|
||||
do_deactivate_user(self.obj, acting_user=None)
|
||||
|
||||
def delete(self) -> None:
|
||||
"""
|
||||
This is consistent with Okta SCIM - users don't get DELETEd, they're deactivated
|
||||
by changing their "active" attr to False.
|
||||
"""
|
||||
raise scim_exceptions.BadRequestError(
|
||||
'DELETE operation not supported. Use PUT or PATCH to modify the "active" attribute instead.'
|
||||
)
|
||||
|
||||
|
||||
def get_extra_model_filter_kwargs_getter(
|
||||
model: Type[models.Model],
|
||||
) -> Callable[[HttpRequest, Any, Any], Dict[str, object]]:
|
||||
"""Registered as GET_EXTRA_MODEL_FILTER_KWARGS_GETTER in our
|
||||
SCIM configuration.
|
||||
|
||||
Returns a function which generates additional kwargs
|
||||
to add to QuerySet's .filter() when fetching a UserProfile
|
||||
corresponding to the requested SCIM User from the database.
|
||||
|
||||
It's *crucial* for security that we filter by realm_id (based on
|
||||
the subdomain of the request) to prevent a SCIM client authorized
|
||||
for subdomain X from being able to interact with all of the Users
|
||||
on the entire server.
|
||||
|
||||
This should be extended for Groups when implementing them by
|
||||
checking the `model` parameter; because we only support
|
||||
UserProfiles, such a check is unnecessary.
|
||||
"""
|
||||
|
||||
def get_extra_filter_kwargs(
|
||||
request: HttpRequest, *args: Any, **kwargs: Any
|
||||
) -> Dict[str, object]:
|
||||
realm = RequestNotes.get_notes(request).realm
|
||||
assert realm is not None
|
||||
return {"realm_id": realm.id, "is_bot": False}
|
||||
|
||||
return get_extra_filter_kwargs
|
||||
|
||||
|
||||
def base_scim_location_getter(request: HttpRequest, *args: Any, **kwargs: Any) -> str:
|
||||
"""Used as the base url for constructing the Location of a SCIM resource.
|
||||
|
||||
Since SCIM synchronization is scoped to an individual realm, we
|
||||
need these locations to be namespaced within the realm's domain
|
||||
namespace, which is conveniently accessed via realm.uri.
|
||||
"""
|
||||
|
||||
realm = RequestNotes.get_notes(request).realm
|
||||
assert realm is not None
|
||||
|
||||
return realm.uri
|
||||
|
||||
|
||||
class ConflictError(scim_exceptions.IntegrityError):
|
||||
"""
|
||||
Per https://datatracker.ietf.org/doc/html/rfc7644#section-3.3
|
||||
|
||||
If the service provider determines that the creation of the requested
|
||||
resource conflicts with existing resources (e.g., a "User" resource
|
||||
with a duplicate "userName"), the service provider MUST return HTTP
|
||||
status code 409 (Conflict) with a "scimType" error code of
|
||||
"uniqueness"
|
||||
|
||||
scim_exceptions.IntegrityError class omits to include the scimType.
|
||||
"""
|
||||
|
||||
scim_type = "uniqueness"
|
|
@ -0,0 +1,62 @@
|
|||
from typing import List, Optional, Tuple
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django_scim.filters import UserFilterQuery
|
||||
|
||||
from zerver.lib.request import RequestNotes
|
||||
|
||||
|
||||
# This is in a separate file due to circular import issues django-scim2 runs into
|
||||
# when this is placed in zerver.lib.scim.
|
||||
class ZulipUserFilterQuery(UserFilterQuery):
|
||||
"""This class implements the filter functionality of SCIM2.
|
||||
E.g. requests such as
|
||||
/scim/v2/Users?filter=userName eq "hamlet@zulip.com"
|
||||
can be made to refer to resources via their properties.
|
||||
This gets fairly complicated in its full scope
|
||||
(https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2)
|
||||
and django-scim2 implements an entire mechanism of converting
|
||||
this SCIM2 filter syntax into SQL queries.
|
||||
|
||||
What we have to do in this class is to customize django-scim2 so
|
||||
that it knows which SCIM attributes map to which UserProfile
|
||||
fields. We can assume that get_extra_model_filter_kwargs_getter
|
||||
has already ensured that we will only interact with non-bot user
|
||||
accounts in the realm associated with this SCIM configuration.
|
||||
"""
|
||||
|
||||
# attr_map describes which table.column the given SCIM2 User
|
||||
# attributes refer to.
|
||||
attr_map = {
|
||||
# attr, sub attr, uri
|
||||
("userName", None, None): "zerver_userprofile.delivery_email",
|
||||
# We can only reasonably support filtering by name.formatted
|
||||
# as UserProfile.full_name is its equivalent. We don't store
|
||||
# first/last name information for UserProfile, so we can't
|
||||
# support filtering based on name.givenName or name.familyName.
|
||||
("name", "formatted", None): "zerver_userprofile.full_name",
|
||||
("active", None, None): "zerver_userprofile.is_active",
|
||||
}
|
||||
|
||||
# joins tells django-scim2 to always add the specified JOINS
|
||||
# to the formed SQL queries. We need to JOIN the Realm table
|
||||
# because we need to limit the results to the realm (subdomain)
|
||||
# of the request.
|
||||
joins = ("INNER JOIN zerver_realm ON zerver_realm.id = realm_id",)
|
||||
|
||||
@classmethod
|
||||
def get_extras(cls, q: str, request: Optional[HttpRequest] = None) -> Tuple[str, List[object]]:
|
||||
"""
|
||||
Return extra SQL and params to be attached to end of current Query's
|
||||
SQL and params. The return format matches the format that should be used
|
||||
for providing raw SQL with params to Django's .raw():
|
||||
https://docs.djangoproject.com/en/3.2/topics/db/sql/#passing-parameters-into-raw
|
||||
|
||||
Here we ensure that results are limited to the subdomain of the request
|
||||
and also exclude bots, as we currently don't want them to be managed by SCIM2.
|
||||
"""
|
||||
assert request is not None
|
||||
realm = RequestNotes.get_notes(request).realm
|
||||
assert realm is not None
|
||||
|
||||
return "AND zerver_realm.id = %s AND zerver_userprofile.is_bot = False", [realm.id]
|
|
@ -326,6 +326,12 @@ Output:
|
|||
self.validate_api_response_openapi(url, "patch", result, info, kwargs)
|
||||
return result
|
||||
|
||||
def json_patch(self, url: str, payload: Dict[str, Any] = {}, **kwargs: Any) -> HttpResponse:
|
||||
data = orjson.dumps(payload)
|
||||
django_client = self.client # see WRAPPER_COMMENT
|
||||
self.set_http_headers(kwargs)
|
||||
return django_client.patch(url, data=data, content_type="application/json", **kwargs)
|
||||
|
||||
@instrument_url
|
||||
def client_put(self, url: str, info: Dict[str, Any] = {}, **kwargs: Any) -> HttpResponse:
|
||||
encoded = urllib.parse.urlencode(info)
|
||||
|
@ -333,6 +339,12 @@ Output:
|
|||
self.set_http_headers(kwargs)
|
||||
return django_client.put(url, encoded, **kwargs)
|
||||
|
||||
def json_put(self, url: str, payload: Dict[str, Any] = {}, **kwargs: Any) -> HttpResponse:
|
||||
data = orjson.dumps(payload)
|
||||
django_client = self.client # see WRAPPER_COMMENT
|
||||
self.set_http_headers(kwargs)
|
||||
return django_client.put(url, data=data, content_type="application/json", **kwargs)
|
||||
|
||||
@instrument_url
|
||||
def client_delete(self, url: str, info: Dict[str, Any] = {}, **kwargs: Any) -> HttpResponse:
|
||||
encoded = urllib.parse.urlencode(info)
|
||||
|
|
|
@ -489,6 +489,12 @@ def write_instrumentation_reports(full_suite: bool, include_webhooks: bool) -> N
|
|||
"static/(?P<path>.+)",
|
||||
"flush_caches",
|
||||
"external_content/(?P<digest>[^/]+)/(?P<received_url>[^/]+)",
|
||||
# These are SCIM2 urls overriden from django-scim2 to return Not Implemented.
|
||||
# We actually test them, but it's not being detected as a tested pattern,
|
||||
# possibly due to the use of re_path. TODO: Investigate and get them
|
||||
# recognized as tested.
|
||||
"scim/v2/Groups(?:/(?P<uuid>[^/]+))?",
|
||||
"scim/v2/Groups/.search",
|
||||
*(webhook.url for webhook in WEBHOOK_INTEGRATIONS if not include_webhooks),
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
from argparse import ArgumentParser
|
||||
from typing import Any
|
||||
|
||||
from zerver.lib.management import ZulipBaseCommand
|
||||
from zerver.models import SCIMClient
|
||||
|
||||
|
||||
class Command(ZulipBaseCommand):
|
||||
help = """Create a SCIM client entry in the database."""
|
||||
|
||||
def add_arguments(self, parser: ArgumentParser) -> None:
|
||||
self.add_realm_args(parser)
|
||||
parser.add_argument("name", help="name of the client")
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> None:
|
||||
client_name = options["name"]
|
||||
realm = self.get_realm(options)
|
||||
assert realm
|
||||
|
||||
SCIMClient.objects.create(realm=realm, name=client_name)
|
|
@ -28,6 +28,8 @@ from django.utils.cache import patch_vary_headers
|
|||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.csrf import csrf_failure as html_csrf_failure
|
||||
from django_scim.middleware import SCIMAuthCheckMiddleware
|
||||
from django_scim.settings import scim_settings
|
||||
from sentry_sdk import capture_exception
|
||||
from sentry_sdk.integrations.logging import ignore_logger
|
||||
|
||||
|
@ -44,7 +46,7 @@ from zerver.lib.subdomains import get_subdomain
|
|||
from zerver.lib.types import ViewFuncT
|
||||
from zerver.lib.user_agent import parse_user_agent
|
||||
from zerver.lib.utils import statsd
|
||||
from zerver.models import Realm, flush_per_request_caches, get_realm
|
||||
from zerver.models import Realm, SCIMClient, flush_per_request_caches, get_realm
|
||||
|
||||
logger = logging.getLogger("zulip.requests")
|
||||
slow_query_logger = logging.getLogger("zulip.slow_queries")
|
||||
|
@ -664,3 +666,71 @@ class ZulipCommonMiddleware(CommonMiddleware):
|
|||
if settings.RUNNING_INSIDE_TORNADO:
|
||||
return False
|
||||
return super().should_redirect_with_slash(request)
|
||||
|
||||
|
||||
def validate_scim_bearer_token(request: HttpRequest) -> Optional[SCIMClient]:
|
||||
"""
|
||||
This function verifies the request is allowed to make SCIM requests on this subdomain,
|
||||
by checking the provided bearer token and ensuring it matches a scim client configured
|
||||
for this subdomain in settings.SCIM_CONFIG.
|
||||
If succesful, returns the corresponding SCIMClient object. Returns None otherwise.
|
||||
"""
|
||||
|
||||
subdomain = get_subdomain(request)
|
||||
scim_config_dict = settings.SCIM_CONFIG.get(subdomain)
|
||||
if not scim_config_dict:
|
||||
return None
|
||||
|
||||
valid_bearer_token = scim_config_dict.get("bearer_token")
|
||||
scim_client_name = scim_config_dict.get("scim_client_name")
|
||||
# We really don't want a misconfiguration where this is unset,
|
||||
# allowing free access to the SCIM API:
|
||||
assert valid_bearer_token
|
||||
assert scim_client_name
|
||||
|
||||
if request.headers.get("Authorization") != f"Bearer {valid_bearer_token}":
|
||||
return None
|
||||
|
||||
request_notes = RequestNotes.get_notes(request)
|
||||
assert request_notes.realm
|
||||
|
||||
# While API authentication code paths are sufficiently high
|
||||
# traffic that we prefer to use a cache, SCIM is much lower
|
||||
# traffic, and doing a database query is plenty fast.
|
||||
return SCIMClient.objects.get(realm=request_notes.realm, name=scim_client_name)
|
||||
|
||||
|
||||
class ZulipSCIMAuthCheckMiddleware(SCIMAuthCheckMiddleware):
|
||||
"""
|
||||
Overriden version of middleware implemented in django-scim2
|
||||
(https://github.com/15five/django-scim2/blob/master/src/django_scim/middleware.py)
|
||||
to also handle authenticating the client.
|
||||
"""
|
||||
|
||||
def process_request(self, request: HttpRequest) -> Optional[HttpResponse]:
|
||||
# This determines whether this is a SCIM request based on the request's path
|
||||
# and if it is, logs request information, including the body, as well as the response
|
||||
# for debugging purposes to the `django_scim.middleware` logger, at DEBUG level.
|
||||
# We keep those logs in /var/log/zulip/scim.log
|
||||
if self.should_log_request(request):
|
||||
self.log_request(request)
|
||||
|
||||
# Here we verify the request is indeed to a SCIM endpoint. That's ensured
|
||||
# by comparing the path with self.reverse_url, which is the root SCIM path /scim/b2/.
|
||||
# Of course we don't want to proceed with authenticating the request for SCIM
|
||||
# if a non-SCIM endpoint is being queried.
|
||||
if not request.path.startswith(self.reverse_url):
|
||||
return None
|
||||
|
||||
scim_client = validate_scim_bearer_token(request)
|
||||
if not scim_client:
|
||||
response = HttpResponse(status=401)
|
||||
response["WWW-Authenticate"] = scim_settings.WWW_AUTHENTICATE_HEADER
|
||||
return response
|
||||
|
||||
# The client has been successfully authenticated for SCIM on this subdomain,
|
||||
# so we can assign the corresponding SCIMClient object to request.user - which
|
||||
# will allow this request to pass request.user.is_authenticated checks from now on,
|
||||
# to be served by the relevant views implemented in django-scim2.
|
||||
request.user = scim_client
|
||||
return None
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
# Generated by Django 3.2.7 on 2021-10-03 18:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("zerver", "0366_group_group_membership"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SCIMClient",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.TextField(),
|
||||
),
|
||||
(
|
||||
"realm",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="zerver.realm"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("realm", "name")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -4184,3 +4184,26 @@ def flush_alert_word(*, instance: AlertWord, **kwargs: object) -> None:
|
|||
|
||||
post_save.connect(flush_alert_word, sender=AlertWord)
|
||||
post_delete.connect(flush_alert_word, sender=AlertWord)
|
||||
|
||||
|
||||
class SCIMClient(models.Model):
|
||||
realm: Realm = models.ForeignKey(Realm, on_delete=CASCADE)
|
||||
name: str = models.TextField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ("realm", "name")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"<SCIMClient {self.name} for realm {self.realm_id}>"
|
||||
|
||||
def format_requestor_for_logs(self) -> str:
|
||||
return f"scim-client:{self.name}:realm:{self.realm_id}"
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""
|
||||
The purpose of this is to make SCIMClient behave like a UserProfile
|
||||
when an instance is assigned to request.user - we need it to pass
|
||||
request.user.is_authenticated verifications.
|
||||
"""
|
||||
return True
|
||||
|
|
|
@ -0,0 +1,610 @@
|
|||
import copy
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Iterator, Mapping
|
||||
from unittest import mock
|
||||
|
||||
import orjson
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
|
||||
from zerver.lib.actions import do_change_full_name
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.models import SCIMClient, UserProfile, get_realm
|
||||
|
||||
|
||||
class SCIMTestCase(ZulipTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.realm = get_realm("zulip")
|
||||
self.scim_client = SCIMClient.objects.create(
|
||||
realm=self.realm, name=settings.SCIM_CONFIG["zulip"]["scim_client_name"]
|
||||
)
|
||||
|
||||
def scim_headers(self) -> Mapping[str, str]:
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {settings.SCIM_CONFIG['zulip']['bearer_token']}"}
|
||||
|
||||
def generate_user_schema(self, user_profile: UserProfile) -> Dict[str, Any]:
|
||||
return {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": user_profile.id,
|
||||
"userName": user_profile.delivery_email,
|
||||
"name": {"formatted": user_profile.full_name},
|
||||
"displayName": user_profile.full_name,
|
||||
"active": True,
|
||||
"meta": {
|
||||
"resourceType": "User",
|
||||
"created": user_profile.date_joined.isoformat(),
|
||||
"lastModified": user_profile.date_joined.isoformat(),
|
||||
"location": f"http://zulip.testserver/scim/v2/Users/{user_profile.id}",
|
||||
},
|
||||
}
|
||||
|
||||
def assert_uniqueness_error(self, result: HttpResponse, extra_message: str) -> None:
|
||||
self.assertEqual(result.status_code, 409)
|
||||
output_data = orjson.loads(result.content)
|
||||
|
||||
expected_response_schema = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": f"Email address already in use: {extra_message}",
|
||||
"status": 409,
|
||||
"scimType": "uniqueness",
|
||||
}
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
@contextmanager
|
||||
def mock_name_formatted_included(self, value: bool) -> Iterator[None]:
|
||||
config_dict = copy.deepcopy(settings.SCIM_CONFIG)
|
||||
config_dict["zulip"]["name_formatted_included"] = value
|
||||
with self.settings(SCIM_CONFIG=config_dict):
|
||||
yield
|
||||
|
||||
|
||||
class TestNonSCIMAPIAccess(SCIMTestCase):
|
||||
def test_scim_client_cant_access_different_apis(self) -> None:
|
||||
"""
|
||||
Verify that the SCIM client credentials can't be used to get
|
||||
authenticated for non-SCIM API.
|
||||
"""
|
||||
hamlet = self.example_user("hamlet")
|
||||
|
||||
# First verify validate_scim_bearer_token doesn't even get called,
|
||||
# as verification of SCIM credentials shouldn't even be attempted,
|
||||
# because we're not querying a SCIM endpoint.
|
||||
with mock.patch("zerver.middleware.validate_scim_bearer_token", return_value=None) as m:
|
||||
result = self.client_get(f"/api/v1/users/{hamlet.id}", **self.scim_headers())
|
||||
|
||||
# The SCIM format of the Authorization header (bearer token) is rejected as a bad request
|
||||
# by our regular API authentication logic.
|
||||
self.assert_json_error(result, "This endpoint requires HTTP basic authentication.", 400)
|
||||
m.assert_not_called()
|
||||
|
||||
# Now simply test end-to-end that access gets denied, without any mocking
|
||||
# interfering with the process.
|
||||
result = self.client_get(f"/api/v1/users/{hamlet.id}", **self.scim_headers())
|
||||
self.assert_json_error(result, "This endpoint requires HTTP basic authentication.", 400)
|
||||
|
||||
|
||||
class TestSCIMUser(SCIMTestCase):
|
||||
def test_get_by_id(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
expected_response_schema = self.generate_user_schema(hamlet)
|
||||
|
||||
result = self.client_get(f"/scim/v2/Users/{hamlet.id}", **self.scim_headers())
|
||||
|
||||
self.assertEqual(result.status_code, 200)
|
||||
output_data = orjson.loads(result.content)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
def test_get_basic_filter_by_username(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
|
||||
expected_response_schema = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": 1,
|
||||
"itemsPerPage": 50,
|
||||
"startIndex": 1,
|
||||
"Resources": [self.generate_user_schema(hamlet)],
|
||||
}
|
||||
|
||||
result = self.client_get(
|
||||
f'/scim/v2/Users?filter=userName eq "{hamlet.delivery_email}"', **self.scim_headers()
|
||||
)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
output_data = orjson.loads(result.content)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
# Now we verify the filter feature doesn't allow access to users
|
||||
# on different subdomains.
|
||||
different_realm_user = self.mit_user("starnine")
|
||||
self.assertNotEqual(different_realm_user.realm_id, hamlet.realm_id)
|
||||
|
||||
result = self.client_get(
|
||||
f'/scim/v2/Users?filter=userName eq "{different_realm_user.delivery_email}"',
|
||||
**self.scim_headers(),
|
||||
)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
output_data = orjson.loads(result.content)
|
||||
|
||||
expected_empty_results_response_schema = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": 0,
|
||||
"itemsPerPage": 50,
|
||||
"startIndex": 1,
|
||||
"Resources": [],
|
||||
}
|
||||
|
||||
self.assertEqual(output_data, expected_empty_results_response_schema)
|
||||
|
||||
def test_get_all_with_pagination(self) -> None:
|
||||
realm = get_realm("zulip")
|
||||
|
||||
result_all = self.client_get("/scim/v2/Users", **self.scim_headers())
|
||||
self.assertEqual(result_all.status_code, 200)
|
||||
output_data_all = orjson.loads(result_all.content)
|
||||
|
||||
expected_response_schema = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": UserProfile.objects.filter(realm=realm, is_bot=False).count(),
|
||||
"itemsPerPage": 50,
|
||||
"startIndex": 1,
|
||||
"Resources": [],
|
||||
}
|
||||
for user_profile in UserProfile.objects.filter(realm=realm, is_bot=False).order_by("id"):
|
||||
user_schema = self.generate_user_schema(user_profile)
|
||||
expected_response_schema["Resources"].append(user_schema)
|
||||
|
||||
self.assertEqual(output_data_all, expected_response_schema)
|
||||
|
||||
# Test pagination works, as defined in https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4
|
||||
result_offset_limited = self.client_get(
|
||||
"/scim/v2/Users?startIndex=4&count=3", **self.scim_headers()
|
||||
)
|
||||
self.assertEqual(result_offset_limited.status_code, 200)
|
||||
output_data_offset_limited = orjson.loads(result_offset_limited.content)
|
||||
self.assertEqual(output_data_offset_limited["itemsPerPage"], 3)
|
||||
self.assertEqual(output_data_offset_limited["startIndex"], 4)
|
||||
self.assertEqual(
|
||||
output_data_offset_limited["totalResults"], output_data_all["totalResults"]
|
||||
)
|
||||
self.assert_length(output_data_offset_limited["Resources"], 3)
|
||||
|
||||
self.assertEqual(output_data_offset_limited["Resources"], output_data_all["Resources"][3:6])
|
||||
|
||||
def test_get_user_with_no_name_formatted_included_config(self) -> None:
|
||||
"""
|
||||
Some clients don't support name.formatted and rely and name.givenName and name.familyName.
|
||||
We have the name_formatted_included configuration option for it for supporting that
|
||||
behavior. Here we test the return dict representation of the User has the appropriate
|
||||
format and values.
|
||||
"""
|
||||
hamlet = self.example_user("hamlet")
|
||||
do_change_full_name(hamlet, "Firstname Lastname", acting_user=None)
|
||||
expected_response_schema = self.generate_user_schema(hamlet)
|
||||
expected_response_schema["name"] = {"givenName": "Firstname", "familyName": "Lastname"}
|
||||
|
||||
with self.mock_name_formatted_included(False):
|
||||
result = self.client_get(f"/scim/v2/Users/{hamlet.id}", **self.scim_headers())
|
||||
|
||||
self.assertEqual(result.status_code, 200)
|
||||
output_data = orjson.loads(result.content)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
do_change_full_name(hamlet, "Firstnameonly", acting_user=None)
|
||||
expected_response_schema = self.generate_user_schema(hamlet)
|
||||
expected_response_schema["name"] = {"givenName": "Firstnameonly", "familyName": ""}
|
||||
|
||||
with self.mock_name_formatted_included(False):
|
||||
result = self.client_get(f"/scim/v2/Users/{hamlet.id}", **self.scim_headers())
|
||||
|
||||
self.assertEqual(result.status_code, 200)
|
||||
output_data = orjson.loads(result.content)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
def test_post(self) -> None:
|
||||
# A payload for creating a new user with the specified account details.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "newuser@zulip.com",
|
||||
"name": {"formatted": "New User", "givenName": "New", "familyName": "User"},
|
||||
"active": True,
|
||||
}
|
||||
|
||||
original_user_count = UserProfile.objects.count()
|
||||
result = self.client_post(
|
||||
"/scim/v2/Users", payload, content_type="application/json", **self.scim_headers()
|
||||
)
|
||||
|
||||
self.assertEqual(result.status_code, 201)
|
||||
output_data = orjson.loads(result.content)
|
||||
|
||||
new_user_count = UserProfile.objects.count()
|
||||
self.assertEqual(new_user_count, original_user_count + 1)
|
||||
|
||||
new_user = UserProfile.objects.last()
|
||||
self.assertEqual(new_user.delivery_email, "newuser@zulip.com")
|
||||
self.assertEqual(new_user.full_name, "New User")
|
||||
|
||||
expected_response_schema = self.generate_user_schema(new_user)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
def test_post_with_no_name_formatted_included_config(self) -> None:
|
||||
# A payload for creating a new user with the specified account details.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "newuser@zulip.com",
|
||||
"name": {"givenName": "New", "familyName": "User"},
|
||||
"active": True,
|
||||
}
|
||||
|
||||
original_user_count = UserProfile.objects.count()
|
||||
with self.mock_name_formatted_included(False):
|
||||
result = self.client_post(
|
||||
"/scim/v2/Users", payload, content_type="application/json", **self.scim_headers()
|
||||
)
|
||||
|
||||
self.assertEqual(result.status_code, 201)
|
||||
output_data = orjson.loads(result.content)
|
||||
|
||||
new_user_count = UserProfile.objects.count()
|
||||
self.assertEqual(new_user_count, original_user_count + 1)
|
||||
|
||||
new_user = UserProfile.objects.last()
|
||||
self.assertEqual(new_user.delivery_email, "newuser@zulip.com")
|
||||
self.assertEqual(new_user.full_name, "New User")
|
||||
|
||||
expected_response_schema = self.generate_user_schema(new_user)
|
||||
expected_response_schema["name"] = {"givenName": "New", "familyName": "User"}
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
def test_post_email_exists(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
# A payload for creating a new user with an email that already exists. Thus
|
||||
# this should fail.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": hamlet.delivery_email,
|
||||
"name": {"formatted": "New User", "givenName": "New", "familyName": "User"},
|
||||
"active": True,
|
||||
}
|
||||
|
||||
result = self.client_post(
|
||||
"/scim/v2/Users", payload, content_type="application/json", **self.scim_headers()
|
||||
)
|
||||
self.assert_uniqueness_error(result, f"['{hamlet.delivery_email} already has an account']")
|
||||
|
||||
def test_post_name_attribute_missing(self) -> None:
|
||||
# A payload for creating a new user without a name, which should make this request fail.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "newuser@zulip.com",
|
||||
"active": True,
|
||||
}
|
||||
|
||||
result = self.client_post(
|
||||
"/scim/v2/Users", payload, content_type="application/json", **self.scim_headers()
|
||||
)
|
||||
response_dict = result.json()
|
||||
self.assertEqual(
|
||||
response_dict,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": "Must specify name.formatted, name.givenName or name.familyName when creating a new user",
|
||||
"status": 400,
|
||||
},
|
||||
)
|
||||
|
||||
def test_post_active_set_to_false(self) -> None:
|
||||
# A payload for creating a new user with is_active=False, which is an invalid operation.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "newuser@zulip.com",
|
||||
"name": {"formatted": "New User", "givenName": "New", "familyName": "User"},
|
||||
"active": False,
|
||||
}
|
||||
|
||||
result = self.client_post(
|
||||
"/scim/v2/Users", payload, content_type="application/json", **self.scim_headers()
|
||||
)
|
||||
response_dict = result.json()
|
||||
self.assertEqual(
|
||||
response_dict,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": "New user must have active=True",
|
||||
"status": 400,
|
||||
},
|
||||
)
|
||||
|
||||
def test_post_email_domain_not_allow(self) -> None:
|
||||
realm = get_realm("zulip")
|
||||
realm.emails_restricted_to_domains = True
|
||||
realm.save(update_fields=["emails_restricted_to_domains"])
|
||||
|
||||
# A payload for creating a new user with the specified details.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "newuser@acme.com",
|
||||
"name": {"formatted": "New User", "givenName": "New", "familyName": "User"},
|
||||
"active": True,
|
||||
}
|
||||
|
||||
result = self.client_post(
|
||||
"/scim/v2/Users", payload, content_type="application/json", **self.scim_headers()
|
||||
)
|
||||
response_dict = result.json()
|
||||
self.assertEqual(
|
||||
response_dict,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": "This email domain isn't allowed in this organization.",
|
||||
"status": 400,
|
||||
},
|
||||
)
|
||||
|
||||
def test_post_to_try_creating_new_user_on_different_subdomain(self) -> None:
|
||||
# A payload for creating a new user with the specified details.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "newuser@acme.com",
|
||||
"name": {"formatted": "New User", "givenName": "New", "familyName": "User"},
|
||||
"active": True,
|
||||
}
|
||||
|
||||
# Now we make the SCIM request to a different subdomain than our credentials
|
||||
# are configured for. Unauthorized is the expected response.
|
||||
result = self.client_post(
|
||||
"/scim/v2/Users",
|
||||
payload,
|
||||
content_type="application/json",
|
||||
subdomain="lear",
|
||||
**self.scim_headers(),
|
||||
)
|
||||
self.assertEqual(result.status_code, 401)
|
||||
|
||||
def test_delete(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
result = self.client_delete(f"/scim/v2/Users/{hamlet.id}", **self.scim_headers())
|
||||
|
||||
expected_response_schema = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": 'DELETE operation not supported. Use PUT or PATCH to modify the "active" attribute instead.',
|
||||
"status": 400,
|
||||
}
|
||||
|
||||
self.assertEqual(result.status_code, 400)
|
||||
output_data = orjson.loads(result.content)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
def test_put_change_email_and_name(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
# PUT replaces all specified attributes of the user. Thus,
|
||||
# this payload will replace hamlet's account details with the new ones,
|
||||
# as specified.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": hamlet.id,
|
||||
"userName": "bjensen@zulip.com",
|
||||
"name": {
|
||||
"formatted": "Ms. Barbara J Jensen III",
|
||||
"familyName": "Jensen",
|
||||
"givenName": "Barbara",
|
||||
"middleName": "Jane",
|
||||
},
|
||||
}
|
||||
result = self.json_put(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
hamlet.refresh_from_db()
|
||||
self.assertEqual(hamlet.delivery_email, "bjensen@zulip.com")
|
||||
self.assertEqual(hamlet.full_name, "Ms. Barbara J Jensen III")
|
||||
|
||||
output_data = orjson.loads(result.content)
|
||||
expected_response_schema = self.generate_user_schema(hamlet)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
def test_put_change_name_only(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
hamlet_email = hamlet.delivery_email
|
||||
# This payload specified hamlet's current email to not change this attribute,
|
||||
# and only alters the name.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": hamlet.id,
|
||||
"userName": hamlet_email,
|
||||
"name": {
|
||||
"formatted": "Ms. Barbara J Jensen III",
|
||||
"familyName": "Jensen",
|
||||
"givenName": "Barbara",
|
||||
"middleName": "Jane",
|
||||
},
|
||||
}
|
||||
result = self.json_put(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
hamlet.refresh_from_db()
|
||||
self.assertEqual(hamlet.delivery_email, hamlet_email)
|
||||
self.assertEqual(hamlet.full_name, "Ms. Barbara J Jensen III")
|
||||
|
||||
output_data = orjson.loads(result.content)
|
||||
expected_response_schema = self.generate_user_schema(hamlet)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
def test_put_email_exists(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
cordelia = self.example_user("cordelia")
|
||||
# This payload will attempt to change hamlet's email to cordelia's email.
|
||||
# That would would violate email uniqueness of course, so should fail.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": hamlet.id,
|
||||
"userName": cordelia.delivery_email,
|
||||
"name": {
|
||||
"formatted": "Ms. Barbara J Jensen III",
|
||||
"familyName": "Jensen",
|
||||
"givenName": "Barbara",
|
||||
"middleName": "Jane",
|
||||
},
|
||||
}
|
||||
result = self.json_put(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assert_uniqueness_error(
|
||||
result, f"['{cordelia.delivery_email} already has an account']"
|
||||
)
|
||||
|
||||
def test_put_deactivate_reactivate_user(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
# This payload flips the active attribute to deactivate the user.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": hamlet.id,
|
||||
"userName": hamlet.delivery_email,
|
||||
"active": False,
|
||||
}
|
||||
result = self.json_put(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
hamlet.refresh_from_db()
|
||||
self.assertEqual(hamlet.is_active, False)
|
||||
|
||||
# We modify the active attribute in the payload to cause reactivation of the user.
|
||||
payload["active"] = True
|
||||
result = self.json_put(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
hamlet.refresh_from_db()
|
||||
self.assertEqual(hamlet.is_active, True)
|
||||
|
||||
def test_patch_with_path(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
# Payload for a PATCH request to change the user's email to the specified value.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [{"op": "replace", "path": "userName", "value": "hamlet_new@zulip.com"}],
|
||||
}
|
||||
|
||||
result = self.json_patch(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
hamlet.refresh_from_db()
|
||||
self.assertEqual(hamlet.delivery_email, "hamlet_new@zulip.com")
|
||||
|
||||
output_data = orjson.loads(result.content)
|
||||
expected_response_schema = self.generate_user_schema(hamlet)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
# Multiple operations:
|
||||
# This payload changes the user's email and name to the specified values.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{"op": "replace", "path": "userName", "value": "hamlet_new2@zulip.com"},
|
||||
{"op": "replace", "path": "name.formatted", "value": "New Name"},
|
||||
],
|
||||
}
|
||||
result = self.json_patch(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
hamlet.refresh_from_db()
|
||||
self.assertEqual(hamlet.full_name, "New Name")
|
||||
self.assertEqual(hamlet.delivery_email, "hamlet_new2@zulip.com")
|
||||
|
||||
output_data = orjson.loads(result.content)
|
||||
expected_response_schema = self.generate_user_schema(hamlet)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
def test_patch_without_path(self) -> None:
|
||||
"""
|
||||
PATCH requests can also specify Operations in a different form,
|
||||
without specifying the "path" op attribute and instead specifying
|
||||
the user attribute to modify in the "value" dict.
|
||||
"""
|
||||
|
||||
hamlet = self.example_user("hamlet")
|
||||
# This payload changes the user's email to the specified value.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [{"op": "replace", "value": {"userName": "hamlet_new@zulip.com"}}],
|
||||
}
|
||||
|
||||
result = self.json_patch(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
hamlet.refresh_from_db()
|
||||
self.assertEqual(hamlet.delivery_email, "hamlet_new@zulip.com")
|
||||
|
||||
output_data = orjson.loads(result.content)
|
||||
expected_response_schema = self.generate_user_schema(hamlet)
|
||||
self.assertEqual(output_data, expected_response_schema)
|
||||
|
||||
def test_patch_deactivate_reactivate_user(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
# Payload for a PATCH request to deactivate the user.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [{"op": "replace", "path": "active", "value": False}],
|
||||
}
|
||||
|
||||
result = self.json_patch(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
hamlet.refresh_from_db()
|
||||
self.assertEqual(hamlet.is_active, False)
|
||||
|
||||
# Payload for a PATCH request to reactivate the user.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [{"op": "replace", "path": "active", "value": True}],
|
||||
}
|
||||
result = self.json_patch(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 200)
|
||||
hamlet.refresh_from_db()
|
||||
self.assertEqual(hamlet.is_active, True)
|
||||
|
||||
def test_patch_unsupported_attribute(self) -> None:
|
||||
hamlet = self.example_user("hamlet")
|
||||
# Payload for a PATCH request to change the middle name of the user - which is not supported.
|
||||
payload = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [{"op": "replace", "path": "name.middleName", "value": "John"}],
|
||||
}
|
||||
|
||||
with self.assertLogs("django.request", "ERROR") as m:
|
||||
result = self.json_patch(f"/scim/v2/Users/{hamlet.id}", payload, **self.scim_headers())
|
||||
self.assertEqual(
|
||||
result.json(),
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": "Not Implemented",
|
||||
"status": 501,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
m.output, [f"ERROR:django.request:Not Implemented: /scim/v2/Users/{hamlet.id}"]
|
||||
)
|
||||
|
||||
|
||||
class TestSCIMGroup(SCIMTestCase):
|
||||
"""
|
||||
SCIM groups aren't implemented yet. An implementation will modify this class
|
||||
to actually test desired behavior.
|
||||
"""
|
||||
|
||||
def test_endpoints_disabled(self) -> None:
|
||||
with self.assertLogs("django.request", "ERROR") as m:
|
||||
result = self.client_get("/scim/v2/Groups", **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 501)
|
||||
self.assertEqual(m.output, ["ERROR:django.request:Not Implemented: /scim/v2/Groups"])
|
||||
with self.assertLogs("django.request", "ERROR") as m:
|
||||
result = self.client_get("/scim/v2/Groups/1", **self.scim_headers())
|
||||
self.assertEqual(result.status_code, 501)
|
||||
self.assertEqual(m.output, ["ERROR:django.request:Not Implemented: /scim/v2/Groups/1"])
|
||||
with self.assertLogs("django.request", "ERROR") as m:
|
||||
result = self.client_post(
|
||||
"/scim/v2/Groups/.search",
|
||||
{},
|
||||
content_type="application/json",
|
||||
**self.scim_headers(),
|
||||
)
|
||||
self.assertEqual(result.status_code, 501)
|
||||
self.assertEqual(
|
||||
m.output, ["ERROR:django.request:Not Implemented: /scim/v2/Groups/.search"]
|
||||
)
|
|
@ -187,6 +187,7 @@ MIDDLEWARE = (
|
|||
"zerver.middleware.HostDomainMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"zerver.middleware.ZulipSCIMAuthCheckMiddleware",
|
||||
# Make sure 2FA middlewares come after authentication middleware.
|
||||
"django_otp.middleware.OTPMiddleware", # Required by two factor auth.
|
||||
"two_factor.middleware.threadlocals.ThreadLocals", # Required by Twilio
|
||||
|
@ -213,6 +214,7 @@ INSTALLED_APPS = [
|
|||
"confirmation",
|
||||
"zerver",
|
||||
"social_django",
|
||||
"django_scim",
|
||||
# 2FA related apps.
|
||||
"django_otp",
|
||||
"django_otp.plugins.otp_static",
|
||||
|
@ -716,6 +718,7 @@ TRACEMALLOC_DUMP_DIR = zulip_path("/var/log/zulip/tracemalloc")
|
|||
DELIVER_SCHEDULED_MESSAGES_LOG_PATH = zulip_path("/var/log/zulip/deliver_scheduled_messages.log")
|
||||
RETENTION_LOG_PATH = zulip_path("/var/log/zulip/message_retention.log")
|
||||
AUTH_LOG_PATH = zulip_path("/var/log/zulip/auth.log")
|
||||
SCIM_LOG_PATH = zulip_path("/var/log/zulip/scim.log")
|
||||
|
||||
ZULIP_WORKER_TEST_FILE = "/tmp/zulip-worker-test-file"
|
||||
|
||||
|
@ -817,6 +820,12 @@ LOGGING: Dict[str, Any] = {
|
|||
"formatter": "default",
|
||||
"filename": LDAP_LOG_PATH,
|
||||
},
|
||||
"scim_file": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.handlers.WatchedFileHandler",
|
||||
"formatter": "default",
|
||||
"filename": SCIM_LOG_PATH,
|
||||
},
|
||||
"slow_queries_file": {
|
||||
"level": "INFO",
|
||||
"class": "logging.handlers.WatchedFileHandler",
|
||||
|
@ -913,6 +922,11 @@ LOGGING: Dict[str, Any] = {
|
|||
"handlers": ["console", "ldap_file", "errors_file"],
|
||||
"propagate": False,
|
||||
},
|
||||
"django_scim": {
|
||||
"level": "DEBUG",
|
||||
"handlers": ["scim_file", "errors_file"],
|
||||
"propagate": False,
|
||||
},
|
||||
"pika": {
|
||||
# pika is super chatty on INFO.
|
||||
"level": "WARNING",
|
||||
|
@ -1194,3 +1208,22 @@ if SENTRY_DSN:
|
|||
from .sentry import setup_sentry
|
||||
|
||||
setup_sentry(SENTRY_DSN, get_config("machine", "deploy_type", "development"))
|
||||
|
||||
SCIM_SERVICE_PROVIDER = {
|
||||
"USER_ADAPTER": "zerver.lib.scim.ZulipSCIMUser",
|
||||
"USER_FILTER_PARSER": "zerver.lib.scim_filter.ZulipUserFilterQuery",
|
||||
# NETLOC is actually overriden by the behavior of base_scim_location_getter,
|
||||
# but django-scim2 requires it to be set, even though it ends up not being used.
|
||||
# So we need to give it some value here, and EXTERNAL_HOST is the most generic.
|
||||
"NETLOC": EXTERNAL_HOST,
|
||||
"SCHEME": EXTERNAL_URI_SCHEME,
|
||||
"GET_EXTRA_MODEL_FILTER_KWARGS_GETTER": "zerver.lib.scim.get_extra_model_filter_kwargs_getter",
|
||||
"BASE_LOCATION_GETTER": "zerver.lib.scim.base_scim_location_getter",
|
||||
"AUTHENTICATION_SCHEMES": [
|
||||
{
|
||||
"type": "bearer",
|
||||
"name": "Bearer",
|
||||
"description": "Bearer token",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
@ -179,3 +179,11 @@ SOCIAL_AUTH_SAML_SP_ENTITY_ID = "http://localhost:9991"
|
|||
SOCIAL_AUTH_SUBDOMAIN = "auth"
|
||||
|
||||
MEMCACHED_USERNAME: Optional[str] = None
|
||||
|
||||
SCIM_CONFIG = {
|
||||
"zulip": {
|
||||
"bearer_token": "token1234",
|
||||
"scim_client_name": "test-scim-client",
|
||||
"name_formatted_included": True,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -271,3 +271,11 @@ RATE_LIMITING_RULES: Dict[str, List[Tuple[int, int]]] = {
|
|||
}
|
||||
|
||||
FREE_TRIAL_DAYS: Optional[int] = None
|
||||
|
||||
SCIM_CONFIG = {
|
||||
"zulip": {
|
||||
"bearer_token": "token1234",
|
||||
"scim_client_name": "test-scim-client",
|
||||
"name_formatted_included": True,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ from django.contrib.auth.views import (
|
|||
PasswordResetConfirmView,
|
||||
PasswordResetDoneView,
|
||||
)
|
||||
from django.urls import path
|
||||
from django.urls import path, re_path
|
||||
from django.urls.resolvers import URLPattern, URLResolver
|
||||
from django.utils.module_loading import import_string
|
||||
from django.views.generic import RedirectView, TemplateView
|
||||
|
@ -740,6 +740,24 @@ urls += [
|
|||
urls += [path("", include("social_django.urls", namespace="social"))]
|
||||
urls += [path("saml/metadata.xml", saml_sp_metadata)]
|
||||
|
||||
# SCIM2
|
||||
|
||||
from django_scim import views as scim_views
|
||||
|
||||
urls += [
|
||||
# These first couple entries mark the Groups feature of SCIM as
|
||||
# something we haven't implemented.
|
||||
re_path(
|
||||
r"^scim/v2/Groups/.search$",
|
||||
scim_views.SCIMView.as_view(implemented=False),
|
||||
),
|
||||
re_path(
|
||||
r"^scim/v2/Groups(?:/(?P<uuid>[^/]+))?$",
|
||||
scim_views.SCIMView.as_view(implemented=False),
|
||||
),
|
||||
path("scim/v2/", include("django_scim.urls", namespace="scim")),
|
||||
]
|
||||
|
||||
# User documentation site
|
||||
help_documentation_view = MarkdownDirectoryView.as_view(
|
||||
template_name="zerver/documentation_main.html", path_template="/zerver/help/%s.md"
|
||||
|
|
Loading…
Reference in New Issue