diff --git a/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx b/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx new file mode 100644 index 00000000000..d44b87d5edd --- /dev/null +++ b/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx @@ -0,0 +1,193 @@ +import React, { useState } from "react"; + +import { + Button, + ChakraBox as Box, + ChakraCode as Code, + ChakraSpinner as Spinner, + ChakraText as Text, + Tabs, +} from "fidesui"; + +import FormModal from "~/features/common/modals/FormModal"; +import { useGetDatastoreConnectionByKeyQuery } from "~/features/datastore-connections"; + +import { + useGetConnectorTemplateVersionConfigQuery, + useGetConnectorTemplateVersionDatasetQuery, +} from "./connector-template.slice"; + +interface SaaSVersionContentProps { + connectorType: string; + version: string; +} + +const SaaSVersionContent = ({ + connectorType, + version, +}: SaaSVersionContentProps) => { + const { + data: configYaml, + isLoading: configLoading, + isError: configError, + } = useGetConnectorTemplateVersionConfigQuery({ connectorType, version }); + + const { + data: datasetYaml, + isLoading: datasetLoading, + isError: datasetError, + } = useGetConnectorTemplateVersionDatasetQuery({ connectorType, version }); + + if (configLoading) { + return ( + + + + ); + } + + if (configError) { + return ( + + Could not load version config. + + ); + } + + const tabItems = [ + { + key: "config", + label: "Config", + children: ( + + {configYaml} + + ), + }, + { + key: "dataset", + label: "Dataset", + children: datasetLoading ? ( + + + + ) : datasetError ? ( + + No dataset available for this version. + + ) : ( + + {datasetYaml} + + ), + }, + ]; + + return ; +}; + +interface SaaSVersionModalProps { + isOpen: boolean; + onClose: () => void; + connectorType: string; + version: string; +} + +const SaaSVersionModal = ({ + isOpen, + onClose, + connectorType, + version, +}: SaaSVersionModalProps) => ( + + Close + + } + > + + +); + +interface PendingModalState { + connectionKey: string; + version: string; +} + +interface ActiveModalState { + connectorType: string; + version: string; +} + +/** + * Hook providing a version detail modal keyed by connection key + version string. + * Resolves connector_type via the connection config before opening the modal. + */ +export const useSaaSVersionModal = () => { + const [pending, setPending] = useState(null); + const [active, setActive] = useState(null); + + const { data: connection } = useGetDatastoreConnectionByKeyQuery( + pending?.connectionKey ?? "", + { skip: !pending?.connectionKey }, + ); + + // Once the connection resolves, promote pending to active so the modal opens. + // connectorType is captured into active so the modal doesn't depend on the + // query after pending is cleared (skip: true returns undefined data). + // If the connection has no saas_config.type (non-SaaS), bail out silently + // so pending doesn't stay set indefinitely. + React.useEffect(() => { + if (!pending || !connection) return; + if (connection.saas_config?.type) { + setActive({ connectorType: connection.saas_config.type, version: pending.version }); + } + setPending(null); + }, [pending, connection]); + + const openVersionModal = (connectionKey: string, version: string) => { + setPending({ connectionKey, version }); + }; + + const handleClose = () => setActive(null); + + const modal = active ? ( + + ) : null; + + return { openVersionModal, modal }; +}; + +export default SaaSVersionModal; diff --git a/clients/admin-ui/src/features/connector-templates/__tests__/SaaSVersionModal.test.tsx b/clients/admin-ui/src/features/connector-templates/__tests__/SaaSVersionModal.test.tsx new file mode 100644 index 00000000000..2ae83d78be8 --- /dev/null +++ b/clients/admin-ui/src/features/connector-templates/__tests__/SaaSVersionModal.test.tsx @@ -0,0 +1,201 @@ +// Mock ESM-only packages — must be before imports (Jest hoists these) +jest.mock("query-string", () => ({ + __esModule: true, + default: { stringify: jest.fn(), parse: jest.fn() }, +})); +jest.mock("react-dnd", () => ({ + useDrag: jest.fn(() => [{}, jest.fn()]), + useDrop: jest.fn(() => [{}, jest.fn()]), + DndProvider: ({ children }: { children: React.ReactNode }) => children, +})); +// eslint-disable-next-line global-require +jest.mock("nuqs", () => require("../../../../__tests__/utils/nuqs-mock").nuqsMock); + +// RTK Query hook mocks +jest.mock("~/features/connector-templates/connector-template.slice", () => ({ + useGetConnectorTemplateVersionConfigQuery: jest.fn(), + useGetConnectorTemplateVersionDatasetQuery: jest.fn(), +})); + +jest.mock("~/features/datastore-connections", () => ({ + useGetDatastoreConnectionByKeyQuery: jest.fn(), + // Store imports datastoreConnectionSlice from this module — provide a minimal stub + datastoreConnectionSlice: { + name: "datastoreConnection", + reducer: (state = {}) => state, + }, +})); + +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import React from "react"; + +import { render } from "~/../__tests__/utils/test-utils"; +import SaaSVersionModal, { + useSaaSVersionModal, +} from "~/features/connector-templates/SaaSVersionModal"; +import { + useGetConnectorTemplateVersionConfigQuery, + useGetConnectorTemplateVersionDatasetQuery, +} from "~/features/connector-templates/connector-template.slice"; +import { useGetDatastoreConnectionByKeyQuery } from "~/features/datastore-connections"; + +// ── Typed mocks ──────────────────────────────────────────────────────────────── + +const mockUseConfig = useGetConnectorTemplateVersionConfigQuery as jest.Mock; +const mockUseDataset = useGetConnectorTemplateVersionDatasetQuery as jest.Mock; +const mockUseConnection = useGetDatastoreConnectionByKeyQuery as jest.Mock; + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const STRIPE_CONFIG_YAML = `connector_type: stripe\nversion: "0.0.11"\n`; +const STRIPE_DATASET_YAML = `dataset:\n - name: stripe_dataset\n`; + +function setupDefaultMocks() { + mockUseConfig.mockReturnValue({ + data: STRIPE_CONFIG_YAML, + isLoading: false, + isError: false, + }); + mockUseDataset.mockReturnValue({ + data: STRIPE_DATASET_YAML, + isLoading: false, + isError: false, + }); + mockUseConnection.mockReturnValue({ data: null }); +} + +// ── SaaSVersionModal (direct usage) ─────────────────────────────────────────── + +describe("SaaSVersionModal", () => { + beforeEach(setupDefaultMocks); + + it("shows a loading spinner while config is fetching", () => { + mockUseConfig.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + + render( + , + ); + + // Chakra Spinner renders as a div with class chakra-spinner (no ARIA role in JSDOM) + expect(document.querySelector(".chakra-spinner")).toBeInTheDocument(); + }); + + it("renders the modal title with connector type and version", () => { + render( + , + ); + + expect(screen.getByText("stripe — v0.0.11")).toBeInTheDocument(); + }); + + it("calls the config query with the correct connector type and version", () => { + render( + , + ); + + expect(mockUseConfig).toHaveBeenCalledWith({ connectorType: "stripe", version: "0.0.11" }); + }); + + it("calls the dataset query with the correct connector type and version", () => { + render( + , + ); + + expect(mockUseDataset).toHaveBeenCalledWith({ connectorType: "stripe", version: "0.0.11" }); + }); + + it("shows 'No dataset available' in the Dataset tab when the dataset endpoint errors", () => { + mockUseDataset.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + + render( + , + ); + + // Activate the Dataset tab, then assert the fallback message + fireEvent.click(screen.getByText("Dataset")); + expect(screen.getByText("No dataset available for this version.")).toBeInTheDocument(); + }); + + it("shows an error message when config fails to load", () => { + mockUseConfig.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + + render( + , + ); + + expect(screen.getByText("Could not load version config.")).toBeInTheDocument(); + }); + + it("calls onClose when the Close button is clicked", () => { + const onClose = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("version-modal-close-btn")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not render when isOpen is false", () => { + render( + , + ); + + expect(screen.queryByText("stripe — v0.0.11")).not.toBeInTheDocument(); + }); +}); + +// ── useSaaSVersionModal hook ─────────────────────────────────────────────────── + +const HookConsumer = ({ + connectionKey, + version, +}: { + connectionKey: string; + version: string; +}) => { + const { openVersionModal, modal } = useSaaSVersionModal(); + return ( + <> + {modal} + + + ); +}; + +describe("useSaaSVersionModal", () => { + beforeEach(setupDefaultMocks); + + it("opens the modal once the connection resolves a connector type", async () => { + mockUseConnection.mockReturnValue({ + data: { saas_config: { type: "stripe" } }, + }); + + render(); + + fireEvent.click(screen.getByTestId("trigger")); + + await waitFor(() => { + expect(screen.getByText("stripe — v0.0.11")).toBeInTheDocument(); + }); + }); + + it("does not open if the connection has no saas_config type", async () => { + mockUseConnection.mockReturnValue({ data: { saas_config: null } }); + + render(); + + fireEvent.click(screen.getByTestId("trigger")); + + await waitFor(() => { + expect(screen.queryByText(/— v0\.0\.11/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/clients/admin-ui/src/features/connector-templates/connector-template.slice.ts b/clients/admin-ui/src/features/connector-templates/connector-template.slice.ts index 4bd935dae92..4a46e411560 100644 --- a/clients/admin-ui/src/features/connector-templates/connector-template.slice.ts +++ b/clients/admin-ui/src/features/connector-templates/connector-template.slice.ts @@ -6,6 +6,13 @@ import { baseApi } from "~/features/common/api.slice"; export interface State {} const initialState: State = {}; +export interface SaaSConfigVersionResponse { + connector_type: string; + version: string; + is_custom: boolean; + created_at: string; +} + export const connectorTemplateSlice = createSlice({ name: "connectorTemplate", initialState, @@ -36,10 +43,38 @@ export const connectorTemplateApi = baseApi.injectEndpoints({ }), invalidatesTags: () => ["Connection Type"], }), + getConnectorTemplateVersions: build.query< + SaaSConfigVersionResponse[], + string + >({ + query: (connectorType) => + `${CONNECTOR_TEMPLATE}/${connectorType}/versions`, + }), + getConnectorTemplateVersionConfig: build.query< + string, + { connectorType: string; version: string } + >({ + query: ({ connectorType, version }) => ({ + url: `${CONNECTOR_TEMPLATE}/${connectorType}/versions/${version}/config`, + responseHandler: "text", + }), + }), + getConnectorTemplateVersionDataset: build.query< + string, + { connectorType: string; version: string } + >({ + query: ({ connectorType, version }) => ({ + url: `${CONNECTOR_TEMPLATE}/${connectorType}/versions/${version}/dataset`, + responseHandler: "text", + }), + }), }), }); export const { useRegisterConnectorTemplateMutation, useDeleteConnectorTemplateMutation, + useGetConnectorTemplateVersionsQuery, + useGetConnectorTemplateVersionConfigQuery, + useGetConnectorTemplateVersionDatasetQuery, } = connectorTemplateApi; diff --git a/clients/admin-ui/src/features/integrations/VersionHistoryTab.tsx b/clients/admin-ui/src/features/integrations/VersionHistoryTab.tsx new file mode 100644 index 00000000000..9577ba969e3 --- /dev/null +++ b/clients/admin-ui/src/features/integrations/VersionHistoryTab.tsx @@ -0,0 +1,113 @@ +import { formatDate } from "common/utils"; +import { + Button, + ChakraSpinner as Spinner, + ChakraText as Text, + CUSTOM_TAG_COLOR, + Table, + Tag, + Typography, +} from "fidesui"; +import React, { useState } from "react"; + +import SaaSVersionModal from "~/features/connector-templates/SaaSVersionModal"; +import { + SaaSConfigVersionResponse, + useGetConnectorTemplateVersionsQuery, +} from "~/features/connector-templates/connector-template.slice"; + +interface VersionHistoryTabProps { + connectorType: string; +} + +const VersionHistoryTab = ({ connectorType }: VersionHistoryTabProps) => { + const { data: versions, isLoading } = + useGetConnectorTemplateVersionsQuery(connectorType); + + const [selected, setSelected] = useState( + null, + ); + + const columns = [ + { + title: "Version", + dataIndex: "version", + key: "version", + render: (v: string) => ( + + v{v} + + ), + }, + { + title: "Type", + dataIndex: "is_custom", + key: "is_custom", + render: (isCustom: boolean) => ( + + {isCustom ? "Custom" : "OOB"} + + ), + }, + { + title: "Captured at", + dataIndex: "created_at", + key: "created_at", + render: (ts: string) => ( + + {formatDate(ts)} + + ), + }, + { + title: "", + key: "actions", + render: (_: unknown, row: SaaSConfigVersionResponse) => ( + + ), + }, + ]; + + if (isLoading) { + return ; + } + + if (!versions || versions.length === 0) { + return ( + + No version history captured yet. + + ); + } + + return ( + <> + + All captured versions of this connector's configuration. Each + entry reflects the config and dataset snapshot at the time it was + recorded. + + + `${row.version}-${row.is_custom}` + } + size="small" + pagination={false} + /> + {selected && ( + setSelected(null)} + connectorType={selected.connector_type} + version={selected.version} + /> + )} + + ); +}; + +export default VersionHistoryTab; diff --git a/clients/admin-ui/src/features/integrations/__tests__/VersionHistoryTab.test.tsx b/clients/admin-ui/src/features/integrations/__tests__/VersionHistoryTab.test.tsx new file mode 100644 index 00000000000..eafd4b2ae3a --- /dev/null +++ b/clients/admin-ui/src/features/integrations/__tests__/VersionHistoryTab.test.tsx @@ -0,0 +1,134 @@ +// Mock ESM-only packages — must be before imports (Jest hoists these) +jest.mock("query-string", () => ({ + __esModule: true, + default: { stringify: jest.fn(), parse: jest.fn() }, +})); +jest.mock("react-dnd", () => ({ + useDrag: jest.fn(() => [{}, jest.fn()]), + useDrop: jest.fn(() => [{}, jest.fn()]), + DndProvider: ({ children }: { children: React.ReactNode }) => children, +})); +// eslint-disable-next-line global-require +jest.mock("nuqs", () => require("../../../../__tests__/utils/nuqs-mock").nuqsMock); + +jest.mock("~/features/connector-templates/connector-template.slice", () => ({ + useGetConnectorTemplateVersionsQuery: jest.fn(), +})); + +// Stub SaaSVersionModal so its own RTK deps don't need wiring up +jest.mock("~/features/connector-templates/SaaSVersionModal", () => ({ + __esModule: true, + default: ({ + isOpen, + connectorType, + version, + }: { + isOpen: boolean; + connectorType: string; + version: string; + onClose: () => void; + }) => + isOpen ? ( +
+ {connectorType} v{version} +
+ ) : null, +})); + +import { fireEvent, screen } from "@testing-library/react"; +import React from "react"; + +import { render } from "~/../__tests__/utils/test-utils"; +import { useGetConnectorTemplateVersionsQuery } from "~/features/connector-templates/connector-template.slice"; +import VersionHistoryTab from "~/features/integrations/VersionHistoryTab"; + +// ── Typed mock ───────────────────────────────────────────────────────────────── + +const mockUseVersions = useGetConnectorTemplateVersionsQuery as jest.Mock; + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const VERSIONS = [ + { + connector_type: "stripe", + version: "0.0.12", + is_custom: false, + created_at: "2026-03-01T10:00:00Z", + }, + { + connector_type: "stripe", + version: "0.0.11", + is_custom: true, + created_at: "2026-02-15T08:00:00Z", + }, +]; + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("VersionHistoryTab", () => { + it("shows a spinner while loading", () => { + mockUseVersions.mockReturnValue({ data: undefined, isLoading: true }); + + render(); + + // Chakra Spinner renders as a div with class chakra-spinner (no ARIA role in JSDOM) + expect(document.querySelector(".chakra-spinner")).toBeInTheDocument(); + }); + + it("shows empty-state message when no versions are available", () => { + mockUseVersions.mockReturnValue({ data: [], isLoading: false }); + + render(); + + expect(screen.getByText("No version history captured yet.")).toBeInTheDocument(); + }); + + it("renders a row for each captured version", () => { + mockUseVersions.mockReturnValue({ data: VERSIONS, isLoading: false }); + + render(); + + expect(screen.getByText("v0.0.12")).toBeInTheDocument(); + expect(screen.getByText("v0.0.11")).toBeInTheDocument(); + }); + + it("shows OOB badge for non-custom and Custom badge for custom versions", () => { + mockUseVersions.mockReturnValue({ data: VERSIONS, isLoading: false }); + + render(); + + expect(screen.getByText("OOB")).toBeInTheDocument(); + expect(screen.getByText("Custom")).toBeInTheDocument(); + }); + + it("opens the version modal when a View button is clicked", () => { + mockUseVersions.mockReturnValue({ data: VERSIONS, isLoading: false }); + + render(); + + const viewButtons = screen.getAllByRole("button", { name: /view/i }); + fireEvent.click(viewButtons[0]); + + expect(screen.getByTestId("version-modal")).toBeInTheDocument(); + expect(screen.getByText("stripe v0.0.12")).toBeInTheDocument(); + }); + + it("shows the second version's details when its View button is clicked", () => { + mockUseVersions.mockReturnValue({ data: VERSIONS, isLoading: false }); + + render(); + + const viewButtons = screen.getAllByRole("button", { name: /view/i }); + fireEvent.click(viewButtons[1]); + + expect(screen.getByText("stripe v0.0.11")).toBeInTheDocument(); + }); + + it("passes the connector type to the query", () => { + mockUseVersions.mockReturnValue({ data: [], isLoading: false }); + + render(); + + expect(mockUseVersions).toHaveBeenCalledWith("hubspot"); + }); +}); diff --git a/clients/admin-ui/src/features/integrations/hooks/useFeatureBasedTabs.tsx b/clients/admin-ui/src/features/integrations/hooks/useFeatureBasedTabs.tsx index ed93d985669..460a9b90c8d 100644 --- a/clients/admin-ui/src/features/integrations/hooks/useFeatureBasedTabs.tsx +++ b/clients/admin-ui/src/features/integrations/hooks/useFeatureBasedTabs.tsx @@ -18,6 +18,7 @@ import ConnectionStatusNotice, { ConnectionStatusData, } from "~/features/integrations/ConnectionStatusNotice"; import IntegrationLinkedSystems from "~/features/integrations/IntegrationLinkedSystems"; +import VersionHistoryTab from "~/features/integrations/VersionHistoryTab"; import { ConnectionSystemTypeMap, IntegrationFeature } from "~/types/api"; interface UseFeatureBasedTabsProps { @@ -181,6 +182,15 @@ export const useFeatureBasedTabs = ({ }); } + const connectorType = connection?.saas_config?.type; + if (connectorType) { + tabItems.push({ + label: "Version history", + key: "version-history", + children: , + }); + } + return tabItems; }, [ enabledFeatures, diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx index f4ab8c172a7..0c4def2dca2 100644 --- a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx +++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx @@ -187,6 +187,7 @@ const ActivityTimeline = ({ subjectRequest }: ActivityTimelineProps) => { onOpenErrorPanel={openErrorPanel} onCloseErrorPanel={closeErrorPanel} privacyRequest={subjectRequest} + connectionKey={currentKey || undefined} /> ); diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/EventLog.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/EventLog.tsx index 5c4b013173b..b4e213551e0 100644 --- a/clients/admin-ui/src/features/privacy-requests/events-and-logs/EventLog.tsx +++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/EventLog.tsx @@ -22,6 +22,7 @@ import { } from "privacy-requests/types"; import React from "react"; +import { useSaaSVersionModal } from "~/features/connector-templates/SaaSVersionModal"; import { ActionType } from "~/types/api"; type EventDetailsProps = { @@ -29,6 +30,7 @@ type EventDetailsProps = { allEventLogs?: ExecutionLog[]; // All event logs from all groups for total calculation onDetailPanel: (message: string, status?: ExecutionLogStatus) => void; privacyRequest?: PrivacyRequestEntity; + connectionKey?: string; }; const actionTypeToLabel = (actionType: string) => { @@ -153,7 +155,9 @@ const EventLog = ({ allEventLogs, onDetailPanel, privacyRequest, + connectionKey, }: EventDetailsProps) => { + const { openVersionModal, modal: versionModal } = useSaaSVersionModal(); // Check if any logs have collection_name OR if there's a finished entry to determine if we should show Records and Collection columns const hasDatasetEntries = eventLogs?.some((log) => log.collection_name) || @@ -269,7 +273,24 @@ const EventLog = ({ {hasDatasetEntries && !isRequestFinishedView && (
{detail.saas_version ? ( - v{detail.saas_version} + connectionKey ? ( + + ) : ( + + v{detail.saas_version} + + ) ) : ( + {versionModal} void; onOpenErrorPanel: (message: string, status?: ExecutionLogStatus) => void; privacyRequest?: PrivacyRequestEntity; + connectionKey?: string; }; const LogDrawer = ({ @@ -44,6 +45,7 @@ const LogDrawer = ({ onCloseErrorPanel, onOpenErrorPanel, privacyRequest, + connectionKey, }: LogDrawerProps) => { const headerText = isViewingError ? "Event detail" : "Event log"; @@ -99,6 +101,7 @@ const LogDrawer = ({ allEventLogs={allEventLogs} onDetailPanel={onOpenErrorPanel} privacyRequest={privacyRequest} + connectionKey={connectionKey} /> ) : null} {isViewingError ? ( diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/__tests__/EventLog.test.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/__tests__/EventLog.test.tsx new file mode 100644 index 00000000000..c5ddffc69f5 --- /dev/null +++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/__tests__/EventLog.test.tsx @@ -0,0 +1,140 @@ +// Mock ESM-only packages — must be before imports (Jest hoists these) +jest.mock("query-string", () => ({ + __esModule: true, + default: { stringify: jest.fn(), parse: jest.fn() }, +})); +jest.mock("react-dnd", () => ({ + useDrag: jest.fn(() => [{}, jest.fn()]), + useDrop: jest.fn(() => [{}, jest.fn()]), + DndProvider: ({ children }: { children: React.ReactNode }) => children, +})); +// eslint-disable-next-line global-require +jest.mock("nuqs", () => require("../../../../../__tests__/utils/nuqs-mock").nuqsMock); + +// Capture openVersionModal so tests can assert on calls +const mockOpenVersionModal = jest.fn(); +jest.mock("~/features/connector-templates/SaaSVersionModal", () => ({ + useSaaSVersionModal: () => ({ + openVersionModal: mockOpenVersionModal, + modal: null, + }), +})); + +import { fireEvent, screen } from "@testing-library/react"; +import React from "react"; + +import { render } from "~/../__tests__/utils/test-utils"; +import EventLog from "~/features/privacy-requests/events-and-logs/EventLog"; +import { + ExecutionLog, + ExecutionLogStatus, +} from "~/features/privacy-requests/types"; +import { ActionType } from "~/types/api"; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeLog = (overrides: Partial = {}): ExecutionLog => ({ + collection_name: "stripe_customer", + fields_affected: [], + message: "success - retrieved 3 records", + action_type: ActionType.ACCESS, + status: ExecutionLogStatus.COMPLETE, + updated_at: "2026-03-01T10:00:00Z", + saas_version: null, + ...overrides, +}); + +const noop = () => {}; + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("EventLog — version badge", () => { + beforeEach(() => { + mockOpenVersionModal.mockClear(); + }); + + it("renders the version badge when saas_version is present", () => { + render( + , + ); + + expect(screen.getByText("v0.0.11")).toBeInTheDocument(); + }); + + it("shows a dash in the Version column when saas_version is null", () => { + render( + , + ); + + // Records column also shows "-" for completed rows with no parseable count, + // so just confirm the Version column header is present (dataset entries exist) + expect(screen.getByText("Version")).toBeInTheDocument(); + }); + + it("does not make the badge clickable when connectionKey is absent", () => { + render( + , + ); + + const wrapper = screen.getByTestId("version-badge-wrapper"); + + expect(wrapper).not.toHaveAttribute("title"); + expect(wrapper.style.cursor).toBeFalsy(); + + fireEvent.click(wrapper); + expect(mockOpenVersionModal).not.toHaveBeenCalled(); + }); + + it("makes the badge clickable and triggers openVersionModal when connectionKey is given", () => { + render( + , + ); + + const wrapper = screen.getByTestId("version-badge-wrapper"); + + expect(wrapper).toHaveAttribute("title", "View version config"); + + fireEvent.click(wrapper); + expect(mockOpenVersionModal).toHaveBeenCalledTimes(1); + expect(mockOpenVersionModal).toHaveBeenCalledWith("stripe_conn", "0.0.11"); + }); + + it("passes the correct version for each row when multiple versioned logs are shown", () => { + const logs = [ + makeLog({ saas_version: "0.0.11", updated_at: "2026-03-01T10:00:00Z" }), + makeLog({ saas_version: "0.0.12", updated_at: "2026-03-02T10:00:00Z" }), + ]; + + render( + , + ); + + const wrappers = screen.getAllByTestId("version-badge-wrapper"); + expect(wrappers).toHaveLength(2); + + fireEvent.click(wrappers[0]); + expect(mockOpenVersionModal).toHaveBeenLastCalledWith("stripe_conn", "0.0.11"); + + fireEvent.click(wrappers[1]); + expect(mockOpenVersionModal).toHaveBeenLastCalledWith("stripe_conn", "0.0.12"); + }); + + it("does not show the Version column when no logs have a collection_name", () => { + render( + , + ); + + expect(screen.queryByText("Version")).not.toBeInTheDocument(); + }); +}); diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_03_13_0000_c3e5f7a9b1d2_add_saas_config_version_table.py b/src/fides/api/alembic/migrations/versions/xx_2026_03_13_0000_c3e5f7a9b1d2_add_saas_config_version_table.py new file mode 100644 index 00000000000..e8d3cb35bda --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_03_13_0000_c3e5f7a9b1d2_add_saas_config_version_table.py @@ -0,0 +1,57 @@ +"""add saas_config_version table + +Stores a snapshot of each SaaS integration config and dataset per +(connector_type, version) pair. Rows are written on startup for bundled +OOB connectors, on custom template upload/update, and on direct SaaS config +PATCH. Rows are never deleted so that execution logs can always resolve the +config/dataset that was active when a DSR ran. + +Revision ID: c3e5f7a9b1d2 +Revises: a1ca9ddf3c3c +Create Date: 2026-03-13 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "c3e5f7a9b1d2" +down_revision = "a1ca9ddf3c3c" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "saas_config_version", + 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("connector_type", sa.String(), nullable=False), + sa.Column("version", sa.String(), nullable=False), + sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("dataset", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("is_custom", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("connector_type", "version", "is_custom", name="uq_saas_config_version"), + ) + op.create_index( + op.f("ix_saas_config_version_connector_type"), + "saas_config_version", + ["connector_type"], + unique=False, + ) + op.create_index( + op.f("ix_saas_config_version_id"), + "saas_config_version", + ["id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_saas_config_version_id"), table_name="saas_config_version") + op.drop_index(op.f("ix_saas_config_version_connector_type"), table_name="saas_config_version") + op.drop_table("saas_config_version") diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_03_17_0000_d4e6f8a0b2c3_add_connection_config_saas_history_table.py b/src/fides/api/alembic/migrations/versions/xx_2026_03_17_0000_d4e6f8a0b2c3_add_connection_config_saas_history_table.py new file mode 100644 index 00000000000..a99bb86f673 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_03_17_0000_d4e6f8a0b2c3_add_connection_config_saas_history_table.py @@ -0,0 +1,91 @@ +"""add connection_config_saas_history table + +Stores a per-connection snapshot of a SaaS config (and associated datasets) +each time ConnectionConfig.update_saas_config() is called. Unlike the +template-level saas_config_version table, this table is append-only and +scoped to an individual connection instance, so divergent configs are +preserved correctly. + +Revision ID: d4e6f8a0b2c3 +Revises: c3e5f7a9b1d2 +Create Date: 2026-03-17 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "d4e6f8a0b2c3" +down_revision = "c3e5f7a9b1d2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "connection_config_saas_history", + 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("connection_config_id", sa.String(length=255), nullable=True), + sa.Column("connection_key", sa.String(), nullable=False), + sa.Column("version", sa.String(), nullable=False), + sa.Column( + "config", postgresql.JSONB(astext_type=sa.Text()), nullable=False + ), + sa.Column( + "dataset", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.ForeignKeyConstraint( + ["connection_config_id"], + ["connectionconfig.id"], + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_connection_config_saas_history_id"), + "connection_config_saas_history", + ["id"], + unique=False, + ) + op.create_index( + "ix_connection_config_saas_history_config_id_created_at", + "connection_config_saas_history", + ["connection_config_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_connection_config_saas_history_key_version", + "connection_config_saas_history", + ["connection_key", "version"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_connection_config_saas_history_key_version", + table_name="connection_config_saas_history", + ) + op.drop_index( + "ix_connection_config_saas_history_config_id_created_at", + table_name="connection_config_saas_history", + ) + op.drop_index( + op.f("ix_connection_config_saas_history_id"), + table_name="connection_config_saas_history", + ) + op.drop_table("connection_config_saas_history") diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py index c204a0062eb..85ce2706b7a 100644 --- a/src/fides/api/db/base.py +++ b/src/fides/api/db/base.py @@ -92,6 +92,7 @@ ) from fides.api.models.questionnaire import ChatMessage, Questionnaire from fides.api.models.registration import UserRegistration +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.saas_template_dataset import SaasTemplateDataset from fides.api.models.storage import StorageConfig from fides.api.models.system_compass_sync import SystemCompassSync diff --git a/src/fides/api/db/seed.py b/src/fides/api/db/seed.py index 85277fab09a..014c303514a 100644 --- a/src/fides/api/db/seed.py +++ b/src/fides/api/db/seed.py @@ -12,6 +12,7 @@ from fides.api.common_exceptions import KeyOrNameAlreadyExists from fides.api.db.base_class import FidesBase +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.db.ctl_session import sync_session from fides.api.db.system import upsert_system from fides.api.models.application_config import ApplicationConfig @@ -322,6 +323,45 @@ def load_default_dsr_policies(session: Session) -> None: log.info("All default policies & rules created") +def sync_oob_saas_config_versions(session: Session) -> None: + """ + Upserts a SaaSConfigVersion row for every bundled (OOB) SaaS connector + template currently loaded in memory. + + Called on startup so the table is bootstrapped on first deploy and + picks up new template versions automatically on each upgrade. + Rows are immutable once written, so this is safe to call repeatedly. + """ + # Import here to avoid circular imports at module load time + from fides.api.service.connectors.saas.connector_registry_service import ( # pylint: disable=import-outside-toplevel + FileConnectorTemplateLoader, + ) + from fides.api.util.saas_util import ( # pylint: disable=import-outside-toplevel + load_config_from_string, + load_dataset_from_string, + ) + from fides.api.schemas.saas.saas_config import SaaSConfig # pylint: disable=import-outside-toplevel + + templates = FileConnectorTemplateLoader.get_connector_templates() + for connector_type, template in templates.items(): + try: + saas_config = SaaSConfig(**load_config_from_string(template.config)) + dataset = load_dataset_from_string(template.dataset) + SaaSConfigVersion.upsert( + db=session, + connector_type=connector_type, + version=saas_config.version, + config=saas_config.model_dump(mode="json"), + dataset=dataset, + is_custom=False, + ) + except Exception: # pylint: disable=broad-except + log.exception( + "Failed to sync SaaSConfigVersion for OOB connector '{}'", + connector_type, + ) + + def load_default_resources(session: Session) -> None: """ Seed the database with default resources that the application @@ -330,6 +370,7 @@ def load_default_resources(session: Session) -> None: load_default_organization(session) load_default_taxonomy(session) load_default_dsr_policies(session) + sync_oob_saas_config_versions(session) async def load_samples(async_session: AsyncSession) -> None: diff --git a/src/fides/api/models/connection_config_saas_history.py b/src/fides/api/models/connection_config_saas_history.py new file mode 100644 index 00000000000..97a45d58123 --- /dev/null +++ b/src/fides/api/models/connection_config_saas_history.py @@ -0,0 +1,71 @@ +from typing import Any, Dict, List, Optional + +from sqlalchemy import Column, ForeignKey, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import Session + +from fides.api.db.base_class import Base + + +class ConnectionConfigSaaSHistory(Base): + """ + Append-only snapshot of a connection's SaaS config taken each time + ConnectionConfig.update_saas_config() is called. + + Unlike SaaSConfigVersion (which stores one row per connector_type/version), + this table is scoped to an individual ConnectionConfig instance. It captures + divergent configs — e.g. when a connection is individually PATCHed to a + version that differs from the shared template. + + connection_key is denormalized so that history is still queryable if the + parent ConnectionConfig row is deleted (FK uses ON DELETE SET NULL). + """ + + @declared_attr + def __tablename__(self) -> str: + return "connection_config_saas_history" + + connection_config_id = Column( + String, + ForeignKey("connectionconfig.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + connection_key = Column(String, nullable=False) + version = Column(String, nullable=False) + config = Column(JSONB, nullable=False) + dataset = Column(JSONB, nullable=True) + + def __repr__(self) -> str: + return ( + f"" + ) + + @classmethod + def create_snapshot( + cls, + db: Session, + connection_config_id: str, + connection_key: str, + version: str, + config: Dict[str, Any], + datasets: Optional[List[Dict[str, Any]]] = None, + ) -> "ConnectionConfigSaaSHistory": + """ + Appends a new history row. Always creates a new row — no upsert logic — + so every write is preserved as a distinct audit entry. + """ + return cls.create( + db=db, + data={ + "connection_config_id": connection_config_id, + "connection_key": connection_key, + "version": version, + "config": config, + "dataset": datasets or None, + }, + ) diff --git a/src/fides/api/models/connectionconfig.py b/src/fides/api/models/connectionconfig.py index 7e2e32646f2..84eaa8c648a 100644 --- a/src/fides/api/models/connectionconfig.py +++ b/src/fides/api/models/connectionconfig.py @@ -348,6 +348,11 @@ def update_saas_config( Updates the SaaS config and initializes any empty secrets with connector param default values if available (will not override any existing secrets) """ + from fides.api.models.connection_config_saas_history import ( + ConnectionConfigSaaSHistory, + ) + from fides.api.models.datasetconfig import DatasetConfig + default_secrets = { connector_param.name: connector_param.default_value for connector_param in saas_config.connector_params @@ -356,6 +361,23 @@ def update_saas_config( updated_secrets = {**default_secrets, **(self.secrets or {})} self.secrets = updated_secrets self.saas_config = saas_config.model_dump(mode="json") + + datasets = [ + dc.ctl_dataset.dict() + for dc in db.query(DatasetConfig) + .filter(DatasetConfig.connection_config_id == self.id) + .all() + if dc.ctl_dataset + ] + ConnectionConfigSaaSHistory.create_snapshot( + db=db, + connection_config_id=self.id, + connection_key=self.key, + version=saas_config.version, + config=self.saas_config, + datasets=datasets or None, + ) + self.save(db) def update_test_status( diff --git a/src/fides/api/models/saas_config_version.py b/src/fides/api/models/saas_config_version.py new file mode 100644 index 00000000000..37a47352f4d --- /dev/null +++ b/src/fides/api/models/saas_config_version.py @@ -0,0 +1,90 @@ +from typing import Any, Dict, Optional + +from sqlalchemy import Boolean, Column, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import Session + +from fides.api.db.base_class import Base + + +class SaaSConfigVersion(Base): + """ + Stores a snapshot of each SaaS integration config and dataset per version. + + A new row is upserted whenever a SaaS integration version is seen for the + first time — on startup (for bundled OOB connectors), on custom template + upload/update, or on direct SaaS config PATCH. Rows are never deleted so + that execution logs can always resolve the config/dataset that was active + when a DSR ran. + """ + + @declared_attr + def __tablename__(self) -> str: + return "saas_config_version" + + __table_args__ = ( + # is_custom is part of the key: the same version string can exist once + # as an OOB template and once as a custom override without conflict. + UniqueConstraint( + "connector_type", "version", "is_custom", name="uq_saas_config_version" + ), + ) + + connector_type = Column(String, nullable=False, index=True) + version = Column(String, nullable=False) + config = Column(JSONB, nullable=False) + dataset = Column(JSONB, nullable=True) + is_custom = Column(Boolean, nullable=False, default=False) + + def __repr__(self) -> str: + return f"" + + @classmethod + def upsert( + cls, + db: Session, + connector_type: str, + version: str, + config: Dict[str, Any], + dataset: Optional[Dict[str, Any]] = None, + is_custom: bool = False, + ) -> "SaaSConfigVersion": + """ + Insert or update a version snapshot. + + - OOB rows (is_custom=False): treated as immutable once written — the + version string is controlled by Ethyca and the content never changes + for a given version. + - Custom rows (is_custom=True): config/dataset are updated in place so + that users can iterate on a custom template without bumping the version. + """ + existing = ( + db.query(cls) + .filter( + cls.connector_type == connector_type, + cls.version == version, + cls.is_custom == is_custom, + ) + .first() + ) + + if existing: + if is_custom: + existing.config = config + existing.dataset = dataset + db.add(existing) + db.commit() + db.refresh(existing) + return existing + + return cls.create( + db=db, + data={ + "connector_type": connector_type, + "version": version, + "config": config, + "dataset": dataset, + "is_custom": is_custom, + }, + ) diff --git a/src/fides/api/schemas/saas/connection_config_saas_history.py b/src/fides/api/schemas/saas/connection_config_saas_history.py new file mode 100644 index 00000000000..e0512bc03dd --- /dev/null +++ b/src/fides/api/schemas/saas/connection_config_saas_history.py @@ -0,0 +1,19 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fides.api.schemas.base_class import FidesSchema + + +class ConnectionConfigSaaSHistoryResponse(FidesSchema): + """Summary of a per-connection SaaS config snapshot, used for list responses.""" + + id: str + version: str + created_at: datetime + + +class ConnectionConfigSaaSHistoryDetailResponse(ConnectionConfigSaaSHistoryResponse): + """Full detail for a single snapshot, including config and dataset.""" + + config: Dict[str, Any] + dataset: Optional[List[Dict[str, Any]]] = None diff --git a/src/fides/api/schemas/saas/saas_config_version.py b/src/fides/api/schemas/saas/saas_config_version.py new file mode 100644 index 00000000000..8ca832a0081 --- /dev/null +++ b/src/fides/api/schemas/saas/saas_config_version.py @@ -0,0 +1,20 @@ +from datetime import datetime +from typing import Optional + +from fides.api.schemas.base_class import FidesSchema + + +class SaaSConfigVersionResponse(FidesSchema): + """Summary of a stored SaaS integration version, used for list responses.""" + + connector_type: str + version: str + is_custom: bool + created_at: datetime + + +class SaaSConfigVersionDetailResponse(SaaSConfigVersionResponse): + """Full detail for a single version, including config and dataset as raw dicts.""" + + config: dict + dataset: Optional[dict] = None diff --git a/src/fides/api/service/connectors/saas/connector_registry_service.py b/src/fides/api/service/connectors/saas/connector_registry_service.py index 251893f2404..1c611775667 100644 --- a/src/fides/api/service/connectors/saas/connector_registry_service.py +++ b/src/fides/api/service/connectors/saas/connector_registry_service.py @@ -12,6 +12,7 @@ from fides.api.cryptography.cryptographic_util import str_to_b64_str from fides.api.models.connectionconfig import ConnectionConfig from fides.api.models.custom_connector_template import CustomConnectorTemplate +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.saas_template_dataset import SaasTemplateDataset from fides.api.schemas.saas.connector_template import ( ConnectorTemplate, @@ -330,6 +331,15 @@ def save_template(cls, db: Session, zip_file: ZipFile) -> str: dataset_json=template_dataset_json, ) + SaaSConfigVersion.upsert( + db=db, + connector_type=connector_type, + version=saas_config.version, + config=saas_config.model_dump(mode="json"), + dataset=template_dataset_json, + is_custom=True, + ) + # Bump the Redis version counter and clear the local cache so # every server detects the change on its next read. cls.get_connector_templates.bump_version() # type: ignore[attr-defined] diff --git a/src/fides/api/v1/endpoints/connector_template_endpoints.py b/src/fides/api/v1/endpoints/connector_template_endpoints.py index e2c689fc02e..ef1ae78788c 100644 --- a/src/fides/api/v1/endpoints/connector_template_endpoints.py +++ b/src/fides/api/v1/endpoints/connector_template_endpoints.py @@ -14,16 +14,19 @@ ) from fides.api import deps +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.oauth.utils import verify_oauth_client from fides.api.schemas.saas.connector_template import ( ConnectorTemplate, ConnectorTemplateListResponse, ) +from fides.api.schemas.saas.saas_config_version import SaaSConfigVersionResponse from fides.api.service.connectors.saas.connector_registry_service import ( ConnectorRegistry, CustomConnectorTemplateLoader, ) from fides.api.util.api_router import APIRouter +from fides.api.util.saas_util import load_config_from_string, load_dataset_from_string from fides.common.scope_registry import ( CONNECTOR_TEMPLATE_READ, CONNECTOR_TEMPLATE_REGISTER, @@ -34,6 +37,9 @@ CONNECTOR_TEMPLATES_CONFIG, CONNECTOR_TEMPLATES_DATASET, CONNECTOR_TEMPLATES_REGISTER, + CONNECTOR_TEMPLATES_VERSION_CONFIG, + CONNECTOR_TEMPLATES_VERSION_DATASET, + CONNECTOR_TEMPLATES_VERSIONS, DELETE_CUSTOM_TEMPLATE, REGISTER_CONNECTOR_TEMPLATE, V1_URL_PREFIX, @@ -252,3 +258,108 @@ def delete_custom_connector_template( return JSONResponse( content={"message": "Custom connector template successfully deleted."} ) + + +@router.get( + CONNECTOR_TEMPLATES_VERSIONS, + dependencies=[Security(verify_oauth_client, scopes=[CONNECTOR_TEMPLATE_READ])], + response_model=List[SaaSConfigVersionResponse], +) +def list_connector_template_versions( + connector_template_type: str, + db: Session = Depends(deps.get_db), +) -> List[SaaSConfigVersionResponse]: + """ + Returns all stored versions for a connector template type, newest first. + + Each entry includes the version string, whether it is a custom template, + and when it was first recorded. Use the version string with the + `/versions/{version}/config` and `/versions/{version}/dataset` endpoints + to inspect the full config or dataset for that version. + """ + rows = ( + db.query(SaaSConfigVersion) + .filter(SaaSConfigVersion.connector_type == connector_template_type) + .order_by(SaaSConfigVersion.created_at.desc()) + .all() + ) + return rows + + +@router.get( + CONNECTOR_TEMPLATES_VERSION_CONFIG, + dependencies=[Security(verify_oauth_client, scopes=[CONNECTOR_TEMPLATE_READ])], +) +def get_connector_template_version_config( + connector_template_type: str, + version: str, + db: Session = Depends(deps.get_db), +) -> Response: + """ + Retrieves the SaaS config for a specific version of a connector template. + + Returns the config as raw YAML, in the same format as + `GET /connector-templates/{type}/config`. + """ + import yaml # pylint: disable=import-outside-toplevel + + row = ( + db.query(SaaSConfigVersion) + .filter( + SaaSConfigVersion.connector_type == connector_template_type, + SaaSConfigVersion.version == version, + ) + .order_by(SaaSConfigVersion.created_at.desc()) + .first() + ) + if not row: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"No stored version '{version}' found for connector type '{connector_template_type}'", + ) + return Response( + content=yaml.dump({"saas_config": row.config}, default_flow_style=False), + media_type="text/yaml", + ) + + +@router.get( + CONNECTOR_TEMPLATES_VERSION_DATASET, + dependencies=[Security(verify_oauth_client, scopes=[CONNECTOR_TEMPLATE_READ])], +) +def get_connector_template_version_dataset( + connector_template_type: str, + version: str, + db: Session = Depends(deps.get_db), +) -> Response: + """ + Retrieves the dataset for a specific version of a connector template. + + Returns the dataset as raw YAML, in the same format as + `GET /connector-templates/{type}/dataset`. + """ + import yaml # pylint: disable=import-outside-toplevel + + row = ( + db.query(SaaSConfigVersion) + .filter( + SaaSConfigVersion.connector_type == connector_template_type, + SaaSConfigVersion.version == version, + ) + .order_by(SaaSConfigVersion.created_at.desc()) + .first() + ) + if not row: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"No stored version '{version}' found for connector type '{connector_template_type}'", + ) + if not row.dataset: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"No dataset stored for version '{version}' of connector type '{connector_template_type}'", + ) + return Response( + content=yaml.dump({"dataset": [row.dataset]}, default_flow_style=False), + media_type="text/yaml", + ) diff --git a/src/fides/api/v1/endpoints/saas_config_endpoints.py b/src/fides/api/v1/endpoints/saas_config_endpoints.py index 359d1439449..7aaa5a84765 100644 --- a/src/fides/api/v1/endpoints/saas_config_endpoints.py +++ b/src/fides/api/v1/endpoints/saas_config_endpoints.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional from fastapi import Depends, HTTPException, Request from fastapi.encoders import jsonable_encoder @@ -23,9 +23,12 @@ SaaSConfigNotFoundException, ) from fides.api.common_exceptions import ValidationError as FidesValidationError +from fides.api.models.connection_config_saas_history import ConnectionConfigSaaSHistory from fides.api.models.connectionconfig import ConnectionConfig, ConnectionType from fides.api.models.datasetconfig import DatasetConfig +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.event_audit import EventAuditStatus, EventAuditType +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.sql_models import System # type: ignore from fides.api.oauth.utils import verify_oauth_client from fides.api.schemas.connection_configuration.connection_config import ( @@ -34,6 +37,10 @@ from fides.api.schemas.connection_configuration.saas_config_template_values import ( SaasConnectionTemplateValues, ) +from fides.api.schemas.saas.connection_config_saas_history import ( + ConnectionConfigSaaSHistoryDetailResponse, + ConnectionConfigSaaSHistoryResponse, +) from fides.api.schemas.saas.saas_config import ( SaaSConfig, SaaSConfigValidationDetails, @@ -65,6 +72,8 @@ from fides.common.urn_registry import ( AUTHORIZE, SAAS_CONFIG, + SAAS_CONFIG_HISTORY, + SAAS_CONFIG_HISTORY_BY_VERSION, SAAS_CONFIG_VALIDATE, SAAS_CONNECTOR_FROM_TEMPLATE, V1_URL_PREFIX, @@ -210,6 +219,16 @@ def patch_saas_config( connection_config.update_saas_config(db, saas_config=saas_config) + patched_template = ConnectorRegistry.get_connector_template(saas_config.type) + SaaSConfigVersion.upsert( + db=db, + connector_type=saas_config.type, + version=saas_config.version, + config=saas_config.model_dump(mode="json"), + dataset=None, # PATCH only updates the config; dataset is managed separately + is_custom=patched_template.custom if patched_template else True, + ) + # Create audit event for SaaS config update event_audit_service = EventAuditService(db) event_details, description = generate_connection_audit_event_details( @@ -302,6 +321,67 @@ def delete_saas_config( connection_config.update(db, data={"saas_config": None}) +@router.get( + SAAS_CONFIG_HISTORY, + dependencies=[Security(verify_oauth_client, scopes=[SAAS_CONFIG_READ])], + response_model=List[ConnectionConfigSaaSHistoryResponse], +) +def list_saas_config_history( + db: Session = Depends(deps.get_db), + connection_config: ConnectionConfig = Depends(_get_saas_connection_config), +) -> List[ConnectionConfigSaaSHistory]: + """ + Returns all per-connection SaaS config snapshots for the given connection, + ordered newest first. + """ + logger.info( + "Listing SaaS config history for connection '{}'", connection_config.key + ) + return ( + db.query(ConnectionConfigSaaSHistory) + .filter( + ConnectionConfigSaaSHistory.connection_config_id == connection_config.id + ) + .order_by(ConnectionConfigSaaSHistory.created_at.desc()) + .all() + ) + + +@router.get( + SAAS_CONFIG_HISTORY_BY_VERSION, + dependencies=[Security(verify_oauth_client, scopes=[SAAS_CONFIG_READ])], + response_model=ConnectionConfigSaaSHistoryDetailResponse, +) +def get_saas_config_history_by_version( + version: str, + db: Session = Depends(deps.get_db), + connection_config: ConnectionConfig = Depends(_get_saas_connection_config), +) -> ConnectionConfigSaaSHistory: + """ + Returns the most recent snapshot for the given connection and version string. + """ + logger.info( + "Fetching SaaS config history for connection '{}' version '{}'", + connection_config.key, + version, + ) + snapshot = ( + db.query(ConnectionConfigSaaSHistory) + .filter( + ConnectionConfigSaaSHistory.connection_config_id == connection_config.id, + ConnectionConfigSaaSHistory.version == version, + ) + .order_by(ConnectionConfigSaaSHistory.created_at.desc()) + .first() + ) + if not snapshot: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"No SaaS config history found for connection '{connection_config.key}' version '{version}'", + ) + return snapshot + + @router.get( AUTHORIZE, dependencies=[Security(verify_oauth_client, scopes=[CONNECTION_AUTHORIZE])], diff --git a/src/fides/common/urn_registry.py b/src/fides/common/urn_registry.py index 7854eea469a..f6367e05d02 100644 --- a/src/fides/common/urn_registry.py +++ b/src/fides/common/urn_registry.py @@ -192,6 +192,8 @@ # SaaS Config URLs SAAS_CONFIG_VALIDATE = CONNECTION_BY_KEY + "/validate_saas_config" SAAS_CONFIG = CONNECTION_BY_KEY + "/saas_config" +SAAS_CONFIG_HISTORY = CONNECTION_BY_KEY + "/saas-history" +SAAS_CONFIG_HISTORY_BY_VERSION = CONNECTION_BY_KEY + "/saas-history/{version}" SAAS_CONNECTOR_FROM_TEMPLATE = "/connection/instantiate/{connector_template_type}" # Connector Template URLs @@ -199,6 +201,9 @@ CONNECTOR_TEMPLATES_REGISTER = "/connector-templates/register" CONNECTOR_TEMPLATES_CONFIG = "/connector-templates/{connector_template_type}/config" CONNECTOR_TEMPLATES_DATASET = "/connector-templates/{connector_template_type}/dataset" +CONNECTOR_TEMPLATES_VERSIONS = "/connector-templates/{connector_template_type}/versions" +CONNECTOR_TEMPLATES_VERSION_CONFIG = "/connector-templates/{connector_template_type}/versions/{version}/config" +CONNECTOR_TEMPLATES_VERSION_DATASET = "/connector-templates/{connector_template_type}/versions/{version}/dataset" DELETE_CUSTOM_TEMPLATE = "/connector-templates/{connector_template_type}" # Deprecated: Old connector template register URL diff --git a/tests/ops/api/v1/endpoints/test_saas_config_endpoints.py b/tests/ops/api/v1/endpoints/test_saas_config_endpoints.py index a6e4d1dcdf0..c8aed37cf37 100644 --- a/tests/ops/api/v1/endpoints/test_saas_config_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_saas_config_endpoints.py @@ -24,6 +24,8 @@ from fides.common.urn_registry import ( AUTHORIZE, SAAS_CONFIG, + SAAS_CONFIG_HISTORY, + SAAS_CONFIG_HISTORY_BY_VERSION, SAAS_CONFIG_VALIDATE, V1_URL_PREFIX, ) @@ -584,3 +586,238 @@ def test_get_authorize_url( response = api_client.get(authorize_url, headers=auth_header) response.raise_for_status() assert response.text == f'"{authorization_url}"' + + +@pytest.mark.unit_saas +class TestListSaaSConfigHistory: + @pytest.fixture + def history_url(self, saas_example_connection_config) -> str: + path = V1_URL_PREFIX + SAAS_CONFIG_HISTORY + return path.format(connection_key=saas_example_connection_config.key) + + def test_list_saas_config_history_unauthenticated( + self, history_url, api_client: TestClient + ) -> None: + response = api_client.get(history_url, headers={}) + assert response.status_code == 401 + + def test_list_saas_config_history_wrong_scope( + self, + history_url, + api_client: TestClient, + generate_auth_header, + ) -> None: + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + response = api_client.get(history_url, headers=auth_header) + assert response.status_code == 403 + + def test_list_saas_config_history_connection_not_found( + self, + api_client: TestClient, + generate_auth_header, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY).format( + connection_key="nonexistent_key" + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 404 + + def test_list_saas_config_history_empty( + self, + history_url, + api_client: TestClient, + generate_auth_header, + ) -> None: + """Connection exists but update_saas_config has never been called — no snapshots.""" + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(history_url, headers=auth_header) + assert response.status_code == 200 + assert response.json() == [] + + def test_list_saas_config_history_after_patch( + self, + saas_example_config, + saas_example_connection_config, + api_client: TestClient, + db: Session, + generate_auth_header, + ) -> None: + """PATCH the saas config, then verify a history snapshot was created.""" + patch_url = (V1_URL_PREFIX + SAAS_CONFIG).format( + connection_key=saas_example_connection_config.key + ) + patch_auth = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + patch_resp = api_client.patch( + patch_url, headers=patch_auth, json=saas_example_config + ) + assert patch_resp.status_code == 200 + + history_url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY).format( + connection_key=saas_example_connection_config.key + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(history_url, headers=auth_header) + assert response.status_code == 200 + items = response.json() + assert len(items) == 1 + item = items[0] + assert item["version"] == saas_example_config["version"] + assert "id" in item + assert "created_at" in item + # list response must not include the full config blob + assert "config" not in item + + def test_list_saas_config_history_multiple_patches_newest_first( + self, + saas_example_config, + saas_example_connection_config, + api_client: TestClient, + db: Session, + generate_auth_header, + ) -> None: + """Two PATCHes produce two snapshots ordered newest first.""" + patch_url = (V1_URL_PREFIX + SAAS_CONFIG).format( + connection_key=saas_example_connection_config.key + ) + patch_auth = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + + # first patch + api_client.patch(patch_url, headers=patch_auth, json=saas_example_config) + + # second patch — bump version so it's distinguishable + config_v2 = dict(saas_example_config) + config_v2["version"] = "0.0.2" + api_client.patch(patch_url, headers=patch_auth, json=config_v2) + + history_url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY).format( + connection_key=saas_example_connection_config.key + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(history_url, headers=auth_header) + assert response.status_code == 200 + items = response.json() + assert len(items) == 2 + # newest first + assert items[0]["version"] == "0.0.2" + assert items[1]["version"] == saas_example_config["version"] + + +@pytest.mark.unit_saas +class TestGetSaaSConfigHistoryByVersion: + @pytest.fixture + def patched_connection_config( + self, + saas_example_config, + saas_example_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> ConnectionConfig: + """Connection config that has had update_saas_config called once.""" + patch_url = (V1_URL_PREFIX + SAAS_CONFIG).format( + connection_key=saas_example_connection_config.key + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + api_client.patch(patch_url, headers=auth_header, json=saas_example_config) + return saas_example_connection_config + + def test_get_saas_config_history_by_version_unauthenticated( + self, + patched_connection_config, + api_client: TestClient, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=patched_connection_config.key, version="0.0.1" + ) + response = api_client.get(url, headers={}) + assert response.status_code == 401 + + def test_get_saas_config_history_by_version_wrong_scope( + self, + patched_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=patched_connection_config.key, version="0.0.1" + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 403 + + def test_get_saas_config_history_by_version_connection_not_found( + self, + api_client: TestClient, + generate_auth_header, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key="nonexistent_key", version="0.0.1" + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 404 + + def test_get_saas_config_history_by_version_not_found( + self, + patched_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=patched_connection_config.key, version="9.9.9" + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 404 + + def test_get_saas_config_history_by_version_found( + self, + saas_example_config, + patched_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> None: + version = saas_example_config["version"] + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=patched_connection_config.key, version=version + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 200 + data = response.json() + assert data["version"] == version + assert "id" in data + assert "created_at" in data + assert "config" in data + assert data["config"]["fides_key"] == saas_example_config["fides_key"] + # no datasets associated in this fixture + assert data["dataset"] is None + + def test_get_saas_config_history_by_version_returns_most_recent( + self, + saas_example_config, + saas_example_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> None: + """When the same version is patched twice, the most recent snapshot is returned.""" + patch_url = (V1_URL_PREFIX + SAAS_CONFIG).format( + connection_key=saas_example_connection_config.key + ) + patch_auth = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + + # patch twice with the same version + api_client.patch(patch_url, headers=patch_auth, json=saas_example_config) + modified = dict(saas_example_config) + modified["description"] = "second patch" + api_client.patch(patch_url, headers=patch_auth, json=modified) + + version = saas_example_config["version"] + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=saas_example_connection_config.key, version=version + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 200 + data = response.json() + assert data["config"].get("description") == "second patch"