From aa76b4b786370cae73ab97d89f1dc8c197b461c0 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 18 Jul 2023 10:33:07 -0400 Subject: [PATCH] show/hide connector values (#3775) --- CHANGELOG.md | 1 + .../forms/ConnectorParameters.tsx | 47 +++++-- .../forms/ConnectorParametersForm.tsx | 120 ++++++++++-------- .../system_portal_config/forms/helpers.ts | 10 +- .../src/features/system/system.slice.ts | 21 ++- .../models/ConnectionConfigurationResponse.ts | 1 + .../api/v1/endpoints/connection_endpoints.py | 47 +------ .../v1/endpoints/connection_type_endpoints.py | 10 +- .../api/v1/endpoints/saas_config_endpoints.py | 2 + src/fides/api/api/v1/endpoints/system.py | 67 +++++++++- src/fides/api/db/seed.py | 2 + .../connection_config.py | 107 +++++++++------- .../connection_type_system_map.py | 23 ++++ .../enums/__init__.py | 0 .../enums/system_type.py | 8 ++ .../enums/test_status.py | 17 +++ .../saas_config_template_values.py | 16 +++ .../saas/connector_registry_service.py | 2 +- src/fides/api/util/connection_type.py | 6 +- src/fides/api/util/connection_util.py | 60 ++++++++- .../saas/connection_template_fixtures.py | 2 +- .../test_connection_config_endpoints.py | 27 ++-- .../test_connection_template_endpoints.py | 4 +- .../api/v1/endpoints/test_manual_webhooks.py | 8 +- .../test_policy_webhook_endpoints.py | 1 + tests/ops/api/v1/endpoints/test_system.py | 42 +++++- .../test_connection_config.py | 62 +++++++++ tests/ops/util/test_connection_type.py | 2 +- 28 files changed, 522 insertions(+), 193 deletions(-) create mode 100644 src/fides/api/schemas/connection_configuration/connection_type_system_map.py create mode 100644 src/fides/api/schemas/connection_configuration/enums/__init__.py create mode 100644 src/fides/api/schemas/connection_configuration/enums/system_type.py create mode 100644 src/fides/api/schemas/connection_configuration/enums/test_status.py create mode 100644 src/fides/api/schemas/connection_configuration/saas_config_template_values.py create mode 100644 tests/ops/schemas/connection_configuration/test_connection_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b5f4859ec..47e54fc17aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The types of changes are: - Bumped supported Python versions to `3.10.12`, `3.9.17`, and `3.8.17` [#3733](https://github.com/ethyca/fides/pull/3733) - Logging Updates [#3758](https://github.com/ethyca/fides/pull/3758) - Add polyfill service to fides-js route [#3759](https://github.com/ethyca/fides/pull/3759) +- Show/hide integration values [#3775](https://github.com/ethyca/fides/pull/3775) - Sort system cards alphabetically by name on "View systems" page [#3781](https://github.com/ethyca/fides/pull/3781) ### Removed diff --git a/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParameters.tsx b/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParameters.tsx index 5b2f10ddd28..efa9b20aca8 100644 --- a/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParameters.tsx +++ b/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParameters.tsx @@ -7,16 +7,14 @@ import { useCreateSassConnectionConfigMutation, useDeleteDatastoreConnectionMutation, useGetConnectionConfigDatasetConfigsQuery, - useUpdateDatastoreConnectionSecretsMutation, } from "datastore-connections/datastore-connection.slice"; import { useDatasetConfigField } from "datastore-connections/system_portal_config/forms/fields/DatasetConfigField/DatasetConfigField"; import { CreateSaasConnectionConfigRequest, CreateSaasConnectionConfigResponse, - DatastoreConnectionSecretsRequest, DatastoreConnectionSecretsResponse, } from "datastore-connections/types"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; import { DEFAULT_TOAST_PARAMS } from "~/features/common/toast"; @@ -25,9 +23,11 @@ import { formatKey } from "~/features/datastore-connections/system_portal_config import TestConnectionMessage from "~/features/datastore-connections/system_portal_config/TestConnectionMessage"; import TestData from "~/features/datastore-connections/TestData"; import { + ConnectionConfigSecretsRequest, selectActiveSystem, setActiveSystem, usePatchSystemConnectionConfigsMutation, + usePatchSystemConnectionSecretsMutation, } from "~/features/system/system.slice"; import { AccessLevel, @@ -69,7 +69,7 @@ const createSaasConnector = async ( }; Object.entries(secretsSchema!.properties).forEach((key) => { - params.connectionConfig.secrets[key[0]] = values[key[0]]; + params.connectionConfig.secrets[key[0]] = values.secrets[key[0]]; }); return (await createSaasConnectorFunc( params @@ -124,18 +124,32 @@ export const patchConnectionConfig = async ( const upsertConnectionConfigSecrets = async ( values: ConnectionConfigFormValues, secretsSchema: ConnectionTypeSecretSchemaReponse, - connectionConfigFidesKey: string, - upsertFunc: any + systemFidesKey: string, + originalSecrets: Record, + patchFunc: any ) => { - const params2: DatastoreConnectionSecretsRequest = { - connection_key: connectionConfigFidesKey, + const params2: ConnectionConfigSecretsRequest = { + systemFidesKey, secrets: {}, }; Object.entries(secretsSchema!.properties).forEach((key) => { - params2.secrets[key[0]] = values[key[0]]; + /* + * Only patch secrets that have changed. Otherwise, sensitive secrets + * would get overwritten with "**********" strings + */ + if ( + !(key[0] in originalSecrets) || + values.secrets[key[0]] !== originalSecrets[key[0]] + ) { + params2.secrets[key[0]] = values.secrets[key[0]]; + } }); - return (await upsertFunc( + if (Object.keys(params2.secrets).length === 0) { + return Promise.resolve(); + } + + return (await patchFunc( params2 ).unwrap()) as DatastoreConnectionSecretsResponse; }; @@ -180,8 +194,8 @@ export const useConnectorForm = ({ }); const [createSassConnectionConfig] = useCreateSassConnectionConfigMutation(); - const [updateDatastoreConnectionSecrets] = - useUpdateDatastoreConnectionSecretsMutation(); + const [updateSystemConnectionSecrets] = + usePatchSystemConnectionSecretsMutation(); const [patchDatastoreConnection] = usePatchSystemConnectionConfigsMutation(); const [deleteDatastoreConnection, deleteDatastoreConnectionResult] = useDeleteDatastoreConnectionMutation(); @@ -189,6 +203,10 @@ export const useConnectorForm = ({ connectionConfig?.key || "" ); + const originalSecrets = useMemo( + () => (connectionConfig ? { ...connectionConfig.secrets } : {}), + [connectionConfig] + ); const activeSystem = useAppSelector(selectActiveSystem) as SystemResponse; const handleDelete = async (id: string) => { @@ -248,8 +266,9 @@ export const useConnectorForm = ({ await upsertConnectionConfigSecrets( secretsPayload, secretsSchema!, - payload.succeeded[0].key, - updateDatastoreConnectionSecrets + systemFidesKey, + originalSecrets, + updateSystemConnectionSecrets ); } } diff --git a/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParametersForm.tsx b/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParametersForm.tsx index c3ccc1c6091..0e7274bc191 100644 --- a/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParametersForm.tsx +++ b/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParametersForm.tsx @@ -101,7 +101,7 @@ const ConnectorParametersForm: React.FC = ({ const validateField = (label: string, value: string, type?: string) => { let error; - if (typeof value === "undefined" || value === "") { + if (typeof value === "undefined" || value === "" || value === undefined) { error = `${label} is required`; } if (type === FIDES_DATASET_REFERENCE) { @@ -146,9 +146,9 @@ const ConnectorParametersForm: React.FC = ({ item: ConnectionTypeSecretSchemaProperty ): JSX.Element => ( @@ -156,57 +156,68 @@ const ConnectorParametersForm: React.FC = ({ : false } > - {({ field, form }: { field: FieldInputProps; form: any }) => ( - - {getFormLabel(key, item.title)} - - {item.type !== "integer" && ( - - )} - {item.type === "integer" && ( - - - - - - - - )} - {form.errors[key]} - - ; form: any }) => { + const error = form.errors.secrets && form.errors.secrets[key]; + const touch = form.touched.secrets ? form.touched.secrets[key] : false; + + return ( + - + {item.type !== "integer" && ( + + )} + {item.type === "integer" && ( + { + form.setFieldValue(field.name, value); + }} + defaultValue={field.value ?? 0} + min={0} + size="sm" + > + + + + + + + )} + {error} + + - - - - - )} + + + + + + ); + }} ); @@ -219,6 +230,9 @@ const ConnectorParametersForm: React.FC = ({ connectionConfig.connection_type === ConnectionType.SAAS ? (connectionConfig.saas_config?.fides_key as string) : connectionConfig.key; + // @ts-ignore + initialValues.secrets = connectionConfig.secrets; + return initialValues; } return fillInDefaults(initialValues, secretsSchema); }; diff --git a/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/helpers.ts b/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/helpers.ts index eb3f1b7188a..933966754d6 100644 --- a/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/helpers.ts +++ b/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/helpers.ts @@ -14,10 +14,16 @@ export const fillInDefaults = ( Object.entries(connectionSchema.properties).forEach((key) => { const [name, schema] = key; + if (!("secrets" in filledInValues)) { + filledInValues.secrets = {}; + } + if (schema.type === "integer") { - filledInValues[name] = schema.default ? Number(schema.default) : 0; + filledInValues.secrets[name] = schema.default + ? Number(schema.default) + : 0; } else { - filledInValues[name] = schema.default ?? ""; + filledInValues.secrets[name] = schema.default ?? ""; } }); } diff --git a/clients/admin-ui/src/features/system/system.slice.ts b/clients/admin-ui/src/features/system/system.slice.ts index 5f0dcffcb8e..c5534e8a1e9 100644 --- a/clients/admin-ui/src/features/system/system.slice.ts +++ b/clients/admin-ui/src/features/system/system.slice.ts @@ -7,6 +7,7 @@ import { ConnectionConfigurationResponse, System, SystemResponse, + TestStatusMessage, } from "~/types/api"; interface SystemDeleteResponse { @@ -20,6 +21,13 @@ interface UpsertResponse { updated: number; } +export type ConnectionConfigSecretsRequest = { + systemFidesKey: string; + secrets: { + [key: string]: any; + }; +}; + const systemApi = baseApi.injectEndpoints({ endpoints: (build) => ({ getAllSystems: build.query({ @@ -98,7 +106,17 @@ const systemApi = baseApi.injectEndpoints({ }), invalidatesTags: ["Datamap", "System", "Datastore Connection"], }), - + patchSystemConnectionSecrets: build.mutation< + TestStatusMessage, + ConnectionConfigSecretsRequest + >({ + query: ({ secrets, systemFidesKey }) => ({ + url: `/system/${systemFidesKey}/connection/secrets?verify=false`, + method: "PATCH", + body: secrets, + }), + invalidatesTags: () => ["Datastore Connection"], + }), getSystemConnectionConfigs: build.query< ConnectionConfigurationResponse[], string @@ -120,6 +138,7 @@ export const { useUpsertSystemsMutation, usePatchSystemConnectionConfigsMutation, useGetSystemConnectionConfigsQuery, + usePatchSystemConnectionSecretsMutation, } = systemApi; export interface State { diff --git a/clients/admin-ui/src/types/api/models/ConnectionConfigurationResponse.ts b/clients/admin-ui/src/types/api/models/ConnectionConfigurationResponse.ts index 897bf2b8273..ed88a25abb2 100644 --- a/clients/admin-ui/src/types/api/models/ConnectionConfigurationResponse.ts +++ b/clients/admin-ui/src/types/api/models/ConnectionConfigurationResponse.ts @@ -23,4 +23,5 @@ export type ConnectionConfigurationResponse = { last_test_timestamp?: string; last_test_succeeded?: boolean; saas_config?: SaaSConfigBase; + secrets?: object; }; diff --git a/src/fides/api/api/v1/endpoints/connection_endpoints.py b/src/fides/api/api/v1/endpoints/connection_endpoints.py index eed569b0321..0611cc966b2 100644 --- a/src/fides/api/api/v1/endpoints/connection_endpoints.py +++ b/src/fides/api/api/v1/endpoints/connection_endpoints.py @@ -16,33 +16,27 @@ from starlette.status import HTTP_200_OK, HTTP_204_NO_CONTENT from fides.api.api import deps -from fides.api.common_exceptions import ClientUnsuccessfulException, ConnectionException -from fides.api.models.connectionconfig import ( - ConnectionConfig, - ConnectionTestStatus, - ConnectionType, -) +from fides.api.models.connectionconfig import ConnectionConfig, ConnectionType from fides.api.oauth.utils import verify_oauth_client from fides.api.schemas.connection_configuration import connection_secrets_schemas from fides.api.schemas.connection_configuration.connection_config import ( BulkPutConnectionConfiguration, ConnectionConfigurationResponse, CreateConnectionConfigurationWithSecrets, - SystemType, - TestStatus, ) from fides.api.schemas.connection_configuration.connection_secrets import ( TestStatusMessage, ) -from fides.api.service.connectors import get_connector +from fides.api.schemas.connection_configuration.enums.system_type import SystemType +from fides.api.schemas.connection_configuration.enums.test_status import TestStatus from fides.api.util.api_router import APIRouter from fides.api.util.connection_util import ( + connection_status, delete_connection_config, get_connection_config_or_error, patch_connection_configs, validate_secrets, ) -from fides.api.util.logger import Pii from fides.common.api.scope_registry import ( CONNECTION_CREATE_OR_UPDATE, CONNECTION_DELETE, @@ -202,39 +196,6 @@ def delete_connection( delete_connection_config(db, connection_key) -def connection_status( - connection_config: ConnectionConfig, msg: str, db: Session = Depends(deps.get_db) -) -> TestStatusMessage: - """Connect, verify with a trivial query or API request, and report the status.""" - - connector = get_connector(connection_config) - try: - status: ConnectionTestStatus | None = connector.test_connection() - - except (ConnectionException, ClientUnsuccessfulException) as exc: - logger.warning( - "Connection test failed on {}: {}", - connection_config.key, - Pii(str(exc)), - ) - connection_config.update_test_status( - test_status=ConnectionTestStatus.failed, db=db - ) - return TestStatusMessage( - msg=msg, - test_status=ConnectionTestStatus.failed, - failure_reason=str(exc), - ) - - logger.info("Connection test {} on {}", status.value, connection_config.key) # type: ignore - connection_config.update_test_status(test_status=status, db=db) # type: ignore - - return TestStatusMessage( - msg=msg, - test_status=status, - ) - - @router.put( CONNECTION_SECRETS, status_code=HTTP_200_OK, diff --git a/src/fides/api/api/v1/endpoints/connection_type_endpoints.py b/src/fides/api/api/v1/endpoints/connection_type_endpoints.py index b846b8d961a..bf6cf903cdf 100644 --- a/src/fides/api/api/v1/endpoints/connection_type_endpoints.py +++ b/src/fides/api/api/v1/endpoints/connection_type_endpoints.py @@ -8,14 +8,14 @@ from fides.api.common_exceptions import NoSuchConnectionTypeSecretSchemaError from fides.api.oauth.utils import verify_oauth_client -from fides.api.schemas.connection_configuration.connection_config import ( +from fides.api.schemas.connection_configuration.connection_type_system_map import ( ConnectionSystemTypeMap, - SystemType, ) +from fides.api.schemas.connection_configuration.enums.system_type import SystemType from fides.api.schemas.policy import ActionType from fides.api.util.api_router import APIRouter from fides.api.util.connection_type import ( - connection_type_secret_schema, + get_connection_type_secret_schema, get_connection_types, ) from fides.common.api.scope_registry import CONNECTION_TYPE_READ @@ -77,7 +77,7 @@ def get_all_connection_types( CONNECTION_TYPE_SECRETS, dependencies=[Security(verify_oauth_client, scopes=[CONNECTION_TYPE_READ])], ) -def get_connection_type_secret_schema( +def get_connection_type_secret_schema_route( *, connection_type: str ) -> Optional[Dict[str, Any]]: """Returns the secret fields that should be supplied to authenticate with a particular connection type @@ -86,7 +86,7 @@ def get_connection_type_secret_schema( to authenticate. """ try: - return connection_type_secret_schema(connection_type=connection_type) + return get_connection_type_secret_schema(connection_type=connection_type) except NoSuchConnectionTypeSecretSchemaError: raise HTTPException( status_code=HTTP_404_NOT_FOUND, diff --git a/src/fides/api/api/v1/endpoints/saas_config_endpoints.py b/src/fides/api/api/v1/endpoints/saas_config_endpoints.py index 642f68c7893..be9e1a2863a 100644 --- a/src/fides/api/api/v1/endpoints/saas_config_endpoints.py +++ b/src/fides/api/api/v1/endpoints/saas_config_endpoints.py @@ -25,6 +25,8 @@ from fides.api.oauth.utils import verify_oauth_client from fides.api.schemas.connection_configuration.connection_config import ( SaasConnectionTemplateResponse, +) +from fides.api.schemas.connection_configuration.saas_config_template_values import ( SaasConnectionTemplateValues, ) from fides.api.schemas.saas.connector_template import ConnectorTemplate diff --git a/src/fides/api/api/v1/endpoints/system.py b/src/fides/api/api/v1/endpoints/system.py index fd359a23b09..e94082f00e0 100644 --- a/src/fides/api/api/v1/endpoints/system.py +++ b/src/fides/api/api/v1/endpoints/system.py @@ -1,4 +1,4 @@ -from typing import Dict, List +from typing import Dict, List, Optional from fastapi import Depends, Response, Security from fastapi_pagination import Page, Params @@ -6,6 +6,7 @@ from fastapi_pagination.ext.sqlalchemy import paginate from fideslang.models import System as SystemSchema from fideslang.validation import FidesKey +from loguru import logger from pydantic.types import conlist from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -35,18 +36,27 @@ verify_oauth_client_for_system_from_request_body_cli, ) from fides.api.oauth.utils import verify_oauth_client_prod +from fides.api.schemas.connection_configuration import connection_secrets_schemas from fides.api.schemas.connection_configuration.connection_config import ( BulkPutConnectionConfiguration, ConnectionConfigurationResponse, CreateConnectionConfigurationWithSecrets, SaasConnectionTemplateResponse, +) +from fides.api.schemas.connection_configuration.connection_secrets import ( + TestStatusMessage, +) +from fides.api.schemas.connection_configuration.saas_config_template_values import ( SaasConnectionTemplateValues, ) from fides.api.schemas.system import SystemResponse from fides.api.util.api_router import APIRouter from fides.api.util.connection_util import ( + connection_status, delete_connection_config, + get_connection_config_or_error, patch_connection_configs, + validate_secrets, ) from fides.common.api.scope_registry import ( CONNECTION_CREATE_OR_UPDATE, @@ -124,6 +134,61 @@ def patch_connections( return patch_connection_configs(db, configs, system) +@SYSTEM_CONNECTIONS_ROUTER.patch( + "/secrets", + dependencies=[ + Security( + verify_oauth_client_for_system_from_fides_key, + scopes=[CONNECTION_CREATE_OR_UPDATE], + ) + ], + status_code=HTTP_200_OK, + response_model=TestStatusMessage, +) +def patch_connection_secrets( + fides_key: FidesKey, + *, + db: Session = Depends(deps.get_db), + unvalidated_secrets: connection_secrets_schemas, + verify: Optional[bool] = True, +) -> TestStatusMessage: + """ + Patch secrets that will be used to connect to a specified connection_type. + + The specific secrets will be connection-dependent. For example, the components needed to connect to a Postgres DB + will differ from Dynamo DB. + """ + + system = get_system(db, fides_key) + connection_config = get_connection_config_or_error( + db, system.connection_configs.key + ) + # Inserts unchanged sensitive values. The FE does not send masked values sensitive secrets. + if connection_config.secrets is not None: + for key, value in connection_config.secrets.items(): + if key not in unvalidated_secrets: + unvalidated_secrets[key] = value # type: ignore + else: + connection_config.secrets = {} + + validated_secrets = validate_secrets( + db, unvalidated_secrets, connection_config + ).dict() + + for key, value in validated_secrets.items(): + connection_config.secrets[key] = value # type: ignore + + # Save validated secrets, regardless of whether they've been verified. + logger.info("Updating connection config secrets for '{}'", connection_config.key) + connection_config.save(db=db) + + msg = f"Secrets updated for ConnectionConfig with key: {connection_config.key}." + if verify: + return connection_status(connection_config, msg, db) + + return TestStatusMessage(msg=msg, test_status=None) + + @SYSTEM_CONNECTIONS_ROUTER.delete( "/{connection_key}", dependencies=[ diff --git a/src/fides/api/db/seed.py b/src/fides/api/db/seed.py index 53f5e63f286..151b4d94aad 100644 --- a/src/fides/api/db/seed.py +++ b/src/fides/api/db/seed.py @@ -29,6 +29,8 @@ from fides.api.oauth.roles import OWNER from fides.api.schemas.connection_configuration.connection_config import ( CreateConnectionConfigurationWithSecrets, +) +from fides.api.schemas.connection_configuration.saas_config_template_values import ( SaasConnectionTemplateValues, ) from fides.api.schemas.dataset import DatasetConfigCtlDataset diff --git a/src/fides/api/schemas/connection_configuration/connection_config.py b/src/fides/api/schemas/connection_configuration/connection_config.py index 8ab65baeaf6..5031b77029e 100644 --- a/src/fides/api/schemas/connection_configuration/connection_config.py +++ b/src/fides/api/schemas/connection_configuration/connection_config.py @@ -1,16 +1,18 @@ from datetime import datetime -from enum import Enum -from typing import List, Optional, Union +from typing import Any, Dict, List, Optional, cast from fideslang.models import Dataset from fideslang.validation import FidesKey -from pydantic import BaseModel, Extra +from loguru import logger +from pydantic import BaseModel, Extra, root_validator +from fides.api.common_exceptions import NoSuchConnectionTypeSecretSchemaError from fides.api.models.connectionconfig import AccessLevel, ConnectionType from fides.api.schemas.api import BulkResponse, BulkUpdateFailed from fides.api.schemas.connection_configuration import connection_secrets_schemas from fides.api.schemas.policy import ActionType from fides.api.schemas.saas.saas_config import SaaSConfigBase +from fides.api.util.connection_type import get_connection_type_secret_schema class CreateConnectionConfiguration(BaseModel): @@ -47,43 +49,40 @@ class Config: extra = Extra.forbid -class TestStatus(Enum): - passed = "passed" - failed = "failed" - untested = "untested" - - def str_to_bool(self) -> Optional[bool]: - """Translates query param string to optional/bool value - for filtering ConnectionConfig.last_test_succeeded field""" - if self == self.passed: - return True - if self == self.failed: - return False - return None - - -class SystemType(Enum): - saas = "saas" - database = "database" - manual = "manual" - email = "email" - - -class ConnectionSystemTypeMap(BaseModel): +def mask_sensitive_fields( + connection_secrets: Dict[str, Any], secret_schema: Dict[str, Any] +) -> Dict[str, Any]: """ - Describes the returned schema for connection types + Mask sensitive fields in the given secrets based on the provided schema. + This function traverses the given secrets dictionary and uses the provided schema to + identify fields that have been marked as sensitive. The function replaces the sensitive + field values with a mask string ('********'). + Args: + connection_secrets (Dict[str, Any]): The secrets to be masked. + secret_schema (Dict[str, Any]): The schema defining which fields are sensitive. + Returns: + Dict[str, Any]: The secrets dictionary with sensitive fields masked. """ + if connection_secrets is None: + return connection_secrets - identifier: Union[ConnectionType, str] - type: SystemType - human_readable: str - encoded_icon: Optional[str] + connection_secret_keys = connection_secrets.keys() + secret_schema_keys = secret_schema["properties"].keys() + new_connection_secrets = {} - class Config: - """Use enum values and set orm mode""" + for key in connection_secret_keys: + if key in secret_schema_keys: + new_connection_secrets[key] = connection_secrets[key] - use_enum_values = True - orm_mode = True + for key, val in new_connection_secrets.items(): + prop = secret_schema["properties"].get(key, {}) + + if isinstance(val, dict): + mask_sensitive_fields(val, prop) + elif prop.get("sensitive", False): + new_connection_secrets[key] = "**********" + + return new_connection_secrets class ConnectionConfigurationResponse(BaseModel): @@ -104,6 +103,34 @@ class ConnectionConfigurationResponse(BaseModel): last_test_timestamp: Optional[datetime] last_test_succeeded: Optional[bool] saas_config: Optional[SaaSConfigBase] + secrets: Optional[Dict[str, Any]] + + @root_validator() + def mask_sensitive_values(cls, values: Dict[str, Any]) -> Dict[str, Any]: + """Mask sensitive values in the response.""" + if values.get("secrets") is None: + return values + + connection_type = ( + values["saas_config"].type + if values.get("connection_type") == ConnectionType.saas + else values.get("connection_type").value # type: ignore + ) + try: + secret_schema = get_connection_type_secret_schema( + connection_type=connection_type + ) + except NoSuchConnectionTypeSecretSchemaError as e: + logger.error(e) + # if there is no schema, we don't know what values to mask. + # so all the secrets are removed. + values["secrets"] = None + return values + + values["secrets"] = mask_sensitive_fields( + cast(dict, values.get("secrets")), secret_schema + ) + return values class Config: """Set orm_mode to support mapping to ConnectionConfig""" @@ -125,16 +152,6 @@ class BulkPatchConnectionConfigurationWithSecrets(BulkResponse): failed: List[BulkUpdateFailed] -class SaasConnectionTemplateValues(BaseModel): - """Schema with values to create both a Saas ConnectionConfig and DatasetConfig from a template""" - - name: Optional[str] # For ConnectionConfig - key: Optional[FidesKey] # For ConnectionConfig - description: Optional[str] # For ConnectionConfig - secrets: connection_secrets_schemas # For ConnectionConfig - instance_key: FidesKey # For DatasetConfig.fides_key - - class SaasConnectionTemplateResponse(BaseModel): connection: ConnectionConfigurationResponse dataset: Dataset diff --git a/src/fides/api/schemas/connection_configuration/connection_type_system_map.py b/src/fides/api/schemas/connection_configuration/connection_type_system_map.py new file mode 100644 index 00000000000..355aa396a76 --- /dev/null +++ b/src/fides/api/schemas/connection_configuration/connection_type_system_map.py @@ -0,0 +1,23 @@ +from typing import Optional, Union + +from pydantic import BaseModel + +from fides.api.models.connectionconfig import ConnectionType +from fides.api.schemas.connection_configuration.enums.system_type import SystemType + + +class ConnectionSystemTypeMap(BaseModel): + """ + Describes the returned schema for connection types + """ + + identifier: Union[ConnectionType, str] + type: SystemType + human_readable: str + encoded_icon: Optional[str] + + class Config: + """Use enum values and set orm mode""" + + use_enum_values = True + orm_mode = True diff --git a/src/fides/api/schemas/connection_configuration/enums/__init__.py b/src/fides/api/schemas/connection_configuration/enums/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/fides/api/schemas/connection_configuration/enums/system_type.py b/src/fides/api/schemas/connection_configuration/enums/system_type.py new file mode 100644 index 00000000000..275a63f3f51 --- /dev/null +++ b/src/fides/api/schemas/connection_configuration/enums/system_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class SystemType(Enum): + saas = "saas" + database = "database" + manual = "manual" + email = "email" diff --git a/src/fides/api/schemas/connection_configuration/enums/test_status.py b/src/fides/api/schemas/connection_configuration/enums/test_status.py new file mode 100644 index 00000000000..6e5b8fd3bfc --- /dev/null +++ b/src/fides/api/schemas/connection_configuration/enums/test_status.py @@ -0,0 +1,17 @@ +from enum import Enum +from typing import Optional + + +class TestStatus(Enum): + passed = "passed" + failed = "failed" + untested = "untested" + + def str_to_bool(self) -> Optional[bool]: + """Translates query param string to optional/bool value + for filtering ConnectionConfig.last_test_succeeded field""" + if self == self.passed: + return True + if self == self.failed: + return False + return None diff --git a/src/fides/api/schemas/connection_configuration/saas_config_template_values.py b/src/fides/api/schemas/connection_configuration/saas_config_template_values.py new file mode 100644 index 00000000000..1ba0fad11f8 --- /dev/null +++ b/src/fides/api/schemas/connection_configuration/saas_config_template_values.py @@ -0,0 +1,16 @@ +from typing import Optional + +from fideslang.validation import FidesKey +from pydantic import BaseModel + +from fides.api.schemas.connection_configuration import connection_secrets_schemas + + +class SaasConnectionTemplateValues(BaseModel): + """Schema with values to create both a Saas ConnectionConfig and DatasetConfig from a template""" + + name: Optional[str] # For ConnectionConfig + key: Optional[FidesKey] # For ConnectionConfig + description: Optional[str] # For ConnectionConfig + secrets: connection_secrets_schemas # For ConnectionConfig + instance_key: FidesKey # For DatasetConfig.fides_key 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 fe37d50ad17..4e55c7e32dd 100644 --- a/src/fides/api/service/connectors/saas/connector_registry_service.py +++ b/src/fides/api/service/connectors/saas/connector_registry_service.py @@ -25,7 +25,7 @@ ) from fides.api.models.custom_connector_template import CustomConnectorTemplate from fides.api.models.datasetconfig import DatasetConfig -from fides.api.schemas.connection_configuration.connection_config import ( +from fides.api.schemas.connection_configuration.saas_config_template_values import ( SaasConnectionTemplateValues, ) from fides.api.schemas.saas.connector_template import ConnectorTemplate diff --git a/src/fides/api/util/connection_type.py b/src/fides/api/util/connection_type.py index 1b8fcfd163f..eb746ed6d13 100644 --- a/src/fides/api/util/connection_type.py +++ b/src/fides/api/util/connection_type.py @@ -10,10 +10,10 @@ SaaSSchemaFactory, secrets_schemas, ) -from fides.api.schemas.connection_configuration.connection_config import ( +from fides.api.schemas.connection_configuration.connection_type_system_map import ( ConnectionSystemTypeMap, - SystemType, ) +from fides.api.schemas.connection_configuration.enums.system_type import SystemType from fides.api.schemas.policy import SUPPORTED_ACTION_TYPES, ActionType from fides.api.schemas.saas.saas_config import SaaSConfig from fides.api.service.connectors.consent_email_connector import ( @@ -28,7 +28,7 @@ from fides.api.util.saas_util import load_config_from_string -def connection_type_secret_schema(*, connection_type: str) -> dict[str, Any]: +def get_connection_type_secret_schema(*, connection_type: str) -> dict[str, Any]: """Returns the secret fields that should be supplied to authenticate with a particular connection type. Note that this does not return actual secrets, instead we return the *types* of diff --git a/src/fides/api/util/connection_util.py b/src/fides/api/util/connection_util.py index 9ef357f6781..e47037e2c4a 100644 --- a/src/fides/api/util/connection_util.py +++ b/src/fides/api/util/connection_util.py @@ -1,6 +1,6 @@ from typing import List, Optional -from fastapi import HTTPException +from fastapi import Depends, HTTPException from fideslang.validation import FidesKey from loguru import logger from pydantic import ValidationError @@ -12,9 +12,18 @@ HTTP_422_UNPROCESSABLE_ENTITY, ) -from fides.api.common_exceptions import KeyOrNameAlreadyExists +from fides.api.api import deps +from fides.api.common_exceptions import ( + ClientUnsuccessfulException, + ConnectionException, + KeyOrNameAlreadyExists, +) from fides.api.common_exceptions import ValidationError as FidesValidationError -from fides.api.models.connectionconfig import ConnectionConfig, ConnectionType +from fides.api.models.connectionconfig import ( + ConnectionConfig, + ConnectionTestStatus, + ConnectionType, +) from fides.api.models.datasetconfig import DatasetConfig from fides.api.models.manual_webhook import AccessManualWebhook from fides.api.models.privacy_request import PrivacyRequest, PrivacyRequestStatus @@ -30,11 +39,17 @@ BulkPutConnectionConfiguration, ConnectionConfigurationResponse, CreateConnectionConfigurationWithSecrets, - SaasConnectionTemplateValues, +) +from fides.api.schemas.connection_configuration.connection_secrets import ( + TestStatusMessage, ) from fides.api.schemas.connection_configuration.connection_secrets_saas import ( validate_saas_secrets_external_references, ) +from fides.api.schemas.connection_configuration.saas_config_template_values import ( + SaasConnectionTemplateValues, +) +from fides.api.service.connectors import get_connector from fides.api.service.connectors.saas.connector_registry_service import ( ConnectorRegistry, create_connection_config_from_template_no_save, @@ -42,6 +57,7 @@ from fides.api.service.privacy_request.request_runner_service import ( queue_privacy_request, ) +from fides.api.util.logger import Pii from fides.common.api.v1.urn_registry import CONNECTION_TYPES, SAAS_CONFIG # pylint: disable=too-many-nested-blocks,too-many-branches,too-many-statements @@ -234,10 +250,11 @@ def patch_connection_configs( data=orig_data, ) ) - except Exception: + except Exception as e: logger.warning( "Create/update failed for connection config with key '{}'.", config.key ) + logger.error(e) # remove secrets information from the return for security reasons. orig_data.pop("secrets", None) orig_data.pop("saas_connector_type", None) @@ -299,3 +316,36 @@ def delete_connection_config(db: Session, connection_key: FidesKey) -> None: # so we queue any privacy requests that are no longer blocked by webhooks if connection_type == ConnectionType.manual_webhook: requeue_requires_input_requests(db) + + +def connection_status( + connection_config: ConnectionConfig, msg: str, db: Session = Depends(deps.get_db) +) -> TestStatusMessage: + """Connect, verify with a trivial query or API request, and report the status.""" + + connector = get_connector(connection_config) + try: + status: ConnectionTestStatus | None = connector.test_connection() + + except (ConnectionException, ClientUnsuccessfulException) as exc: + logger.warning( + "Connection test failed on {}: {}", + connection_config.key, + Pii(str(exc)), + ) + connection_config.update_test_status( + test_status=ConnectionTestStatus.failed, db=db + ) + return TestStatusMessage( + msg=msg, + test_status=ConnectionTestStatus.failed, + failure_reason=str(exc), + ) + + logger.info("Connection test {} on {}", status.value, connection_config.key) # type: ignore + connection_config.update_test_status(test_status=status, db=db) # type: ignore + + return TestStatusMessage( + msg=msg, + test_status=status, + ) diff --git a/tests/fixtures/saas/connection_template_fixtures.py b/tests/fixtures/saas/connection_template_fixtures.py index 04aeeac7042..c7e0debe205 100644 --- a/tests/fixtures/saas/connection_template_fixtures.py +++ b/tests/fixtures/saas/connection_template_fixtures.py @@ -6,7 +6,7 @@ from fides.api.models.connectionconfig import ConnectionConfig from fides.api.models.datasetconfig import DatasetConfig -from fides.api.schemas.connection_configuration.connection_config import ( +from fides.api.schemas.connection_configuration.saas_config_template_values import ( SaasConnectionTemplateValues, ) from fides.api.service.connectors.saas.connector_registry_service import ( diff --git a/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py b/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py index d1da15afa1e..eddf1cce70a 100644 --- a/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py @@ -310,7 +310,9 @@ def test_patch_connections_bulk_create( assert postgres_connection["updated_at"] is not None assert postgres_connection["last_test_timestamp"] is None assert postgres_connection["disabled"] is False - assert "secrets" not in postgres_connection + assert postgres_connection["secrets"]["password"] == "**********" + assert postgres_connection["secrets"]["url"] == "**********" + # assert "secrets" not in postgres_connection mongo_connection = response_body["succeeded"][1] mongo_resource = db.query(ConnectionConfig).filter_by(key="my_mongo_db").first() @@ -322,7 +324,7 @@ def test_patch_connections_bulk_create( assert mongo_connection["created_at"] is not None assert mongo_connection["updated_at"] is not None assert mongo_connection["last_test_timestamp"] is None - assert "secrets" not in mongo_connection + assert mongo_connection["secrets"] is None assert response_body["failed"] == [] # No failures @@ -510,7 +512,8 @@ def test_patch_connections_bulk_update( postgres_connection = response_body["succeeded"][0] assert postgres_connection["access"] == "read" assert postgres_connection["disabled"] is True - assert "secrets" not in postgres_connection + assert postgres_connection["secrets"]["password"] == "**********" + assert postgres_connection["secrets"]["url"] == "**********" assert postgres_connection["updated_at"] is not None postgres_resource = ( db.query(ConnectionConfig).filter_by(key="postgres_db_1").first() @@ -525,7 +528,7 @@ def test_patch_connections_bulk_update( assert mongo_connection["updated_at"] is not None mongo_resource = db.query(ConnectionConfig).filter_by(key="my_mongo_db").first() assert mongo_resource.access.value == "write" - assert "secrets" not in mongo_connection + assert mongo_connection["secrets"] is None assert not mongo_resource.disabled mysql_connection = response_body["succeeded"][2] @@ -533,14 +536,14 @@ def test_patch_connections_bulk_update( assert mysql_connection["updated_at"] is not None mysql_resource = db.query(ConnectionConfig).filter_by(key="my_mysql_db").first() assert mysql_resource.access.value == "read" - assert "secrets" not in mysql_connection + assert mysql_connection["secrets"] is None mssql_connection = response_body["succeeded"][3] assert mssql_connection["access"] == "write" assert mssql_connection["updated_at"] is not None mssql_resource = db.query(ConnectionConfig).filter_by(key="my_mssql_db").first() assert mssql_resource.access.value == "write" - assert "secrets" not in mssql_connection + assert mssql_connection["secrets"] is None mariadb_connection = response_body["succeeded"][4] assert mariadb_connection["access"] == "write" @@ -549,7 +552,7 @@ def test_patch_connections_bulk_update( db.query(ConnectionConfig).filter_by(key="my_mariadb_db").first() ) assert mariadb_resource.access.value == "write" - assert "secrets" not in mariadb_connection + assert mariadb_connection["secrets"] is None bigquery_connection = response_body["succeeded"][5] assert bigquery_connection["access"] == "write" @@ -558,7 +561,7 @@ def test_patch_connections_bulk_update( db.query(ConnectionConfig).filter_by(key="my_bigquery_db").first() ) assert bigquery_resource.access.value == "write" - assert "secrets" not in bigquery_connection + assert bigquery_connection["secrets"] is None redshift_connection = response_body["succeeded"][6] assert redshift_connection["access"] == "read" @@ -567,7 +570,7 @@ def test_patch_connections_bulk_update( db.query(ConnectionConfig).filter_by(key="my_redshift_cluster").first() ) assert redshift_resource.access.value == "read" - assert "secrets" not in redshift_connection + assert redshift_connection["secrets"] is None snowflake_connection = response_body["succeeded"][7] assert snowflake_connection["access"] == "write" @@ -578,7 +581,7 @@ def test_patch_connections_bulk_update( ) assert snowflake_resource.access.value == "write" assert snowflake_resource.description == "Backup snowflake db" - assert "secrets" not in snowflake_connection + assert snowflake_connection["secrets"] is None manual_webhook_connection = response_body["succeeded"][8] assert manual_webhook_connection["access"] == "read" @@ -588,7 +591,7 @@ def test_patch_connections_bulk_update( ) assert manual_webhook_resource.access.value == "read" assert manual_webhook_resource.connection_type == ConnectionType.manual_webhook - assert "secrets" not in manual_webhook_connection + assert manual_webhook_connection["secrets"] is None postgres_resource.delete(db) mongo_resource.delete(db) @@ -772,6 +775,7 @@ def test_get_connection_configs( "access", "updated_at", "saas_config", + "secrets", "name", "last_test_timestamp", "last_test_succeeded", @@ -1189,6 +1193,7 @@ def test_get_connection_config( "disabled", "description", "saas_config", + "secrets", } assert response_body["key"] == "my_postgres_db_1" diff --git a/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py b/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py index c7d6b901e63..d8e72c4ba56 100644 --- a/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py @@ -12,7 +12,7 @@ ) from fides.api.models.datasetconfig import DatasetConfig from fides.api.models.policy import ActionType -from fides.api.schemas.connection_configuration.connection_config import SystemType +from fides.api.schemas.connection_configuration.enums.system_type import SystemType from fides.api.service.connectors.saas.connector_registry_service import ( ConnectorRegistry, ) @@ -1286,7 +1286,7 @@ def test_instantiate_connection_from_template( connection_data = resp.json()["connection"] assert connection_data["key"] == "mailchimp_connection_config" assert connection_data["name"] == "Mailchimp Connector" - assert "secrets" not in connection_data + assert connection_data["secrets"]["api_key"] == "**********" dataset_data = resp.json()["dataset"] assert dataset_data["fides_key"] == "secondary_mailchimp_instance" diff --git a/tests/ops/api/v1/endpoints/test_manual_webhooks.py b/tests/ops/api/v1/endpoints/test_manual_webhooks.py index c8c5a994b4a..d964af80e4a 100644 --- a/tests/ops/api/v1/endpoints/test_manual_webhooks.py +++ b/tests/ops/api/v1/endpoints/test_manual_webhooks.py @@ -98,7 +98,7 @@ def test_get_manual_webhook( assert connection_config_details["access"] == "read" assert connection_config_details["created_at"] is not None assert connection_config_details["updated_at"] is not None - assert "secrets" not in connection_config_details + assert connection_config_details["secrets"] is None class TestPostAccessManualWebhook: @@ -349,7 +349,7 @@ def test_post_manual_webhook( assert connection_config_details["access"] == "read" assert connection_config_details["created_at"] is not None assert connection_config_details["updated_at"] is not None - assert "secrets" not in connection_config_details + assert connection_config_details["secrets"] is None manual_webhook = AccessManualWebhook.get(db=db, object_id=resp["id"]) manual_webhook.delete(db) @@ -441,7 +441,7 @@ def test_patch_manual_webhook( assert connection_config_details["access"] == "read" assert connection_config_details["created_at"] is not None assert connection_config_details["updated_at"] is not None - assert "secrets" not in connection_config_details + assert connection_config_details["secrets"] is None class TestDeleteAccessManualWebhook: @@ -564,7 +564,7 @@ def test_get_manual_webhooks( assert connection_config_details["access"] == "read" assert connection_config_details["created_at"] is not None assert connection_config_details["updated_at"] is not None - assert "secrets" not in connection_config_details + assert connection_config_details["secrets"] is None class TestManualWebhookTest: diff --git a/tests/ops/api/v1/endpoints/test_policy_webhook_endpoints.py b/tests/ops/api/v1/endpoints/test_policy_webhook_endpoints.py index d86192d249d..7c9182c9e60 100644 --- a/tests/ops/api/v1/endpoints/test_policy_webhook_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_policy_webhook_endpoints.py @@ -36,6 +36,7 @@ def embedded_http_connection_config(connection_config: ConnectionConfig) -> Dict "disabled": False, "description": None, "saas_config": None, + "secrets": None, } diff --git a/tests/ops/api/v1/endpoints/test_system.py b/tests/ops/api/v1/endpoints/test_system.py index e56ee71cec4..227976627a0 100644 --- a/tests/ops/api/v1/endpoints/test_system.py +++ b/tests/ops/api/v1/endpoints/test_system.py @@ -249,6 +249,7 @@ def test_get_connection_configs( "access", "updated_at", "saas_config", + "secrets", "name", "last_test_timestamp", "last_test_succeeded", @@ -266,6 +267,45 @@ def test_get_connection_configs( assert response_body["page"] == 1 assert response_body["size"] == page_size + def test_get_connection_configs_masks_secrets( + self, + api_client: TestClient, + generate_auth_header, + connection_config, + url, + connections, + db: Session, + ) -> None: + auth_header = generate_auth_header(scopes=[CONNECTION_CREATE_OR_UPDATE]) + api_client.patch(url, headers=auth_header, json=connections) + + auth_header = generate_auth_header(scopes=[CONNECTION_READ]) + resp = api_client.get(url, headers=auth_header) + assert resp.status_code == HTTP_200_OK + + response_body = json.loads(resp.text) + assert len(response_body["items"]) == 3 + connection_1 = response_body["items"][0]["secrets"] + connection_2 = response_body["items"][1]["secrets"] + connection_3 = response_body["items"][2]["secrets"] + assert connection_1 == { + "api_key": "**********", + "domain": "test_mailchimp_domain", + "username": "test_mailchimp_username", + } + + assert connection_2 == { + "db_schema": "test", + "dbname": "test", + "host": "http://localhost", + "password": "**********", + "port": 5432, + "url": "**********", + "username": "test", + } + + assert connection_3 == None + @pytest.mark.parametrize( "acting_user_role, expected_status_code, assign_system", [ @@ -872,7 +912,7 @@ def test_instantiate_connection_from_template( connection_data = resp.json()["connection"] assert connection_data["key"] == "mailchimp_connection_config" assert connection_data["name"] == "Mailchimp Connector" - assert "secrets" not in connection_data + assert connection_data["secrets"]["api_key"] == "**********" dataset_data = resp.json()["dataset"] assert dataset_data["fides_key"] == "secondary_mailchimp_instance" diff --git a/tests/ops/schemas/connection_configuration/test_connection_config.py b/tests/ops/schemas/connection_configuration/test_connection_config.py new file mode 100644 index 00000000000..deef6eca248 --- /dev/null +++ b/tests/ops/schemas/connection_configuration/test_connection_config.py @@ -0,0 +1,62 @@ +from fides.api.schemas.connection_configuration.connection_config import ( + mask_sensitive_fields, +) + +import pytest + + +class TestMaskSenstiveValues: + @pytest.fixture(scope="function") + def secret_schema(self): + return { + "additionalProperties": False, + "description": "Aircall secrets schema", + "properties": { + "api_id": {"sensitive": False, "title": "API ID", "type": "string"}, + "api_token": { + "sensitive": True, + "title": "API Token", + "type": "string", + }, + "domain": { + "default": "api.aircall.io", + "sensitive": False, + "title": "Domain", + "type": "string", + }, + }, + "required": ["api_id", "api_token"], + "title": "aircall_schema", + "type": "object", + } + + @pytest.fixture(scope="function") + def connection_secrets(self): + return { + "api_id": "secret-test", + "api_token": "testing with new value", + "domain": "api.aircall.io", + } + + def test_mask_sensitive_fields(self, secret_schema, connection_secrets): + masked_secrets = mask_sensitive_fields(connection_secrets, secret_schema) + assert masked_secrets == { + "api_id": "secret-test", + "api_token": "**********", + "domain": "api.aircall.io", + } + + def test_mask_sensitive_fields_remove_non_schema_values( + self, connection_secrets, secret_schema + ): + connection_secrets["non_schema_value"] = "this should be removed" + connection_secrets["another_non_schema_value"] = "this should also be removed" + + masked_secrets = mask_sensitive_fields(connection_secrets, secret_schema) + keys = masked_secrets.keys() + assert "non_schema_value" not in keys + assert "another_non_schema_value" not in keys + + def test_return_none_if_no_secrets(self, secret_schema): + masked_secrets = mask_sensitive_fields(None, secret_schema) + assert masked_secrets is None diff --git a/tests/ops/util/test_connection_type.py b/tests/ops/util/test_connection_type.py index 906052637c7..25f3800dc2c 100644 --- a/tests/ops/util/test_connection_type.py +++ b/tests/ops/util/test_connection_type.py @@ -2,7 +2,7 @@ from fides.api.models.connectionconfig import ConnectionType from fides.api.models.policy import ActionType -from fides.api.schemas.connection_configuration.connection_config import SystemType +from fides.api.schemas.connection_configuration.enums.system_type import SystemType from fides.api.service.connectors.saas.connector_registry_service import ( ConnectorRegistry, )