diff --git a/changelog.d/19554.feature b/changelog.d/19554.feature new file mode 100644 index 00000000000..d30d3da1f6c --- /dev/null +++ b/changelog.d/19554.feature @@ -0,0 +1 @@ +When Matrix Authentication Service (MAS) integration is enabled, allow MAS to set the user locked status in Synapse. \ No newline at end of file diff --git a/synapse/rest/synapse/mas/users.py b/synapse/rest/synapse/mas/users.py index 55c73375551..01db41bcfac 100644 --- a/synapse/rest/synapse/mas/users.py +++ b/synapse/rest/synapse/mas/users.py @@ -112,6 +112,18 @@ class PostBody(RequestBodyModel): unset_emails: StrictBool = False set_emails: list[StrictStr] | None = None + locked: StrictBool | None = None + """ + True to lock user. False to unlock. None to leave the same. + + This is mostly for informational purposes; if the user's account is locked in MAS + but not in Synapse, the token introspection response will prevent them from using + their account. + + However, having a local copy of the locked state in Synapse is useful for excluding + the user from the user directory. + """ + @model_validator(mode="before") @classmethod def validate_exclusive(cls, values: Any) -> Any: @@ -206,6 +218,9 @@ async def _async_render_POST( validated_at=current_time, ) + if body.locked is not None: + await self.store.set_user_locked_status(user_id.to_string(), body.locked) + if body.unset_avatar_url: await self.profile_handler.set_avatar_url( target_user=user_id, diff --git a/tests/rest/synapse/mas/test_users.py b/tests/rest/synapse/mas/test_users.py index f0f26a939c9..6f44761bb8b 100644 --- a/tests/rest/synapse/mas/test_users.py +++ b/tests/rest/synapse/mas/test_users.py @@ -362,6 +362,64 @@ def test_provision_user_invalid_json_types(self) -> None: ) self.assertEqual(channel.code, 400, f"Should fail for content: {content}") + def test_lock_and_unlock(self) -> None: + store = self.hs.get_datastores().main + + # Create a user in the locked state + alice = UserID("alice", "test") + channel = self.make_request( + "POST", + "/_synapse/mas/provision_user", + shorthand=False, + access_token=self.SHARED_SECRET, + content={ + "localpart": alice.localpart, + "locked": True, + }, + ) + # This created the user, hence the 201 status code + self.assertEqual(channel.code, 201, channel.json_body) + self.assertEqual(channel.json_body, {}) + self.assertTrue( + self.get_success(store.get_user_locked_status(alice.to_string())) + ) + + # Then transition from locked to unlocked + channel = self.make_request( + "POST", + "/_synapse/mas/provision_user", + shorthand=False, + access_token=self.SHARED_SECRET, + content={ + "localpart": alice.localpart, + "locked": False, + }, + ) + # This updated the user, hence the 200 status code + self.assertEqual(channel.code, 200, channel.json_body) + self.assertEqual(channel.json_body, {}) + self.assertFalse( + self.get_success(store.get_user_locked_status(alice.to_string())) + ) + + # And back from unlocked to locked + channel = self.make_request( + "POST", + "/_synapse/mas/provision_user", + shorthand=False, + access_token=self.SHARED_SECRET, + content={ + "localpart": alice.localpart, + "locked": True, + }, + ) + # This updated the user, hence the 200 status code + self.assertEqual(channel.code, 200, channel.json_body) + self.assertEqual(channel.json_body, {}) + self.assertTrue( + self.get_success(store.get_user_locked_status(alice.to_string())) + ) + @skip_unless(HAS_AUTHLIB, "requires authlib") class MasIsLocalpartAvailableResource(BaseTestCase):