From 3aa948c50c1f0b1d6e87ea7f222cc155dea77e3b Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Mon, 16 Mar 2026 18:27:54 +0000 Subject: [PATCH] When Matrix Authentication Service (MAS) integration is enabled, allow MAS to set the user locked status in Synapse. (#19554) Companion PR: https://github.com/element-hq/matrix-authentication-service/pull/5550 to 1) send this flag and 2) provision users proactively when their lock status changes. --- Currently Synapse and MAS have two independent user lock implementations. This PR makes it so that MAS can push its lock status to Synapse when 'provisioning' the user. Having the lock status in Synapse is useful for removing users from the user directory when they are locked. There is otherwise no authentication requirement to have it in Synapse; the enforcement is done by MAS at token introspection time. --------- Signed-off-by: Olivier 'reivilibre --- changelog.d/19554.feature | 1 + synapse/rest/synapse/mas/users.py | 15 +++++++ tests/rest/synapse/mas/test_users.py | 58 ++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 changelog.d/19554.feature diff --git a/changelog.d/19554.feature b/changelog.d/19554.feature new file mode 100644 index 0000000000..d30d3da1f6 --- /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 55c7337555..01db41bcfa 100644 --- a/synapse/rest/synapse/mas/users.py +++ b/synapse/rest/synapse/mas/users.py @@ -112,6 +112,18 @@ class MasProvisionUserResource(MasBaseResource): 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 @@ class MasProvisionUserResource(MasBaseResource): 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 f0f26a939c..6f44761bb8 100644 --- a/tests/rest/synapse/mas/test_users.py +++ b/tests/rest/synapse/mas/test_users.py @@ -362,6 +362,64 @@ class MasProvisionUserResource(BaseTestCase): ) 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):