diff --git a/changelog.d/10598.feature b/changelog.d/10598.feature
new file mode 100644
index 0000000000..92c159118b
--- /dev/null
+++ b/changelog.d/10598.feature
@@ -0,0 +1 @@
+Allow editing a user's `external_ids` via the "Edit User" admin API. Contributed by @dklimpel.
\ No newline at end of file
diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md
index 4b5dd4685a..6a9335d6ec 100644
--- a/docs/admin_api/user_admin_api.md
+++ b/docs/admin_api/user_admin_api.md
@@ -81,6 +81,16 @@ with a body of:
"address": "<user_mail_2>"
}
],
+ "external_ids": [
+ {
+ "auth_provider": "<provider1>",
+ "external_id": "<user_id_provider_1>"
+ },
+ {
+ "auth_provider": "<provider2>",
+ "external_id": "<user_id_provider_2>"
+ }
+ ],
"avatar_url": "<avatar_url>",
"admin": false,
"deactivated": false
@@ -90,26 +100,34 @@ with a body of:
To use it, you will need to authenticate by providing an `access_token` for a
server admin: [Admin API](../usage/administration/admin_api)
+Returns HTTP status code:
+- `201` - When a new user object was created.
+- `200` - When a user was modified.
+
URL parameters:
- `user_id`: fully-qualified user id: for example, `@user:server.com`.
Body parameters:
-- `password`, optional. If provided, the user's password is updated and all
+- `password` - string, optional. If provided, the user's password is updated and all
devices are logged out.
-
-- `displayname`, optional, defaults to the value of `user_id`.
-
-- `threepids`, optional, allows setting the third-party IDs (email, msisdn)
+- `displayname` - string, optional, defaults to the value of `user_id`.
+- `threepids` - array, optional, allows setting the third-party IDs (email, msisdn)
+ - `medium` - string. Kind of third-party ID, either `email` or `msisdn`.
+ - `address` - string. Value of third-party ID.
belonging to a user.
-
-- `avatar_url`, optional, must be a
+- `external_ids` - array, optional. Allow setting the identifier of the external identity
+ provider for SSO (Single sign-on). Details in
+ [Sample Configuration File](../usage/configuration/homeserver_sample_config.html)
+ section `sso` and `oidc_providers`.
+ - `auth_provider` - string. ID of the external identity provider. Value of `idp_id`
+ in homeserver configuration.
+ - `external_id` - string, user ID in the external identity provider.
+- `avatar_url` - string, optional, must be a
[MXC URI](https://matrix.org/docs/spec/client_server/r0.6.0#matrix-content-mxc-uris).
-
-- `admin`, optional, defaults to `false`.
-
-- `deactivated`, optional. If unspecified, deactivation state will be left
+- `admin` - bool, optional, defaults to `false`.
+- `deactivated` - bool, optional. If unspecified, deactivation state will be left
unchanged on existing accounts and set to `false` for new accounts.
A user cannot be erased by deactivating with this API. For details on
deactivating users see [Deactivate Account](#deactivate-account).
diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py
index 41f21ba118..c885fd77ab 100644
--- a/synapse/rest/admin/users.py
+++ b/synapse/rest/admin/users.py
@@ -196,20 +196,57 @@ class UserRestServletV2(RestServlet):
user = await self.admin_handler.get_user(target_user)
user_id = target_user.to_string()
+ # check for required parameters for each threepid
+ threepids = body.get("threepids")
+ if threepids is not None:
+ for threepid in threepids:
+ assert_params_in_dict(threepid, ["medium", "address"])
+
+ # check for required parameters for each external_id
+ external_ids = body.get("external_ids")
+ if external_ids is not None:
+ for external_id in external_ids:
+ assert_params_in_dict(external_id, ["auth_provider", "external_id"])
+
+ user_type = body.get("user_type", None)
+ if user_type is not None and user_type not in UserTypes.ALL_USER_TYPES:
+ raise SynapseError(400, "Invalid user type")
+
+ set_admin_to = body.get("admin", False)
+ if not isinstance(set_admin_to, bool):
+ raise SynapseError(
+ HTTPStatus.BAD_REQUEST,
+ "Param 'admin' must be a boolean, if given",
+ Codes.BAD_JSON,
+ )
+
+ password = body.get("password", None)
+ if password is not None:
+ if not isinstance(password, str) or len(password) > 512:
+ raise SynapseError(400, "Invalid password")
+
+ deactivate = body.get("deactivated", False)
+ if not isinstance(deactivate, bool):
+ raise SynapseError(400, "'deactivated' parameter is not of type boolean")
+
+ # convert into List[Tuple[str, str]]
+ if external_ids is not None:
+ new_external_ids = []
+ for external_id in external_ids:
+ new_external_ids.append(
+ (external_id["auth_provider"], external_id["external_id"])
+ )
+
if user: # modify user
if "displayname" in body:
await self.profile_handler.set_displayname(
target_user, requester, body["displayname"], True
)
- if "threepids" in body:
- # check for required parameters for each threepid
- for threepid in body["threepids"]:
- assert_params_in_dict(threepid, ["medium", "address"])
-
+ if threepids is not None:
# remove old threepids from user
- threepids = await self.store.user_get_threepids(user_id)
- for threepid in threepids:
+ old_threepids = await self.store.user_get_threepids(user_id)
+ for threepid in old_threepids:
try:
await self.auth_handler.delete_threepid(
user_id, threepid["medium"], threepid["address"], None
@@ -220,18 +257,39 @@ class UserRestServletV2(RestServlet):
# add new threepids to user
current_time = self.hs.get_clock().time_msec()
- for threepid in body["threepids"]:
+ for threepid in threepids:
await self.auth_handler.add_threepid(
user_id, threepid["medium"], threepid["address"], current_time
)
- if "avatar_url" in body and type(body["avatar_url"]) == str:
+ if external_ids is not None:
+ # get changed external_ids (added and removed)
+ cur_external_ids = await self.store.get_external_ids_by_user(user_id)
+ add_external_ids = set(new_external_ids) - set(cur_external_ids)
+ del_external_ids = set(cur_external_ids) - set(new_external_ids)
+
+ # remove old external_ids
+ for auth_provider, external_id in del_external_ids:
+ await self.store.remove_user_external_id(
+ auth_provider,
+ external_id,
+ user_id,
+ )
+
+ # add new external_ids
+ for auth_provider, external_id in add_external_ids:
+ await self.store.record_user_external_id(
+ auth_provider,
+ external_id,
+ user_id,
+ )
+
+ if "avatar_url" in body and isinstance(body["avatar_url"], str):
await self.profile_handler.set_avatar_url(
target_user, requester, body["avatar_url"], True
)
if "admin" in body:
- set_admin_to = bool(body["admin"])
if set_admin_to != user["admin"]:
auth_user = requester.user
if target_user == auth_user and not set_admin_to:
@@ -239,29 +297,18 @@ class UserRestServletV2(RestServlet):
await self.store.set_server_admin(target_user, set_admin_to)
- if "password" in body:
- if not isinstance(body["password"], str) or len(body["password"]) > 512:
- raise SynapseError(400, "Invalid password")
- else:
- new_password = body["password"]
- logout_devices = True
-
- new_password_hash = await self.auth_handler.hash(new_password)
-
- await self.set_password_handler.set_password(
- target_user.to_string(),
- new_password_hash,
- logout_devices,
- requester,
- )
+ if password is not None:
+ logout_devices = True
+ new_password_hash = await self.auth_handler.hash(password)
+
+ await self.set_password_handler.set_password(
+ target_user.to_string(),
+ new_password_hash,
+ logout_devices,
+ requester,
+ )
if "deactivated" in body:
- deactivate = body["deactivated"]
- if not isinstance(deactivate, bool):
- raise SynapseError(
- 400, "'deactivated' parameter is not of type boolean"
- )
-
if deactivate and not user["deactivated"]:
await self.deactivate_account_handler.deactivate_account(
target_user.to_string(), False, requester, by_admin=True
@@ -285,36 +332,24 @@ class UserRestServletV2(RestServlet):
return 200, user
else: # create user
- password = body.get("password")
+ displayname = body.get("displayname", None)
+
password_hash = None
if password is not None:
- if not isinstance(password, str) or len(password) > 512:
- raise SynapseError(400, "Invalid password")
password_hash = await self.auth_handler.hash(password)
- admin = body.get("admin", None)
- user_type = body.get("user_type", None)
- displayname = body.get("displayname", None)
-
- if user_type is not None and user_type not in UserTypes.ALL_USER_TYPES:
- raise SynapseError(400, "Invalid user type")
-
user_id = await self.registration_handler.register_user(
localpart=target_user.localpart,
password_hash=password_hash,
- admin=bool(admin),
+ admin=set_admin_to,
default_display_name=displayname,
user_type=user_type,
by_admin=True,
)
- if "threepids" in body:
- # check for required parameters for each threepid
- for threepid in body["threepids"]:
- assert_params_in_dict(threepid, ["medium", "address"])
-
+ if threepids is not None:
current_time = self.hs.get_clock().time_msec()
- for threepid in body["threepids"]:
+ for threepid in threepids:
await self.auth_handler.add_threepid(
user_id, threepid["medium"], threepid["address"], current_time
)
@@ -334,6 +369,14 @@ class UserRestServletV2(RestServlet):
data={},
)
+ if external_ids is not None:
+ for auth_provider, external_id in new_external_ids:
+ await self.store.record_user_external_id(
+ auth_provider,
+ external_id,
+ user_id,
+ )
+
if "avatar_url" in body and isinstance(body["avatar_url"], str):
await self.profile_handler.set_avatar_url(
target_user, requester, body["avatar_url"], True
diff --git a/synapse/storage/databases/main/registration.py b/synapse/storage/databases/main/registration.py
index 14670c2881..c67bea81c6 100644
--- a/synapse/storage/databases/main/registration.py
+++ b/synapse/storage/databases/main/registration.py
@@ -599,6 +599,28 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
desc="record_user_external_id",
)
+ async def remove_user_external_id(
+ self, auth_provider: str, external_id: str, user_id: str
+ ) -> None:
+ """Remove a mapping from an external user id to a mxid
+
+ If the mapping is not found, this method does nothing.
+
+ Args:
+ auth_provider: identifier for the remote auth provider
+ external_id: id on that system
+ user_id: complete mxid that it is mapped to
+ """
+ await self.db_pool.simple_delete(
+ table="user_external_ids",
+ keyvalues={
+ "auth_provider": auth_provider,
+ "external_id": external_id,
+ "user_id": user_id,
+ },
+ desc="remove_user_external_id",
+ )
+
async def get_user_by_external_id(
self, auth_provider: str, external_id: str
) -> Optional[str]:
diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py
index 13fab5579b..a736ec4754 100644
--- a/tests/rest/admin/test_user.py
+++ b/tests/rest/admin/test_user.py
@@ -1240,56 +1240,114 @@ class UserRestTestCase(unittest.HomeserverTestCase):
self.assertEqual(404, channel.code, msg=channel.json_body)
self.assertEqual("M_NOT_FOUND", channel.json_body["errcode"])
- def test_get_user(self):
+ def test_invalid_parameter(self):
"""
- Test a simple get of a user.
+ If parameters are invalid, an error is returned.
"""
+
+ # admin not bool
channel = self.make_request(
- "GET",
+ "PUT",
self.url_other_user,
access_token=self.admin_user_tok,
+ content={"admin": "not_bool"},
)
+ self.assertEqual(400, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.BAD_JSON, channel.json_body["errcode"])
- self.assertEqual(200, channel.code, msg=channel.json_body)
- self.assertEqual("@user:test", channel.json_body["name"])
- self.assertEqual("User", channel.json_body["displayname"])
- self._check_fields(channel.json_body)
+ # deactivated not bool
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={"deactivated": "not_bool"},
+ )
+ self.assertEqual(400, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.UNKNOWN, channel.json_body["errcode"])
- def test_get_user_with_sso(self):
- """
- Test get a user with SSO details.
- """
- self.get_success(
- self.store.record_user_external_id(
- "auth_provider1", "external_id1", self.other_user
- )
+ # password not str
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={"password": True},
)
- self.get_success(
- self.store.record_user_external_id(
- "auth_provider2", "external_id2", self.other_user
- )
+ self.assertEqual(400, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.UNKNOWN, channel.json_body["errcode"])
+
+ # password not length
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={"password": "x" * 513},
)
+ self.assertEqual(400, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.UNKNOWN, channel.json_body["errcode"])
+ # user_type not valid
channel = self.make_request(
- "GET",
+ "PUT",
self.url_other_user,
access_token=self.admin_user_tok,
+ content={"user_type": "new type"},
)
+ self.assertEqual(400, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.UNKNOWN, channel.json_body["errcode"])
- self.assertEqual(200, channel.code, msg=channel.json_body)
- self.assertEqual("@user:test", channel.json_body["name"])
- self.assertEqual(
- "external_id1", channel.json_body["external_ids"][0]["external_id"]
+ # external_ids not valid
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={
+ "external_ids": {"auth_provider": "prov", "wrong_external_id": "id"}
+ },
)
- self.assertEqual(
- "auth_provider1", channel.json_body["external_ids"][0]["auth_provider"]
+ self.assertEqual(400, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.MISSING_PARAM, channel.json_body["errcode"])
+
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={"external_ids": {"external_id": "id"}},
)
- self.assertEqual(
- "external_id2", channel.json_body["external_ids"][1]["external_id"]
+ self.assertEqual(400, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.MISSING_PARAM, channel.json_body["errcode"])
+
+ # threepids not valid
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={"threepids": {"medium": "email", "wrong_address": "id"}},
)
- self.assertEqual(
- "auth_provider2", channel.json_body["external_ids"][1]["auth_provider"]
+ self.assertEqual(400, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.MISSING_PARAM, channel.json_body["errcode"])
+
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={"threepids": {"address": "value"}},
)
+ self.assertEqual(400, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.MISSING_PARAM, channel.json_body["errcode"])
+
+ def test_get_user(self):
+ """
+ Test a simple get of a user.
+ """
+ channel = self.make_request(
+ "GET",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual("@user:test", channel.json_body["name"])
+ self.assertEqual("User", channel.json_body["displayname"])
self._check_fields(channel.json_body)
def test_create_server_admin(self):
@@ -1353,6 +1411,12 @@ class UserRestTestCase(unittest.HomeserverTestCase):
"admin": False,
"displayname": "Bob's name",
"threepids": [{"medium": "email", "address": "bob@bob.bob"}],
+ "external_ids": [
+ {
+ "external_id": "external_id1",
+ "auth_provider": "auth_provider1",
+ },
+ ],
"avatar_url": "mxc://fibble/wibble",
}
@@ -1368,6 +1432,12 @@ class UserRestTestCase(unittest.HomeserverTestCase):
self.assertEqual("Bob's name", channel.json_body["displayname"])
self.assertEqual("email", channel.json_body["threepids"][0]["medium"])
self.assertEqual("bob@bob.bob", channel.json_body["threepids"][0]["address"])
+ self.assertEqual(
+ "external_id1", channel.json_body["external_ids"][0]["external_id"]
+ )
+ self.assertEqual(
+ "auth_provider1", channel.json_body["external_ids"][0]["auth_provider"]
+ )
self.assertFalse(channel.json_body["admin"])
self.assertEqual("mxc://fibble/wibble", channel.json_body["avatar_url"])
self._check_fields(channel.json_body)
@@ -1632,6 +1702,103 @@ class UserRestTestCase(unittest.HomeserverTestCase):
self.assertEqual("email", channel.json_body["threepids"][0]["medium"])
self.assertEqual("bob3@bob.bob", channel.json_body["threepids"][0]["address"])
+ def test_set_external_id(self):
+ """
+ Test setting external id for an other user.
+ """
+
+ # Add two external_ids
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={
+ "external_ids": [
+ {
+ "external_id": "external_id1",
+ "auth_provider": "auth_provider1",
+ },
+ {
+ "external_id": "external_id2",
+ "auth_provider": "auth_provider2",
+ },
+ ]
+ },
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual("@user:test", channel.json_body["name"])
+ self.assertEqual(2, len(channel.json_body["external_ids"]))
+ # result does not always have the same sort order, therefore it becomes sorted
+ self.assertEqual(
+ sorted(channel.json_body["external_ids"], key=lambda k: k["auth_provider"]),
+ [
+ {"auth_provider": "auth_provider1", "external_id": "external_id1"},
+ {"auth_provider": "auth_provider2", "external_id": "external_id2"},
+ ],
+ )
+ self._check_fields(channel.json_body)
+
+ # Set a new and remove an external_id
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={
+ "external_ids": [
+ {
+ "external_id": "external_id2",
+ "auth_provider": "auth_provider2",
+ },
+ {
+ "external_id": "external_id3",
+ "auth_provider": "auth_provider3",
+ },
+ ]
+ },
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual("@user:test", channel.json_body["name"])
+ self.assertEqual(2, len(channel.json_body["external_ids"]))
+ self.assertEqual(
+ channel.json_body["external_ids"],
+ [
+ {"auth_provider": "auth_provider2", "external_id": "external_id2"},
+ {"auth_provider": "auth_provider3", "external_id": "external_id3"},
+ ],
+ )
+ self._check_fields(channel.json_body)
+
+ # Get user
+ channel = self.make_request(
+ "GET",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual("@user:test", channel.json_body["name"])
+ self.assertEqual(
+ channel.json_body["external_ids"],
+ [
+ {"auth_provider": "auth_provider2", "external_id": "external_id2"},
+ {"auth_provider": "auth_provider3", "external_id": "external_id3"},
+ ],
+ )
+ self._check_fields(channel.json_body)
+
+ # Remove external_ids
+ channel = self.make_request(
+ "PUT",
+ self.url_other_user,
+ access_token=self.admin_user_tok,
+ content={"external_ids": []},
+ )
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual("@user:test", channel.json_body["name"])
+ self.assertEqual(0, len(channel.json_body["external_ids"]))
+
def test_deactivate_user(self):
"""
Test deactivating another user.
|