diff --git a/changelog/7297-chat-configurations.yaml b/changelog/7297-chat-configurations.yaml new file mode 100644 index 00000000000..2f7780ea97f --- /dev/null +++ b/changelog/7297-chat-configurations.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added support for chat provider configurations +pr: 7297 +labels: ["db-migration"] diff --git a/clients/admin-ui/src/features/chat-provider/ChatConfiguration.tsx b/clients/admin-ui/src/features/chat-provider/ChatConfiguration.tsx new file mode 100644 index 00000000000..1c3a1dd8f68 --- /dev/null +++ b/clients/admin-ui/src/features/chat-provider/ChatConfiguration.tsx @@ -0,0 +1,95 @@ +import { Flex, Form, Select, Spin } from "fidesui"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; + +import { useGetChatConfigQuery } from "./chatProvider.slice"; +import ConfigurationCard from "./components/ConfigurationCard"; +import { CHAT_PROVIDER_LABELS, CHAT_PROVIDER_TYPES } from "./constants"; +import SlackChatForm from "./forms/SlackChatForm"; + +const ChatConfiguration = () => { + const router = useRouter(); + const [selectedProviderType, setSelectedProviderType] = useState(""); + + // Get config ID from URL if editing + const configId = useMemo(() => { + const { id } = router.query; + return typeof id === "string" ? id : undefined; + }, [router.query]); + + // Check if we're in edit mode based on URL parameter + const isEditMode = !!configId; + + // Fetch existing config if editing + const { data: existingConfig, isLoading } = useGetChatConfigQuery(configId!, { + skip: !configId, + }); + + // Auto-select provider if one is already configured (edit mode) + useEffect(() => { + if (existingConfig?.provider_type) { + setSelectedProviderType(existingConfig.provider_type); + } + }, [existingConfig?.provider_type]); + + // Provider options for the dropdown + const providerOptions = useMemo(() => { + return [ + { + value: CHAT_PROVIDER_TYPES.SLACK, + label: CHAT_PROVIDER_LABELS[CHAT_PROVIDER_TYPES.SLACK], + }, + ]; + }, []); + + // Show loading state + if (isLoading) { + return ( + + + + ); + } + + const renderProviderForm = () => { + switch (selectedProviderType) { + case CHAT_PROVIDER_TYPES.SLACK: + return ; + default: + return null; + } + }; + + return ( +
+ {/* Provider Selection - only show in create mode (when no existing config) */} + {!isEditMode && ( + +
+ + + + + + + + + + + + + + + + + + + {isEditMode ? ( + <> + {/* Authorize button - show when not authorized */} + {!isAuthorized && ( + + )} + {/* Authorized status - show when OAuth is complete */} + {isAuthorized && } + + ) : ( + + )} + + + + +
+ ); +}; + +export default SlackChatForm; diff --git a/clients/admin-ui/src/features/chat-provider/icons/SlackIcon.tsx b/clients/admin-ui/src/features/chat-provider/icons/SlackIcon.tsx new file mode 100644 index 00000000000..5aa80cb9be7 --- /dev/null +++ b/clients/admin-ui/src/features/chat-provider/icons/SlackIcon.tsx @@ -0,0 +1,28 @@ +const SlackIcon = () => ( + + + + + + +); + +export default SlackIcon; diff --git a/clients/admin-ui/src/features/chat-provider/utils/urlHelpers.ts b/clients/admin-ui/src/features/chat-provider/utils/urlHelpers.ts new file mode 100644 index 00000000000..4e2d483a991 --- /dev/null +++ b/clients/admin-ui/src/features/chat-provider/utils/urlHelpers.ts @@ -0,0 +1,38 @@ +/** + * Cleans up URL query parameters while optionally preserving specific params. + * Useful after OAuth callbacks to remove temporary state/error params. + * + * @param preserveParams - Object with param names and values to keep in URL + */ +export const cleanupUrlParams = ( + preserveParams?: Record, +): void => { + const url = new URL(window.location.href); + const { pathname } = url; + + if (preserveParams && Object.keys(preserveParams).length > 0) { + const params = new URLSearchParams(); + Object.entries(preserveParams).forEach(([key, value]) => { + params.set(key, value); + }); + const newUrl = `${pathname}?${params.toString()}`; + window.history.replaceState({}, "", newUrl); + } else { + window.history.replaceState({}, "", pathname); + } +}; + +/** + * Gets OAuth error message based on error code. + */ +export const getOAuthErrorMessage = (errorCode: string): string => { + const errorMessages: Record = { + invalid_state: + "Authorization failed: Invalid state token. Please try again.", + not_configured: "Authorization failed: Chat provider not configured.", + token_failed: "Authorization failed: Could not obtain access token.", + no_token: "Authorization failed: No token received from Slack.", + }; + + return errorMessages[errorCode] ?? "Authorization failed. Please try again."; +}; diff --git a/clients/admin-ui/src/features/common/NotificationTabs.tsx b/clients/admin-ui/src/features/common/NotificationTabs.tsx index a55a9daf319..5432f5ffc41 100644 --- a/clients/admin-ui/src/features/common/NotificationTabs.tsx +++ b/clients/admin-ui/src/features/common/NotificationTabs.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/router"; import { useAppSelector } from "~/app/hooks"; import { useFeatures } from "~/features/common/features"; import { + CHAT_PROVIDERS_ROUTE, MESSAGING_PROVIDERS_ROUTE, NOTIFICATIONS_DIGESTS_ROUTE, NOTIFICATIONS_TEMPLATES_ROUTE, @@ -15,12 +16,14 @@ import { selectThisUsersScopes } from "../user-management"; const NotificationTabs = () => { const router = useRouter(); const currentPath = router.pathname; - const { plus } = useFeatures(); + const { plus, flags } = useFeatures(); // Determine which tab is active based on the current path - let selectedKey = "providers"; // Default to digests for non-Plus + let selectedKey = "email-providers"; // Default to email providers for non-Plus if (currentPath.startsWith(MESSAGING_PROVIDERS_ROUTE)) { - selectedKey = "providers"; + selectedKey = "email-providers"; + } else if (currentPath.startsWith(CHAT_PROVIDERS_ROUTE)) { + selectedKey = "chat-providers"; } else if (currentPath.startsWith(NOTIFICATIONS_TEMPLATES_ROUTE)) { selectedKey = "templates"; } else if (currentPath.startsWith(NOTIFICATIONS_DIGESTS_ROUTE)) { @@ -43,12 +46,20 @@ const NotificationTabs = () => { path: NOTIFICATIONS_DIGESTS_ROUTE, }, { - key: "providers", - label: "Providers", + key: "email-providers", + label: "Email providers", requiresPlus: false, scopes: [ScopeRegistryEnum.MESSAGING_CREATE_OR_UPDATE], path: MESSAGING_PROVIDERS_ROUTE, }, + { + key: "chat-providers", + label: "Chat providers", + requiresPlus: true, + requiresFlag: "alphaDataProtectionAssessments" as const, + scopes: [ScopeRegistryEnum.MESSAGING_CREATE_OR_UPDATE], + path: CHAT_PROVIDERS_ROUTE, + }, ]; // Remove unavailable tabs if not running plus @@ -56,6 +67,14 @@ const NotificationTabs = () => { menuItems = menuItems.filter((item) => !item.requiresPlus); } + // Remove tabs that require a feature flag that isn't enabled + menuItems = menuItems.filter( + (item) => + !("requiresFlag" in item) || + (item.requiresFlag === "alphaDataProtectionAssessments" && + flags?.alphaDataProtectionAssessments), + ); + // Filter scopes const userScopes = useAppSelector(selectThisUsersScopes); menuItems = menuItems.filter((item) => diff --git a/clients/admin-ui/src/features/common/api.slice.ts b/clients/admin-ui/src/features/common/api.slice.ts index f36aec450a4..56122e793c2 100644 --- a/clients/admin-ui/src/features/common/api.slice.ts +++ b/clients/admin-ui/src/features/common/api.slice.ts @@ -81,6 +81,7 @@ export const baseApi = createApi({ "Configuration Settings", "TCF Purpose Override", "OpenID Provider", + "Chat Provider Config", "Taxonomy", "Taxonomy History", "Digest Configs", diff --git a/clients/admin-ui/src/features/common/nav/routes.ts b/clients/admin-ui/src/features/common/nav/routes.ts index 4cafbd9d638..85ad20f6097 100644 --- a/clients/admin-ui/src/features/common/nav/routes.ts +++ b/clients/admin-ui/src/features/common/nav/routes.ts @@ -93,11 +93,16 @@ export const NOTIFICATIONS_DIGESTS_ROUTE = "/notifications/digests"; export const NOTIFICATIONS_ADD_DIGEST_ROUTE = "/notifications/digests/new"; export const NOTIFICATIONS_EDIT_DIGEST_ROUTE = "/notifications/digests/[id]"; -// Messaging providers (now part of notifications) +// Email providers (messaging providers, now part of notifications) export const MESSAGING_PROVIDERS_ROUTE = "/notifications/providers"; export const MESSAGING_PROVIDERS_EDIT_ROUTE = "/notifications/providers/[key]"; export const MESSAGING_PROVIDERS_NEW_ROUTE = "/notifications/providers/new"; +// Chat providers (Slack, Teams, etc.) +export const CHAT_PROVIDERS_ROUTE = "/notifications/chat-providers"; +export const CHAT_PROVIDERS_CONFIGURE_ROUTE = + "/notifications/chat-providers/configure"; + // OpenID Authentication group export const OPENID_AUTHENTICATION_ROUTE = "/settings/openid-authentication"; diff --git a/clients/admin-ui/src/flags.json b/clients/admin-ui/src/flags.json index 3d6bd5c6b04..2178ece5221 100644 --- a/clients/admin-ui/src/flags.json +++ b/clients/admin-ui/src/flags.json @@ -76,5 +76,12 @@ "development": true, "test": false, "production": false + }, + "alphaDataProtectionAssessments": { + "label": "Alpha data protection assessments", + "description": "Enable data protection impact assessments feature", + "development": true, + "test": false, + "production": false } } diff --git a/clients/admin-ui/src/pages/notifications/chat-providers/configure.tsx b/clients/admin-ui/src/pages/notifications/chat-providers/configure.tsx new file mode 100644 index 00000000000..6546316f819 --- /dev/null +++ b/clients/admin-ui/src/pages/notifications/chat-providers/configure.tsx @@ -0,0 +1,9 @@ +import { NextPage } from "next"; + +import { CreateChatConfiguration } from "~/features/chat-provider/CreateChatConfiguration"; + +const ConfigureChatProviderPage: NextPage = () => { + return ; +}; + +export default ConfigureChatProviderPage; diff --git a/clients/admin-ui/src/pages/notifications/chat-providers/index.tsx b/clients/admin-ui/src/pages/notifications/chat-providers/index.tsx new file mode 100644 index 00000000000..3934986a516 --- /dev/null +++ b/clients/admin-ui/src/pages/notifications/chat-providers/index.tsx @@ -0,0 +1,22 @@ +import { NextPage } from "next"; + +import { ChatConfigurations } from "~/features/chat-provider/ChatConfigurations"; +import Layout from "~/features/common/Layout"; +import NotificationTabs from "~/features/common/NotificationTabs"; +import PageHeader from "~/features/common/PageHeader"; +import Restrict from "~/features/common/Restrict"; +import { ScopeRegistryEnum } from "~/types/api"; + +const ChatProvidersPage: NextPage = () => { + return ( + + + + + + + + ); +}; + +export default ChatProvidersPage; diff --git a/clients/admin-ui/src/pages/settings/organization.tsx b/clients/admin-ui/src/pages/settings/organization.tsx index 280ae672a95..fa5fb52182b 100644 --- a/clients/admin-ui/src/pages/settings/organization.tsx +++ b/clients/admin-ui/src/pages/settings/organization.tsx @@ -22,7 +22,7 @@ const OrganizationPage: NextPage = () => { return ( - + Please use this section to manage your organization‘s details, diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index a5c59c8f995..7cec71a466e 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -62,6 +62,13 @@ export type { BulkPutStorageConfigResponse } from "./models/BulkPutStorageConfig export type { BulkReviewResponse } from "./models/BulkReviewResponse"; export type { BulkSoftDeletePrivacyRequests } from "./models/BulkSoftDeletePrivacyRequests"; export type { BulkUpdateFailed } from "./models/BulkUpdateFailed"; +export type { ChatChannelsResponse } from "./models/ChatChannelsResponse"; +export type { ChatConfigCreate } from "./models/ChatConfigCreate"; +export type { ChatConfigListResponse } from "./models/ChatConfigListResponse"; +export type { ChatConfigUpdate } from "./models/ChatConfigUpdate"; +export type { ChatProviderSecrets } from "./models/ChatProviderSecrets"; +export type { ChatProviderSettingsResponse } from "./models/ChatProviderSettingsResponse"; +export type { ChatProviderTestResponse } from "./models/ChatProviderTestResponse"; export type { CheckpointActionRequiredDetails } from "./models/CheckpointActionRequiredDetails"; export type { Classification } from "./models/Classification"; export type { ClassificationResponse } from "./models/ClassificationResponse"; @@ -541,10 +548,13 @@ export { ScopeRegistryEnum } from "./models/ScopeRegistryEnum"; export type { ScyllaDocsSchema } from "./models/ScyllaDocsSchema"; export type { SecurityApplicationConfig } from "./models/SecurityApplicationConfig"; export type { Selection } from "./models/Selection"; +export type { SendMessageRequest } from "./models/SendMessageRequest"; +export type { SendMessageResponse } from "./models/SendMessageResponse"; export { ServiceHealth } from "./models/ServiceHealth"; export { ServingComponent } from "./models/ServingComponent"; export type { SharedMonitorConfig } from "./models/SharedMonitorConfig"; export type { SingleFieldSubmissionResponse } from "./models/SingleFieldSubmissionResponse"; +export type { SlackChannel } from "./models/SlackChannel"; export type { SnowflakeDocsSchema } from "./models/SnowflakeDocsSchema"; export type { SovrnDocsSchema } from "./models/SovrnDocsSchema"; export { SpecialCategoryLegalBasisEnum } from "./models/SpecialCategoryLegalBasisEnum"; diff --git a/clients/admin-ui/src/types/api/models/ChatChannelsResponse.ts b/clients/admin-ui/src/types/api/models/ChatChannelsResponse.ts new file mode 100644 index 00000000000..175c204b534 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ChatChannelsResponse.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { SlackChannel } from "./SlackChannel"; + +/** + * Response schema for list of available channels. + */ +export type ChatChannelsResponse = { + channels: Array; +}; diff --git a/clients/admin-ui/src/types/api/models/ChatConfigCreate.ts b/clients/admin-ui/src/types/api/models/ChatConfigCreate.ts new file mode 100644 index 00000000000..2f9d3bfff6d --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ChatConfigCreate.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Schema for creating a new chat configuration. + */ +export type ChatConfigCreate = { + provider_type?: string; + workspace_url: string; + client_id?: string | null; + client_secret?: string | null; + signing_secret?: string | null; +}; diff --git a/clients/admin-ui/src/types/api/models/ChatConfigListResponse.ts b/clients/admin-ui/src/types/api/models/ChatConfigListResponse.ts new file mode 100644 index 00000000000..07c72060950 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ChatConfigListResponse.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { ChatProviderSettingsResponse } from "./ChatProviderSettingsResponse"; + +/** + * Response schema for list of chat configurations. + */ +export type ChatConfigListResponse = { + items: Array; + total: number; +}; diff --git a/clients/admin-ui/src/types/api/models/ChatConfigUpdate.ts b/clients/admin-ui/src/types/api/models/ChatConfigUpdate.ts new file mode 100644 index 00000000000..52e6b0a91b0 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ChatConfigUpdate.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Schema for updating an existing chat configuration. + */ +export type ChatConfigUpdate = { + provider_type?: string | null; + workspace_url?: string | null; + client_id?: string | null; + client_secret?: string | null; + signing_secret?: string | null; +}; diff --git a/clients/admin-ui/src/types/api/models/ChatProviderSecrets.ts b/clients/admin-ui/src/types/api/models/ChatProviderSecrets.ts new file mode 100644 index 00000000000..dab6b26d3e2 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ChatProviderSecrets.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Schema for updating secrets only. + */ +export type ChatProviderSecrets = { + client_secret?: string | null; + signing_secret?: string | null; +}; diff --git a/clients/admin-ui/src/types/api/models/ChatProviderSettingsResponse.ts b/clients/admin-ui/src/types/api/models/ChatProviderSettingsResponse.ts new file mode 100644 index 00000000000..e7350089c7a --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ChatProviderSettingsResponse.ts @@ -0,0 +1,20 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Response schema for chat provider settings (excludes secrets). + */ +export type ChatProviderSettingsResponse = { + id: string; + enabled: boolean; + provider_type: string; + workspace_url?: string | null; + client_id?: string | null; + authorized: boolean; + has_signing_secret?: boolean; + created_at: string; + updated_at: string; + workspace_name?: string | null; + connected_by_email?: string | null; +}; diff --git a/clients/admin-ui/src/types/api/models/ChatProviderTestResponse.ts b/clients/admin-ui/src/types/api/models/ChatProviderTestResponse.ts new file mode 100644 index 00000000000..e32615cf6ae --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ChatProviderTestResponse.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Response schema for chat provider connection test. + */ +export type ChatProviderTestResponse = { + success: boolean; + message: string; +}; diff --git a/clients/admin-ui/src/types/api/models/NotificationApplicationConfig.ts b/clients/admin-ui/src/types/api/models/NotificationApplicationConfig.ts index 6f8df6b46bb..482494d8322 100644 --- a/clients/admin-ui/src/types/api/models/NotificationApplicationConfig.ts +++ b/clients/admin-ui/src/types/api/models/NotificationApplicationConfig.ts @@ -11,4 +11,5 @@ export type NotificationApplicationConfig = { send_request_review_notification?: boolean | null; notification_service_type?: string | null; enable_property_specific_messaging?: boolean | null; + privacy_assessments_channel?: string | null; }; diff --git a/clients/admin-ui/src/types/api/models/SendMessageRequest.ts b/clients/admin-ui/src/types/api/models/SendMessageRequest.ts new file mode 100644 index 00000000000..7adb4f426a1 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/SendMessageRequest.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Request schema for sending a chat message. + */ +export type SendMessageRequest = { + channel_id: string; + message: string; +}; diff --git a/clients/admin-ui/src/types/api/models/SendMessageResponse.ts b/clients/admin-ui/src/types/api/models/SendMessageResponse.ts new file mode 100644 index 00000000000..cce7d784e41 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/SendMessageResponse.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Response schema for send message result. + */ +export type SendMessageResponse = { + success: boolean; + message: string; +}; diff --git a/clients/admin-ui/src/types/api/models/SlackChannel.ts b/clients/admin-ui/src/types/api/models/SlackChannel.ts new file mode 100644 index 00000000000..9228417d55d --- /dev/null +++ b/clients/admin-ui/src/types/api/models/SlackChannel.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Schema for a Slack channel. + */ +export type SlackChannel = { + id: string; + name: string; +}; diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_14_1200_add_chat_config.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_14_1200_add_chat_config.py new file mode 100644 index 00000000000..a5f90ca5ef6 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_14_1200_add_chat_config.py @@ -0,0 +1,67 @@ +"""add chat config + +Revision ID: c1d2e3f4a5b6 +Revises: 6d5f70dd0ba5 +Create Date: 2026-01-14 12:00:00.000000 + +""" + +import sqlalchemy as sa +import sqlalchemy_utils +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c1d2e3f4a5b6" +down_revision = "6d5f70dd0ba5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "chat_config", + 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("provider_type", sa.String(), nullable=False, server_default="slack"), + sa.Column("workspace_url", sa.String(), nullable=True), + sa.Column("client_id", sa.String(), nullable=True), + sa.Column( + "client_secret", + sqlalchemy_utils.types.encrypted.encrypted_type.StringEncryptedType(), + nullable=True, + ), + sa.Column( + "access_token", + sqlalchemy_utils.types.encrypted.encrypted_type.StringEncryptedType(), + nullable=True, + ), + sa.Column( + "signing_secret", + sqlalchemy_utils.types.encrypted.encrypted_type.StringEncryptedType(), + nullable=True, + ), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("workspace_name", sa.String(), nullable=True), + sa.Column("connected_by_email", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("workspace_url", name="chat_config_workspace_url_unique"), + ) + op.create_index( + op.f("ix_chat_config_id"), "chat_config", ["id"], unique=False + ) + + +def downgrade(): + op.drop_index(op.f("ix_chat_config_id"), table_name="chat_config") + op.drop_table("chat_config") diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py index 97891b2f715..f69af376156 100644 --- a/src/fides/api/db/base.py +++ b/src/fides/api/db/base.py @@ -7,6 +7,7 @@ from fides.api.models.attachment import Attachment, AttachmentReference from fides.api.models.audit_log import AuditLog from fides.api.models.authentication_request import AuthenticationRequest +from fides.api.models.chat_config import ChatConfig from fides.api.models.client import ClientDetail from fides.api.models.comment import Comment, CommentReference from fides.api.models.connection_oauth_credentials import OAuthConfig diff --git a/src/fides/api/models/chat_config.py b/src/fides/api/models/chat_config.py new file mode 100644 index 00000000000..86757fd995e --- /dev/null +++ b/src/fides/api/models/chat_config.py @@ -0,0 +1,69 @@ +"""SQLAlchemy model for chat configuration (Slack, Teams, etc.).""" + +from sqlalchemy import Boolean, Column, String, UniqueConstraint +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy_utils.types.encrypted.encrypted_type import ( + AesGcmEngine, + StringEncryptedType, +) + +from fides.api.db.base_class import Base +from fides.config import CONFIG + + +class ChatConfig(Base): + """ + Stores configuration for chat providers (Slack, Microsoft Teams, etc.). + + Multiple configurations can exist (e.g., different Slack workspaces), + but only one can be enabled at a time globally. + """ + + @declared_attr + def __tablename__(self) -> str: + return "chat_config" + + provider_type = Column(String, nullable=False, default="slack") + workspace_url = Column(String, nullable=True) + client_id = Column(String, nullable=True) + client_secret = Column( + StringEncryptedType( + type_in=String(), + key=CONFIG.security.app_encryption_key, + engine=AesGcmEngine, + padding="pkcs5", + ), + nullable=True, + ) + access_token = Column( + StringEncryptedType( + type_in=String(), + key=CONFIG.security.app_encryption_key, + engine=AesGcmEngine, + padding="pkcs5", + ), + nullable=True, + ) + signing_secret = Column( + StringEncryptedType( + type_in=String(), + key=CONFIG.security.app_encryption_key, + engine=AesGcmEngine, + padding="pkcs5", + ), + nullable=True, + ) + enabled = Column(Boolean, nullable=False, default=False) + workspace_name = Column(String, nullable=True) + connected_by_email = Column(String, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "workspace_url", name="chat_config_workspace_url_unique" + ), + ) + + @property + def authorized(self) -> bool: + """Check if the provider has been authorized (has an access token).""" + return self.access_token is not None diff --git a/src/fides/api/schemas/application_config.py b/src/fides/api/schemas/application_config.py index e0175e00e25..722bc8b2649 100644 --- a/src/fides/api/schemas/application_config.py +++ b/src/fides/api/schemas/application_config.py @@ -49,6 +49,7 @@ class NotificationApplicationConfig(FidesSchema): send_request_review_notification: Optional[bool] = None notification_service_type: Optional[str] = None enable_property_specific_messaging: Optional[bool] = None + privacy_assessments_channel: Optional[str] = None model_config = ConfigDict(extra="forbid") @field_validator("notification_service_type", mode="before") diff --git a/src/fides/api/service/privacy_request/request_runner_service.py b/src/fides/api/service/privacy_request/request_runner_service.py index d16a18af931..e38c2556abb 100644 --- a/src/fides/api/service/privacy_request/request_runner_service.py +++ b/src/fides/api/service/privacy_request/request_runner_service.py @@ -894,10 +894,12 @@ def run_privacy_request( ) if consent_message_enabled: try: - email_sent = initiate_consent_request_completion_email( - session, - identity_data, - privacy_request.property_id, + email_sent = ( + initiate_consent_request_completion_email( + session, + identity_data, + privacy_request.property_id, + ) ) if email_sent: privacy_request.add_success_execution_log( @@ -956,9 +958,7 @@ def initiate_consent_request_completion_email( # Consent requests can be submitted with only fides_user_device_id or external_id, # which don't have an email to send a completion message to. # This is not an error - just skip sending the completion email. - logger.info( - "Skipping consent completion email: no email in identity data" - ) + logger.info("Skipping consent completion email: no email in identity data") return False to_identity: Identity = Identity(email=email) diff --git a/src/fides/api/task/conditional_dependencies/policy_evaluation.py b/src/fides/api/task/conditional_dependencies/policy_evaluation.py index 3ac0c76ffc5..2e7225ee9c5 100644 --- a/src/fides/api/task/conditional_dependencies/policy_evaluation.py +++ b/src/fides/api/task/conditional_dependencies/policy_evaluation.py @@ -148,9 +148,7 @@ def evaluate_policy_conditions( matches: list[tuple[PolicyEvaluationSpecificity, PolicyEvaluationResult]] = [] for policy_condition in policy_conditions: - result = self._evaluate_single_condition( - policy_condition, data_transformer - ) + result = self._evaluate_single_condition(policy_condition, data_transformer) if result: logger.debug( f"Policy {policy_condition.policy.key} matched with specificity " @@ -159,7 +157,9 @@ def evaluate_policy_conditions( matches.append(result) if not matches: - logger.debug(f"No policies matched for action type {action_type}, falling back to default policy") + logger.debug( + f"No policies matched for action type {action_type}, falling back to default policy" + ) return self._get_default_policy(action_type) # Sort by: condition count (desc), location tier (desc) @@ -167,9 +167,7 @@ def evaluate_policy_conditions( best_specificity, best_match = matches[0] # Check for ambiguous ties - multiple policies with same specificity - tied_policies = [ - m[1].policy.key for m in matches if m[0] == best_specificity - ] + tied_policies = [m[1].policy.key for m in matches if m[0] == best_specificity] if len(tied_policies) > 1: raise PolicyEvaluationError( f"Ambiguous policy match: policies {tied_policies} have identical " @@ -223,7 +221,7 @@ def _evaluate_single_condition( policy=policy_condition.policy, evaluation_result=evaluation_result, is_default=False, - ) + ), ) def _get_default_policy(self, action_type: ActionType) -> PolicyEvaluationResult: @@ -245,7 +243,11 @@ def _get_default_policy(self, action_type: ActionType) -> PolicyEvaluationResult default_key = self.DEFAULT_POLICY_KEYS.get(action_type) # Query for the specific default policy by key - policy = Policy.get_by(self.db, field="key", value=default_key) if default_key else None + policy = ( + Policy.get_by(self.db, field="key", value=default_key) + if default_key + else None + ) if not policy: raise PolicyEvaluationError( diff --git a/src/fides/api/task/conditional_dependencies/schemas.py b/src/fides/api/task/conditional_dependencies/schemas.py index 627a1369f59..00ed23f940c 100644 --- a/src/fides/api/task/conditional_dependencies/schemas.py +++ b/src/fides/api/task/conditional_dependencies/schemas.py @@ -140,11 +140,14 @@ class PolicyEvaluationResult(BaseModel): evaluation_result: Optional[EvaluationResult] = Field( None, description="Detailed evaluation report (None for default policy)" ) - is_default: bool = Field( - False, description="Whether the default policy was used" - ) + is_default: bool = Field(False, description="Whether the default policy was used") + + model_config = { + "from_attributes": True, + "frozen": True, + "arbitrary_types_allowed": True, + } - model_config = {"from_attributes": True, "frozen": True, "arbitrary_types_allowed": True} class PolicyEvaluationSpecificity(BaseModel): """Specificity of a policy evaluation. diff --git a/src/fides/api/task/conditional_dependencies/util.py b/src/fides/api/task/conditional_dependencies/util.py index d12195bf793..ef6b9dc2f80 100644 --- a/src/fides/api/task/conditional_dependencies/util.py +++ b/src/fides/api/task/conditional_dependencies/util.py @@ -121,7 +121,7 @@ def set_nested_value(path: list[str], value: Any) -> dict[str, Any]: def extract_leaves( - condition: Union[ConditionLeaf, ConditionGroup, dict, None] + condition: Union[ConditionLeaf, ConditionGroup, dict, None], ) -> list[ConditionLeaf]: """ Recursively extracts all leaf conditions from a condition tree. @@ -162,7 +162,7 @@ def extract_leaves( def extract_field_addresses( - condition: Union[ConditionLeaf, ConditionGroup, dict, None] + condition: Union[ConditionLeaf, ConditionGroup, dict, None], ) -> set[str]: """ Recursively extracts all field addresses from a condition tree. diff --git a/src/fides/config/config_proxy.py b/src/fides/config/config_proxy.py index a8f40de12dd..ec2de4ec7f4 100644 --- a/src/fides/config/config_proxy.py +++ b/src/fides/config/config_proxy.py @@ -104,6 +104,7 @@ class NotificationSettingsProxy(ConfigProxyBase): send_request_review_notification: bool notification_service_type: Optional[str] enable_property_specific_messaging: Optional[str] + privacy_assessments_channel: Optional[str] class ExecutionSettingsProxy(ConfigProxyBase): diff --git a/tests/api/task/conditional_dependencies/test_policy_evaluation.py b/tests/api/task/conditional_dependencies/test_policy_evaluation.py index a4b6914423d..422e1de5b1d 100644 --- a/tests/api/task/conditional_dependencies/test_policy_evaluation.py +++ b/tests/api/task/conditional_dependencies/test_policy_evaluation.py @@ -44,6 +44,7 @@ PrivacyRequestFields.origin.value: ("https://example.com", Operator.eq), } + @pytest.fixture def evaluator(db: Session) -> PolicyEvaluator: return PolicyEvaluator(db) @@ -71,14 +72,21 @@ def _create_policy_with_rule( "policy_id": policy.id, } if action_type == ActionType.erasure: - rule_data["masking_strategy"] = {"strategy": "null_rewrite", "configuration": {}} + rule_data["masking_strategy"] = { + "strategy": "null_rewrite", + "configuration": {}, + } Rule.create(db=db, data=rule_data) return policy def _add_condition(db: Session, policy: Policy, condition_tree) -> None: """Add a condition tree to a policy""" - tree = condition_tree.model_dump() if hasattr(condition_tree, "model_dump") else condition_tree + tree = ( + condition_tree.model_dump() + if hasattr(condition_tree, "model_dump") + else condition_tree + ) PolicyCondition.create(db=db, data={"policy_id": policy.id, "condition_tree": tree}) @@ -125,11 +133,14 @@ def test_routes_by_location( assert result.policy.key == expected - def test_falls_back_when_specific_policy_doesnt_match(self, db: Session, evaluator: PolicyEvaluator): + def test_falls_back_when_specific_policy_doesnt_match( + self, db: Session, evaluator: PolicyEvaluator + ): """Falls back to less specific policy when more specific doesn't match""" _create_policy_with_condition(db, "general", _leaf(LOCATION_REGULATIONS_FIELD)) _create_policy_with_condition( - db, "specific", + db, + "specific", ConditionGroup( logical_operator=GroupOperator.and_, conditions=[_leaf(LOCATION_COUNTRY_FIELD), _leaf(SOURCE_FIELD)], @@ -149,7 +160,9 @@ def test_falls_back_when_specific_policy_doesnt_match(self, db: Session, evaluat class TestDefaultFallback: """Test default policy fallback""" - def test_uses_default_for_action_type(self, db: Session, evaluator: PolicyEvaluator): + def test_uses_default_for_action_type( + self, db: Session, evaluator: PolicyEvaluator + ): """Queries for specific default policy based on action type when no conditions match""" # Create a conditional policy that won't match _create_policy_with_condition(db, "conditional", _leaf(LOCATION_FIELD, "US")) @@ -174,8 +187,9 @@ def test_uses_default_for_action_type(self, db: Session, evaluator: PolicyEvalua class TestPolicyEvaluationError: - - def test_raises_when_default_policy_missing(self, db: Session, evaluator: PolicyEvaluator): + def test_raises_when_default_policy_missing( + self, db: Session, evaluator: PolicyEvaluator + ): """Raises error when default policy for action type doesn't exist (no seed_data)""" pr = _create_request("US") @@ -195,11 +209,14 @@ class TestPolicySpecificity: an error is raised since this is an ambiguous configuration. """ - def test_more_conditions_beats_fewer_conditions(self, db: Session, evaluator: PolicyEvaluator): + def test_more_conditions_beats_fewer_conditions( + self, db: Session, evaluator: PolicyEvaluator + ): """Policy with more conditions wins, regardless of tier""" # Policy with 2 conditions (tier 3 from country) _create_policy_with_condition( - db, "two_conditions", + db, + "two_conditions", ConditionGroup( logical_operator=GroupOperator.and_, conditions=[_leaf(LOCATION_COUNTRY_FIELD), _leaf(SOURCE_FIELD)], @@ -219,10 +236,22 @@ def test_more_conditions_beats_fewer_conditions(self, db: Session, evaluator: Po "winner_field,loser_field", [ param(LOCATION_FIELD, LOCATION_COUNTRY_FIELD, id="exact_beats_country"), - param(LOCATION_COUNTRY_FIELD, LOCATION_GROUPS_FIELD, id="country_beats_groups"), - param(LOCATION_GROUPS_FIELD, LOCATION_REGULATIONS_FIELD, id="groups_beats_regulations"), - param(LOCATION_REGULATIONS_FIELD, SOURCE_FIELD, id="regulations_beats_non_location"), - param(LOCATION_COUNTRY_FIELD, SOURCE_FIELD, id="country_beats_non_location"), + param( + LOCATION_COUNTRY_FIELD, LOCATION_GROUPS_FIELD, id="country_beats_groups" + ), + param( + LOCATION_GROUPS_FIELD, + LOCATION_REGULATIONS_FIELD, + id="groups_beats_regulations", + ), + param( + LOCATION_REGULATIONS_FIELD, + SOURCE_FIELD, + id="regulations_beats_non_location", + ), + param( + LOCATION_COUNTRY_FIELD, SOURCE_FIELD, id="country_beats_non_location" + ), ], ) def test_higher_tier_wins_for_equal_condition_count(