mirror of https://github.com/zulip/zulip.git
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:
parent
3c89fe222a
commit
3e15ea3f3f
14
help/scim.md
14
help/scim.md
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in New Issue