From 99ad60eaa33ef021fe64d10ded431b670cbf4568 Mon Sep 17 00:00:00 2001 From: Franco Forneron Date: Tue, 31 Oct 2023 16:09:39 -0300 Subject: [PATCH 1/7] add anonymize user action --- actions.yaml | 14 +++ docs/tutorial/getting-started.md | 2 +- src/charm.py | 34 ++++++ tests/integration/test_charm.py | 40 +++++++ tests/unit/test_anonymize_user_action.py | 135 +++++++++++++++++++++++ 5 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_anonymize_user_action.py diff --git a/actions.yaml b/actions.yaml index d9fc446a..d8601248 100644 --- a/actions.yaml +++ b/actions.yaml @@ -1,6 +1,20 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +anonymize-user: + description: | + Anonymizes an user in order to make it GDPR-erased. + properties: + username: + description: | + User name to be anonymized. + type: string + admin: + description: Whether the user is an admin user or not. + type: boolean + default: false + required: + - username reset-instance: description: | Set a new server_name before running this action. diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index 30dbf212..261e409b 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -31,7 +31,7 @@ Synapse requires connections to PostgreSQL. Deploy both charm applications. ### Deploy the charms: ``` juju deploy postgresql-k8s -juju deploy synapse-k8s +juju deploy synapse ``` Run `juju status` to see the current status of the deployment. Synapse diff --git a/src/charm.py b/src/charm.py index 5eebb9a4..fc8af732 100755 --- a/src/charm.py +++ b/src/charm.py @@ -87,6 +87,9 @@ def __init__(self, *args: typing.Any) -> None: self.framework.observe( self.on.promote_user_admin_action, self._on_promote_user_admin_action ) + self.framework.observe( + self.on.anonymize_user_action, self._on_anonymize_user_action + ) def replan_nginx(self) -> None: """Replan NGINX.""" @@ -286,6 +289,37 @@ def _on_promote_user_admin_action(self, event: ActionEvent) -> None: return event.set_results(results) + def _on_anonymize_user_action(self, event: ActionEvent) -> None: + """Promote user admin and report action result. + + Args: + event: Event triggering the promote user admin action. + """ + results = { + "anonymize-user": False, + } + container = self.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) + if not container.can_connect(): + event.fail("Failed to connect to the container") + return + try: + admin_access_token = self.get_admin_access_token() + if not admin_access_token: + event.fail("Failed to get admin access token") + return + username = event.params["username"] + admin = event.params["admin"] + server = self._charm_state.synapse_config.server_name + user = User(username=username, admin=admin) + synapse.deactivate_user( + user=user, server=server, admin_access_token=admin_access_token + ) + results["anonymize-user"] = True + except synapse.APIError as exc: + event.fail(str(exc)) + return + event.set_results(results) + if __name__ == "__main__": # pragma: nocover main(SynapseCharm) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 6658d6a4..b9d96932 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -426,3 +426,43 @@ async def test_promote_user_admin( timeout=5, ) assert res.status_code == 200 + +async def test_anonymize_user( + synapse_app: Application, + get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], +) -> None: + """ + arrange: build and deploy the Synapse charm, create an user, get the access token and assert + that the user is not an admin. + act: run action to promote user to admin. + assert: the Synapse application is active and the API request returns as expected. + """ + operator_username = "operator-new" + action_register_user: Action = await synapse_app.units[0].run_action( # type: ignore + "register-user", username=operator_username, admin=False + ) + await action_register_user.wait() + assert action_register_user.status == "completed" + password = action_register_user.results["user-password"] + synapse_ip = (await get_unit_ips(synapse_app.name))[0] + sess = requests.session() + res = sess.post( + f"http://{synapse_ip}:8080/_matrix/client/r0/login", + # same thing is done on fixture but we are creating a non-admin user here. + json={ # pylint: disable=duplicate-code + "identifier": {"type": "m.id.user", "user": operator_username}, + "password": password, + "type": "m.login.password", + }, + timeout=5, + ) + res.raise_for_status() + access_token = res.json()["access_token"] + authorization_token = f"Bearer {access_token}" + headers = {"Authorization": authorization_token} + + action_promote: Action = await synapse_app.units[0].run_action( # type: ignore + "anonymize-user", username=operator_username + ) + await action_promote.wait() + assert action_promote.status == "completed" \ No newline at end of file diff --git a/tests/unit/test_anonymize_user_action.py b/tests/unit/test_anonymize_user_action.py new file mode 100644 index 00000000..3442b9c0 --- /dev/null +++ b/tests/unit/test_anonymize_user_action.py @@ -0,0 +1,135 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Register user action unit tests.""" + +# Disabled to access _on_register_user_action +# pylint: disable=protected-access + +import unittest.mock +from secrets import token_hex + +import pytest +from ops.charm import ActionEvent +from ops.testing import Harness + +import synapse +from charm import SynapseCharm + + +def test_anonymize_user_action(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: + """ + arrange: start the Synapse charm, set Synapse container to be ready and set server_name. + act: run promote-user-admin action. + assert: event results are returned as expected. + """ + harness.begin_with_initial_hooks() + admin_access_token = token_hex(16) + anonymize_user_mock = unittest.mock.Mock() + monkeypatch.setattr( + SynapseCharm, + "get_admin_access_token", + unittest.mock.MagicMock(return_value=admin_access_token), + ) + monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock) + user = "username" + admin = True + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = { + "username": user, + "admin": admin, + } + + harness.charm._on_anonymize_user_action(event) + + assert event.set_results.call_count == 1 + event.set_results.assert_called_with({"anonymize-user": True}) + anonymize_user_mock.assert_called_with( + user=unittest.mock.ANY, server=unittest.mock.ANY, admin_access_token=admin_access_token + ) + + +def test_anonymize_user_api_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: + """ + arrange: start the Synapse charm, set Synapse container to be ready and set server_name. + act: run promote-user-admin action. + assert: event fails as expected. + """ + harness.begin_with_initial_hooks() + fail_message = "Some fail message" + synapse_api_error = synapse.APIError(fail_message) + anonymize_user_mock = unittest.mock.MagicMock(side_effect=synapse_api_error) + monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock) + admin_access_token = token_hex(16) + monkeypatch.setattr( + SynapseCharm, + "get_admin_access_token", + unittest.mock.MagicMock(return_value=admin_access_token), + ) + user = "username" + admin = True + event = unittest.mock.MagicMock(spec=ActionEvent) + event.params = { + "username": user, + "admin": admin, + } + + def event_store_failure(failure_message: str) -> None: + """Define a failure message for the event. + + Args: + failure_message: failure message content to be defined. + """ + event.fail_message = failure_message + + event.fail = event_store_failure + event.params = { + "username": user, + "admin": admin, + } + + harness.charm._on_anonymize_user_action(event) + + assert fail_message in event.fail_message + + +def test_anonymize_user_container_down(harness: Harness) -> None: + """ + arrange: start the Synapse charm, set Synapse container to be off. + act: run promote-user-admin action. + assert: event fails as expected. + """ + harness.begin_with_initial_hooks() + harness.set_can_connect(harness.model.unit.containers[synapse.SYNAPSE_CONTAINER_NAME], False) + event = unittest.mock.Mock() + + harness.charm._on_anonymize_user_action(event) + + assert event.set_results.call_count == 0 + assert event.fail.call_count == 1 + assert "Failed to connect to the container" == event.fail.call_args[0][0] + + +def test_anonymize_user_action_no_token( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + arrange: start the Synapse charm, set Synapse container to be ready and set server_name. + act: run promote-user-admin action. + assert: event fails as expected. + """ + harness.begin_with_initial_hooks() + anonymize_user_mock = unittest.mock.Mock() + monkeypatch.setattr( + SynapseCharm, + "get_admin_access_token", + unittest.mock.MagicMock(return_value=None), + ) + monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock) + event = unittest.mock.Mock() + + harness.charm._on_anonymize_user_action(event) + + assert event.set_results.call_count == 0 + assert event.fail.call_count == 1 + assert "Failed to get admin access token" == event.fail.call_args[0][0] From 3d39af9666de21d26dfc16d0cb44c7e1c09bb3b7 Mon Sep 17 00:00:00 2001 From: Franco Forneron Date: Tue, 31 Oct 2023 17:42:54 -0300 Subject: [PATCH 2/7] add anonymize user action --- src/charm.py | 4 +--- tests/integration/test_charm.py | 10 ++++------ tests/unit/test_anonymize_user_action.py | 8 +++----- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/charm.py b/src/charm.py index fc8af732..63be8353 100755 --- a/src/charm.py +++ b/src/charm.py @@ -87,9 +87,7 @@ def __init__(self, *args: typing.Any) -> None: self.framework.observe( self.on.promote_user_admin_action, self._on_promote_user_admin_action ) - self.framework.observe( - self.on.anonymize_user_action, self._on_anonymize_user_action - ) + self.framework.observe(self.on.anonymize_user_action, self._on_anonymize_user_action) def replan_nginx(self) -> None: """Replan NGINX.""" diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index b9d96932..b399ecb6 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -427,6 +427,7 @@ async def test_promote_user_admin( ) assert res.status_code == 200 + async def test_anonymize_user( synapse_app: Application, get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], @@ -457,12 +458,9 @@ async def test_anonymize_user( timeout=5, ) res.raise_for_status() - access_token = res.json()["access_token"] - authorization_token = f"Bearer {access_token}" - headers = {"Authorization": authorization_token} - action_promote: Action = await synapse_app.units[0].run_action( # type: ignore + action_anonymize: Action = await synapse_app.units[0].run_action( # type: ignore "anonymize-user", username=operator_username ) - await action_promote.wait() - assert action_promote.status == "completed" \ No newline at end of file + await action_anonymize.wait() + assert action_anonymize.status == "completed" diff --git a/tests/unit/test_anonymize_user_action.py b/tests/unit/test_anonymize_user_action.py index 3442b9c0..ce458a84 100644 --- a/tests/unit/test_anonymize_user_action.py +++ b/tests/unit/test_anonymize_user_action.py @@ -61,13 +61,13 @@ def test_anonymize_user_api_error(harness: Harness, monkeypatch: pytest.MonkeyPa anonymize_user_mock = unittest.mock.MagicMock(side_effect=synapse_api_error) monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock) admin_access_token = token_hex(16) + user = "username" + admin = True monkeypatch.setattr( SynapseCharm, "get_admin_access_token", unittest.mock.MagicMock(return_value=admin_access_token), ) - user = "username" - admin = True event = unittest.mock.MagicMock(spec=ActionEvent) event.params = { "username": user, @@ -110,9 +110,7 @@ def test_anonymize_user_container_down(harness: Harness) -> None: assert "Failed to connect to the container" == event.fail.call_args[0][0] -def test_anonymize_user_action_no_token( - harness: Harness, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_anonymize_user_action_no_token(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: """ arrange: start the Synapse charm, set Synapse container to be ready and set server_name. act: run promote-user-admin action. From cee68342502558c888cea7ed70ad13b9b0a307a3 Mon Sep 17 00:00:00 2001 From: Franco Forneron Date: Wed, 1 Nov 2023 13:01:08 -0300 Subject: [PATCH 3/7] address comments --- actions.yaml | 2 +- src/charm.py | 13 ++++--- tests/integration/test_charm.py | 45 ++++++++++++++++-------- tests/unit/test_anonymize_user_action.py | 12 +++---- 4 files changed, 45 insertions(+), 27 deletions(-) diff --git a/actions.yaml b/actions.yaml index d8601248..69535151 100644 --- a/actions.yaml +++ b/actions.yaml @@ -3,7 +3,7 @@ anonymize-user: description: | - Anonymizes an user in order to make it GDPR-erased. + Anonymizes an user in order to make it GDPR compliant. properties: username: description: | diff --git a/src/charm.py b/src/charm.py index 63be8353..7d8e1345 100755 --- a/src/charm.py +++ b/src/charm.py @@ -288,17 +288,17 @@ def _on_promote_user_admin_action(self, event: ActionEvent) -> None: event.set_results(results) def _on_anonymize_user_action(self, event: ActionEvent) -> None: - """Promote user admin and report action result. + """Anonymize user and report action result. Args: - event: Event triggering the promote user admin action. + event: Event triggering the anonymize user action. """ results = { "anonymize-user": False, } container = self.unit.get_container(synapse.SYNAPSE_CONTAINER_NAME) if not container.can_connect(): - event.fail("Failed to connect to the container") + event.fail("Container not yet ready. Try again later") return try: admin_access_token = self.get_admin_access_token() @@ -313,8 +313,11 @@ def _on_anonymize_user_action(self, event: ActionEvent) -> None: user=user, server=server, admin_access_token=admin_access_token ) results["anonymize-user"] = True - except synapse.APIError as exc: - event.fail(str(exc)) + except synapse.APIError: + event.fail( + "Action failed to anonymize the user. " + "Please check the user is correctly created and active." + ) return event.set_results(results) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index b399ecb6..7dd8586e 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -13,6 +13,7 @@ from juju.action import Action from juju.application import Application from juju.model import Model +from juju.unit import Unit from ops.model import ActiveStatus from pytest_operator.plugin import OpsTest from saml_test_helper import SamlK8sTestHelper @@ -435,32 +436,46 @@ async def test_anonymize_user( """ arrange: build and deploy the Synapse charm, create an user, get the access token and assert that the user is not an admin. - act: run action to promote user to admin. + act: run action to anonymize user. assert: the Synapse application is active and the API request returns as expected. """ operator_username = "operator-new" - action_register_user: Action = await synapse_app.units[0].run_action( # type: ignore + synapse_unit: Unit = next(iter(synapse_app.units)) + action_register_user: Action = await synapse_unit.run_action( "register-user", username=operator_username, admin=False ) await action_register_user.wait() assert action_register_user.status == "completed" password = action_register_user.results["user-password"] synapse_ip = (await get_unit_ips(synapse_app.name))[0] - sess = requests.session() - res = sess.post( - f"http://{synapse_ip}:8080/_matrix/client/r0/login", - # same thing is done on fixture but we are creating a non-admin user here. - json={ # pylint: disable=duplicate-code - "identifier": {"type": "m.id.user", "user": operator_username}, - "password": password, - "type": "m.login.password", - }, - timeout=5, - ) - res.raise_for_status() + with requests.session() as sess: + res = sess.post( + f"http://{synapse_ip}:8080/_matrix/client/r0/login", + # same thing is done on fixture but we are creating a non-admin user here. + json={ # pylint: disable=duplicate-code + "identifier": {"type": "m.id.user", "user": operator_username}, + "password": password, + "type": "m.login.password", + }, + timeout=5, + ) + res.raise_for_status() - action_anonymize: Action = await synapse_app.units[0].run_action( # type: ignore + action_anonymize: Action = await synapse_unit.run_action( "anonymize-user", username=operator_username ) await action_anonymize.wait() assert action_anonymize.status == "completed" + + with requests.session() as sess: + res = sess.post( + f"http://{synapse_ip}:8080/_matrix/client/r0/login", + # same thing is done on fixture but we are creating a non-admin user here. + json={ # pylint: disable=duplicate-code + "identifier": {"type": "m.id.user", "user": operator_username}, + "password": password, + "type": "m.login.password", + }, + timeout=5, + ) + assert res.status_code == 403 diff --git a/tests/unit/test_anonymize_user_action.py b/tests/unit/test_anonymize_user_action.py index ce458a84..57ae8ff9 100644 --- a/tests/unit/test_anonymize_user_action.py +++ b/tests/unit/test_anonymize_user_action.py @@ -20,7 +20,7 @@ def test_anonymize_user_action(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: """ arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run promote-user-admin action. + act: run anonymize-user action. assert: event results are returned as expected. """ harness.begin_with_initial_hooks() @@ -52,11 +52,11 @@ def test_anonymize_user_action(harness: Harness, monkeypatch: pytest.MonkeyPatch def test_anonymize_user_api_error(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: """ arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run promote-user-admin action. + act: run anonymize-user action. assert: event fails as expected. """ harness.begin_with_initial_hooks() - fail_message = "Some fail message" + fail_message = "Action failed to anonymize the user. Please check the user is correctly created and active." synapse_api_error = synapse.APIError(fail_message) anonymize_user_mock = unittest.mock.MagicMock(side_effect=synapse_api_error) monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock) @@ -96,7 +96,7 @@ def event_store_failure(failure_message: str) -> None: def test_anonymize_user_container_down(harness: Harness) -> None: """ arrange: start the Synapse charm, set Synapse container to be off. - act: run promote-user-admin action. + act: run anonymize-user action. assert: event fails as expected. """ harness.begin_with_initial_hooks() @@ -107,13 +107,13 @@ def test_anonymize_user_container_down(harness: Harness) -> None: assert event.set_results.call_count == 0 assert event.fail.call_count == 1 - assert "Failed to connect to the container" == event.fail.call_args[0][0] + assert "Container not yet ready. Try again later" == event.fail.call_args[0][0] def test_anonymize_user_action_no_token(harness: Harness, monkeypatch: pytest.MonkeyPatch) -> None: """ arrange: start the Synapse charm, set Synapse container to be ready and set server_name. - act: run promote-user-admin action. + act: run anonymize-user action. assert: event fails as expected. """ harness.begin_with_initial_hooks() From ae87815c3465c8092c5442133d94dcca3b3b23b0 Mon Sep 17 00:00:00 2001 From: Franco Forneron Date: Wed, 1 Nov 2023 15:02:27 -0300 Subject: [PATCH 4/7] fix linting --- tests/unit/test_anonymize_user_action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_anonymize_user_action.py b/tests/unit/test_anonymize_user_action.py index 57ae8ff9..87f4b683 100644 --- a/tests/unit/test_anonymize_user_action.py +++ b/tests/unit/test_anonymize_user_action.py @@ -56,7 +56,7 @@ def test_anonymize_user_api_error(harness: Harness, monkeypatch: pytest.MonkeyPa assert: event fails as expected. """ harness.begin_with_initial_hooks() - fail_message = "Action failed to anonymize the user. Please check the user is correctly created and active." + fail_message = "Failed to anonymize the user. Check if the user is created and active." synapse_api_error = synapse.APIError(fail_message) anonymize_user_mock = unittest.mock.MagicMock(side_effect=synapse_api_error) monkeypatch.setattr("synapse.deactivate_user", anonymize_user_mock) From 0875b8682df72000b5499e978d4679cac883e567 Mon Sep 17 00:00:00 2001 From: Franco Forneron Date: Wed, 1 Nov 2023 15:15:36 -0300 Subject: [PATCH 5/7] fix linting --- src/charm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/charm.py b/src/charm.py index 7d8e1345..bcfeb39f 100755 --- a/src/charm.py +++ b/src/charm.py @@ -315,8 +315,8 @@ def _on_anonymize_user_action(self, event: ActionEvent) -> None: results["anonymize-user"] = True except synapse.APIError: event.fail( - "Action failed to anonymize the user. " - "Please check the user is correctly created and active." + "Failed to anonymize the user. " + "Check if the user is created and active." ) return event.set_results(results) From e15bff9966122f988c23106703141be3332d1f09 Mon Sep 17 00:00:00 2001 From: Franco Forneron Date: Wed, 1 Nov 2023 15:17:59 -0300 Subject: [PATCH 6/7] fix linting --- src/charm.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/charm.py b/src/charm.py index bcfeb39f..720865cb 100755 --- a/src/charm.py +++ b/src/charm.py @@ -314,10 +314,7 @@ def _on_anonymize_user_action(self, event: ActionEvent) -> None: ) results["anonymize-user"] = True except synapse.APIError: - event.fail( - "Failed to anonymize the user. " - "Check if the user is created and active." - ) + event.fail("Failed to anonymize the user. Check if the user is created and active.") return event.set_results(results) From 426718dd82c141d6ce22d6f30902d1a870d169cb Mon Sep 17 00:00:00 2001 From: Franco Forneron Date: Fri, 3 Nov 2023 09:35:04 -0300 Subject: [PATCH 7/7] address comments --- actions.yaml | 4 ---- src/charm.py | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/actions.yaml b/actions.yaml index 69535151..d77409ca 100644 --- a/actions.yaml +++ b/actions.yaml @@ -9,10 +9,6 @@ anonymize-user: description: | User name to be anonymized. type: string - admin: - description: Whether the user is an admin user or not. - type: boolean - default: false required: - username reset-instance: diff --git a/src/charm.py b/src/charm.py index 720865cb..1141e5ac 100755 --- a/src/charm.py +++ b/src/charm.py @@ -306,9 +306,8 @@ def _on_anonymize_user_action(self, event: ActionEvent) -> None: event.fail("Failed to get admin access token") return username = event.params["username"] - admin = event.params["admin"] server = self._charm_state.synapse_config.server_name - user = User(username=username, admin=admin) + user = User(username=username, admin=False) synapse.deactivate_user( user=user, server=server, admin_access_token=admin_access_token )