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 && (
+
+
+
+
+
+ )}
+
+ {/* Render the appropriate form based on selected provider */}
+ {selectedProviderType && renderProviderForm()}
+
+ );
+};
+
+export default ChatConfiguration;
diff --git a/clients/admin-ui/src/features/chat-provider/ChatConfigurations.tsx b/clients/admin-ui/src/features/chat-provider/ChatConfigurations.tsx
new file mode 100644
index 00000000000..34542a740f0
--- /dev/null
+++ b/clients/admin-ui/src/features/chat-provider/ChatConfigurations.tsx
@@ -0,0 +1,361 @@
+/* eslint-disable react/no-unstable-nested-components */
+import type { ColumnsType } from "antd/es/table";
+import {
+ Button,
+ Empty,
+ Flex,
+ Icons,
+ Modal,
+ Space,
+ Switch,
+ Table,
+ Tag,
+ Typography,
+ useMessage,
+} from "fidesui";
+import { useRouter } from "next/router";
+import { useCallback, useMemo, useState } from "react";
+
+import { getErrorMessage, isErrorResult } from "~/features/common/helpers";
+import { CHAT_PROVIDERS_CONFIGURE_ROUTE } from "~/features/common/nav/routes";
+import { useHasPermission } from "~/features/common/Restrict";
+import { TableSkeletonLoader } from "~/features/common/table/v2";
+import { ChatProviderSettingsResponse, ScopeRegistryEnum } from "~/types/api";
+
+import {
+ useDeleteChatConfigMutation,
+ useEnableChatConfigMutation,
+ useGetChatConfigsQuery,
+} from "./chatProvider.slice";
+import SlackIcon from "./icons/SlackIcon";
+
+const { Text } = Typography;
+
+// Define column keys for type safety
+enum ChatProviderColumnKeys {
+ PROVIDER = "provider",
+ STATUS = "status",
+ ENABLED = "enabled",
+ ACTIONS = "actions",
+}
+
+const EmptyTableNotice = () => {
+ return (
+
+ No chat providers found.
+
+ Click "Add a chat provider" to configure Slack or other
+ chat integrations.
+
+
+ }
+ data-testid="no-results-notice"
+ />
+ );
+};
+
+export const ChatConfigurations = () => {
+ const router = useRouter();
+ const message = useMessage();
+ const { data: configsData, isLoading, refetch } = useGetChatConfigsQuery();
+ const [deleteConfig] = useDeleteChatConfigMutation();
+ const [enableConfig] = useEnableChatConfigMutation();
+
+ // Permissions
+ const userCanUpdate = useHasPermission([
+ ScopeRegistryEnum.MESSAGING_CREATE_OR_UPDATE,
+ ]);
+
+ // Delete modal state
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [configToDelete, setConfigToDelete] =
+ useState(null);
+
+ // Use items from the list response
+ const tableData = useMemo(() => {
+ return configsData?.items ?? [];
+ }, [configsData]);
+
+ const handleEditConfiguration = useCallback(
+ (configId: string) => {
+ router.push(`${CHAT_PROVIDERS_CONFIGURE_ROUTE}?id=${configId}`);
+ },
+ [router],
+ );
+
+ const handleDeleteConfiguration = useCallback(
+ (config: ChatProviderSettingsResponse) => {
+ setConfigToDelete(config);
+ setDeleteModalOpen(true);
+ },
+ [],
+ );
+
+ const handleEnableConfiguration = useCallback(
+ async (configId: string) => {
+ try {
+ const result = await enableConfig(configId);
+ if (isErrorResult(result)) {
+ message.error(getErrorMessage(result.error, "Failed to enable"));
+ } else {
+ message.success("Chat provider enabled");
+ refetch();
+ }
+ } catch {
+ message.error("Failed to enable chat provider");
+ }
+ },
+ [enableConfig, message, refetch],
+ );
+
+ const confirmDelete = useCallback(async () => {
+ if (!configToDelete) {
+ return;
+ }
+ try {
+ const result = await deleteConfig(configToDelete.id);
+ if (isErrorResult(result)) {
+ message.error(getErrorMessage(result.error, "Failed to delete"));
+ } else {
+ message.success("Chat provider deleted successfully");
+ refetch();
+ }
+ } finally {
+ setDeleteModalOpen(false);
+ setConfigToDelete(null);
+ }
+ }, [configToDelete, deleteConfig, message, refetch]);
+
+ const cancelDelete = useCallback(() => {
+ setDeleteModalOpen(false);
+ setConfigToDelete(null);
+ }, []);
+
+ // Column definitions
+ const columns: ColumnsType = useMemo(
+ () => [
+ {
+ title: "Provider",
+ key: ChatProviderColumnKeys.PROVIDER,
+ render: (_value: unknown, record: ChatProviderSettingsResponse) => {
+ const getProviderIcon = () => {
+ switch (record.provider_type) {
+ case "slack":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ // Display as "Slack (workspace_name)" if authorized, otherwise "Slack (workspace_url)"
+ const providerLabel =
+ record.provider_type.charAt(0).toUpperCase() +
+ record.provider_type.slice(1);
+
+ let displayName = providerLabel;
+ if (record.workspace_name) {
+ displayName = `${providerLabel} (${record.workspace_name})`;
+ } else if (record.workspace_url) {
+ displayName = `${providerLabel} (${record.workspace_url})`;
+ }
+
+ return (
+
+ {getProviderIcon()}
+ {displayName}
+
+ );
+ },
+ },
+ {
+ title: "Status",
+ key: ChatProviderColumnKeys.STATUS,
+ render: (_value: unknown, record: ChatProviderSettingsResponse) => {
+ if (record.authorized) {
+ return (
+
+ Authorized
+
+ );
+ }
+ if (record.client_id) {
+ return (
+
+ Not authorized
+
+ );
+ }
+ return Not configured;
+ },
+ },
+ {
+ title: "Enabled",
+ key: ChatProviderColumnKeys.ENABLED,
+ width: 100,
+ render: (_value: unknown, record: ChatProviderSettingsResponse) => (
+ {
+ if (!record.enabled) {
+ handleEnableConfiguration(record.id);
+ }
+ }}
+ title={
+ record.enabled
+ ? "This provider is currently active"
+ : "Click to enable this provider"
+ }
+ data-testid="enable-chat-provider-switch"
+ />
+ ),
+ },
+ {
+ title: "Actions",
+ key: ChatProviderColumnKeys.ACTIONS,
+ render: (_value: unknown, record: ChatProviderSettingsResponse) => (
+
+ {userCanUpdate && !record.authorized && record.client_id && (
+
+ )}
+ {userCanUpdate && (
+
+ ),
+ },
+ ],
+ [
+ userCanUpdate,
+ handleEditConfiguration,
+ handleDeleteConfiguration,
+ handleEnableConfiguration,
+ ],
+ );
+
+ // Delete confirmation modal
+ const deleteModal = useMemo(
+ () => (
+
+ Cancel
+ ,
+
+ Delete
+ ,
+ ]}
+ >
+
+
+ Are you sure you want to delete the Slack provider for{" "}
+
+ {configToDelete?.workspace_name ?? configToDelete?.workspace_url}
+
+ ?
+
+ This action cannot be undone.
+
+
+ ),
+ [deleteModalOpen, configToDelete, cancelDelete, confirmDelete],
+ );
+
+ return (
+ <>
+
+
+ Configure chat providers to enable notifications, alerts, and
+ AI-powered privacy assessment questionnaires through platforms like
+ Slack.
+
+
+ {userCanUpdate && (
+
+ {
+ router.push(CHAT_PROVIDERS_CONFIGURE_ROUTE);
+ }}
+ role="link"
+ type="primary"
+ icon={}
+ iconPosition="end"
+ data-testid="add-chat-provider-btn"
+ >
+ Add a chat provider
+
+
+ )}
+
+ {isLoading ? (
+
+ ) : (
+ ,
+ }}
+ />
+ )}
+
+
+ {deleteModal}
+ >
+ );
+};
+
+export default ChatConfigurations;
diff --git a/clients/admin-ui/src/features/chat-provider/CreateChatConfiguration.tsx b/clients/admin-ui/src/features/chat-provider/CreateChatConfiguration.tsx
new file mode 100644
index 00000000000..8c4b987c146
--- /dev/null
+++ b/clients/admin-ui/src/features/chat-provider/CreateChatConfiguration.tsx
@@ -0,0 +1,24 @@
+import { Typography } from "fidesui";
+
+import Layout from "~/features/common/Layout";
+import BackButton from "~/features/common/nav/BackButton";
+import { CHAT_PROVIDERS_ROUTE } from "~/features/common/nav/routes";
+
+import ChatConfiguration from "./ChatConfiguration";
+
+const { Title } = Typography;
+
+export const CreateChatConfiguration = () => {
+ return (
+
+
+
+
+ Configure your chat provider
+
+
+
+ );
+};
+
+export default CreateChatConfiguration;
diff --git a/clients/admin-ui/src/features/chat-provider/chatProvider.slice.ts b/clients/admin-ui/src/features/chat-provider/chatProvider.slice.ts
new file mode 100644
index 00000000000..347ff3ba638
--- /dev/null
+++ b/clients/admin-ui/src/features/chat-provider/chatProvider.slice.ts
@@ -0,0 +1,107 @@
+import { baseApi } from "~/features/common/api.slice";
+import type {
+ ChatConfigCreate,
+ ChatConfigListResponse,
+ ChatConfigUpdate,
+ ChatProviderSecrets,
+ ChatProviderSettingsResponse,
+ ChatProviderTestResponse,
+ SendMessageRequest,
+ SendMessageResponse,
+} from "~/types/api";
+
+const chatProviderApi = baseApi.injectEndpoints({
+ endpoints: (build) => ({
+ testChatConnection: build.mutation({
+ query: () => ({
+ url: "plus/chat/test",
+ method: "POST",
+ }),
+ }),
+ // Chat Provider Configuration CRUD endpoints
+ getChatConfigs: build.query({
+ query: () => ({ url: "plus/chat/config" }),
+ providesTags: ["Chat Provider Config"],
+ }),
+ createChatConfig: build.mutation<
+ ChatProviderSettingsResponse,
+ ChatConfigCreate
+ >({
+ query: (body) => ({
+ url: "plus/chat/config",
+ method: "POST",
+ body,
+ }),
+ invalidatesTags: ["Chat Provider Config"],
+ }),
+ getChatConfig: build.query({
+ query: (configId) => ({ url: `plus/chat/config/${configId}` }),
+ providesTags: (_result, _error, id) => [
+ { type: "Chat Provider Config", id },
+ ],
+ }),
+ updateChatConfig: build.mutation<
+ ChatProviderSettingsResponse,
+ { configId: string; data: ChatConfigUpdate }
+ >({
+ query: ({ configId, data }) => ({
+ url: `plus/chat/config/${configId}`,
+ method: "PATCH",
+ body: data,
+ }),
+ invalidatesTags: (_result, _error, { configId }) => [
+ "Chat Provider Config",
+ { type: "Chat Provider Config", id: configId },
+ ],
+ }),
+ deleteChatConfig: build.mutation({
+ query: (configId) => ({
+ url: `plus/chat/config/${configId}`,
+ method: "DELETE",
+ }),
+ invalidatesTags: ["Chat Provider Config"],
+ }),
+ enableChatConfig: build.mutation({
+ query: (configId) => ({
+ url: `plus/chat/config/${configId}/enable`,
+ method: "PUT",
+ }),
+ invalidatesTags: ["Chat Provider Config"],
+ }),
+ updateChatConfigSecrets: build.mutation<
+ ChatProviderSettingsResponse,
+ { configId: string; secrets: ChatProviderSecrets }
+ >({
+ query: ({ configId, secrets }) => ({
+ url: `plus/chat/config/${configId}/secret`,
+ method: "PUT",
+ body: secrets,
+ }),
+ invalidatesTags: (_result, _error, { configId }) => [
+ "Chat Provider Config",
+ { type: "Chat Provider Config", id: configId },
+ ],
+ }),
+ sendChatMessage: build.mutation({
+ query: (body) => ({
+ url: "plus/chat/send",
+ method: "POST",
+ body,
+ }),
+ }),
+ }),
+});
+
+export const {
+ useTestChatConnectionMutation,
+ // Chat Provider Configuration CRUD hooks
+ useGetChatConfigsQuery,
+ useCreateChatConfigMutation,
+ useGetChatConfigQuery,
+ useUpdateChatConfigMutation,
+ useDeleteChatConfigMutation,
+ useEnableChatConfigMutation,
+ useUpdateChatConfigSecretsMutation,
+ // Messaging
+ useSendChatMessageMutation,
+} = chatProviderApi;
diff --git a/clients/admin-ui/src/features/chat-provider/components/AuthorizationStatus.tsx b/clients/admin-ui/src/features/chat-provider/components/AuthorizationStatus.tsx
new file mode 100644
index 00000000000..cfd7dfb3104
--- /dev/null
+++ b/clients/admin-ui/src/features/chat-provider/components/AuthorizationStatus.tsx
@@ -0,0 +1,32 @@
+import { GreenCheckCircleIcon, Space, Tag, Typography } from "fidesui";
+
+const { Text } = Typography;
+
+interface AuthorizationStatusProps {
+ authorized: boolean;
+}
+
+/**
+ * Displays the authorization status with appropriate styling.
+ * Uses Ant Design's semantic colors instead of hardcoded values.
+ */
+const AuthorizationStatus = ({ authorized }: AuthorizationStatusProps) => {
+ if (authorized) {
+ return (
+
+
+
+ Authorized
+
+
+ );
+ }
+
+ return (
+
+ Not authorized
+
+ );
+};
+
+export default AuthorizationStatus;
diff --git a/clients/admin-ui/src/features/chat-provider/components/ConfigurationCard.tsx b/clients/admin-ui/src/features/chat-provider/components/ConfigurationCard.tsx
new file mode 100644
index 00000000000..33bba5d4dbe
--- /dev/null
+++ b/clients/admin-ui/src/features/chat-provider/components/ConfigurationCard.tsx
@@ -0,0 +1,47 @@
+import { Card, Space, Typography } from "fidesui";
+import { ReactNode } from "react";
+
+const { Title } = Typography;
+
+interface ConfigurationCardProps {
+ title: string;
+ icon?: ReactNode;
+ children: ReactNode;
+ maxWidth?: number;
+}
+
+/**
+ * Reusable card component for configuration forms.
+ * Uses Ant Design Card with consistent styling.
+ */
+const ConfigurationCard = ({
+ title,
+ icon,
+ children,
+ maxWidth = 720,
+}: ConfigurationCardProps) => {
+ return (
+
+ {icon}
+
+ {title}
+
+
+ ) : (
+
+ {title}
+
+ )
+ }
+ style={{ maxWidth }}
+ className="mt-6"
+ >
+ {children}
+
+ );
+};
+
+export default ConfigurationCard;
diff --git a/clients/admin-ui/src/features/chat-provider/constants.ts b/clients/admin-ui/src/features/chat-provider/constants.ts
new file mode 100644
index 00000000000..fb07938713a
--- /dev/null
+++ b/clients/admin-ui/src/features/chat-provider/constants.ts
@@ -0,0 +1,15 @@
+/**
+ * Chat provider types and configuration constants
+ */
+
+export const CHAT_PROVIDER_TYPES = {
+ SLACK: "slack",
+} as const;
+
+export const CHAT_PROVIDER_LABELS = {
+ [CHAT_PROVIDER_TYPES.SLACK]: "Slack",
+} as const;
+
+export const CHAT_PROVIDER_CONFIG_MAX_WIDTH = 720;
+
+export const SECRET_PLACEHOLDER = "**********";
diff --git a/clients/admin-ui/src/features/chat-provider/forms/SlackChatForm.tsx b/clients/admin-ui/src/features/chat-provider/forms/SlackChatForm.tsx
new file mode 100644
index 00000000000..2196f98ba6c
--- /dev/null
+++ b/clients/admin-ui/src/features/chat-provider/forms/SlackChatForm.tsx
@@ -0,0 +1,283 @@
+import { Button, Flex, Form, Input, Space, useMessage } from "fidesui";
+import { useRouter } from "next/router";
+import { useEffect } from "react";
+
+import { isErrorResult } from "~/features/common/helpers";
+import { useAPIHelper } from "~/features/common/hooks";
+import { CHAT_PROVIDERS_ROUTE } from "~/features/common/nav/routes";
+import { ChatConfigCreate, ChatConfigUpdate } from "~/types/api";
+
+import {
+ useCreateChatConfigMutation,
+ useGetChatConfigQuery,
+ useUpdateChatConfigMutation,
+} from "../chatProvider.slice";
+import AuthorizationStatus from "../components/AuthorizationStatus";
+import ConfigurationCard from "../components/ConfigurationCard";
+import { SECRET_PLACEHOLDER } from "../constants";
+import SlackIcon from "../icons/SlackIcon";
+import { cleanupUrlParams, getOAuthErrorMessage } from "../utils/urlHelpers";
+
+interface SlackChatFormProps {
+ configId?: string;
+}
+
+const SlackChatForm = ({ configId }: SlackChatFormProps) => {
+ const router = useRouter();
+ const { handleError } = useAPIHelper();
+ const message = useMessage();
+ const [form] = Form.useForm();
+
+ // Watch any form field change to trigger re-render for button state
+ Form.useWatch([], form);
+
+ // Fetch existing config if editing
+ const { data: existingConfig, refetch } = useGetChatConfigQuery(configId!, {
+ skip: !configId,
+ });
+ const [createConfig] = useCreateChatConfigMutation();
+ const [updateConfig] = useUpdateChatConfigMutation();
+
+ const isEditMode = !!configId;
+ const isAuthorized = !!existingConfig?.authorized;
+
+ // Check for OAuth callback results in URL
+ useEffect(() => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const chatSuccess = urlParams.get("chat_success");
+ const chatError = urlParams.get("chat_error");
+
+ if (chatSuccess === "true") {
+ message.success("Slack authorization successful!");
+ refetch();
+ cleanupUrlParams(configId ? { id: configId } : undefined);
+ } else if (chatError) {
+ message.error(getOAuthErrorMessage(chatError));
+ cleanupUrlParams(configId ? { id: configId } : undefined);
+ }
+ }, [refetch, message, configId]);
+
+ const hasSigningSecret = existingConfig?.has_signing_secret;
+
+ const initialValues = {
+ workspace_url: existingConfig?.workspace_url ?? "",
+ client_id: existingConfig?.client_id ?? "",
+ client_secret: isEditMode ? SECRET_PLACEHOLDER : "",
+ signing_secret: isEditMode && hasSigningSecret ? SECRET_PLACEHOLDER : "",
+ };
+
+ // Update form when existingConfig changes
+ useEffect(() => {
+ if (existingConfig) {
+ form.setFieldsValue({
+ workspace_url: existingConfig.workspace_url ?? "",
+ client_id: existingConfig.client_id ?? "",
+ client_secret: SECRET_PLACEHOLDER,
+ signing_secret: existingConfig.has_signing_secret
+ ? SECRET_PLACEHOLDER
+ : "",
+ });
+ }
+ }, [existingConfig, form]);
+
+ const handleSubmit = async (values: {
+ workspace_url: string;
+ client_id: string;
+ client_secret: string;
+ signing_secret: string;
+ }) => {
+ try {
+ if (isEditMode && configId) {
+ // Update existing config
+ const payload: ChatConfigUpdate = {
+ workspace_url: values.workspace_url || undefined,
+ client_id: values.client_id || undefined,
+ // Only send secrets if they're not the placeholder
+ client_secret:
+ values.client_secret && values.client_secret !== SECRET_PLACEHOLDER
+ ? values.client_secret
+ : undefined,
+ signing_secret:
+ values.signing_secret &&
+ values.signing_secret !== SECRET_PLACEHOLDER
+ ? values.signing_secret
+ : undefined,
+ };
+
+ const result = await updateConfig({ configId, data: payload });
+
+ if (isErrorResult(result)) {
+ handleError(result.error);
+ } else {
+ message.success("Slack configuration saved successfully.");
+ refetch();
+ // Reset form with current values but clear the secrets
+ form.setFieldsValue({
+ ...values,
+ client_secret: SECRET_PLACEHOLDER,
+ signing_secret: values.signing_secret ? SECRET_PLACEHOLDER : "",
+ });
+ }
+ } else {
+ // Create new config
+ const payload: ChatConfigCreate = {
+ provider_type: "slack",
+ workspace_url: values.workspace_url,
+ client_id: values.client_id || undefined,
+ client_secret: values.client_secret || undefined,
+ signing_secret: values.signing_secret || undefined,
+ };
+
+ const result = await createConfig(payload);
+
+ if (isErrorResult(result)) {
+ handleError(result.error);
+ } else if ("data" in result && result.data) {
+ message.success("Slack configuration created successfully.");
+ // Redirect to edit mode with the new config ID
+ router.push(`${CHAT_PROVIDERS_ROUTE}/configure?id=${result.data.id}`);
+ }
+ }
+ } catch (error) {
+ handleError(error);
+ }
+ };
+
+ const handleAuthorize = () => {
+ const authorizeUrl = configId
+ ? `/api/v1/plus/chat/authorize?config_id=${configId}`
+ : "/api/v1/plus/chat/authorize";
+ window.location.href = authorizeUrl;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isEditMode ? (
+ <>
+ {/* Authorize button - show when not authorized */}
+ {!isAuthorized && (
+
+ Authorize with Slack
+
+ )}
+ {/* Authorized status - show when OAuth is complete */}
+ {isAuthorized && }
+ >
+ ) : (
+ router.push(CHAT_PROVIDERS_ROUTE)}
+ data-testid="cancel-btn"
+ >
+ Cancel
+
+ )}
+
+ Save
+
+
+
+
+
+ );
+};
+
+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(