diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index a23c29c077a..1a7298b60ca 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -1428,6 +1428,165 @@ dataset: - name: updated_at data_categories: [system.operations] data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: privacyexperience + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + fields: + - name: acknowledgement_button_label + data_categories: [ system.operations ] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: banner_description + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: banner_title + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: component + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: component_description + data_categories: [ system.operations ] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: component_title + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: confirmation_button_label + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: created_at + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: delivery_mechanism + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: disabled + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: id + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: link_label + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: privacy_experience_template_id + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: regions + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: reject_button_label + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: updated_at + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: version + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: privacyexperiencehistory + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + fields: + - name: acknowledgement_button_label + data_categories: [ system.operations ] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: banner_description + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: banner_title + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: component + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: component_description + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: component_title + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: confirmation_button_label + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: created_at + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: delivery_mechanism + data_categories: [ system.operations ] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: disabled + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: id + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: link_label + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: privacy_experience_id + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: privacy_experience_template_id + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: regions + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: reject_button_label + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: updated_at + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: version + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: privacyexperiencetemplate + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + fields: + - name: acknowledgement_button_label + data_categories: [ system.operations ] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: banner_description + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: banner_title + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: component + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: component_description + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: component_title + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: confirmation_button_label + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: created_at + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: delivery_mechanism + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: disabled + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: id + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: link_label + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: regions + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: reject_button_label + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: updated_at + data_categories: [system.operations] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified - name: privacynotice data_categories: [] data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e865cdb6f..ed6f8ab6e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ The types of changes are: - Added Data flow modal [#3008](https://github.com/ethyca/fides/pull/3008) - Update datamap table export [#3038](https://github.com/ethyca/fides/pull/3038) - Added more advanced privacy center styling [#2943](https://github.com/ethyca/fides/pull/2943) +- Backend privacy experiences foundation [#3146](https://github.com/ethyca/fides/pull/3146) ### Changed diff --git a/src/fides/api/ctl/migrations/versions/e92da354691e_privacy_experiences.py b/src/fides/api/ctl/migrations/versions/e92da354691e_privacy_experiences.py new file mode 100644 index 00000000000..45e07dc04d8 --- /dev/null +++ b/src/fides/api/ctl/migrations/versions/e92da354691e_privacy_experiences.py @@ -0,0 +1,178 @@ +"""privacy experiences + +Revision ID: e92da354691e +Revises: 5b03859e51b5 +Create Date: 2023-04-24 14:49:37.588144 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "e92da354691e" +down_revision = "5b03859e51b5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "privacyexperiencetemplate", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("disabled", sa.Boolean(), nullable=False), + sa.Column("component", sa.String(), nullable=False), + sa.Column("delivery_mechanism", sa.String(), nullable=False), + sa.Column("regions", sa.ARRAY(sa.String()), nullable=False), + sa.Column("component_title", sa.String(), nullable=True), + sa.Column("component_description", sa.String(), nullable=True), + sa.Column("banner_title", sa.String(), nullable=True), + sa.Column("banner_description", sa.String(), nullable=True), + sa.Column("link_label", sa.String(), nullable=True), + sa.Column("confirmation_button_label", sa.String(), nullable=True), + sa.Column("reject_button_label", sa.String(), nullable=True), + sa.Column("acknowledgement_button_label", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_privacyexperiencetemplate_id"), + "privacyexperiencetemplate", + ["id"], + unique=False, + ) + op.create_index( + op.f("ix_privacyexperiencetemplate_regions"), + "privacyexperiencetemplate", + ["regions"], + unique=False, + ) + op.create_table( + "privacyexperience", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("disabled", sa.Boolean(), nullable=False), + sa.Column("component", sa.String(), nullable=False), + sa.Column("delivery_mechanism", sa.String(), nullable=False), + sa.Column("regions", sa.ARRAY(sa.String()), nullable=False), + sa.Column("component_title", sa.String(), nullable=True), + sa.Column("component_description", sa.String(), nullable=True), + sa.Column("banner_title", sa.String(), nullable=True), + sa.Column("banner_description", sa.String(), nullable=True), + sa.Column("link_label", sa.String(), nullable=True), + sa.Column("confirmation_button_label", sa.String(), nullable=True), + sa.Column("reject_button_label", sa.String(), nullable=True), + sa.Column("acknowledgement_button_label", sa.String(), nullable=True), + sa.Column("version", sa.Float(), nullable=False), + sa.Column("privacy_experience_template_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["privacy_experience_template_id"], + ["privacyexperiencetemplate.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_privacyexperience_id"), "privacyexperience", ["id"], unique=False + ) + op.create_index( + op.f("ix_privacyexperience_regions"), + "privacyexperience", + ["regions"], + unique=False, + ) + op.create_table( + "privacyexperiencehistory", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("disabled", sa.Boolean(), nullable=False), + sa.Column("component", sa.String(), nullable=False), + sa.Column("delivery_mechanism", sa.String(), nullable=False), + sa.Column("regions", sa.ARRAY(sa.String()), nullable=False), + sa.Column("component_title", sa.String(), nullable=True), + sa.Column("component_description", sa.String(), nullable=True), + sa.Column("banner_title", sa.String(), nullable=True), + sa.Column("banner_description", sa.String(), nullable=True), + sa.Column("link_label", sa.String(), nullable=True), + sa.Column("confirmation_button_label", sa.String(), nullable=True), + sa.Column("reject_button_label", sa.String(), nullable=True), + sa.Column("acknowledgement_button_label", sa.String(), nullable=True), + sa.Column("version", sa.Float(), nullable=False), + sa.Column("privacy_experience_template_id", sa.String(), nullable=True), + sa.Column("privacy_experience_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["privacy_experience_id"], + ["privacyexperience.id"], + ), + sa.ForeignKeyConstraint( + ["privacy_experience_template_id"], + ["privacyexperiencetemplate.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_privacyexperiencehistory_id"), + "privacyexperiencehistory", + ["id"], + unique=False, + ) + op.create_index( + op.f("ix_privacyexperiencehistory_regions"), + "privacyexperiencehistory", + ["regions"], + unique=False, + ) + + +def downgrade(): + op.drop_index( + op.f("ix_privacyexperiencehistory_regions"), + table_name="privacyexperiencehistory", + ) + op.drop_index( + op.f("ix_privacyexperiencehistory_id"), table_name="privacyexperiencehistory" + ) + op.drop_table("privacyexperiencehistory") + op.drop_index(op.f("ix_privacyexperience_regions"), table_name="privacyexperience") + op.drop_index(op.f("ix_privacyexperience_id"), table_name="privacyexperience") + op.drop_table("privacyexperience") + op.drop_index( + op.f("ix_privacyexperiencetemplate_regions"), + table_name="privacyexperiencetemplate", + ) + op.drop_index( + op.f("ix_privacyexperiencetemplate_id"), table_name="privacyexperiencetemplate" + ) + op.drop_table("privacyexperiencetemplate") diff --git a/src/fides/api/ctl/sql_models.py b/src/fides/api/ctl/sql_models.py index ad02b02d6f2..ede8b88dc7b 100644 --- a/src/fides/api/ctl/sql_models.py +++ b/src/fides/api/ctl/sql_models.py @@ -7,20 +7,10 @@ from __future__ import annotations from enum import Enum as EnumType -from typing import ( - Any, - Dict, - List, - Optional, - Set, - Type, - TypeVar, -) +from typing import Any, Dict, List, Optional, Set, Type, TypeVar -from fideslang.models import ( - DataCategory as FideslangDataCategory, - Dataset as FideslangDataset, -) +from fideslang.models import DataCategory as FideslangDataCategory +from fideslang.models import Dataset as FideslangDataset from pydantic import BaseModel from sqlalchemy import ARRAY, BOOLEAN, JSON, Column from sqlalchemy import Enum as EnumColumn @@ -150,6 +140,7 @@ class ClassificationInstance(Base): DataCategoryType = TypeVar("DataCategoryType", bound="DataCategory") + # Privacy Types class DataCategory(Base, FidesBase): """ diff --git a/src/fides/api/ops/api/v1/api.py b/src/fides/api/ops/api/v1/api.py index 60322618440..691eb626bc5 100644 --- a/src/fides/api/ops/api/v1/api.py +++ b/src/fides/api/ops/api/v1/api.py @@ -13,6 +13,7 @@ oauth_endpoints, policy_endpoints, policy_webhook_endpoints, + privacy_experience_endpoints, privacy_notice_endpoints, privacy_preference_endpoints, privacy_request_endpoints, @@ -36,6 +37,7 @@ api_router.include_router(oauth_endpoints.router) api_router.include_router(policy_endpoints.router) api_router.include_router(policy_webhook_endpoints.router) +api_router.include_router(privacy_experience_endpoints.router) api_router.include_router(privacy_notice_endpoints.router) api_router.include_router(privacy_preference_endpoints.router) api_router.include_router(privacy_request_endpoints.router) diff --git a/src/fides/api/ops/api/v1/endpoints/privacy_experience_endpoints.py b/src/fides/api/ops/api/v1/endpoints/privacy_experience_endpoints.py new file mode 100644 index 00000000000..d4c2e9b4d68 --- /dev/null +++ b/src/fides/api/ops/api/v1/endpoints/privacy_experience_endpoints.py @@ -0,0 +1,71 @@ +from typing import List, Optional + +from fastapi import Depends, Security +from fastapi_pagination import Page, Params +from fastapi_pagination import paginate as fastapi_paginate +from fastapi_pagination.bases import AbstractPage +from loguru import logger +from sqlalchemy.orm import Session +from starlette.status import HTTP_200_OK + +from fides.api.ops.api import deps +from fides.api.ops.api.v1 import scope_registry +from fides.api.ops.api.v1 import urn_registry as urls +from fides.api.ops.models.privacy_experience import ComponentType, PrivacyExperience +from fides.api.ops.models.privacy_notice import PrivacyNotice, PrivacyNoticeRegion +from fides.api.ops.schemas.privacy_experience import PrivacyExperienceResponse +from fides.api.ops.util.api_router import APIRouter +from fides.api.ops.util.oauth_util import verify_oauth_client + +router = APIRouter(tags=["Privacy Experience"], prefix=urls.V1_URL_PREFIX) + + +@router.get( + urls.PRIVACY_EXPERIENCE, + status_code=HTTP_200_OK, + response_model=Page[PrivacyExperienceResponse], + dependencies=[ + Security(verify_oauth_client, scopes=[scope_registry.PRIVACY_EXPERIENCE_READ]) + ], +) +def privacy_experience_list( + *, + db: Session = Depends(deps.get_db), + params: Params = Depends(), + show_disabled: Optional[bool] = True, + region: Optional[PrivacyNoticeRegion] = None, + component: Optional[ComponentType] = None, + has_notices: Optional[bool] = None, +) -> AbstractPage[PrivacyExperience]: + """ + Return a paginated list of `PrivacyExperience` records in this system. + Includes some query params to help filter the list if needed + """ + logger.info("Finding all Privacy Experiences with pagination params '{}'", params) + experience_query = db.query(PrivacyExperience) + + if show_disabled is False: + experience_query = experience_query.filter( + PrivacyExperience.disabled.is_(False) + ) + if region is not None: + experience_query = experience_query.filter( + PrivacyExperience.regions.contains([region]) + ) + if component is not None: + experience_query = experience_query.filter( + PrivacyExperience.component == component + ) + + results: List[PrivacyExperience] = [] + for privacy_experience in experience_query.order_by( + PrivacyExperience.created_at.desc() + ): + privacy_notices: List[ + PrivacyNotice + ] = privacy_experience.get_related_privacy_notices(db, region, show_disabled) + privacy_experience.privacy_notices = privacy_notices + if not (has_notices and not privacy_notices): + results.append(privacy_experience) + + return fastapi_paginate(results, params=params) diff --git a/src/fides/api/ops/api/v1/scope_registry.py b/src/fides/api/ops/api/v1/scope_registry.py index 8259c25e5d9..bf9c13fde90 100644 --- a/src/fides/api/ops/api/v1/scope_registry.py +++ b/src/fides/api/ops/api/v1/scope_registry.py @@ -41,6 +41,7 @@ ORGANIZATION = "organization" PASSWORD_RESET = "password-reset" POLICY = "policy" +PRIVACY_EXPERIENCE = "privacy-experience" PRIVACY_NOTICE = "privacy-notice" PRIVACY_PREFERENCE_HISTORY = "privacy-preference-history" PRIVACY_REQUEST = "privacy-request" @@ -156,6 +157,10 @@ POLICY_DELETE = f"{POLICY}:{DELETE}" POLICY_READ = f"{POLICY}:{READ}" +PRIVACY_EXPERIENCE_CREATE = f"{PRIVACY_EXPERIENCE}:{CREATE}" +PRIVACY_EXPERIENCE_UPDATE = f"{PRIVACY_EXPERIENCE}:{UPDATE}" +PRIVACY_EXPERIENCE_READ = f"{PRIVACY_EXPERIENCE}:{READ}" + PRIVACY_NOTICE_CREATE = f"{PRIVACY_NOTICE}:{CREATE}" PRIVACY_NOTICE_UPDATE = f"{PRIVACY_NOTICE}:{UPDATE}" PRIVACY_NOTICE_READ = f"{PRIVACY_NOTICE}:{READ}" @@ -294,6 +299,9 @@ POLICY_CREATE_OR_UPDATE: "Create or modify policies", POLICY_DELETE: "Remove policies", POLICY_READ: "View policies", + PRIVACY_EXPERIENCE_CREATE: "Create privacy experiences", + PRIVACY_EXPERIENCE_UPDATE: "Update privacy experiences", + PRIVACY_EXPERIENCE_READ: "View privacy experiences", PRIVACY_NOTICE_CREATE: "Create privacy notices", PRIVACY_NOTICE_UPDATE: "Update privacy notices", PRIVACY_NOTICE_READ: "View privacy notices", diff --git a/src/fides/api/ops/api/v1/urn_registry.py b/src/fides/api/ops/api/v1/urn_registry.py index b62bcfd81af..7643c28250f 100644 --- a/src/fides/api/ops/api/v1/urn_registry.py +++ b/src/fides/api/ops/api/v1/urn_registry.py @@ -73,6 +73,10 @@ POLICY_LIST = "/dsr/policy" POLICY_DETAIL = "/dsr/policy/{policy_key}" +# Privacy Experience URLs +PRIVACY_EXPERIENCE = "/privacy-experience" +PRIVACY_EXPERIENCE_DETAIL = "/privacy-experience/{privacy_experience_id}" + # Privacy Notice URLs PRIVACY_NOTICE = "/privacy-notice" PRIVACY_NOTICE_DETAIL = "/privacy-notice/{privacy_notice_id}" diff --git a/src/fides/api/ops/db/base.py b/src/fides/api/ops/db/base.py index 1ab43f86c54..25037e7b074 100644 --- a/src/fides/api/ops/db/base.py +++ b/src/fides/api/ops/db/base.py @@ -9,6 +9,11 @@ from fides.api.ops.models.manual_webhook import AccessManualWebhook from fides.api.ops.models.messaging import MessagingConfig from fides.api.ops.models.policy import Policy, Rule, RuleTarget +from fides.api.ops.models.privacy_experience import ( + PrivacyExperience, + PrivacyExperienceHistory, + PrivacyExperienceTemplate, +) from fides.api.ops.models.privacy_notice import PrivacyNotice, PrivacyNoticeHistory from fides.api.ops.models.privacy_preference import ( CurrentPrivacyPreference, diff --git a/src/fides/api/ops/models/policy.py b/src/fides/api/ops/models/policy.py index 320c5cc6c02..25157186db6 100644 --- a/src/fides/api/ops/models/policy.py +++ b/src/fides/api/ops/models/policy.py @@ -14,8 +14,8 @@ AesGcmEngine, StringEncryptedType, ) -from fides.api.ctl.sql_models import DataCategory # type: ignore +from fides.api.ctl.sql_models import DataCategory # type: ignore from fides.api.ops import common_exceptions from fides.api.ops.common_exceptions import ( StorageConfigNotFoundException, diff --git a/src/fides/api/ops/models/privacy_experience.py b/src/fides/api/ops/models/privacy_experience.py new file mode 100644 index 00000000000..3cad3a26d45 --- /dev/null +++ b/src/fides/api/ops/models/privacy_experience.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, List, Optional, Type + +from sqlalchemy import Boolean, Column +from sqlalchemy import Enum as EnumColumn +from sqlalchemy import Float, ForeignKey, String, and_, or_ +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.orm import Session, relationship +from sqlalchemy.util import hybridproperty + +from fides.api.ops.models.privacy_notice import PrivacyNotice, PrivacyNoticeRegion +from fides.lib.db.base_class import Base + + +class ComponentType(Enum): + """ + The component type - not formalized in the db + """ + + overlay = "overlay" + privacy_center = "privacy_center" + + +class DeliveryMechanism(Enum): + """ + The delivery mechanism - not formalized in the db + """ + + banner = "banner" + link = "link" + + +class PrivacyExperienceBase: + """Base Privacy Experience fields that are common between templates, experiences, and historical records""" + + disabled = Column(Boolean, nullable=False, default=False) + component = Column(EnumColumn(ComponentType), nullable=False) + delivery_mechanism = Column(EnumColumn(DeliveryMechanism), nullable=False) + regions = Column( + ARRAY(EnumColumn(PrivacyNoticeRegion, native_enum=False)), + index=True, + nullable=False, + ) + component_title = Column(String) + component_description = Column(String) + banner_title = Column(String) + banner_description = Column(String) + link_label = Column(String) + confirmation_button_label = Column(String) + reject_button_label = Column(String) + acknowledgement_button_label = Column(String) + + +class PrivacyExperienceTemplate(PrivacyExperienceBase, Base): + """Stores the out of the box Privacy Experiences""" + + +class PrivacyExperience(PrivacyExperienceBase, Base): + """Stores saved Privacy Experiences to surface for users""" + + version = Column(Float, nullable=False, default=1.0) + privacy_experience_template_id = Column( + String, ForeignKey(PrivacyExperienceTemplate.id_field_path), nullable=True + ) + + # Attribute that can be added as the result of "get_related_privacy_notices". Privacy notices aren't directly + # related to experiences. + privacy_notices: List[PrivacyNotice] = [] + + def get_related_privacy_notices( + self, + db: Session, + region: Optional[PrivacyNoticeRegion] = None, + show_disabled: Optional[bool] = True, + ) -> List[PrivacyNotice]: + """Return privacy notices that overlap on at least one region + and match on ComponentType + + If region parameter, further restrict on notices matching a specific region. Same + thing goes for show_disabled filter. + """ + privacy_notice_query = db.query(PrivacyNotice) + + if show_disabled is False: + privacy_notice_query = privacy_notice_query.filter( + PrivacyNotice.disabled.is_(False) + ) + + if region is not None: + privacy_notice_query = privacy_notice_query.filter( + PrivacyNotice.regions.contains([region]) + ) + + return ( + privacy_notice_query.filter(PrivacyNotice.regions.overlap(self.regions)) # type: ignore + .filter( + or_( + and_( + self.component == ComponentType.overlay, + PrivacyNotice.displayed_in_overlay, + ), + and_( + self.component == ComponentType.privacy_center, + PrivacyNotice.displayed_in_privacy_center, + ), + ) + ) + .order_by(PrivacyNotice.created_at.desc()) + .all() + ) + + histories = relationship( + "PrivacyExperienceHistory", backref="privacy_experience", lazy="dynamic" + ) + + @classmethod + def create( + cls: Type[PrivacyExperience], + db: Session, + *, + data: dict[str, Any], + check_name: bool = False, + ) -> PrivacyExperience: + """Create a privacy experience and the clone this record into the history table for record keeping""" + privacy_experience = super().create(db=db, data=data, check_name=check_name) + + # create the history after the initial object creation succeeds, to avoid + # writing history if the creation fails and so that we can get the generated ID + history_data = {**data, "privacy_experience_id": privacy_experience.id} + PrivacyExperienceHistory.create(db, data=history_data, check_name=False) + return privacy_experience + + def update(self, db: Session, *, data: dict[str, Any]) -> PrivacyExperience: + """ + Overrides the base update method to automatically bump the version of the + PrivacyExperience record and also create a new PrivacyExperienceHistory entry + """ + + # run through potential updates now + for key, value in data.items(): + setattr(self, key, value) + + # only if there's a modification do we write the history record + if db.is_modified(self): + # on any update to a privacy experience record, its version must be incremented + # version gets incremented by a full integer, i.e. 1.0 -> 2.0 -> 3.0 + self.version = float(self.version) + 1.0 # type: ignore + self.save(db) + + # history record data is identical to the new privacy experience record data + # except the experience's 'id' must be moved to the FK column + # and is no longer the history record 'id' column + history_data = self.__dict__.copy() + history_data.pop("_sa_instance_state") + history_data.pop("id") + history_data.pop("created_at") + history_data.pop("updated_at") + history_data["privacy_experience_id"] = self.id + + PrivacyExperienceHistory.create(db, data=history_data, check_name=False) + + return self + + @hybridproperty + def privacy_experience_history_id(self) -> Optional[str]: + """Convenience property that returns the historical privacy experience id for the current version. + + Note that there are possibly many historical records for the given experience, this just returns the current + corresponding historical record. + """ + history: PrivacyExperienceHistory = self.histories.filter_by( # type: ignore # pylint: disable=no-member + version=self.version + ).first() + return history.id if history else None + + +class PrivacyExperienceHistory(PrivacyExperienceBase, Base): + """Stores historical records of Privacy Experiences for Consent Reporting""" + + version = Column(Float, nullable=False, default=1.0) + privacy_experience_template_id = Column( + String, ForeignKey(PrivacyExperienceTemplate.id_field_path), nullable=True + ) + privacy_experience_id = Column( + String, ForeignKey(PrivacyExperience.id_field_path), nullable=False + ) diff --git a/src/fides/api/ops/schemas/privacy_experience.py b/src/fides/api/ops/schemas/privacy_experience.py new file mode 100644 index 00000000000..ec7bacc1b96 --- /dev/null +++ b/src/fides/api/ops/schemas/privacy_experience.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from pydantic import Extra, conlist + +from fides.api.custom_types import SafeStr +from fides.api.ops.models.privacy_experience import ComponentType, DeliveryMechanism +from fides.api.ops.models.privacy_notice import PrivacyNoticeRegion +from fides.api.ops.schemas.base_class import BaseSchema +from fides.api.ops.schemas.privacy_notice import PrivacyNoticeResponse + + +class PrivacyExperience(BaseSchema): + """ + Base for PrivacyExperience API objects + """ + + disabled: Optional[bool] = False + component: ComponentType + delivery_mechanism: DeliveryMechanism + regions: Optional[conlist(PrivacyNoticeRegion, min_items=1)] # type: ignore + component_title: Optional[SafeStr] + component_description: Optional[SafeStr] + banner_title: Optional[SafeStr] + banner_description: Optional[SafeStr] + link_label: Optional[SafeStr] + confirmation_button_label: Optional[SafeStr] + reject_button_label: Optional[SafeStr] + acknowledgement_button_label: Optional[SafeStr] + + class Config: + """Populate models with the raw value of enum fields, rather than the enum itself""" + + use_enum_values = True + orm_mode = True + extra = Extra.forbid + + +class PrivacyExperienceResponse(PrivacyExperience): + """ + An API representation of a PrivacyExperience used for response payloads + """ + + id: str + created_at: datetime + updated_at: datetime + version: float + privacy_experience_history_id: str + privacy_experience_template_id: Optional[str] + privacy_notices: Optional[List[PrivacyNoticeResponse]] + + +class PrivacyExperienceHistorySchema(PrivacyExperience): + """ + An API representation of a PrivacyExperienceHistory used for response payloads + """ + + version: float + privacy_experience_id: str + privacy_experience_template_id: str + + class Config: + use_enum_values = True + orm_mode = True diff --git a/src/fides/lib/oauth/roles.py b/src/fides/lib/oauth/roles.py index 3544bc807e1..6b671cbeb4d 100644 --- a/src/fides/lib/oauth/roles.py +++ b/src/fides/lib/oauth/roles.py @@ -25,6 +25,7 @@ MESSAGING_READ, ORGANIZATION_READ, POLICY_READ, + PRIVACY_EXPERIENCE_READ, PRIVACY_NOTICE_READ, PRIVACY_REQUEST_CALLBACK_RESUME, PRIVACY_REQUEST_NOTIFICATIONS_CREATE_OR_UPDATE, @@ -100,6 +101,7 @@ class RoleRegistryEnum(Enum): MASKING_READ, ORGANIZATION_READ, POLICY_READ, + PRIVACY_EXPERIENCE_READ, PRIVACY_NOTICE_READ, PRIVACY_REQUEST_READ, PRIVACY_REQUEST_NOTIFICATIONS_READ, diff --git a/tests/fixtures/application_fixtures.py b/tests/fixtures/application_fixtures.py index c3a728769d2..c582f6953ba 100644 --- a/tests/fixtures/application_fixtures.py +++ b/tests/fixtures/application_fixtures.py @@ -14,11 +14,9 @@ from sqlalchemy.orm.exc import ObjectDeletedError, StaleDataError from toml import load as load_toml -from fides.api.ctl.sql_models import ( - DataCategory as DataCategoryDbModel, - Dataset as CtlDataset, - System, -) +from fides.api.ctl.sql_models import DataCategory as DataCategoryDbModel +from fides.api.ctl.sql_models import Dataset as CtlDataset +from fides.api.ctl.sql_models import System from fides.api.ops.common_exceptions import SystemManagerException from fides.api.ops.models.application_config import ApplicationConfig from fides.api.ops.models.connectionconfig import ( @@ -36,6 +34,11 @@ Rule, RuleTarget, ) +from fides.api.ops.models.privacy_experience import ( + ComponentType, + DeliveryMechanism, + PrivacyExperience, +) from fides.api.ops.models.privacy_notice import ( ConsentMechanism, EnforcementLevel, @@ -1442,6 +1445,7 @@ def privacy_notice(db: Session) -> Generator: "consent_mechanism": ConsentMechanism.opt_in, "data_uses": ["advertising", "third_party_sharing"], "enforcement_level": EnforcementLevel.system_wide, + "displayed_in_privacy_center": True, }, ) @@ -1460,6 +1464,7 @@ def privacy_notice_us_ca_provide(db: Session) -> Generator: "consent_mechanism": ConsentMechanism.opt_in, "data_uses": ["provide"], "enforcement_level": EnforcementLevel.system_wide, + "displayed_in_privacy_center": False, }, ) @@ -1522,6 +1527,8 @@ def privacy_notice_us_co_provide_service_operations(db: Session) -> Generator: "consent_mechanism": ConsentMechanism.opt_in, "data_uses": ["provide.service.operations"], "enforcement_level": EnforcementLevel.system_wide, + "displayed_in_privacy_center": False, + "displayed_in_overlay": False, }, ) @@ -1540,6 +1547,24 @@ def privacy_notice_eu_fr_provide_service_frontend_only(db: Session) -> Generator "consent_mechanism": ConsentMechanism.opt_in, "data_uses": ["provide.service"], "enforcement_level": EnforcementLevel.frontend, + "displayed_in_overlay": True, + }, + ) + + yield privacy_notice + + +@pytest.fixture(scope="function") +def privacy_notice_eu_cy_provide_service_frontend_only(db: Session) -> Generator: + privacy_notice = PrivacyNotice.create( + db=db, + data={ + "name": "example privacy notice us_co provide.service.operations", + "description": "a sample privacy notice configuration", + "regions": [PrivacyNoticeRegion.eu_cy], + "consent_mechanism": ConsentMechanism.opt_out, + "data_uses": ["provide.service"], + "enforcement_level": EnforcementLevel.frontend, }, ) @@ -1985,3 +2010,63 @@ def consent_records( for record in records: record.delete(db) + + +@pytest.fixture(scope="function") +def privacy_experience_privacy_center_link(db: Session) -> Generator: + privacy_experience = PrivacyExperience.create( + db=db, + data={ + "component": ComponentType.privacy_center, + "delivery_mechanism": DeliveryMechanism.link, + "regions": [ + PrivacyNoticeRegion.us_ca, + PrivacyNoticeRegion.us_co, + ], + "component_title": "Manage your consent preferences", + "component_description": "On this page you can opt in and out of these data uses cases", + "link_label": "Manage your privacy", + }, + ) + + yield privacy_experience + + +@pytest.fixture(scope="function") +def privacy_experience_overlay_link(db: Session) -> Generator: + privacy_experience = PrivacyExperience.create( + db=db, + data={ + "component": ComponentType.overlay, + "delivery_mechanism": DeliveryMechanism.link, + "regions": [PrivacyNoticeRegion.eu_fr], + "component_title": "Manage your consent preferences", + "component_description": "On this page you can opt in and out of these data uses cases", + "link_label": "Manage your privacy", + }, + ) + + yield privacy_experience + + +@pytest.fixture(scope="function") +def privacy_experience_overlay_banner(db: Session) -> Generator: + privacy_experience = PrivacyExperience.create( + db=db, + data={ + "component": ComponentType.overlay, + "delivery_mechanism": DeliveryMechanism.banner, + "regions": [ + PrivacyNoticeRegion.us_ca, + ], + "component_title": "Manage your consent", + "component_description": "On this page you can opt in and out of these data uses cases", + "banner_title": "Manage your consent", + "banner_description": "We use cookies to recognize visitors and remember their preferences", + "confirmation_button_label": "Accept all", + "reject_button_label": "Reject all", + "disabled": True, + }, + ) + + yield privacy_experience diff --git a/tests/ops/api/v1/endpoints/test_privacy_experience_endpoints.py b/tests/ops/api/v1/endpoints/test_privacy_experience_endpoints.py new file mode 100644 index 00000000000..519da167975 --- /dev/null +++ b/tests/ops/api/v1/endpoints/test_privacy_experience_endpoints.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import pytest +from starlette.status import HTTP_200_OK, HTTP_403_FORBIDDEN +from starlette.testclient import TestClient + +from fides.api.ops.api.v1 import scope_registry as scopes +from fides.api.ops.api.v1.urn_registry import PRIVACY_EXPERIENCE, V1_URL_PREFIX + + +class TestGetPrivacyExperiences: + @pytest.fixture(scope="function") + def url(self) -> str: + return V1_URL_PREFIX + PRIVACY_EXPERIENCE + + def test_get_privacy_experiences_unauthenticated(self, url, api_client): + resp = api_client.get(url) + assert resp.status_code == 401 + + def test_get_privacy_experiences_wrong_scope( + self, url, api_client: TestClient, generate_auth_header + ): + auth_header = generate_auth_header(scopes=[scopes.STORAGE_READ]) + resp = api_client.get( + url, + headers=auth_header, + ) + assert resp.status_code == 403 + + @pytest.mark.parametrize( + "role,expected_status", + [ + ("owner", HTTP_200_OK), + ("contributor", HTTP_200_OK), + ("viewer_and_approver", HTTP_200_OK), + ("viewer", HTTP_200_OK), + ("approver", HTTP_403_FORBIDDEN), + ], + ) + def test_get_privacy_experience_with_roles( + self, role, expected_status, api_client: TestClient, url, generate_role_header + ) -> None: + auth_header = generate_role_header(roles=[role]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == expected_status + + def test_get_privacy_experiences( + self, + api_client: TestClient, + generate_auth_header, + url, + privacy_notice, + privacy_experience_privacy_center_link, + ): + auth_header = generate_auth_header(scopes=[scopes.PRIVACY_EXPERIENCE_READ]) + resp = api_client.get( + url, + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + + assert "items" in data + + # assert one experience in the response + assert data["total"] == 1 + assert len(data["items"]) == 1 + resp = data["items"][0] + + assert resp["disabled"] is False + assert resp["component"] == "privacy_center" + assert resp["delivery_mechanism"] == "link" + assert resp["regions"] == ["us_ca", "us_co"] + assert resp["component_title"] == "Manage your consent preferences" + assert ( + resp["component_description"] + == "On this page you can opt in and out of these data uses cases" + ) + assert resp["banner_title"] is None + assert resp["banner_description"] is None + assert resp["link_label"] == "Manage your privacy" + assert resp["confirmation_button_label"] is None + assert resp["reject_button_label"] is None + assert resp["acknowledgement_button_label"] is None + assert resp["id"] is not None + assert resp["version"] == 1 + assert resp["privacy_experience_history_id"] is not None + assert resp["privacy_experience_template_id"] is None + assert ( + resp["privacy_experience_history_id"] + == privacy_experience_privacy_center_link.privacy_experience_history_id + ) + assert len(resp["privacy_notices"]) == 1 + assert resp["privacy_notices"][0]["id"] == privacy_notice.id + + def test_get_privacy_experiences_show_disabled_filter( + self, + api_client: TestClient, + generate_auth_header, + url, + privacy_experience_privacy_center_link, + privacy_experience_overlay_link, + privacy_experience_overlay_banner, + ): + auth_header = generate_auth_header(scopes=[scopes.PRIVACY_EXPERIENCE_READ]) + resp = api_client.get( + url, + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 3 + assert len(data["items"]) == 3 + + resp = api_client.get( + url + "?show_disabled=False", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + assert len(data["items"]) == 2 + assert {exp["id"] for exp in data["items"]} == { + privacy_experience_privacy_center_link.id, + privacy_experience_overlay_link.id, + } + + assert privacy_experience_overlay_banner.id not in { + exp["id"] for exp in data["items"] + } + + def test_get_privacy_experiences_region_filter( + self, + api_client: TestClient, + generate_auth_header, + url, + privacy_experience_privacy_center_link, + privacy_experience_overlay_link, + privacy_experience_overlay_banner, + ): + auth_header = generate_auth_header(scopes=[scopes.PRIVACY_EXPERIENCE_READ]) + resp = api_client.get( + url + "?region=eu_fr", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == privacy_experience_overlay_link.id + assert data["items"][0]["regions"] == [ + reg.value for reg in privacy_experience_overlay_link.regions + ] + resp = api_client.get( + url + "?region=us_ca", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + assert len(data["items"]) == 2 + assert {exp["id"] for exp in data["items"]} == { + privacy_experience_privacy_center_link.id, + privacy_experience_overlay_banner.id, + } + + resp = api_client.get( + url + "?region=bad_region", + headers=auth_header, + ) + assert resp.status_code == 422 + + resp = api_client.get( + url + "?region=eu_it", + headers=auth_header, + ) + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + + def test_get_privacy_experiences_components_filter( + self, + api_client: TestClient, + generate_auth_header, + url, + privacy_experience_privacy_center_link, + privacy_experience_overlay_link, + privacy_experience_overlay_banner, + ): + auth_header = generate_auth_header(scopes=[scopes.PRIVACY_EXPERIENCE_READ]) + resp = api_client.get( + url + "?component=overlay", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + assert len(data["items"]) == 2 + assert {exp["id"] for exp in data["items"]} == { + privacy_experience_overlay_link.id, + privacy_experience_overlay_banner.id, + } + + resp = api_client.get( + url + "?component=privacy_center", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert len(data["items"]) == 1 + assert {exp["id"] for exp in data["items"]} == { + privacy_experience_privacy_center_link.id + } + + resp = api_client.get( + url + "?component=bad_type", + headers=auth_header, + ) + assert resp.status_code == 422 + + @pytest.mark.usefixtures( + "privacy_experience_privacy_center_link", + "privacy_experience_overlay_link", + "privacy_experience_overlay_banner", + ) + def test_get_privacy_experiences_has_notices_no_notices( + self, api_client: TestClient, generate_auth_header, url + ): + auth_header = generate_auth_header(scopes=[scopes.PRIVACY_EXPERIENCE_READ]) + resp = api_client.get( + url + "?has_notices=True", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert len(data["items"]) == 0 + + @pytest.mark.usefixtures( + "privacy_experience_privacy_center_link", + "privacy_experience_overlay_link", + "privacy_experience_overlay_banner", + "privacy_notice_eu_cy_provide_service_frontend_only", + ) + def test_get_privacy_experiences_has_notices_no_regions_overlap( + self, api_client: TestClient, generate_auth_header, url + ): + auth_header = generate_auth_header(scopes=[scopes.PRIVACY_EXPERIENCE_READ]) + resp = api_client.get( + url + "?has_notices=True", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert len(data["items"]) == 0 + + @pytest.mark.usefixtures( + "privacy_notice_us_co_provide_service_operations", # not displayed in overlay or privacy center + "privacy_notice_eu_cy_provide_service_frontend_only", # doesn't overlap with any regions + ) + def test_get_privacy_experiences_has_notices( + self, + api_client: TestClient, + generate_auth_header, + url, + privacy_experience_privacy_center_link, + privacy_experience_overlay_link, + privacy_experience_overlay_banner, + privacy_notice, + privacy_notice_us_co_third_party_sharing, + privacy_notice_eu_fr_provide_service_frontend_only, + privacy_notice_us_ca_provide, + ): + auth_header = generate_auth_header(scopes=[scopes.PRIVACY_EXPERIENCE_READ]) + resp = api_client.get( + url + "?has_notices=True", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + + assert data["total"] == 3 + assert len(data["items"]) == 3 + + first_experience = data["items"][0] + assert ( + first_experience["id"] == privacy_experience_overlay_banner.id + ) # Most recently created + assert first_experience["component"] == "overlay" + assert first_experience["regions"] == ["us_ca"] + assert len(first_experience["privacy_notices"]) == 2 + + # Notices match on region and "overlay" + privacy_experience_overlay_notice_1 = first_experience["privacy_notices"][0] + assert ( + privacy_experience_overlay_notice_1["id"] == privacy_notice_us_ca_provide.id + ) + assert privacy_experience_overlay_notice_1["regions"] == ["us_ca"] + assert privacy_experience_overlay_notice_1["displayed_in_overlay"] + + privacy_experience_overlay_notice_2 = first_experience["privacy_notices"][1] + assert privacy_experience_overlay_notice_2["id"] == privacy_notice.id + assert privacy_experience_overlay_notice_2["regions"] == ["us_ca", "us_co"] + assert privacy_experience_overlay_notice_2["displayed_in_overlay"] + + second_experience = data["items"][1] + assert ( + second_experience["id"] == privacy_experience_overlay_link.id + ) # Most recently created + assert second_experience["component"] == "overlay" + assert second_experience["regions"] == ["eu_fr"] + assert len(second_experience["privacy_notices"]) == 1 + + # Notices match on region and "overlay" + privacy_experience_overlay_link_notice_1 = second_experience["privacy_notices"][ + 0 + ] + assert ( + privacy_experience_overlay_link_notice_1["id"] + == privacy_notice_eu_fr_provide_service_frontend_only.id + ) + assert privacy_experience_overlay_link_notice_1["regions"] == ["eu_fr"] + assert privacy_experience_overlay_link_notice_1["displayed_in_overlay"] + + third_experience = data["items"][2] + assert ( + third_experience["id"] == privacy_experience_privacy_center_link.id + ) # Most recently created + assert third_experience["component"] == "privacy_center" + assert third_experience["regions"] == ["us_ca", "us_co"] + assert len(third_experience["privacy_notices"]) == 2 + + # Notices match on region and "overlay" + privacy_experience_privacy_center_notice_1 = third_experience[ + "privacy_notices" + ][0] + assert ( + privacy_experience_privacy_center_notice_1["id"] + == privacy_notice_us_co_third_party_sharing.id + ) + assert privacy_experience_privacy_center_notice_1["regions"] == ["us_co"] + assert privacy_experience_privacy_center_notice_1["displayed_in_privacy_center"] + + privacy_experience_privacy_center_notice_2 = third_experience[ + "privacy_notices" + ][1] + assert privacy_experience_privacy_center_notice_2["id"] == privacy_notice.id + assert privacy_experience_privacy_center_notice_2["regions"] == [ + "us_ca", + "us_co", + ] + assert privacy_experience_privacy_center_notice_2["displayed_in_privacy_center"] + + @pytest.mark.usefixtures( + "privacy_notice_us_co_provide_service_operations", # not displayed in overlay or privacy center + "privacy_notice_eu_cy_provide_service_frontend_only", # doesn't overlap with any regions, + "privacy_experience_overlay_link", # eu_fr, not co + "privacy_experience_overlay_banner", # us_ca, not co + "privacy_notice_eu_fr_provide_service_frontend_only", # eu_fr + "privacy_notice_us_ca_provide", # us_ca + ) + def test_filter_on_notices_and_region( + self, + api_client: TestClient, + generate_auth_header, + url, + privacy_experience_privacy_center_link, + privacy_notice, + privacy_notice_us_co_third_party_sharing, + ): + """Region filter propagates through to the notices too""" + auth_header = generate_auth_header(scopes=[scopes.PRIVACY_EXPERIENCE_READ]) + resp = api_client.get( + url + "?has_notices=True®ion=us_co", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + + assert data["total"] == 1 + assert len(data["items"]) == 1 + + assert data["items"][0]["id"] == privacy_experience_privacy_center_link.id + assert data["items"][0]["regions"] == ["us_ca", "us_co"] + + notices = data["items"][0]["privacy_notices"] + assert len(notices) == 2 + assert notices[0]["regions"] == ["us_co"] + assert notices[0]["id"] == privacy_notice_us_co_third_party_sharing.id + assert notices[0]["displayed_in_privacy_center"] + + assert notices[1]["regions"] == ["us_ca", "us_co"] + assert notices[1]["id"] == privacy_notice.id + assert notices[1]["displayed_in_privacy_center"] + + @pytest.mark.usefixtures( + "privacy_notice_us_co_provide_service_operations", # not displayed in overlay or privacy center + "privacy_notice_eu_cy_provide_service_frontend_only", # doesn't overlap with any regions, + "privacy_experience_overlay_link", # eu_fr, not co + "privacy_experience_overlay_banner", # us_ca, not co + "privacy_notice_eu_fr_provide_service_frontend_only", # eu_fr + "privacy_notice_us_ca_provide", # us_ca + ) + def test_filter_on_notices_and_region_and_show_disabled_is_false( + self, + api_client: TestClient, + generate_auth_header, + db, + url, + privacy_experience_privacy_center_link, + privacy_notice, + privacy_notice_us_co_third_party_sharing, + ): + """Region filter propagates through to the notices too""" + privacy_notice_us_co_third_party_sharing.disabled = True + privacy_notice_us_co_third_party_sharing.save(db) + + auth_header = generate_auth_header(scopes=[scopes.PRIVACY_EXPERIENCE_READ]) + resp = api_client.get( + url + "?has_notices=True®ion=us_co&show_disabled=False", + headers=auth_header, + ) + assert resp.status_code == 200 + data = resp.json() + + assert data["total"] == 1 + assert len(data["items"]) == 1 + + assert data["items"][0]["id"] == privacy_experience_privacy_center_link.id + assert data["items"][0]["regions"] == ["us_ca", "us_co"] + + notices = data["items"][0]["privacy_notices"] + assert len(notices) == 1 + assert notices[0]["regions"] == ["us_ca", "us_co"] + assert notices[0]["id"] == privacy_notice.id + assert notices[0]["displayed_in_privacy_center"] diff --git a/tests/ops/conftest.py b/tests/ops/conftest.py index e815c96e439..361457a9c18 100644 --- a/tests/ops/conftest.py +++ b/tests/ops/conftest.py @@ -2,9 +2,9 @@ tests/conftest.py file. """ -from fideslang import DEFAULT_TAXONOMY import pytest import requests +from fideslang import DEFAULT_TAXONOMY from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import ObjectDeletedError diff --git a/tests/ops/models/test_privacy_experience.py b/tests/ops/models/test_privacy_experience.py new file mode 100644 index 00000000000..65e5034eeed --- /dev/null +++ b/tests/ops/models/test_privacy_experience.py @@ -0,0 +1,218 @@ +from fides.api.ops.models.privacy_experience import ( + ComponentType, + DeliveryMechanism, + PrivacyExperience, +) +from fides.api.ops.models.privacy_notice import ( + ConsentMechanism, + EnforcementLevel, + PrivacyNotice, + PrivacyNoticeRegion, +) + + +class TestPrivacyExperience: + def test_create_privacy_experience(self, db): + """Assert PrivacyExperience and its historical record are created""" + exp = PrivacyExperience.create( + db=db, + data={ + "component": "overlay", + "delivery_mechanism": "banner", + "regions": ["us_va"], + "component_title": "Manage your privacy", + "banner_title": "Consent Options", + "banner_description": "Here's where you change your consent", + "confirmation_button_label": "Approve", + "reject_button_label": "Discard", + }, + check_name=False, + ) + + assert exp.disabled is False + assert exp.component == ComponentType.overlay + assert exp.delivery_mechanism == DeliveryMechanism.banner + assert exp.regions == [PrivacyNoticeRegion.us_va] + assert exp.component_title == "Manage your privacy" + assert exp.component_description is None + assert exp.banner_title == "Consent Options" + assert exp.banner_description == "Here's where you change your consent" + assert exp.link_label is None + assert exp.confirmation_button_label == "Approve" + assert exp.reject_button_label == "Discard" + assert exp.acknowledgement_button_label is None + assert exp.version == 1.0 + assert exp.privacy_experience_template_id is None + + history = exp.histories[0] + assert exp.privacy_experience_history_id == history.id + assert history.disabled is False + assert history.component == ComponentType.overlay + assert history.delivery_mechanism == DeliveryMechanism.banner + assert history.regions == [PrivacyNoticeRegion.us_va] + assert history.component_title == "Manage your privacy" + assert history.component_description is None + assert history.banner_title == "Consent Options" + assert history.banner_description == "Here's where you change your consent" + assert history.link_label is None + assert history.confirmation_button_label == "Approve" + assert history.reject_button_label == "Discard" + assert history.acknowledgement_button_label is None + assert history.version == 1.0 + assert history.privacy_experience_template_id is None + assert history.privacy_experience_id == exp.id + + history.delete(db) + exp.delete(db=db) + + def test_update_privacy_experience(self, db): + """Assert PrivacyExperience and its historical record are created""" + exp = PrivacyExperience.create( + db=db, + data={ + "component": "privacy_center", + "delivery_mechanism": "link", + "regions": ["eu_hu"], + "component_title": "Manage your privacy", + "link_label": "Go to the privacy center", + }, + check_name=False, + ) + + assert exp.disabled is False + assert exp.component == ComponentType.privacy_center + assert exp.delivery_mechanism == DeliveryMechanism.link + assert exp.regions == [PrivacyNoticeRegion.eu_hu] + assert exp.component_title == "Manage your privacy" + assert exp.component_description is None + assert exp.banner_description is None + assert exp.link_label == "Go to the privacy center" + assert exp.version == 1.0 + assert exp.privacy_experience_template_id is None + assert exp.acknowledgement_button_label is None + + assert exp.id is not None + exp_created_at = exp.created_at + exp_updated_at = exp.updated_at + + history = exp.histories[0] + assert exp.privacy_experience_history_id == history.id + assert history.disabled is False + assert history.component == ComponentType.privacy_center + assert history.delivery_mechanism == DeliveryMechanism.link + assert history.regions == [PrivacyNoticeRegion.eu_hu] + assert history.component_title == "Manage your privacy" + assert history.component_description is None + assert history.banner_description is None + assert history.link_label == "Go to the privacy center" + assert history.version == 1.0 + assert history.privacy_experience_template_id is None + + exp.update( + db, + data={ + "regions": [PrivacyNoticeRegion.eu_hu, PrivacyNoticeRegion.eu_at], + "banner_description": "Please verify your consent_options", + }, + ) + db.refresh(exp) + assert exp.regions == [PrivacyNoticeRegion.eu_hu, PrivacyNoticeRegion.eu_at] + assert exp.banner_description == "Please verify your consent_options" + assert exp.version == 2.0 + assert exp.histories.count() == 2 + assert exp.created_at == exp_created_at + assert exp.updated_at > exp_updated_at + + assert exp.privacy_experience_history_id == exp.histories[1].id + history_2 = exp.histories[1] + assert history_2.disabled is False + assert history_2.component == ComponentType.privacy_center + assert history_2.delivery_mechanism == DeliveryMechanism.link + assert history_2.regions == [ + PrivacyNoticeRegion.eu_hu, + PrivacyNoticeRegion.eu_at, + ] + assert history_2.component_title == "Manage your privacy" + assert history_2.component_description is None + assert history_2.banner_description == "Please verify your consent_options" + assert history_2.link_label == "Go to the privacy center" + assert history_2.version == 2.0 + assert history_2.privacy_experience_template_id is None + + history_2.delete(db) + history.delete(db) + exp.delete(db=db) + + def test_get_related_privacy_notices(self, db): + privacy_experience = PrivacyExperience.create( + db=db, + data={ + "component": ComponentType.overlay, + "delivery_mechanism": DeliveryMechanism.link, + "regions": [PrivacyNoticeRegion.eu_fr, PrivacyNoticeRegion.eu_at], + "component_title": "Manage your consent preferences", + "component_description": "On this page you can opt in and out of these data uses cases", + "link_label": "Manage your privacy", + }, + ) + + # No privacy notices exist + assert privacy_experience.get_related_privacy_notices(db) == [] + + privacy_notice = PrivacyNotice.create( + db=db, + data={ + "name": "Test privacy notice", + "description": "a test sample privacy notice configuration", + "regions": [PrivacyNoticeRegion.eu_fr], + "consent_mechanism": ConsentMechanism.opt_in, + "data_uses": ["advertising", "third_party_sharing"], + "enforcement_level": EnforcementLevel.system_wide, + "displayed_in_overlay": False, + "displayed_in_api": True, + "displayed_in_privacy_center": True, + }, + ) + + # Privacy Notice has a matching region, but is not displayed in overlay + assert privacy_experience.get_related_privacy_notices(db) == [] + + privacy_notice.displayed_in_overlay = True + privacy_notice.save(db) + + # Privacy Notice both has a matching region,and is displayed in overlay + assert privacy_experience.get_related_privacy_notices(db) == [privacy_notice] + + privacy_notice.regions = ["us_ca"] + privacy_notice.save(db) + # While privacy notice is displayed in the overlay, it doesn't have a matching region + assert privacy_experience.get_related_privacy_notices(db) == [] + + privacy_notice.regions = ["eu_at"] + privacy_notice.save(db) + + # Sanity check + assert privacy_experience.get_related_privacy_notices(db) == [privacy_notice] + + # France filter returns no notices + assert ( + privacy_experience.get_related_privacy_notices( + db, region=PrivacyNoticeRegion.eu_fr + ) + == [] + ) + + # Austria filter returns the one notice + assert privacy_experience.get_related_privacy_notices( + db, region=PrivacyNoticeRegion.eu_at + ) == [privacy_notice] + + privacy_notice.disabled = True + privacy_notice.save(db) + + assert privacy_experience.get_related_privacy_notices(db) == [privacy_notice] + # Disabled show by default but if show_disable is False, they're removed. + assert ( + privacy_experience.get_related_privacy_notices(db, show_disabled=False) + == [] + )