Repository: ethyca/fidesops (language: python)

Issue:
Braze
Link to Pro Adaptors Docs https://support.ethyca.com/docs/braze




Discussion / hints:
I have a meeting tomorrow with Braze to setup a Connectors account

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9bbed510c..850d1f080 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@ The types of changes are:
 * Enable retries on saas connectors for failures at the http request level [#1376](https://github.com/ethyca/fidesops/pull/1376)
 * Add consent request api [#1387](https://github.com/ethyca/fidesops/pull/1387)
 * Add authenticated route to get consent preferences [#1402](https://github.com/ethyca/fidesops/pull/1402)
+* Access and erasure support for Braze [#1248](https://github.com/ethyca/fidesops/pull/1248)
 
 ### Removed
 
diff --git a/data/saas/config/braze_config.yml b/data/saas/config/braze_config.yml
new file mode 100644
index 000000000..0e5c0f727
--- /dev/null
+++ b/data/saas/config/braze_config.yml
@@ -0,0 +1,98 @@
+saas_config:
+  fides_key: <instance_fides_key>
+  name: Braze SaaS Config
+  type: braze
+  description: A sample schema representing the Braze connector for Fidesops
+  version: 0.0.1
+
+  connector_params:
+    - name: domain
+    - name: api_key
+
+  client_config:
+    protocol: https
+    host: <domain>
+    authentication:
+      strategy: bearer
+      configuration:
+        token: <api_key>
+
+  test_request:
+    method: GET
+    path: /email/hard_bounces/
+    query_params:
+      - name: email
+        value: test@test.com
+
+  endpoints:
+    - name: users
+      requests:
+        read:
+          method: POST
+          path: /users/export/ids
+          body: |
+            {
+              "email_address": "<email>",
+              "fields_to_export": [
+                  "apps",
+                  "attributed_campaign",
+                  "attributed_source",
+                  "attributed_adgroup",
+                  "attributed_ad",
+                  "braze_id",
+                  "campaigns_received",
+                  "canvases_received",
+                  "cards_clicked",
+                  "country",
+                  "created_at",
+                  "custom_attributes",
+                  "custom_events",
+                  "devices",
+                  "dob",
+                  "email",
+                  "email_subscribe",
+                  "external_id",
+                  "first_name",
+                  "gender",
+                  "home_city",
+                  "language",
+                  "last_coordinates",
+                  "last_name",
+                  "phone",
+                  "purchases",
+                  "push_subscribe",
+                  "push_tokens",
+                  "random_bucket",
+                  "time_zone",
+                  "total_revenue",
+                  "uninstalled_at",
+                  "user_aliases"
+              ]
+            }
+          param_values:
+            - name: email
+              identity: email
+          data_path: users
+        update:
+          method: POST
+          path: /users/track
+          body: |
+            {
+              "attributes": [
+                {
+                  <all_object_fields>
+                }
+              ]
+            }
+    - name: subscription_groups_email
+      requests:
+        read:
+          method: GET
+          path: /subscription/user/status
+          query_params:
+            - name: email
+              value: <email>
+          param_values:
+            - name: email
+              identity: email
+          data_path: users
diff --git a/data/saas/dataset/braze_dataset.yml b/data/saas/dataset/braze_dataset.yml
new file mode 100644
index 000000000..ddd918a4d
--- /dev/null
+++ b/data/saas/dataset/braze_dataset.yml
@@ -0,0 +1,111 @@
+dataset:
+  - fides_key: <instance_fides_key>
+    name: Braze Dataset
+    description: A sample dataset representing the Braze connector for Fidesops
+    collections:
+      - name: users
+        fields:
+          - name: created_at
+            data_categories: [system.operations]
+            fidesops_meta:
+              data_type: string
+          - name: external_id
+            data_categories: [user.unique_id]
+            fidesops_meta:
+              data_type: string
+              read_only: True
+          - name: braze_id
+            data_categories: [user.unique_id]
+            fidesops_meta:
+              data_type: string
+              primary_key: True
+          - name: first_name
+            data_categories: [user.name]
+            fidesops_meta:
+              data_type: string
+          - name: last_name
+            data_categories: [user.name]
+            fidesops_meta:
+              data_type: string
+          - name: random_bucket
+            data_categories: [system.operations]
+            fidesops_meta:
+              data_type: string
+          - name: email
+            data_categories: [user.contact.email]
+            fidesops_meta:
+              data_type: string
+          - name: dob
+            data_categories: [user.date_of_birth]
+            fidesops_meta:
+              data_type: string
+          - name: country
+            data_categories: [user.contact.address.country]
+            fidesops_meta:
+              data_type: string
+          - name: home_city
+            data_categories: [user.contact.address.city]
+            fidesops_meta:
+              data_type: string
+          - name: language
+            data_categories: [system.operations]
+            fidesops_meta:
+              data_type: string
+          - name: gender
+            data_categories: [user.gender]
+            fidesops_meta:
+              data_type: string
+          - name: phone
+            data_categories: [user.contact.phone_number]
+            fidesops_meta:
+              data_type: string
+          - name: time_zone
+            data_categories: [system.operations]
+            fidesops_meta:
+              data_type: string
+          - name: total_revenue
+            data_categories: [system.operations]
+            fidesops_meta:
+              data_type: string
+          - name: push_subscribe
+            data_categories: [system.operations]
+            fidesops_meta:
+              data_type: string
+          - name: email_subscribe
+            data_categories: [system.operations]
+            fidesops_meta:
+              data_type: string
+          - name: email_opted_in_at
+            data_categories: [system.operations]
+            fidesops_meta:
+              data_type: string
+          - name: user_aliases
+            fidesops_meta:
+              data_type: object[]
+              read_only: True
+            fields:
+              - name: alias_name
+                data_categories: [system.operations]
+                fides_meta:
+                  data_type: string
+              - name: alias_label
+                data_categories: [system.operations]
+                fides_meta:
+                  data_type: string
+      - name: subscription_groups_email
+        fields:
+          - name: email
+            data_categories: [user.contact.email]
+            fides_meta:
+              data_type: string
+          - name: phone
+            data_categories: [user.contact.phone_number]
+            fides_meta:
+              data_type: string
+          - name: external_id
+            data_categories: [system.operations]
+            fides_meta:
+              data_type: string
+          - name: subscription_groups
+            fides_meta:
+              data_type: object[]
diff --git a/src/fidesops/ops/schemas/saas/saas_config.py b/src/fidesops/ops/schemas/saas/saas_config.py
index 69bd908b1..f788d35be 100644
--- a/src/fidesops/ops/schemas/saas/saas_config.py
+++ b/src/fidesops/ops/schemas/saas/saas_config.py
@@ -263,6 +263,7 @@ class SaaSType(Enum):
     sendgrid = "sendgrid"
     datadog = "datadog"
     rollbar = "rollbar"
+    braze = "braze"
 
 
 class SaaSConfigBase(BaseModel):


Test patch:
diff --git a/pytest.ini b/pytest.ini
index 408a6edf7..1d9ef9031 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -29,6 +29,7 @@ markers =
     integration_outreach
     integration_salesforce
     integration_adobe_campaign
+    integration_braze
     unit_saas
 
 filterwarnings =
diff --git a/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py b/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py
index ae280ad06..f05080532 100644
--- a/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py
+++ b/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py
@@ -48,7 +48,7 @@ def test_get_connection_types(
         resp = api_client.get(url, headers=auth_header)
         data = resp.json()["items"]
         assert resp.status_code == 200
-        assert len(data) == 24
+        assert len(data) == 25
 
         assert {
             "identifier": ConnectionType.postgres.value,
@@ -152,7 +152,7 @@ def test_search_system_type(self, api_client, generate_auth_header, url):
         resp = api_client.get(url + "?system_type=saas", headers=auth_header)
         assert resp.status_code == 200
         data = resp.json()["items"]
-        assert len(data) == 14
+        assert len(data) == 15
 
         resp = api_client.get(url + "?system_type=database", headers=auth_header)
         assert resp.status_code == 200
diff --git a/tests/ops/conftest.py b/tests/ops/conftest.py
index 6436c4d39..8c25d8dac 100644
--- a/tests/ops/conftest.py
+++ b/tests/ops/conftest.py
@@ -42,6 +42,7 @@
 from .fixtures.redshift_fixtures import *
 from .fixtures.saas.adobe_campaign_fixtures import *
 from .fixtures.saas.auth0_fixtures import *
+from .fixtures.saas.braze_fixtures import *
 from .fixtures.saas.connection_template_fixtures import *
 from .fixtures.saas.datadog_fixtures import *
 from .fixtures.saas.hubspot_fixtures import *
diff --git a/tests/ops/fixtures/saas/braze_fixtures.py b/tests/ops/fixtures/saas/braze_fixtures.py
new file mode 100644
index 000000000..643f29411
--- /dev/null
+++ b/tests/ops/fixtures/saas/braze_fixtures.py
@@ -0,0 +1,232 @@
+import uuid
+from typing import Any, Dict, Generator
+
+import pydash
+import pytest
+import requests
+from fideslib.cryptography import cryptographic_util
+from fideslib.db import session
+from sqlalchemy.orm import Session
+
+from fidesops.ops.models.connectionconfig import (
+    AccessLevel,
+    ConnectionConfig,
+    ConnectionType,
+)
+from fidesops.ops.models.datasetconfig import DatasetConfig
+from fidesops.ops.util.saas_util import (
+    load_config_with_replacement,
+    load_dataset_with_replacement,
+)
+from tests.ops.test_helpers.saas_test_utils import poll_for_existence
+from tests.ops.test_helpers.vault_client import get_secrets
+
+secrets = get_secrets("braze")
+
+
+@pytest.fixture(scope="session")
+def braze_secrets(saas_config):
+    return {
+        "domain": pydash.get(saas_config, "braze.domain") or secrets["domain"],
+        "api_key": pydash.get(saas_config, "braze.api_key") or secrets["api_key"],
+    }
+
+
+@pytest.fixture(scope="session")
+def braze_identity_email(saas_config):
+    return pydash.get(saas_config, "braze.identity_email") or secrets["identity_email"]
+
+
+@pytest.fixture(scope="session")
+def braze_erasure_identity_email():
+    return f"{cryptographic_util.generate_secure_random_string(13)}@email.com"
+
+
+@pytest.fixture
+def braze_config() -> Dict[str, Any]:
+    return load_config_with_replacement(
+        "data/saas/config/braze_config.yml",
+        "<instance_fides_key>",
+        "braze_instance",
+    )
+
+
+@pytest.fixture
+def braze_dataset() -> Dict[str, Any]:
+    return load_dataset_with_replacement(
+        "data/saas/dataset/braze_dataset.yml",
+        "<instance_fides_key>",
+        "braze_instance",
+    )[0]
+
+
+@pytest.fixture(scope="function")
+def braze_connection_config(db: session, braze_config, braze_secrets) -> Generator:
+    fides_key = braze_config["fides_key"]
+    connection_config = ConnectionConfig.create(
+        db=db,
+        data={
+            "key": fides_key,
+            "name": fides_key,
+            "connection_type": ConnectionType.saas,
+            "access": AccessLevel.write,
+            "secrets": braze_secrets,
+            "saas_config": braze_config,
+        },
+    )
+    yield connection_config
+    connection_config.delete(db)
+
+
+@pytest.fixture
+def braze_dataset_config(
+    db: Session,
+    braze_connection_config: ConnectionConfig,
+    braze_dataset,
+    braze_config,
+) -> Generator:
+    fides_key = braze_config["fides_key"]
+    braze_connection_config.name = fides_key
+    braze_connection_config.key = fides_key
+    braze_connection_config.save(db=db)
+    dataset = DatasetConfig.create(
+        db=db,
+        data={
+            "connection_config_id": braze_connection_config.id,
+            "fides_key": fides_key,
+            "dataset": braze_dataset,
+        },
+    )
+    yield dataset
+    dataset.delete(db=db)
+
+
+@pytest.fixture(scope="function")
+def braze_erasure_data(
+    braze_connection_config, braze_erasure_identity_email, braze_secrets
+) -> Generator:
+    base_url = f"https://{braze_secrets['domain']}"
+    external_id = uuid.uuid4().hex
+    body = {
+        "attributes": [
+            {
+                "external_id": external_id,
+                "first_name": "Walter",
+                "last_name": "White",
+                "email": braze_erasure_identity_email,
+                "phone": "+16175551212",
+                "country": "US",
+                "date_of_first_session": "2022-09-14T16:14:56+00:00",
+                "dob": "1980-12-21",
+                "email_subscribe": "opted_in",
+                "gender": "M",
+                "home_city": "New York",
+                "language": "en",
+                "time_zone": "US/Eastern",
+                "user_aliases": [
+                    {
+                        "external_id": external_id,
+                        "alias_name": "Breaking",
+                        "alias_label": "Bad",
+                    }
+                ],
+                "app_id": f"app_identifier.{external_id}",
+            }
+        ],
+        "events": [
+            {
+                "external_id": external_id,
+                "app_id": f"app_identifier.{external_id}",
+                "name": "watched_trailer",
+                "time": "2013-07-16T19:20:30+1:00",
+            },
+            {
+                "external_id": external_id,
+                "app_id": "app_identifier.{{external_id}}",
+                "name": "this is test event to create",
+                "time": "2022-07-16T19:20:30+1:00",
+            },
+        ],
+        "purchases": [
+            {
+                "external_id": external_id,
+                "product_id": "Car",
+                "currency": "USD",
+                "price": 12.12,
+                "quantity": 6,
+                "time": "2017-05-12T18:47:12Z",
+                "properties": {
+                    "integer_property": 3,
+                    "string_property": "Some Property",
+                    "date_property": "2014-02-02T00:00:00Z",
+                },
+            }
+        ],
+    }
+
+    headers = {"Authorization": f"Bearer {braze_secrets['api_key']}"}
+
+    response = requests.post(url=f"{base_url}/users/track", json=body, headers=headers)
+    response_data = response.json()
+
+    assert response.ok
+
+    error_message = (
+        f"User with email {braze_erasure_identity_email} could not be added to Braze"
+    )
+    poll_for_existence(
+        _user_exists,
+        (braze_erasure_identity_email, braze_secrets),
+        error_message=error_message,
+    )
+
+    yield response_data
+
+    # Remove Data
+
+    export_data = requests.post(
+        url=f"{base_url}/users/export/ids",
+        json={
+            "external_ids": [
+                external_id,
+            ],
+            "fields_to_export": [
+                "braze_id",
+            ],
+        },
+        headers=headers,
+    )
+    assert export_data.ok
+
+    braze_ids = [i["braze_id"] for i in export_data.json().get("users")]
+    delete_user = requests.post(
+        url=f"{base_url}/users/delete", json={"braze_ids": braze_ids}, headers=headers
+    )
+    assert delete_user.status_code == 201
+
+
+def _user_exists(braze_erasure_identity_email: str, braze_secrets):
+    """
+    Confirm whether user exists by calling user search by email api and comparing resulting firstname str.
+    Returns user ID if it exists, returns None if it does not.
+    """
+    base_url = f"https://{braze_secrets['domain']}"
+    headers = {
+        "Authorization": f"Bearer {braze_secrets['api_key']}",
+    }
+    body = {
+        "email_address": braze_erasure_identity_email,
+        "fields_to_export": ["email"],
+    }
+
+    user_response = requests.post(
+        url=f"{base_url}/users/export/ids",
+        json=body,
+        headers=headers,
+    )
+
+    # we expect 404 if user doesn't exist
+    if 404 == user_response.status_code:
+        return None
+
+    return user_response.json()
diff --git a/tests/ops/integration_tests/saas/test_braze_task.py b/tests/ops/integration_tests/saas/test_braze_task.py
new file mode 100644
index 000000000..f3f056324
--- /dev/null
+++ b/tests/ops/integration_tests/saas/test_braze_task.py
@@ -0,0 +1,204 @@
+import random
+import time
+
+import pytest
+import requests
+
+from fidesops.ops.core.config import config
+from fidesops.ops.graph.graph import DatasetGraph
+from fidesops.ops.models.privacy_request import PrivacyRequest
+from fidesops.ops.schemas.redis_cache import Identity
+from fidesops.ops.service.connectors import get_connector
+from fidesops.ops.task import graph_task
+from fidesops.ops.task.graph_task import get_cached_data_for_erasures
+from tests.ops.graph.graph_test_util import assert_rows_match
+
+
+@pytest.mark.integration_saas
+@pytest.mark.integration_braze
+def test_braze_connection_test(braze_connection_config) -> None:
+    get_connector(braze_connection_config).test_connection()
+
+
+@pytest.mark.integration_saas
+@pytest.mark.integration_braze
+@pytest.mark.asyncio
+async def test_saas_access_request_task(
+    db,
+    policy,
+    braze_connection_config,
+    braze_dataset_config,
+    braze_identity_email,
+) -> None:
+    """Full access request based on the Braze SaaS config"""
+
+    privacy_request = PrivacyRequest(
+        id=f"test_braze_access_request_task_{random.randint(0, 250)}"
+    )
+    identity_attribute = "email"
+    identity_value = braze_identity_email
+    identity_kwargs = {identity_attribute: identity_value}
+    identity = Identity(**identity_kwargs)
+    privacy_request.cache_identity(identity)
+
+    dataset_name = braze_connection_config.get_saas_config().fides_key
+    merged_graph = braze_dataset_config.get_graph()
+    graph = DatasetGraph(merged_graph)
+
+    v = await graph_task.run_access_request(
+        privacy_request,
+        policy,
+        graph,
+        [braze_connection_config],
+        {identity_attribute: braze_identity_email},
+        db,
+    )
+    key_users = f"{dataset_name}:users"
+    assert_rows_match(
+        v[key_users],
+        min_size=1,
+        keys=[
+            "external_id",
+            "user_aliases",
+            "braze_id",
+            "first_name",
+            "last_name",
+            identity_attribute,
+            "dob",
+            "country",
+            "home_city",
+            "language",
+            "gender",
+            "phone",
+        ],
+    )
+
+    for entry in v[key_users]:
+        assert identity_value == entry.get(identity_attribute)
+
+    key_subscription_groups = f"{dataset_name}:subscription_groups_email"
+    assert_rows_match(
+        v[key_subscription_groups],
+        min_size=1,
+        keys=[
+            identity_attribute,
+            "phone",
+            "external_id",
+            "subscription_groups",
+        ],
+    )
+
+    for entry in v[key_subscription_groups]:
+        assert identity_value == entry.get(identity_attribute)
+
+
+@pytest.mark.integration_saas
+@pytest.mark.integration_braze
+@pytest.mark.asyncio
+async def test_saas_erasure_task(
+    db,
+    policy,
+    erasure_policy_string_rewrite,
+    braze_connection_config,
+    braze_dataset_config,
+    braze_erasure_identity_email,
+    braze_erasure_data,
+) -> None:
+    privacy_request = PrivacyRequest(
+        id=f"test_braze_erasure_request_task_{random.randint(0, 1000)}"
+    )
+    identity_attribute = "email"
+    identity_value = braze_erasure_identity_email
+    identity_kwargs = {identity_attribute: identity_value}
+    identity = Identity(**identity_kwargs)
+
+    privacy_request.cache_identity(identity)
+
+    dataset_name = braze_connection_config.get_saas_config().fides_key
+    merged_graph = braze_dataset_config.get_graph()
+    graph = DatasetGraph(merged_graph)
+
+    v = await graph_task.run_access_request(
+        privacy_request,
+        policy,
+        graph,
+        [braze_connection_config],
+        identity_kwargs,
+        db,
+    )
+    key_users = f"{dataset_name}:users"
+    assert_rows_match(
+        v[key_users],
+        min_size=1,
+        keys=[
+            "external_id",
+            "user_aliases",
+            "braze_id",
+            "first_name",
+            "last_name",
+            identity_attribute,
+            "dob",
+            "country",
+            "home_city",
+            "language",
+            "gender",
+            "phone",
+        ],
+    )
+
+    temp_masking = config.execution.masking_strict
+    config.execution.masking_strict = True
+
+    x = await graph_task.run_erasure(
+        privacy_request,
+        erasure_policy_string_rewrite,
+        graph,
+        [braze_connection_config],
+        identity_kwargs,
+        get_cached_data_for_erasures(privacy_request.id),
+        db,
+    )
+
+    assert x == {
+        f"{dataset_name}:users": 1,
+        f"{dataset_name}:subscription_groups_email": 0,
+    }
+
+    time.sleep(10)
+
+    # Verifying field is masked
+    braze_secrets = braze_connection_config.secrets
+    base_url = f"https://{braze_secrets['domain']}"
+    headers = {
+        "Authorization": f"Bearer {braze_secrets['api_key']}",
+    }
+    body = {
+        "email_address": braze_erasure_identity_email,
+        "fields_to_export": [
+            "braze_id",
+            "country",
+            "dob",
+            "email",
+            "external_id",
+            "first_name",
+            "gender",
+            "home_city",
+            "language",
+            "last_name",
+            "phone",
+            "user_aliases",
+        ],
+    }
+
+    user_response = requests.post(
+        url=f"{base_url}/users/export/ids",
+        json=body,
+        headers=headers,
+    )
+    users = user_response.json().get("users")
+
+    for user in users:
+        assert user["first_name"] == "MASKED"
+        assert user["last_name"] == "MASKED"
+
+    config.execution.masking_strict = temp_masking