scim: Add supporting for syncing the user role.

This adds support for syncing user role via the newly added "role"
attribute, which can be set to either of
['owner', 'administrator', 'moderator', 'member', 'guest'].

Removes durable=True from the atomic decorator of do_change_user_role,
as django-scim2 runs PATCH operations in an atomic block.
This commit is contained in:
Mateusz Mandera 2023-09-14 23:36:06 +02:00 committed by Tim Abbott
parent 3c89fe222a
commit 3e15ea3f3f
4 changed files with 132 additions and 2 deletions

View File

@ -70,6 +70,20 @@ Zulip's SCIM integration has the following limitations:
* **givenName** * **givenName**
* **familyName** * **familyName**
1. **Optional:** If you'd like to also sync [user role](/help/roles-and-permissions),
you can do it by by adding a custom attribute in Okta. Go to the **Profile Editor**,
click into the entry of the SCIM app you've just set up and **Add Attribute**.
Configure the following:
* **Data type**: `string`
* **Variable name**: `role`
* **External name**: `role`
* **External namespace**: `urn:ietf:params:scim:schemas:core:2.0:User`
With the attribute added, you will now be able to set it for your users directly
or configure an appropriate **Attribute mapping** in the app's **Provisioning**
section.
The valid values are: **owner**, **administrator**, **moderator**, **member**, **guest**.
1. Now that the integration is ready to manage Zulip user accounts, **assign** 1. Now that the integration is ready to manage Zulip user accounts, **assign**
users to the SCIM app. users to the SCIM app.
* When you assign a user, Okta will check if the account exists in your * When you assign a user, Okta will check if the account exists in your

View File

