From cd093243a9b973d3eeb8910400fd15ce72b69d24 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Thu, 12 Mar 2026 11:24:27 -0300 Subject: [PATCH 01/36] Adding base executionlog saas version --- ..._03_11_add_saas_version_to_executionlog.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_03_11_add_saas_version_to_executionlog.py diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_03_11_add_saas_version_to_executionlog.py b/src/fides/api/alembic/migrations/versions/xx_2026_03_11_add_saas_version_to_executionlog.py new file mode 100644 index 00000000000..0a190f538e1 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_03_11_add_saas_version_to_executionlog.py @@ -0,0 +1,31 @@ +"""add saas_version to executionlog + +Adds a nullable saas_version column to the executionlog table to record which +version of a SaaS integration config was active when a DSR collection was processed. +Only populated for SaaS connectors (connection_type = 'saas'); null for all others. + +Revision ID: a1b2c3d4e5f6 +Revises: 04281f44cc0b +Create Date: 2026-03-11 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "04281f44cc0b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "executionlog", + sa.Column("saas_version", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("executionlog", "saas_version") From 85ab8eb3f326d81e8a9290ce58f168f2423227ed Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Thu, 12 Mar 2026 11:25:11 -0300 Subject: [PATCH 02/36] adding execution log saas version model --- src/fides/api/models/privacy_request/execution_log.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/fides/api/models/privacy_request/execution_log.py b/src/fides/api/models/privacy_request/execution_log.py index 5dfaf977a3d..206eee4d1c6 100644 --- a/src/fides/api/models/privacy_request/execution_log.py +++ b/src/fides/api/models/privacy_request/execution_log.py @@ -73,6 +73,9 @@ class ExecutionLog(TaskExecutionLog, Base): index=True, nullable=False, ) + # Version of the SaaS integration config at the time of execution (e.g. "2.0.0"). + # Null for non-SaaS connectors. Useful for troubleshooting OOB and custom integration issues. + saas_version = Column(String, nullable=True) privacy_request_id = Column( String, From 396c00aa380d06cca09bac25aea2d8be85c87c80 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Thu, 12 Mar 2026 11:25:31 -0300 Subject: [PATCH 03/36] adding saas_version on the logs --- src/fides/api/schemas/privacy_request.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/fides/api/schemas/privacy_request.py b/src/fides/api/schemas/privacy_request.py index 8ab39eb3b4c..92575ce5f11 100644 --- a/src/fides/api/schemas/privacy_request.py +++ b/src/fides/api/schemas/privacy_request.py @@ -215,6 +215,7 @@ class ExecutionLogDetailResponse(ExecutionLogResponse): connection_key: Optional[str] = None dataset_name: Optional[str] = None + saas_version: Optional[str] = None class ExecutionAndAuditLogResponse(FidesSchema): @@ -229,6 +230,7 @@ class ExecutionAndAuditLogResponse(FidesSchema): status: Optional[Union[ExecutionLogStatus, AuditLogAction, str]] = None updated_at: Optional[datetime] = None user_id: Optional[str] = None + saas_version: Optional[str] = None model_config = ConfigDict(populate_by_name=True) @field_serializer("status") From a39d035c85f07af919fe44ad8cf0ba63a13c83cf Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Thu, 12 Mar 2026 11:25:54 -0300 Subject: [PATCH 04/36] adding test for the execution log --- .../privacy_request/test_execution_logs.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/ops/models/privacy_request/test_execution_logs.py b/tests/ops/models/privacy_request/test_execution_logs.py index 5234b99e75e..4aac502b189 100644 --- a/tests/ops/models/privacy_request/test_execution_logs.py +++ b/tests/ops/models/privacy_request/test_execution_logs.py @@ -109,6 +109,26 @@ def test_execution_log_empty_optional_fields(db, execution_log_data): execution_log.delete(db) +def test_execution_log_saas_version_stored(db, execution_log_data): + execution_log_data["saas_version"] = "2.3.0" + execution_log = ExecutionLog.create(db, data=execution_log_data) + + retrieved = db.query(ExecutionLog).filter_by(privacy_request_id="test_id").first() + assert retrieved is not None + assert retrieved.saas_version == "2.3.0" + execution_log.delete(db) + + +def test_execution_log_saas_version_null_for_non_saas(db, execution_log_data): + # saas_version not provided — should default to None + execution_log = ExecutionLog.create(db, data=execution_log_data) + + retrieved = db.query(ExecutionLog).filter_by(privacy_request_id="test_id").first() + assert retrieved is not None + assert retrieved.saas_version is None + execution_log.delete(db) + + def test_execution_log_large_data(db, execution_log_data): large_message = "a" * 10000 # Large string large_fields_affected = ["field" + str(i) for i in range(1000)] # Large list From db4557dd0a1d6af1e545c96908f7a404a26110cd Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Thu, 12 Mar 2026 11:49:22 -0300 Subject: [PATCH 05/36] Adding Test for the graph task --- tests/ops/task/test_graph_task.py | 55 +++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/ops/task/test_graph_task.py b/tests/ops/task/test_graph_task.py index c47937ec75c..1b1818b19f7 100644 --- a/tests/ops/task/test_graph_task.py +++ b/tests/ops/task/test_graph_task.py @@ -1090,6 +1090,26 @@ def graph_task(self, privacy_request, policy, db): resources.privacy_request_task = rq return GraphTask(resources) + @pytest.fixture(scope="function") + def saas_graph_task(self, privacy_request, policy, db, saas_example_connection_config): + resources = TaskResources( + privacy_request, + policy, + [saas_example_connection_config], + EMPTY_REQUEST_TASK, + db, + ) + tn = TraversalNode(generate_node("saas_ds", "saas_coll", "id")) + tn.node.dataset.connection_key = saas_example_connection_config.key + rq = tn.to_mock_request_task() + rq.action_type = ActionType.access + rq.status = ExecutionLogStatus.pending + rq.id = str(uuid.uuid4()) + db.add(rq) + db.commit() + resources.privacy_request_task = rq + return GraphTask(resources) + def test_log_start(self, graph_task, db, privacy_request): graph_task.log_start(action_type=ActionType.access) @@ -1197,6 +1217,41 @@ def test_log_end_complete( assert execution_log.status == ExecutionLogStatus.complete + def test_saas_version_null_for_non_saas_connector( + self, graph_task, db, privacy_request + ): + """Non-SaaS connectors (postgres in this fixture) should produce null saas_version.""" + graph_task.log_start(action_type=ActionType.access) + + execution_log = ( + db.query(ExecutionLog) + .filter( + ExecutionLog.privacy_request_id == privacy_request.id, + ExecutionLog.collection_name == "b", + ExecutionLog.dataset_name == "a", + ) + .first() + ) + assert execution_log is not None + assert execution_log.saas_version is None + + def test_saas_version_populated_for_saas_connector( + self, saas_graph_task, saas_example_connection_config, privacy_request, policy, db + ): + """SaaS connectors should stamp saas_version on every execution log entry.""" + saas_graph_task.log_start(action_type=ActionType.access) + execution_log = ( + db.query(ExecutionLog) + .filter( + ExecutionLog.privacy_request_id == privacy_request.id, + ExecutionLog.collection_name == "saas_coll", + ExecutionLog.dataset_name == "saas_ds", + ) + .first() + ) + assert execution_log is not None + assert execution_log.saas_version == saas_example_connection_config.saas_config["version"] + class TestTraversalOnlyBehavior: """Tests for TRAVERSAL_ONLY bridge node behavior in access and erasure requests.""" From b5d0ea7b1cf8d36c721e0d43bf1543ff5cacad48 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Thu, 12 Mar 2026 11:49:46 -0300 Subject: [PATCH 06/36] adding logging on the graph task --- src/fides/api/task/graph_task.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/fides/api/task/graph_task.py b/src/fides/api/task/graph_task.py index ad247c8f322..9adebcca031 100644 --- a/src/fides/api/task/graph_task.py +++ b/src/fides/api/task/graph_task.py @@ -410,6 +410,9 @@ def update_status( """Update status activities - create an execution log (which stores historical logs) and update the Request Task's current status. """ + saas_config = self.connector.configuration.get_saas_config() + saas_version = saas_config.version if saas_config else None + with get_db() as db: ExecutionLog.create( db=db, @@ -422,6 +425,7 @@ def update_status( "status": status, "privacy_request_id": self.resources.request.id, "message": msg, + "saas_version": saas_version, }, ) @@ -438,7 +442,15 @@ def update_status( def log_start(self, action_type: ActionType) -> None: """Task start activities""" - logger.info("Starting node {}", self.key) + saas_config = self.connector.configuration.get_saas_config() + if saas_config: + logger.info( + "Starting node {} (integration version {})", + self.key, + saas_config.version, + ) + else: + logger.info("Starting node {}", self.key) self.update_status( "starting", [], action_type, ExecutionLogStatus.in_processing @@ -446,7 +458,15 @@ def log_start(self, action_type: ActionType) -> None: def log_retry(self, action_type: ActionType) -> None: """Task retry activities""" - logger.info("Retrying node {}", self.key) + saas_config = self.connector.configuration.get_saas_config() + if saas_config: + logger.info( + "Retrying node {} (integration version {})", + self.key, + saas_config.version, + ) + else: + logger.info("Retrying node {}", self.key) self.update_status("retrying", [], action_type, ExecutionLogStatus.retrying) From 9899f9d1f95a1e3ef1775ad607d5d168dcbb9a5b Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Thu, 12 Mar 2026 11:50:07 -0300 Subject: [PATCH 07/36] adding saas_version to the audit query --- src/fides/api/v1/endpoints/privacy_request_endpoints.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/fides/api/v1/endpoints/privacy_request_endpoints.py b/src/fides/api/v1/endpoints/privacy_request_endpoints.py index 3e11868fdc8..b6ea5e9922f 100644 --- a/src/fides/api/v1/endpoints/privacy_request_endpoints.py +++ b/src/fides/api/v1/endpoints/privacy_request_endpoints.py @@ -328,6 +328,7 @@ def execution_and_audit_logs_by_dataset_name( ExecutionLog.fields_affected, ExecutionLog.action_type, null().label("user_id"), + ExecutionLog.saas_version, ).filter(ExecutionLog.privacy_request_id == self.id) audit_log_query: Query = db.query( @@ -345,6 +346,7 @@ def execution_and_audit_logs_by_dataset_name( null().label("fields_affected"), null().label("action_type"), AuditLog.user_id, + null().label("saas_version"), ).filter(AuditLog.privacy_request_id == self.id) combined: Query = execution_log_query.union_all(audit_log_query) From 7baae34a511c92e62eb0186bb42d4ea9673abbe1 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Fri, 13 Mar 2026 13:10:36 -0300 Subject: [PATCH 08/36] Fixing Revision name and settings --- ...0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename src/fides/api/alembic/migrations/versions/{xx_2026_03_11_add_saas_version_to_executionlog.py => xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py} (86%) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_03_11_add_saas_version_to_executionlog.py b/src/fides/api/alembic/migrations/versions/xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py similarity index 86% rename from src/fides/api/alembic/migrations/versions/xx_2026_03_11_add_saas_version_to_executionlog.py rename to src/fides/api/alembic/migrations/versions/xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py index 0a190f538e1..459c8ab883d 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_03_11_add_saas_version_to_executionlog.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py @@ -4,8 +4,8 @@ version of a SaaS integration config was active when a DSR collection was processed. Only populated for SaaS connectors (connection_type = 'saas'); null for all others. -Revision ID: a1b2c3d4e5f6 -Revises: 04281f44cc0b +Revision ID: a1ca9ddf3c3c +Revises: 4ac4864180db Create Date: 2026-03-11 """ @@ -14,8 +14,8 @@ from alembic import op # revision identifiers, used by Alembic. -revision = "a1b2c3d4e5f6" -down_revision = "04281f44cc0b" +revision = "a1ca9ddf3c3c" +down_revision = "4ac4864180db" branch_labels = None depends_on = None From 1053fb43a2ff4e59919f7ea11a78747f0b0fda35 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Fri, 13 Mar 2026 13:28:09 -0300 Subject: [PATCH 09/36] Caching Saas version --- src/fides/api/task/graph_task.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/fides/api/task/graph_task.py b/src/fides/api/task/graph_task.py index 9adebcca031..0a70e348734 100644 --- a/src/fides/api/task/graph_task.py +++ b/src/fides/api/task/graph_task.py @@ -260,6 +260,9 @@ def __init__( self.key: CollectionAddress = self.execution_node.address + saas_config = self.connector.configuration.get_saas_config() + self._saas_version: Optional[str] = saas_config.version if saas_config else None + self.execution_log_id = None # a local copy of the execution log record written to. If we write multiple status # updates, we will use this id to ensure that we're updating rather than creating @@ -410,9 +413,6 @@ def update_status( """Update status activities - create an execution log (which stores historical logs) and update the Request Task's current status. """ - saas_config = self.connector.configuration.get_saas_config() - saas_version = saas_config.version if saas_config else None - with get_db() as db: ExecutionLog.create( db=db, @@ -425,7 +425,7 @@ def update_status( "status": status, "privacy_request_id": self.resources.request.id, "message": msg, - "saas_version": saas_version, + "saas_version": self._saas_version, }, ) @@ -442,12 +442,11 @@ def update_status( def log_start(self, action_type: ActionType) -> None: """Task start activities""" - saas_config = self.connector.configuration.get_saas_config() - if saas_config: + if self._saas_version: logger.info( "Starting node {} (integration version {})", self.key, - saas_config.version, + self._saas_version, ) else: logger.info("Starting node {}", self.key) @@ -458,12 +457,11 @@ def log_start(self, action_type: ActionType) -> None: def log_retry(self, action_type: ActionType) -> None: """Task retry activities""" - saas_config = self.connector.configuration.get_saas_config() - if saas_config: + if self._saas_version: logger.info( "Retrying node {} (integration version {})", self.key, - saas_config.version, + self._saas_version, ) else: logger.info("Retrying node {}", self.key) From 460054452d4833ffeb4014084e555489b61bf2b0 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Fri, 13 Mar 2026 13:31:57 -0300 Subject: [PATCH 10/36] Updating tests --- tests/ops/task/test_graph_task.py | 41 +++++++++++++++++-------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/tests/ops/task/test_graph_task.py b/tests/ops/task/test_graph_task.py index 1b1818b19f7..3127e3b6732 100644 --- a/tests/ops/task/test_graph_task.py +++ b/tests/ops/task/test_graph_task.py @@ -1092,23 +1092,23 @@ def graph_task(self, privacy_request, policy, db): @pytest.fixture(scope="function") def saas_graph_task(self, privacy_request, policy, db, saas_example_connection_config): - resources = TaskResources( - privacy_request, - policy, - [saas_example_connection_config], - EMPTY_REQUEST_TASK, - db, - ) - tn = TraversalNode(generate_node("saas_ds", "saas_coll", "id")) - tn.node.dataset.connection_key = saas_example_connection_config.key - rq = tn.to_mock_request_task() - rq.action_type = ActionType.access - rq.status = ExecutionLogStatus.pending - rq.id = str(uuid.uuid4()) - db.add(rq) - db.commit() - resources.privacy_request_task = rq - return GraphTask(resources) + resources = TaskResources( + privacy_request, + policy, + [saas_example_connection_config], + EMPTY_REQUEST_TASK, + db, + ) + tn = TraversalNode(generate_node("saas_ds", "saas_coll", "id")) + tn.node.dataset.connection_key = saas_example_connection_config.key + rq = tn.to_mock_request_task() + rq.action_type = ActionType.access + rq.status = ExecutionLogStatus.pending + rq.id = str(uuid.uuid4()) + db.add(rq) + db.commit() + resources.privacy_request_task = rq + return GraphTask(resources) def test_log_start(self, graph_task, db, privacy_request): graph_task.log_start(action_type=ActionType.access) @@ -1236,9 +1236,12 @@ def test_saas_version_null_for_non_saas_connector( assert execution_log.saas_version is None def test_saas_version_populated_for_saas_connector( - self, saas_graph_task, saas_example_connection_config, privacy_request, policy, db + self, saas_graph_task, saas_example_connection_config, privacy_request, policy, db ): """SaaS connectors should stamp saas_version on every execution log entry.""" + expected_version = saas_example_connection_config.saas_config["version"] + assert saas_graph_task._saas_version == expected_version + saas_graph_task.log_start(action_type=ActionType.access) execution_log = ( db.query(ExecutionLog) @@ -1250,7 +1253,7 @@ def test_saas_version_populated_for_saas_connector( .first() ) assert execution_log is not None - assert execution_log.saas_version == saas_example_connection_config.saas_config["version"] + assert execution_log.saas_version == expected_version class TestTraversalOnlyBehavior: From 98a562990e4eb78ea3d7db47dee8ee77f237815b Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:50:25 -0300 Subject: [PATCH 11/36] Event logs UI adding version --- .../events-and-logs/EventLog.tsx | 29 ++++++++++++------- .../src/features/privacy-requests/types.ts | 1 + .../models/ExecutionAndAuditLogResponse.ts | 1 + 3 files changed, 21 insertions(+), 10 deletions(-) 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 214d59f6a44..fefbae32b6a 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 @@ -1,6 +1,7 @@ import { formatDate } from "common/utils"; import { ChakraBox as Box, + ChakraHStack as HStack, ChakraTable as Table, ChakraTableContainer as TableContainer, ChakraTbody as Tbody, @@ -9,6 +10,7 @@ import { ChakraTh as Th, ChakraThead as Thead, ChakraTr as Tr, + CUSTOM_TAG_COLOR, Tag, } from "fidesui"; import palette from "fidesui/src/palette/palette.module.scss"; @@ -253,16 +255,23 @@ const EventLog = ({ )} {hasDatasetEntries && !isRequestFinishedView && ( - - {(detail.status as string) === "finished" - ? "Request completed" - : detail.collection_name} - + + + {(detail.status as string) === "finished" + ? "Request completed" + : detail.collection_name} + + {detail.saas_version && ( + + v{detail.saas_version} + + )} + )} diff --git a/clients/admin-ui/src/features/privacy-requests/types.ts b/clients/admin-ui/src/features/privacy-requests/types.ts index e541b540c57..3f6b02989a4 100644 --- a/clients/admin-ui/src/features/privacy-requests/types.ts +++ b/clients/admin-ui/src/features/privacy-requests/types.ts @@ -60,6 +60,7 @@ export interface ExecutionLog { action_type: string; status: ExecutionLogStatus; updated_at: string; + saas_version?: string | null; } export type GetUploadedManualWebhookDataRequest = { diff --git a/clients/admin-ui/src/types/api/models/ExecutionAndAuditLogResponse.ts b/clients/admin-ui/src/types/api/models/ExecutionAndAuditLogResponse.ts index 6c0b3d46237..e9051eab05d 100644 --- a/clients/admin-ui/src/types/api/models/ExecutionAndAuditLogResponse.ts +++ b/clients/admin-ui/src/types/api/models/ExecutionAndAuditLogResponse.ts @@ -20,4 +20,5 @@ export type ExecutionAndAuditLogResponse = { status?: ExecutionLogStatus | AuditLogAction | string | null; updated_at?: string | null; user_id?: string | null; + saas_version?: string | null; }; From a9ca916d164cd60aceaf215dcde0024de0592597 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 16:06:19 -0300 Subject: [PATCH 12/36] Updating dabase dataset. Updating test for endpoints --- .fides/db_dataset.yml | 2 ++ .../privacy_request/test_privacy_request_endpoints.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index 4b8dab1ffbf..5a91c0d35ed 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -1371,6 +1371,8 @@ dataset: data_categories: [system.operations] - name: privacy_request_id data_categories: [system.operations] + - name: saas_version + data_categories: [system.operations] - name: status data_categories: [system.operations] - name: updated_at diff --git a/tests/ops/api/v1/endpoints/privacy_request/test_privacy_request_endpoints.py b/tests/ops/api/v1/endpoints/privacy_request/test_privacy_request_endpoints.py index db8381e1d5a..51d72e1874c 100644 --- a/tests/ops/api/v1/endpoints/privacy_request/test_privacy_request_endpoints.py +++ b/tests/ops/api/v1/endpoints/privacy_request/test_privacy_request_endpoints.py @@ -2057,6 +2057,7 @@ def test_verbose_privacy_requests( "status": "approved", "updated_at": stringify_date(audit_log.updated_at), "user_id": "system", + "saas_version": None, } ], "my-mongo-db": [ @@ -2077,6 +2078,7 @@ def test_verbose_privacy_requests( mongo_execution_log.updated_at ), "user_id": None, + "saas_version": None, } ], "my-postgres-db": [ @@ -2097,6 +2099,7 @@ def test_verbose_privacy_requests( postgres_execution_log.updated_at ), "user_id": None, + "saas_version": None, }, { "connection_key": None, @@ -2124,6 +2127,7 @@ def test_verbose_privacy_requests( second_postgres_execution_log.updated_at ), "user_id": None, + "saas_version": None, }, ], "my-async-connector": [ @@ -2153,6 +2157,7 @@ def test_verbose_privacy_requests( async_execution_log.updated_at ), "user_id": None, + "saas_version": None, } ], }, @@ -3223,6 +3228,7 @@ def test_verbose_privacy_requests( "status": "approved", "updated_at": stringify_date(audit_log.updated_at), "user_id": "system", + "saas_version": None, } ], "my-mongo-db": [ @@ -3243,6 +3249,7 @@ def test_verbose_privacy_requests( mongo_execution_log.updated_at ), "user_id": None, + "saas_version": None, } ], "my-postgres-db": [ @@ -3263,6 +3270,7 @@ def test_verbose_privacy_requests( postgres_execution_log.updated_at ), "user_id": None, + "saas_version": None, }, { "connection_key": None, @@ -3290,6 +3298,7 @@ def test_verbose_privacy_requests( second_postgres_execution_log.updated_at ), "user_id": None, + "saas_version": None, }, ], "my-async-connector": [ @@ -3319,6 +3328,7 @@ def test_verbose_privacy_requests( async_execution_log.updated_at ), "user_id": None, + "saas_version": None, } ], }, From be4745881c241a2ca4198b66543ba83377ee88fe Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 16:25:44 -0300 Subject: [PATCH 13/36] using terniaries and avoiding long expressions --- src/fides/api/task/graph_task.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/fides/api/task/graph_task.py b/src/fides/api/task/graph_task.py index 0a70e348734..e143f038624 100644 --- a/src/fides/api/task/graph_task.py +++ b/src/fides/api/task/graph_task.py @@ -260,8 +260,8 @@ def __init__( self.key: CollectionAddress = self.execution_node.address - saas_config = self.connector.configuration.get_saas_config() - self._saas_version: Optional[str] = saas_config.version if saas_config else None + saas_config_dict = self.connector.configuration.saas_config + self._saas_version: Optional[str] = saas_config_dict.get("version") if saas_config_dict else None self.execution_log_id = None # a local copy of the execution log record written to. If we write multiple status @@ -442,14 +442,11 @@ def update_status( def log_start(self, action_type: ActionType) -> None: """Task start activities""" - if self._saas_version: - logger.info( - "Starting node {} (integration version {})", - self.key, - self._saas_version, - ) - else: - logger.info("Starting node {}", self.key) + logger.info( + "Starting node {}{}", + self.key, + f" (integration version {self._saas_version})" if self._saas_version else "", + ) self.update_status( "starting", [], action_type, ExecutionLogStatus.in_processing @@ -457,14 +454,11 @@ def log_start(self, action_type: ActionType) -> None: def log_retry(self, action_type: ActionType) -> None: """Task retry activities""" - if self._saas_version: - logger.info( - "Retrying node {} (integration version {})", - self.key, - self._saas_version, - ) - else: - logger.info("Retrying node {}", self.key) + logger.info( + "Retrying node {}{}", + self.key, + f" (integration version {self._saas_version})" if self._saas_version else "", + ) self.update_status("retrying", [], action_type, ExecutionLogStatus.retrying) From f5363555493f3c984e2195b5ffdf056ea9805134 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 16:25:53 -0300 Subject: [PATCH 14/36] Removing unnecesary policy --- tests/ops/task/test_graph_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ops/task/test_graph_task.py b/tests/ops/task/test_graph_task.py index 3127e3b6732..4ca38379918 100644 --- a/tests/ops/task/test_graph_task.py +++ b/tests/ops/task/test_graph_task.py @@ -1236,7 +1236,7 @@ def test_saas_version_null_for_non_saas_connector( assert execution_log.saas_version is None def test_saas_version_populated_for_saas_connector( - self, saas_graph_task, saas_example_connection_config, privacy_request, policy, db + self, saas_graph_task, saas_example_connection_config, privacy_request, db ): """SaaS connectors should stamp saas_version on every execution log entry.""" expected_version = saas_example_connection_config.saas_config["version"] From e5a451590a3ad160e3fa3145fbb29163903301a4 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 16:31:18 -0300 Subject: [PATCH 15/36] Adding version in its own column --- .../events-and-logs/EventLog.tsx | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) 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 fefbae32b6a..5c4b013173b 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 @@ -1,7 +1,6 @@ import { formatDate } from "common/utils"; import { ChakraBox as Box, - ChakraHStack as HStack, ChakraTable as Table, ChakraTableContainer as TableContainer, ChakraTbody as Tbody, @@ -255,23 +254,32 @@ const EventLog = ({ )} {hasDatasetEntries && !isRequestFinishedView && ( - + + {(detail.status as string) === "finished" + ? "Request completed" + : detail.collection_name} + + + )} + {hasDatasetEntries && !isRequestFinishedView && ( + + {detail.saas_version ? ( + v{detail.saas_version} + ) : ( - {(detail.status as string) === "finished" - ? "Request completed" - : detail.collection_name} + - - {detail.saas_version && ( - - v{detail.saas_version} - - )} - + )} )} @@ -349,6 +357,18 @@ const EventLog = ({ )} + {hasDatasetEntries && !isRequestFinishedView && ( + + + Version + + + )} From caee311602413a79f5a5ee7d0e65ddb9ed6d695a Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 16:31:27 -0300 Subject: [PATCH 16/36] Adding changelog --- changelog/7650-record-saas-version-on-execution-logs.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/7650-record-saas-version-on-execution-logs.yaml diff --git a/changelog/7650-record-saas-version-on-execution-logs.yaml b/changelog/7650-record-saas-version-on-execution-logs.yaml new file mode 100644 index 00000000000..20167d98608 --- /dev/null +++ b/changelog/7650-record-saas-version-on-execution-logs.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added saas_version field to ExecutionLog to record integration version at request execution time +pr: 7650 +labels: ["db-migration"] From 47253a26e6aa1e0abee5fd95a0517fc240d695f3 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 16:44:27 -0300 Subject: [PATCH 17/36] Updating revision --- ...3_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py b/src/fides/api/alembic/migrations/versions/xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py index 459c8ab883d..7b89edcf5de 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py @@ -5,7 +5,7 @@ Only populated for SaaS connectors (connection_type = 'saas'); null for all others. Revision ID: a1ca9ddf3c3c -Revises: 4ac4864180db +Revises: ea20059aee77 Create Date: 2026-03-11 """ @@ -15,7 +15,7 @@ # revision identifiers, used by Alembic. revision = "a1ca9ddf3c3c" -down_revision = "4ac4864180db" +down_revision = "ea20059aee77" branch_labels = None depends_on = None From db3babc434ca0171fa2d1bc5e001bc17a3b85ee9 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 16:47:17 -0300 Subject: [PATCH 18/36] running ruff --- src/fides/api/task/graph_task.py | 12 +++++++++--- tests/ops/task/test_graph_task.py | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/fides/api/task/graph_task.py b/src/fides/api/task/graph_task.py index e0859bbd7cc..2960f4cd682 100644 --- a/src/fides/api/task/graph_task.py +++ b/src/fides/api/task/graph_task.py @@ -261,7 +261,9 @@ def __init__( self.key: CollectionAddress = self.execution_node.address saas_config_dict = self.connector.configuration.saas_config - self._saas_version: Optional[str] = saas_config_dict.get("version") if saas_config_dict else None + self._saas_version: Optional[str] = ( + saas_config_dict.get("version") if saas_config_dict else None + ) self.execution_log_id = None # a local copy of the execution log record written to. If we write multiple status @@ -448,7 +450,9 @@ def log_start(self, action_type: ActionType) -> None: logger.info( "Starting node {}{}", self.key, - f" (integration version {self._saas_version})" if self._saas_version else "", + f" (integration version {self._saas_version})" + if self._saas_version + else "", ) self.update_status( @@ -460,7 +464,9 @@ def log_retry(self, action_type: ActionType) -> None: logger.info( "Retrying node {}{}", self.key, - f" (integration version {self._saas_version})" if self._saas_version else "", + f" (integration version {self._saas_version})" + if self._saas_version + else "", ) self.update_status("retrying", [], action_type, ExecutionLogStatus.retrying) diff --git a/tests/ops/task/test_graph_task.py b/tests/ops/task/test_graph_task.py index 4ca38379918..5c6056d3262 100644 --- a/tests/ops/task/test_graph_task.py +++ b/tests/ops/task/test_graph_task.py @@ -1091,7 +1091,9 @@ def graph_task(self, privacy_request, policy, db): return GraphTask(resources) @pytest.fixture(scope="function") - def saas_graph_task(self, privacy_request, policy, db, saas_example_connection_config): + def saas_graph_task( + self, privacy_request, policy, db, saas_example_connection_config + ): resources = TaskResources( privacy_request, policy, From 39ecea6663e404955e4f8bb049f7e62348fe51a7 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 10:46:59 -0300 Subject: [PATCH 19/36] Adding saas_versions to endpoint tests --- .../privacy_request/test_privacy_request_endpoints.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/ops/api/v1/endpoints/privacy_request/test_privacy_request_endpoints.py b/tests/ops/api/v1/endpoints/privacy_request/test_privacy_request_endpoints.py index 51d72e1874c..b3fb3467b1f 100644 --- a/tests/ops/api/v1/endpoints/privacy_request/test_privacy_request_endpoints.py +++ b/tests/ops/api/v1/endpoints/privacy_request/test_privacy_request_endpoints.py @@ -3754,6 +3754,7 @@ def test_get_execution_logs( } ], "message": None, + "saas_version": None, "action_type": "access", "status": "pending", "updated_at": stringify_date(postgres_execution_log.updated_at), @@ -3770,6 +3771,7 @@ def test_get_execution_logs( } ], "message": None, + "saas_version": None, "action_type": "access", "status": "in_processing", "updated_at": stringify_date(mongo_execution_log.updated_at), @@ -3791,6 +3793,7 @@ def test_get_execution_logs( }, ], "message": "Database timed out.", + "saas_version": None, "action_type": "access", "status": "error", "updated_at": stringify_date( @@ -3814,6 +3817,7 @@ def test_get_execution_logs( }, ], "message": None, + "saas_version": None, "action_type": "access", "status": "awaiting_processing", "updated_at": stringify_date(async_execution_log.updated_at), From ca3149d98bde04151831f8d023387b91d6d7efc9 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 12:44:11 -0300 Subject: [PATCH 20/36] Renaming migration --- ...log.py => a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/fides/api/alembic/migrations/versions/{xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py => a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py} (100%) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py b/src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py similarity index 100% rename from src/fides/api/alembic/migrations/versions/xx_2026_03_11_0000_a1ca9ddf3c3c_add_saas_version_to_executionlog.py rename to src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py From efe6d6b1ad3a8ee8c40ebe5429ef8513f9c1cd5b Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 13:25:33 -0300 Subject: [PATCH 21/36] Updating down revision --- .../a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py b/src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py index 7b89edcf5de..1a9821186a9 100644 --- a/src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py +++ b/src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py @@ -15,7 +15,7 @@ # revision identifiers, used by Alembic. revision = "a1ca9ddf3c3c" -down_revision = "ea20059aee77" +down_revision = "ecd49f8108e0" branch_labels = None depends_on = None From d564b9b3fd13c29584b35a5dd2aab45905bf2889 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 14:04:56 -0300 Subject: [PATCH 22/36] Fixing up greptile comments on tests --- tests/ops/models/privacy_request/test_execution_logs.py | 7 ++----- tests/ops/task/test_graph_task.py | 1 - 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/ops/models/privacy_request/test_execution_logs.py b/tests/ops/models/privacy_request/test_execution_logs.py index 4aac502b189..f959e4367f9 100644 --- a/tests/ops/models/privacy_request/test_execution_logs.py +++ b/tests/ops/models/privacy_request/test_execution_logs.py @@ -111,22 +111,19 @@ def test_execution_log_empty_optional_fields(db, execution_log_data): def test_execution_log_saas_version_stored(db, execution_log_data): execution_log_data["saas_version"] = "2.3.0" - execution_log = ExecutionLog.create(db, data=execution_log_data) + ExecutionLog.create(db, data=execution_log_data) retrieved = db.query(ExecutionLog).filter_by(privacy_request_id="test_id").first() assert retrieved is not None assert retrieved.saas_version == "2.3.0" - execution_log.delete(db) - def test_execution_log_saas_version_null_for_non_saas(db, execution_log_data): # saas_version not provided — should default to None - execution_log = ExecutionLog.create(db, data=execution_log_data) + ExecutionLog.create(db, data=execution_log_data) retrieved = db.query(ExecutionLog).filter_by(privacy_request_id="test_id").first() assert retrieved is not None assert retrieved.saas_version is None - execution_log.delete(db) def test_execution_log_large_data(db, execution_log_data): diff --git a/tests/ops/task/test_graph_task.py b/tests/ops/task/test_graph_task.py index 5c6056d3262..9cdc2a24575 100644 --- a/tests/ops/task/test_graph_task.py +++ b/tests/ops/task/test_graph_task.py @@ -1242,7 +1242,6 @@ def test_saas_version_populated_for_saas_connector( ): """SaaS connectors should stamp saas_version on every execution log entry.""" expected_version = saas_example_connection_config.saas_config["version"] - assert saas_graph_task._saas_version == expected_version saas_graph_task.log_start(action_type=ActionType.access) execution_log = ( From 836d76a75613cd23914c5056ed903c19dbfcd693 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 12:42:04 -0300 Subject: [PATCH 23/36] Renaming migration --- ...g.py.py => a1ca9ddf3c3c_add_saas_version_to_executionlog.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/fides/api/alembic/migrations/versions/{a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py => a1ca9ddf3c3c_add_saas_version_to_executionlog.py} (95%) diff --git a/src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py b/src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py similarity index 95% rename from src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py rename to src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py index 1a9821186a9..7b89edcf5de 100644 --- a/src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py.py +++ b/src/fides/api/alembic/migrations/versions/a1ca9ddf3c3c_add_saas_version_to_executionlog.py @@ -15,7 +15,7 @@ # revision identifiers, used by Alembic. revision = "a1ca9ddf3c3c" -down_revision = "ecd49f8108e0" +down_revision = "ea20059aee77" branch_labels = None depends_on = None From 74522d57f2741a6c09237668df48d2202d267aa6 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 18 Mar 2026 14:40:35 -0300 Subject: [PATCH 24/36] Addressing code review feedback - Use conditional logger.info calls instead of mixing f-strings with loguru placeholders in log_start/log_retry - Add comment explaining _saas_version is snapshotted at construction time - Fix missing blank line between test functions Co-Authored-By: Claude Sonnet 4.6 --- src/fides/api/task/graph_task.py | 32 +++++++++++-------- .../privacy_request/test_execution_logs.py | 1 + 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/fides/api/task/graph_task.py b/src/fides/api/task/graph_task.py index 2960f4cd682..187f9ff5cf0 100644 --- a/src/fides/api/task/graph_task.py +++ b/src/fides/api/task/graph_task.py @@ -261,6 +261,8 @@ def __init__( self.key: CollectionAddress = self.execution_node.address saas_config_dict = self.connector.configuration.saas_config + # Snapshot version at construction so all log entries reflect the version + # that was active when the task started, not a potentially later value. self._saas_version: Optional[str] = ( saas_config_dict.get("version") if saas_config_dict else None ) @@ -447,13 +449,14 @@ def update_status( def log_start(self, action_type: ActionType) -> None: """Task start activities""" - logger.info( - "Starting node {}{}", - self.key, - f" (integration version {self._saas_version})" - if self._saas_version - else "", - ) + if self._saas_version: + logger.info( + "Starting node {} (integration version {})", + self.key, + self._saas_version, + ) + else: + logger.info("Starting node {}", self.key) self.update_status( "starting", [], action_type, ExecutionLogStatus.in_processing @@ -461,13 +464,14 @@ def log_start(self, action_type: ActionType) -> None: def log_retry(self, action_type: ActionType) -> None: """Task retry activities""" - logger.info( - "Retrying node {}{}", - self.key, - f" (integration version {self._saas_version})" - if self._saas_version - else "", - ) + if self._saas_version: + logger.info( + "Retrying node {} (integration version {})", + self.key, + self._saas_version, + ) + else: + logger.info("Retrying node {}", self.key) self.update_status("retrying", [], action_type, ExecutionLogStatus.retrying) diff --git a/tests/ops/models/privacy_request/test_execution_logs.py b/tests/ops/models/privacy_request/test_execution_logs.py index f959e4367f9..df34d0e6a0f 100644 --- a/tests/ops/models/privacy_request/test_execution_logs.py +++ b/tests/ops/models/privacy_request/test_execution_logs.py @@ -117,6 +117,7 @@ def test_execution_log_saas_version_stored(db, execution_log_data): assert retrieved is not None assert retrieved.saas_version == "2.3.0" + def test_execution_log_saas_version_null_for_non_saas(db, execution_log_data): # saas_version not provided — should default to None ExecutionLog.create(db, data=execution_log_data) From f51ff8b64e45b07035abbd00744813f2adb5afb8 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Fri, 13 Mar 2026 13:55:08 -0300 Subject: [PATCH 25/36] Base saas_config_version model --- src/fides/api/db/base.py | 1 + src/fides/api/models/saas_config_version.py | 72 +++++++++++++++++++ .../api/schemas/saas/saas_config_version.py | 20 ++++++ 3 files changed, 93 insertions(+) create mode 100644 src/fides/api/models/saas_config_version.py create mode 100644 src/fides/api/schemas/saas/saas_config_version.py 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/models/saas_config_version.py b/src/fides/api/models/saas_config_version.py new file mode 100644 index 00000000000..b55e704e468 --- /dev/null +++ b/src/fides/api/models/saas_config_version.py @@ -0,0 +1,72 @@ +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__ = ( + UniqueConstraint("connector_type", "version", 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 a new version snapshot or return the existing one if already stored. + + If the row already exists the stored config/dataset are left unchanged — + a version string is treated as immutable once written. + """ + existing = ( + db.query(cls) + .filter(cls.connector_type == connector_type, cls.version == version) + .first() + ) + if 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/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 From 13ff63f0c87cd9375ac48969e0b5657c30dd0d05 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:27:38 -0300 Subject: [PATCH 26/36] Adding migration for the config version tracker --- ...5f7a9b1d2_add_saas_config_version_table.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_03_13_0000_c3e5f7a9b1d2_add_saas_config_version_table.py 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") From a978bb5c30838c75200d09bc825c92f4a2c36571 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:40:56 -0300 Subject: [PATCH 27/36] Base saas config versionn model --- src/fides/api/models/saas_config_version.py | 30 ++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/fides/api/models/saas_config_version.py b/src/fides/api/models/saas_config_version.py index b55e704e468..37a47352f4d 100644 --- a/src/fides/api/models/saas_config_version.py +++ b/src/fides/api/models/saas_config_version.py @@ -24,7 +24,11 @@ def __tablename__(self) -> str: return "saas_config_version" __table_args__ = ( - UniqueConstraint("connector_type", "version", name="uq_saas_config_version"), + # 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) @@ -34,7 +38,7 @@ def __tablename__(self) -> str: is_custom = Column(Boolean, nullable=False, default=False) def __repr__(self) -> str: - return f"" + return f"" @classmethod def upsert( @@ -47,17 +51,31 @@ def upsert( is_custom: bool = False, ) -> "SaaSConfigVersion": """ - Insert a new version snapshot or return the existing one if already stored. + Insert or update a version snapshot. - If the row already exists the stored config/dataset are left unchanged — - a version string is treated as immutable once written. + - 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) + .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( From 0b72eb0916d678acc2d122d8ed15faeb8cdcb9d8 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:47:12 -0300 Subject: [PATCH 28/36] setting up out of the box sas config version on seed --- src/fides/api/db/seed.py | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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: From bc1e376c01db0b1922bade0462a97b4bea4531df Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:47:38 -0300 Subject: [PATCH 29/36] Updating saas template --- .../connectors/saas/connector_registry_service.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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] From 7df7977b3dd88c5923c2738512a8875a450bd4b4 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:48:00 -0300 Subject: [PATCH 30/36] creating new endpoints for saas_config_versions --- .../endpoints/connector_template_endpoints.py | 111 ++++++++++++++++++ .../api/v1/endpoints/saas_config_endpoints.py | 11 ++ src/fides/common/urn_registry.py | 3 + 3 files changed, 125 insertions(+) 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..ae6667f5969 100644 --- a/src/fides/api/v1/endpoints/saas_config_endpoints.py +++ b/src/fides/api/v1/endpoints/saas_config_endpoints.py @@ -25,6 +25,7 @@ from fides.api.common_exceptions import ValidationError as FidesValidationError 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.sql_models import System # type: ignore from fides.api.oauth.utils import verify_oauth_client @@ -210,6 +211,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( diff --git a/src/fides/common/urn_registry.py b/src/fides/common/urn_registry.py index 7854eea469a..a3ba1c47cfb 100644 --- a/src/fides/common/urn_registry.py +++ b/src/fides/common/urn_registry.py @@ -199,6 +199,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 From 37ea6f6b7782946acec12f342dfbea7e1e9c5eb1 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 13:12:47 -0300 Subject: [PATCH 31/36] Adding model and schemas for connection_config_saas_history This will save a snapshot of the connection config history th check during runtime --- ...dd_connection_config_saas_history_table.py | 91 +++++++++++++++++++ .../models/connection_config_saas_history.py | 71 +++++++++++++++ src/fides/api/models/connectionconfig.py | 22 +++++ .../saas/connection_config_saas_history.py | 19 ++++ 4 files changed, 203 insertions(+) create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_03_17_0000_d4e6f8a0b2c3_add_connection_config_saas_history_table.py create mode 100644 src/fides/api/models/connection_config_saas_history.py create mode 100644 src/fides/api/schemas/saas/connection_config_saas_history.py 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/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/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 From 57543df83f17066ca6182a6a7a1751a01e5a8bc8 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 13:13:30 -0300 Subject: [PATCH 32/36] Adding saas_version_history endpoints --- .../api/v1/endpoints/saas_config_endpoints.py | 71 +++++- src/fides/common/urn_registry.py | 2 + .../endpoints/test_saas_config_endpoints.py | 237 ++++++++++++++++++ 3 files changed, 309 insertions(+), 1 deletion(-) diff --git a/src/fides/api/v1/endpoints/saas_config_endpoints.py b/src/fides/api/v1/endpoints/saas_config_endpoints.py index ae6667f5969..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,10 +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 ( @@ -35,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, @@ -66,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, @@ -313,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 a3ba1c47cfb..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 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" From 0d2296bd0064017fd80644eafa8155485e95d232 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 18 Mar 2026 15:06:23 -0300 Subject: [PATCH 33/36] Addressing code review feedback on PR 2 - Remove duplicate SaaSConfigVersion import in saas_config_endpoints.py - Register ConnectionConfigSaaSHistory in db/base.py for SQLAlchemy metadata - Replace bare db.add/commit/refresh with existing.save(db) in SaaSConfigVersion.upsert - Move `import yaml` from function bodies to module top in connector_template_endpoints.py - Extract _get_version_row helper to eliminate duplicated query in config/dataset endpoints - Clarify datasets falsy check: `datasets if datasets else None` --- src/fides/api/db/base.py | 1 + src/fides/api/models/connectionconfig.py | 2 +- src/fides/api/models/saas_config_version.py | 4 +- .../endpoints/connector_template_endpoints.py | 50 +++++++++++-------- .../api/v1/endpoints/saas_config_endpoints.py | 1 - 5 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py index 85ce2706b7a..10bba6e181a 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.connection_config_saas_history import ConnectionConfigSaaSHistory 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 diff --git a/src/fides/api/models/connectionconfig.py b/src/fides/api/models/connectionconfig.py index 84eaa8c648a..6659d2d0d36 100644 --- a/src/fides/api/models/connectionconfig.py +++ b/src/fides/api/models/connectionconfig.py @@ -375,7 +375,7 @@ def update_saas_config( connection_key=self.key, version=saas_config.version, config=self.saas_config, - datasets=datasets or None, + datasets=datasets if datasets else None, ) self.save(db) diff --git a/src/fides/api/models/saas_config_version.py b/src/fides/api/models/saas_config_version.py index 37a47352f4d..3f54ae02db9 100644 --- a/src/fides/api/models/saas_config_version.py +++ b/src/fides/api/models/saas_config_version.py @@ -73,9 +73,7 @@ def upsert( if is_custom: existing.config = config existing.dataset = dataset - db.add(existing) - db.commit() - db.refresh(existing) + existing.save(db) return existing return cls.create( diff --git a/src/fides/api/v1/endpoints/connector_template_endpoints.py b/src/fides/api/v1/endpoints/connector_template_endpoints.py index ef1ae78788c..f1b196e2f72 100644 --- a/src/fides/api/v1/endpoints/connector_template_endpoints.py +++ b/src/fides/api/v1/endpoints/connector_template_endpoints.py @@ -2,6 +2,8 @@ from typing import List, Optional from zipfile import BadZipFile, ZipFile +import yaml + from fastapi import Body, Depends, HTTPException from fastapi.params import Security from fastapi.responses import JSONResponse, Response @@ -260,6 +262,30 @@ def delete_custom_connector_template( ) +def _get_version_row( + db: Session, + connector_template_type: str, + version: str, +) -> Optional[SaaSConfigVersion]: + """ + Returns the most recent stored row for (connector_type, version). + + When both an OOB row (is_custom=False) and a custom row (is_custom=True) + exist for the same version string, the one created most recently is returned. + Callers that need to distinguish OOB from custom should filter on is_custom + before calling this helper. + """ + return ( + db.query(SaaSConfigVersion) + .filter( + SaaSConfigVersion.connector_type == connector_template_type, + SaaSConfigVersion.version == version, + ) + .order_by(SaaSConfigVersion.created_at.desc()) + .first() + ) + + @router.get( CONNECTOR_TEMPLATES_VERSIONS, dependencies=[Security(verify_oauth_client, scopes=[CONNECTOR_TEMPLATE_READ])], @@ -301,17 +327,7 @@ def get_connector_template_version_config( 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() - ) + row = _get_version_row(db, connector_template_type, version) if not row: raise HTTPException( status_code=HTTP_404_NOT_FOUND, @@ -338,17 +354,7 @@ def get_connector_template_version_dataset( 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() - ) + row = _get_version_row(db, connector_template_type, version) if not row: raise HTTPException( status_code=HTTP_404_NOT_FOUND, diff --git a/src/fides/api/v1/endpoints/saas_config_endpoints.py b/src/fides/api/v1/endpoints/saas_config_endpoints.py index 7aaa5a84765..10359747549 100644 --- a/src/fides/api/v1/endpoints/saas_config_endpoints.py +++ b/src/fides/api/v1/endpoints/saas_config_endpoints.py @@ -26,7 +26,6 @@ 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 c5b4165a6888c43b5dab0cc4ea63789ff5fb7403 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 25 Mar 2026 10:46:58 -0300 Subject: [PATCH 34/36] Fixing up base migration frm PR split --- ...3_add_connection_config_saas_history_table.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) 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 index a99bb86f673..644a07c2e41 100644 --- 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 @@ -62,26 +62,16 @@ def upgrade() -> None: unique=False, ) op.create_index( - "ix_connection_config_saas_history_config_id_created_at", + op.f("ix_connection_config_saas_history_connection_config_id"), "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"], + ["connection_config_id"], 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", + op.f("ix_connection_config_saas_history_connection_config_id"), table_name="connection_config_saas_history", ) op.drop_index( From de4b4f252df1ab1bf7ca6bff99849f346cedf7fa Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 25 Mar 2026 10:47:01 -0300 Subject: [PATCH 35/36] Running static checks --- src/fides/common/urn_registry.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/fides/common/urn_registry.py b/src/fides/common/urn_registry.py index 7a40e26d035..041b1cfac41 100644 --- a/src/fides/common/urn_registry.py +++ b/src/fides/common/urn_registry.py @@ -203,8 +203,12 @@ 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" +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 From 6fe472f59e29a2c89abf8ab209d5f27df80b7ce8 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 25 Mar 2026 15:17:14 -0300 Subject: [PATCH 36/36] Fix CI: ruff imports, mypy return type, db annotations, changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix unsorted imports (ruff I001) in base.py, seed.py, and connector_template_endpoints.py - Fix mypy return type error in list_connector_template_versions — convert ORM rows to SaaSConfigVersionResponse explicitly - Annotate saas_config_version and connection_config_saas_history tables in db_dataset.yml to fix fides_db_scan - Add changelog entry for PR #7688 Co-Authored-By: Claude Sonnet 4.6 --- .fides/db_dataset.yml | 36 +++++++++++++++++++ .../7688-saas-config-version-history.yaml | 4 +++ src/fides/api/db/base.py | 2 +- src/fides/api/db/seed.py | 6 ++-- .../endpoints/connector_template_endpoints.py | 3 +- 5 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 changelog/7688-saas-config-version-history.yaml diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index e2f1fdd0b92..dd216addad2 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -1163,6 +1163,24 @@ dataset: - name: enabled_actions description: 'The privacy actions that are enabled for this connection' data_categories: [system.operations] + - name: connection_config_saas_history + fields: + - name: id + data_categories: [system.operations] + - name: created_at + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] + - name: connection_config_id + data_categories: [system.operations] + - name: connection_key + data_categories: [system.operations] + - name: version + data_categories: [system.operations] + - name: config + data_categories: [system.operations] + - name: dataset + data_categories: [system.operations] - name: consent description: 'A database table used to map consent preference to identities' data_categories: [] @@ -3870,6 +3888,24 @@ dataset: data_categories: [user.unique_id] - name: is_default data_categories: [system.operations] + - name: saas_config_version + fields: + - name: id + data_categories: [system.operations] + - name: created_at + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] + - name: connector_type + data_categories: [system.operations] + - name: version + data_categories: [system.operations] + - name: config + data_categories: [system.operations] + - name: dataset + data_categories: [system.operations] + - name: is_custom + data_categories: [system.operations] - name: saas_template_dataset fields: - name: id diff --git a/changelog/7688-saas-config-version-history.yaml b/changelog/7688-saas-config-version-history.yaml new file mode 100644 index 00000000000..8168bc3afcc --- /dev/null +++ b/changelog/7688-saas-config-version-history.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added SaaS config version history tracking — stores connector template versions on seed and custom template upload, with API endpoints to list versions and retrieve config/dataset snapshots by version +pr: 7688 +labels: ["db-migration"] diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py index 10bba6e181a..f51a281ce49 100644 --- a/src/fides/api/db/base.py +++ b/src/fides/api/db/base.py @@ -10,6 +10,7 @@ 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_config_saas_history import ConnectionConfigSaaSHistory from fides.api.models.connection_oauth_credentials import OAuthConfig from fides.api.models.connectionconfig import ConnectionConfig from fides.api.models.consent_automation import ConsentAutomation @@ -92,7 +93,6 @@ ) from fides.api.models.questionnaire import ChatMessage, Questionnaire from fides.api.models.registration import UserRegistration -from fides.api.models.connection_config_saas_history import ConnectionConfigSaaSHistory 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 diff --git a/src/fides/api/db/seed.py b/src/fides/api/db/seed.py index 014c303514a..7ba309d85fe 100644 --- a/src/fides/api/db/seed.py +++ b/src/fides/api/db/seed.py @@ -12,7 +12,6 @@ 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 @@ -22,6 +21,7 @@ from fides.api.models.fides_user import FidesUser from fides.api.models.fides_user_permissions import FidesUserPermissions from fides.api.models.policy import Policy, Rule, RuleTarget +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.sql_models import ( # type: ignore[attr-defined] Dataset, Organization, @@ -333,6 +333,9 @@ def sync_oob_saas_config_versions(session: Session) -> None: 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.schemas.saas.saas_config import ( + SaaSConfig, # pylint: disable=import-outside-toplevel + ) from fides.api.service.connectors.saas.connector_registry_service import ( # pylint: disable=import-outside-toplevel FileConnectorTemplateLoader, ) @@ -340,7 +343,6 @@ def sync_oob_saas_config_versions(session: Session) -> None: 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(): diff --git a/src/fides/api/v1/endpoints/connector_template_endpoints.py b/src/fides/api/v1/endpoints/connector_template_endpoints.py index f1b196e2f72..98126738367 100644 --- a/src/fides/api/v1/endpoints/connector_template_endpoints.py +++ b/src/fides/api/v1/endpoints/connector_template_endpoints.py @@ -3,7 +3,6 @@ from zipfile import BadZipFile, ZipFile import yaml - from fastapi import Body, Depends, HTTPException from fastapi.params import Security from fastapi.responses import JSONResponse, Response @@ -309,7 +308,7 @@ def list_connector_template_versions( .order_by(SaaSConfigVersion.created_at.desc()) .all() ) - return rows + return [SaaSConfigVersionResponse.model_validate(row) for row in rows] @router.get(