diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cdf0db6df..319a816cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,13 @@ The types of changes are: ## [Unreleased](https://github.com/ethyca/fides/compare/2.40.0...main) ### Added +- Added erasure support for Alchemer integration [#4925](https://github.com/ethyca/fides/pull/4925) + +### Developer Experience +- Upgrade to React 18 and Chakra 2, including other dependencies [#5036](https://github.com/ethyca/fides/pull/5036) - Added support for "output templates" in read SaaS requests [#5054](https://github.com/ethyca/fides/pull/5054) + ### Changed - Updated the sample dataset for the Amplitude integration [#5063](https://github.com/ethyca/fides/pull/5063) - Messaging page now shows a notice if you have properties without any templates [#5077](https://github.com/ethyca/fides/pull/5077) diff --git a/data/saas/config/alchemer_config.yml b/data/saas/config/alchemer_config.yml new file mode 100644 index 0000000000..971c690ae1 --- /dev/null +++ b/data/saas/config/alchemer_config.yml @@ -0,0 +1,46 @@ +saas_config: + fides_key: + name: Alchemer + type: alchemer + description: A sample schema representing the Alchemer erasure only integration for Fides + user_guide: https://docs.ethyca.com/user-guides/integrations/saas-integrations/alchemer + version: 0.1.0 + + connector_params: + - name: domain + label: Domain + description: The API domain for Alchemer. Default api.alchemer.com + default_value: api.alchemer.com + - name: api_key + label: API key + description: The API key for Alchemer + sensitive: True + - name: api_key_secret + label: API key secret + description: The API key secret for Alchemer + sensitive: True + + client_config: + protocol: https + host: + authentication: + strategy: api_key + configuration: + query_params: + - name: api_token + value: + - name: api_token_secret + value: + + test_request: + method: GET + path: /v5/account + + endpoints: + - name: user + requests: + delete: + request_override: alchemer_user_delete + param_values: + - name: email + identity: email diff --git a/data/saas/dataset/alchemer_dataset.yml b/data/saas/dataset/alchemer_dataset.yml new file mode 100644 index 0000000000..4466619878 --- /dev/null +++ b/data/saas/dataset/alchemer_dataset.yml @@ -0,0 +1,9 @@ +dataset: + - fides_key: + name: Alchemer_erasure_only + description: + A sample dataset representing the Alchemer erasure only connector for + Fides + collections: + - name: user + fields: [] diff --git a/data/saas/icon/alchemer.svg b/data/saas/icon/alchemer.svg new file mode 100644 index 0000000000..04d4682abf --- /dev/null +++ b/data/saas/icon/alchemer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/fides/api/service/saas_request/override_implementations/alchemer_request_overrides.py b/src/fides/api/service/saas_request/override_implementations/alchemer_request_overrides.py new file mode 100644 index 0000000000..34549fb3ba --- /dev/null +++ b/src/fides/api/service/saas_request/override_implementations/alchemer_request_overrides.py @@ -0,0 +1,66 @@ +from typing import Any, Dict, List + +from fides.api.models.policy import Policy +from fides.api.models.privacy_request import PrivacyRequest +from fides.api.schemas.saas.shared_schemas import HTTPMethod, SaaSRequestParams +from fides.api.service.connectors.saas.authenticated_client import AuthenticatedClient +from fides.api.service.saas_request.saas_request_override_factory import ( + SaaSRequestType, + register, +) + + +@register("alchemer_user_delete", [SaaSRequestType.DELETE]) +def alchemer_user_delete( + client: AuthenticatedClient, + param_values_per_row: List[Dict[str, Any]], + policy: Policy, + privacy_request: PrivacyRequest, + secrets: Dict[str, Any], +) -> int: + # The delete endpoint has a structure like this + # https://api.alchemer.com/v5/contactlist/31/contactlistcontact/100012345?_method=DELETE + # where 31 is the contact list id and 100012345 is the contact id + # So we first get all the contact lists and extract their ids + # Then we query for all contacts in each list, filtering on our identity email + # Then we call a delete on the contact + rows_deleted = 0 + params = { + "api_token": secrets["api_key"], + "api_token_secret": secrets["api_key_secret"], + } + get_list_ids = client.send( + SaaSRequestParams( + method=HTTPMethod.GET, + path="/v5/contactlist", + params=params, + ) + ) + + list_ids_data = get_list_ids.json() + list_results = [] + for list_id in list_ids_data["data"]: + list_results.append(list_id["id"]) + for list_result in list_results: + contacts_data_call = client.send( + SaaSRequestParams( + method=HTTPMethod.GET, + path=f"/v5/contactlist/{list_result}/contactlistcontact", + params=params, + ) + ) + contacts_data = contacts_data_call.json() + for contact in contacts_data["data"]: + for row_param_values in param_values_per_row: + email = row_param_values["email"] + if contact["email_address"] == email: + client.send( + SaaSRequestParams( + method=HTTPMethod.DELETE, + path=f"/v5/contactlist/{list_result}/contactlistcontact/{contact['id']}", + params=params, + ) + ) + rows_deleted += 1 + + return rows_deleted diff --git a/tests/fixtures/saas/alchemer_fixtures.py b/tests/fixtures/saas/alchemer_fixtures.py new file mode 100644 index 0000000000..4640cd09e5 --- /dev/null +++ b/tests/fixtures/saas/alchemer_fixtures.py @@ -0,0 +1,81 @@ +import random +import string +import time +from typing import Any, Dict, Generator + +import pydash +import pytest +import requests + +from tests.ops.integration_tests.saas.connector_runner import ( + ConnectorRunner, + generate_random_email, +) +from tests.ops.test_helpers.vault_client import get_secrets + +secrets = get_secrets("alchemer") + + +@pytest.fixture(scope="session") +def alchemer_secrets(saas_config) -> Dict[str, Any]: + return { + "domain": pydash.get(saas_config, "alchemer.domain") or secrets["domain"], + "api_key": pydash.get(saas_config, "alchemer.api_key") or secrets["api_key"], + "api_key_secret": pydash.get(saas_config, "alchemer.api_key_secret") + or secrets["api_key_secret"], + } + + +@pytest.fixture(scope="session") +def alchemer_identity_email(saas_config) -> str: + return ( + pydash.get(saas_config, "alchemer.identity_email") or secrets["identity_email"] + ) + + +@pytest.fixture +def alchemer_erasure_identity_email() -> str: + return generate_random_email() + + +@pytest.fixture +def alchemer_erasure_data( + alchemer_erasure_identity_email: str, + alchemer_secrets, +) -> Generator: + gen_string = string.ascii_lowercase + test_contactlist = "".join(random.choice(gen_string) for i in range(10)) + x_contactlist_name = f"Ethyca Test {test_contactlist}" + + base_url = f"https://{alchemer_secrets['domain']}/v5" + params = { + "api_token": alchemer_secrets["api_key"], + "api_token_secret": alchemer_secrets["api_key_secret"], + "list_name": x_contactlist_name, + } + contactlist_url = f"{base_url}/contactlist/" + response = requests.put(contactlist_url, params=params) + + contactlist_id = response.json()["data"]["id"] + contactlistcontact_url = f"{contactlist_url}{contactlist_id}/contactlistcontact" + params = { + "api_token": alchemer_secrets["api_key"], + "api_token_secret": alchemer_secrets["api_key_secret"], + "email_address": alchemer_erasure_identity_email, + } + response = requests.put(contactlistcontact_url, params=params) + time.sleep(5) + + +@pytest.fixture +def alchemer_runner( + db, + cache, + alchemer_secrets, +) -> ConnectorRunner: + return ConnectorRunner( + db, + cache, + "alchemer", + alchemer_secrets, + ) diff --git a/tests/ops/integration_tests/saas/test_alchemer_task.py b/tests/ops/integration_tests/saas/test_alchemer_task.py new file mode 100644 index 0000000000..31de3fa632 --- /dev/null +++ b/tests/ops/integration_tests/saas/test_alchemer_task.py @@ -0,0 +1,31 @@ +import pytest + +from fides.api.models.policy import Policy +from tests.ops.integration_tests.saas.connector_runner import ConnectorRunner + + +@pytest.mark.skip(reason="No active account") +@pytest.mark.integration_saas +class TestAlchemerConnector: + def test_connection(self, alchemer_runner: ConnectorRunner): + alchemer_runner.test_connection() + + async def test_non_strict_erasure_request( + self, + alchemer_runner: ConnectorRunner, + policy: Policy, + erasure_policy_string_rewrite: Policy, + alchemer_erasure_identity_email: str, + alchemer_erasure_data, + ): + ( + _, + erasure_results, + ) = await alchemer_runner.non_strict_erasure_request( + access_policy=policy, + erasure_policy=erasure_policy_string_rewrite, + identities={"email": alchemer_erasure_identity_email}, + ) + assert erasure_results == { + "alchemer_instance:user": 1, + }