@ -372,7 +372,7 @@ def send_stream_events_for_role_update(
send_event_on_commit(user_profile.realm, event, [user_profile.id]) send_event_on_commit(user_profile.realm, event, [user_profile.id])
@transaction.atomic(durable=True) @transaction.atomic(savepoint=False)
def do_change_user_role( def do_change_user_role(
user_profile: UserProfile, value: int, *, acting_user: Optional[UserProfile] user_profile: UserProfile, value: int, *, acting_user: Optional[UserProfile]
) -> None: ) -> None:

View File

@ -11,7 +11,7 @@ from scim2_filter_parser.attr_paths import AttrPath
from zerver.actions.create_user import do_create_user, do_reactivate_user from zerver.actions.create_user import do_create_user, do_reactivate_user
from zerver.actions.user_settings import check_change_full_name, do_change_user_delivery_email from zerver.actions.user_settings import check_change_full_name, do_change_user_delivery_email
from zerver.actions.users import do_deactivate_user from zerver.actions.users import do_change_user_role, do_deactivate_user
from zerver.lib.email_validation import email_allowed_for_realm, validate_email_not_already_in_realm 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.request import RequestNotes
from zerver.lib.subdomains import get_subdomain from zerver.lib.subdomains import get_subdomain
@ -31,6 +31,15 @@ class ZulipSCIMUser(SCIMUser):
id_field = "id" id_field = "id"
ROLE_TYPE_TO_NAME = {
UserProfile.ROLE_REALM_OWNER: "owner",
UserProfile.ROLE_REALM_ADMINISTRATOR: "administrator",
UserProfile.ROLE_MODERATOR: "moderator",
UserProfile.ROLE_MEMBER: "member",
UserProfile.ROLE_GUEST: "guest",
}
ROLE_NAME_TO_TYPE = {v: k for k, v in ROLE_TYPE_TO_NAME.items()}
def __init__(self, obj: UserProfile, request: Optional[HttpRequest] = None) -> None: def __init__(self, obj: UserProfile, request: Optional[HttpRequest] = None) -> None:
# We keep the function signature from the superclass, but this actually # We keep the function signature from the superclass, but this actually
# shouldn't be called with request being None. # shouldn't be called with request being None.
@ -54,6 +63,7 @@ class ZulipSCIMUser(SCIMUser):
self._email_new_value: Optional[str] = None self._email_new_value: Optional[str] = None
self._is_active_new_value: Optional[bool] = None self._is_active_new_value: Optional[bool] = None
self._full_name_new_value: Optional[str] = None self._full_name_new_value: Optional[str] = None
self._role_new_value: Optional[int] = None
self._password_set_to: Optional[str] = None self._password_set_to: Optional[str] = None
def is_new_user(self) -> bool: def is_new_user(self) -> bool:
@ -105,6 +115,7 @@ class ZulipSCIMUser(SCIMUser):
"name": name, "name": name,
"displayName": self.display_name, "displayName": self.display_name,
"active": self.obj.is_active, "active": self.obj.is_active,
"role": self.ROLE_TYPE_TO_NAME[self.obj.role],
# meta is a property implemented in the superclass # meta is a property implemented in the superclass
# TODO: The upstream implementation uses `user_profile.date_joined` # TODO: The upstream implementation uses `user_profile.date_joined`
# as the value of the lastModified meta attribute, which is not # as the value of the lastModified meta attribute, which is not
@ -166,6 +177,11 @@ class ZulipSCIMUser(SCIMUser):
assert isinstance(active, bool) assert isinstance(active, bool)
self.change_is_active(active) self.change_is_active(active)
role_name = d.get("role")
if role_name:
assert isinstance(role_name, str)
self.change_role(role_name)
def change_delivery_email(self, new_value: str) -> None: def change_delivery_email(self, new_value: str) -> None:
# Note that the email_allowed_for_realm check that usually # Note that the email_allowed_for_realm check that usually
# appears adjacent to validate_email is present in save(). # appears adjacent to validate_email is present in save().
@ -181,6 +197,16 @@ class ZulipSCIMUser(SCIMUser):
if new_value != self.obj.is_active: if new_value != self.obj.is_active:
self._is_active_new_value = new_value self._is_active_new_value = new_value
def change_role(self, new_role_name: str) -> None:
try:
role = self.ROLE_NAME_TO_TYPE[new_role_name]
except KeyError:
raise scim_exceptions.BadRequestError(
f"Invalid role: {new_role_name}. Valid values are: {list(self.ROLE_NAME_TO_TYPE.keys())}"
)
if role != self.obj.role:
self._role_new_value = role
def handle_replace( def handle_replace(
self, self,
path: Optional[AttrPath], path: Optional[AttrPath],
@ -215,6 +241,9 @@ class ZulipSCIMUser(SCIMUser):
elif path.first_path == ("active", None, None): elif path.first_path == ("active", None, None):
assert isinstance(val, bool) assert isinstance(val, bool)
self.change_is_active(val) self.change_is_active(val)
elif path.first_path == ("role", None, None):
assert isinstance(val, str)
self.change_role(val)
else: else:
raise scim_exceptions.NotImplementedError("Not Implemented") raise scim_exceptions.NotImplementedError("Not Implemented")
@ -232,6 +261,7 @@ class ZulipSCIMUser(SCIMUser):
email_new_value = getattr(self, "_email_new_value", None) email_new_value = getattr(self, "_email_new_value", None)
is_active_new_value = getattr(self, "_is_active_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) full_name_new_value = getattr(self, "_full_name_new_value", None)
role_new_value = getattr(self, "_role_new_value", None)
password = getattr(self, "_password_set_to", None) password = getattr(self, "_password_set_to", None)
# Clean up the internal "pending change" state, now that we've # Clean up the internal "pending change" state, now that we've
@ -240,6 +270,7 @@ class ZulipSCIMUser(SCIMUser):
self._is_active_new_value = None self._is_active_new_value = None
self._full_name_new_value = None self._full_name_new_value = None
self._password_set_to = None self._password_set_to = None
self._role_new_value = None
if email_new_value: if email_new_value:
try: try:
@ -270,6 +301,7 @@ class ZulipSCIMUser(SCIMUser):
password, password,
realm, realm,
full_name_new_value, full_name_new_value,
role=role_new_value,
tos_version=UserProfile.TOS_VERSION_BEFORE_FIRST_LOGIN, tos_version=UserProfile.TOS_VERSION_BEFORE_FIRST_LOGIN,
acting_user=None, acting_user=None,
) )
@ -287,6 +319,9 @@ class ZulipSCIMUser(SCIMUser):
if email_new_value: if email_new_value:
do_change_user_delivery_email(self.obj, email_new_value) do_change_user_delivery_email(self.obj, email_new_value)
if role_new_value is not None:
do_change_user_role(self.obj, role_new_value, acting_user=None)
if is_active_new_value is not None and is_active_new_value: if is_active_new_value is not None and is_active_new_value:
do_reactivate_user(self.obj, acting_user=None) do_reactivate_user(self.obj, acting_user=None)
elif is_active_new_value is not None and not is_active_new_value: elif is_active_new_value is not None and not is_active_new_value:

View File

@ -7,6 +7,7 @@ import orjson
from django.conf import settings from django.conf import settings
from zerver.actions.user_settings import do_change_full_name from zerver.actions.user_settings import do_change_full_name
from zerver.lib.scim import ZulipSCIMUser
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.models import UserProfile, get_realm from zerver.models import UserProfile, get_realm
@ -33,6 +34,7 @@ class SCIMTestCase(ZulipTestCase):
"userName": user_profile.delivery_email, "userName": user_profile.delivery_email,
"name": {"formatted": user_profile.full_name}, "name": {"formatted": user_profile.full_name},
"displayName": user_profile.full_name, "displayName": user_profile.full_name,
"role": ZulipSCIMUser.ROLE_TYPE_TO_NAME[user_profile.role],
"active": True, "active": True,
"meta": { "meta": {
"resourceType": "User", "resourceType": "User",
@ -337,6 +339,49 @@ class TestSCIMUser(SCIMTestCase):
assert new_user is not None assert new_user is not None
self.assertEqual(new_user.delivery_email, "newuser@zulip.com") self.assertEqual(new_user.delivery_email, "newuser@zulip.com")
self.assertEqual(new_user.full_name, "New User") self.assertEqual(new_user.full_name, "New User")
self.assertEqual(new_user.role, UserProfile.ROLE_MEMBER)
expected_response_schema = self.generate_user_schema(new_user)
self.assertEqual(output_data, expected_response_schema)
def test_post_with_role(self) -> None:
# A payload for creating a new user with the specified account details, including
# specifying the role.
# Start with a payload with an invalid role value, to test error handling.
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,
"role": "wrongrole",
}
result = self.client_post(
"/scim/v2/Users", payload, content_type="application/json", **self.scim_headers()
)
self.assertEqual(
orjson.loads(result.content),
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"detail": "Invalid role: wrongrole. Valid values are: ['owner', 'administrator', 'moderator', 'member', 'guest']",
"status": 400,
},
)
# Now fix the role to make a valid request to create an administrator and proceed.
payload["role"] = "administrator"
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 = UserProfile.objects.last()
assert new_user is not None
self.assertEqual(new_user.delivery_email, "newuser@zulip.com")
self.assertEqual(new_user.role, UserProfile.ROLE_REALM_ADMINISTRATOR)
expected_response_schema = self.generate_user_schema(new_user) expected_response_schema = self.generate_user_schema(new_user)
self.assertEqual(output_data, expected_response_schema) self.assertEqual(output_data, expected_response_schema)
@ -562,6 +607,28 @@ class TestSCIMUser(SCIMTestCase):
result, f"['{cordelia.delivery_email} already has an account']" result, f"['{cordelia.delivery_email} already has an account']"
) )
def test_put_change_user_role(self) -> None:
hamlet = self.example_user("hamlet")
hamlet_email = hamlet.delivery_email
self.assertEqual(hamlet.role, UserProfile.ROLE_MEMBER)
# This payload changes hamlet's role to administrator.
payload = {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": hamlet.id,
"userName": hamlet_email,
"role": "administrator",
}
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.role, UserProfile.ROLE_REALM_ADMINISTRATOR)
output_data = orjson.loads(result.content)
expected_response_schema = self.generate_user_schema(hamlet)
self.assertEqual(output_data, expected_response_schema)
def test_put_deactivate_reactivate_user(self) -> None: def test_put_deactivate_reactivate_user(self) -> None:
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")
# This payload flips the active attribute to deactivate the user. # This payload flips the active attribute to deactivate the user.
@ -647,6 +714,20 @@ class TestSCIMUser(SCIMTestCase):
expected_response_schema = self.generate_user_schema(hamlet) expected_response_schema = self.generate_user_schema(hamlet)
self.assertEqual(output_data, expected_response_schema) self.assertEqual(output_data, expected_response_schema)
def test_patch_change_user_role(self) -> None:
hamlet = self.example_user("hamlet")
# Payload for a PATCH request to change hamlet's role to administrator.
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "role", "value": "administrator"}],
}
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.role, UserProfile.ROLE_REALM_ADMINISTRATOR)
def test_patch_deactivate_reactivate_user(self) -> None: def test_patch_deactivate_reactivate_user(self) -> None:
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")
# Payload for a PATCH request to deactivate the user. # Payload for a PATCH request to deactivate the user.