Skip to content

Commit

Permalink
Merge branch 'main' into ISD-1264-add-federation-domain-whitelist
Browse files Browse the repository at this point in the history
  • Loading branch information
amandahla authored Nov 3, 2023
2 parents 46e135b + 66fa641 commit 697b897
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 8 deletions.
10 changes: 10 additions & 0 deletions actions.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.

anonymize-user:
description: |
Anonymizes an user in order to make it GDPR compliant.
properties:
username:
description: |
User name to be anonymized.
type: string
required:
- username
reset-instance:
description: |
Set a new server_name before running this action.
Expand Down
2 changes: 1 addition & 1 deletion docs/tutorial/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 27 additions & 7 deletions lib/charms/data_platform_libs/v0/data_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ def _on_topic_requested(self, event: TopicRequestedEvent):

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 22
LIBPATCH = 23

PYDEPS = ["ops>=2.0.0"]

Expand Down Expand Up @@ -807,6 +807,9 @@ def _fetch_relation_data_without_secrets(
This is used typically when the Provides side wants to read the Requires side's data,
or when the Requires side may want to read its own data.
"""
if app not in relation.data or not relation.data[app]:
return {}

if fields:
return {k: relation.data[app][k] for k in fields if k in relation.data[app]}
else:
Expand All @@ -830,6 +833,9 @@ def _fetch_relation_data_with_secrets(
normal_fields = []

if not fields:
if app not in relation.data or not relation.data[app]:
return {}

all_fields = list(relation.data[app].keys())
normal_fields = [field for field in all_fields if not self._is_secret_field(field)]

Expand All @@ -853,8 +859,11 @@ def _fetch_relation_data_with_secrets(

def _update_relation_data_without_secrets(
self, app: Application, relation: Relation, data: Dict[str, str]
):
) -> None:
"""Updating databag contents when no secrets are involved."""
if app not in relation.data or relation.data[app] is None:
return

if any(self._is_secret_field(key) for key in data.keys()):
raise SecretsIllegalUpdateError("Can't update secret {key}.")

Expand All @@ -865,8 +874,19 @@ def _delete_relation_data_without_secrets(
self, app: Application, relation: Relation, fields: List[str]
) -> None:
"""Remove databag fields 'fields' from Relation."""
if app not in relation.data or not relation.data[app]:
return

for field in fields:
relation.data[app].pop(field)
try:
relation.data[app].pop(field)
except KeyError:
logger.debug(
"Non-existing field was attempted to be removed from the databag %s, %s",
str(relation.id),
str(field),
)
pass

# Public interface methods
# Handling Relation Fields seamlessly, regardless if in databag or a Juju Secret
Expand All @@ -880,9 +900,6 @@ def get_relation(self, relation_name, relation_id) -> Relation:
"Relation %s %s couldn't be retrieved", relation_name, relation_id
)

if not relation.app:
raise DataInterfacesError("Relation's application missing")

return relation

def fetch_relation_data(
Expand Down Expand Up @@ -1089,7 +1106,10 @@ def _delete_relation_secret(
# Remove secret from the relation if it's fully gone
if not new_content:
field = self._generate_secret_field_name(group)
relation.data[self.local_app].pop(field)
try:
relation.data[self.local_app].pop(field)
except KeyError:
pass

# Return the content that was removed
return True
Expand Down
31 changes: 31 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +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)

def replan_nginx(self) -> None:
"""Replan NGINX."""
Expand Down Expand Up @@ -286,6 +287,36 @@ def _on_promote_user_admin_action(self, event: ActionEvent) -> None:
return
event.set_results(results)

def _on_anonymize_user_action(self, event: ActionEvent) -> None:
"""Anonymize user and report action result.
Args:
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("Container not yet ready. Try again later")
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"]
server = self._charm_state.synapse_config.server_name
user = User(username=username, admin=False)
synapse.deactivate_user(
user=user, server=server, admin_access_token=admin_access_token
)
results["anonymize-user"] = True
except synapse.APIError:
event.fail("Failed to anonymize the user. Check if the user is created and active.")
return
event.set_results(results)


if __name__ == "__main__": # pragma: nocover
main(SynapseCharm)
53 changes: 53 additions & 0 deletions tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -426,3 +427,55 @@ 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 anonymize user.
assert: the Synapse application is active and the API request returns as expected.
"""
operator_username = "operator-new"
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]
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_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
133 changes: 133 additions & 0 deletions tests/unit/test_anonymize_user_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# 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 anonymize-user 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 anonymize-user action.
assert: event fails as expected.
"""
harness.begin_with_initial_hooks()
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)
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),
)
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 anonymize-user 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 "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 anonymize-user 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]

0 comments on commit 697b897

Please sign in to comment.