From b1d6d22dcce8b5407c220eb106f5d8f9faf040fa Mon Sep 17 00:00:00 2001 From: dsill-ethyca Date: Thu, 18 Dec 2025 16:40:36 -0500 Subject: [PATCH] Revert "Okta Oauth2 Implementation (#7044)" This reverts commit f8da0a0c19f0f76f8212921458b69b7975213736. --- .github/workflows/backend_checks.yml | 6 +- .../admin-ui/cypress/e2e/config-wizard.cy.ts | 8 +- .../common/form/FormFieldFromSchema.tsx | 19 +- .../config-wizard/AuthenticateOktaForm.tsx | 226 +++++-- .../src/features/connection-type/types.ts | 1 - .../ConfigureMonitorModal.tsx | 9 +- .../MonitorConfigActionsCell.tsx | 16 +- .../hooks/useMonitorConfigTable.tsx | 20 +- .../integration-type-info/oktaInfo.tsx | 9 +- .../src/types/api/models/OktaConfig.ts | 6 +- docs/fides/docs/config/index.md | 2 +- noxfiles/run_infrastructure.py | 3 +- noxfiles/setup_tests_nox.py | 8 +- requirements.txt | 2 +- src/fides/api/api/v1/endpoints/generate.py | 6 +- src/fides/api/api/v1/endpoints/validate.py | 2 +- .../connection_secrets_okta.py | 126 +--- .../api/service/connectors/okta_connector.py | 94 ++- .../service/connectors/okta_http_client.py | 330 ----------- src/fides/cli/commands/generate.py | 14 +- src/fides/cli/commands/scan.py | 15 +- src/fides/cli/options.py | 24 +- src/fides/cli/utils.py | 20 +- src/fides/connectors/models.py | 13 +- src/fides/connectors/okta.py | 156 ++--- src/fides/core/system.py | 18 +- tests/ctl/api/test_generate.py | 10 +- tests/ctl/api/test_validate.py | 18 +- tests/ctl/cli/test_cli.py | 40 +- tests/ctl/cli/test_utils.py | 42 +- tests/ctl/connectors/test_okta.py | 100 ++-- tests/ctl/core/test_system.py | 51 +- tests/ctl/test_config.toml | 2 +- tests/fixtures/okta_fixtures.py | 17 +- tests/fixtures/saas_example_fixtures.py | 3 + .../connectors/test_okta_http_client.py | 558 ------------------ 36 files changed, 476 insertions(+), 1518 deletions(-) delete mode 100644 src/fides/api/service/connectors/okta_http_client.py delete mode 100644 tests/ops/service/connectors/test_okta_http_client.py diff --git a/.github/workflows/backend_checks.yml b/.github/workflows/backend_checks.yml index 34a499a2bcf..cbc29e709d1 100644 --- a/.github/workflows/backend_checks.yml +++ b/.github/workflows/backend_checks.yml @@ -397,8 +397,7 @@ jobs: DYNAMODB_ACCESS_KEY_ID: op://github-actions/dynamodb/DYNAMODB_ACCESS_KEY_ID DYNAMODB_ACCESS_KEY: op://github-actions/dynamodb/DYNAMODB_ACCESS_KEY DYNAMODB_REGION: op://github-actions/dynamodb/DYNAMODB_REGION - OKTA_CLIENT_ID: op://github-actions/okta/OKTA_CLIENT_ID - OKTA_PRIVATE_KEY: op://github-actions/okta/OKTA_PRIVATE_KEY + OKTA_CLIENT_TOKEN: op://github-actions/ctl/OKTA_CLIENT_TOKEN REDSHIFT_FIDESCTL_PASSWORD: op://github-actions/ctl/REDSHIFT_FIDESCTL_PASSWORD SNOWFLAKE_FIDESCTL_PASSWORD: op://github-actions/ctl/SNOWFLAKE_FIDESCTL_PASSWORD @@ -470,9 +469,8 @@ jobs: GOOGLE_CLOUD_SQL_POSTGRES_DB_IAM_USER: op://github-actions/gcp-postgres/GOOGLE_CLOUD_SQL_POSTGRES_DB_IAM_USER GOOGLE_CLOUD_SQL_POSTGRES_INSTANCE_CONNECTION_NAME: op://github-actions/gcp-postgres/GOOGLE_CLOUD_SQL_POSTGRES_INSTANCE_CONNECTION_NAME GOOGLE_CLOUD_SQL_POSTGRES_KEYFILE_CREDS: op://github-actions/gcp-postgres/GOOGLE_CLOUD_SQL_POSTGRES_KEYFILE_CREDS - OKTA_CLIENT_ID: op://github-actions/okta/OKTA_CLIENT_ID + OKTA_API_TOKEN: op://github-actions/okta/OKTA_API_TOKEN OKTA_ORG_URL: op://github-actions/okta/OKTA_ORG_URL - OKTA_PRIVATE_KEY: op://github-actions/okta/OKTA_PRIVATE_KEY RDS_MYSQL_AWS_ACCESS_KEY_ID: op://github-actions/rds-mysql/RDS_MYSQL_AWS_ACCESS_KEY_ID RDS_MYSQL_AWS_SECRET_ACCESS_KEY: op://github-actions/rds-mysql/RDS_MYSQL_AWS_SECRET_ACCESS_KEY RDS_MYSQL_DB_INSTANCE: op://github-actions/rds-mysql/RDS_MYSQL_DB_INSTANCE diff --git a/clients/admin-ui/cypress/e2e/config-wizard.cy.ts b/clients/admin-ui/cypress/e2e/config-wizard.cy.ts index 56955c372d6..7ed30c0fab3 100644 --- a/clients/admin-ui/cypress/e2e/config-wizard.cy.ts +++ b/clients/admin-ui/cypress/e2e/config-wizard.cy.ts @@ -108,12 +108,8 @@ describe("Config Wizard", () => { cy.getByTestId("okta-btn").click(); // Fill form cy.getByTestId("authenticate-okta-form"); - cy.getByTestId("input-orgUrl").type("https://dev-12345.okta.com"); - cy.getByTestId("input-clientId").type("0oa1abc2def3ghi4jkl5"); - cy.getByTestId("input-privateKey").type( - '{"kty":"RSA","kid":"test","n":"test","e":"AQAB","d":"test"}', - { parseSpecialCharSequences: false }, - ); + cy.getByTestId("input-orgUrl").type("https://ethyca.com/"); + cy.getByTestId("input-token").type("fakeToken"); }); it("Allows submitting the form and reviewing the results", () => { diff --git a/clients/admin-ui/src/features/common/form/FormFieldFromSchema.tsx b/clients/admin-ui/src/features/common/form/FormFieldFromSchema.tsx index 6b442fcb795..70f62b01c5d 100644 --- a/clients/admin-ui/src/features/common/form/FormFieldFromSchema.tsx +++ b/clients/admin-ui/src/features/common/form/FormFieldFromSchema.tsx @@ -7,7 +7,7 @@ import { } from "~/features/connection-type/types"; import { ControlledSelect } from "./ControlledSelect"; -import { CustomTextArea, CustomTextInput } from "./inputs"; +import { CustomTextInput } from "./inputs"; export type FormFieldProps = { name: string; @@ -91,23 +91,6 @@ export const FormFieldFromSchema = ({ ); } - if (fieldSchema.multiline) { - return ( - - ); - } - return ( { - if (!value) { - return true; - } - try { - JSON.parse(value); - return true; - } catch { - return false; - } - }, + "is-valid-key", + "Private key must be in PEM format (starts with -----BEGIN RSA PRIVATE KEY-----)", + (value) => !value || value.includes("-----BEGIN"), ) .label("Private Key"), scopes: Yup.string() .required() .trim() .label("Scopes") - .default("okta.apps.read") - .test( - "valid-scopes", - "Scopes must be a single scope or comma-separated list (e.g., 'okta.apps.read' or 'okta.apps.read, okta.users.read')", - (value) => { - if (!value) { - return true; - } - // Split on comma and check each scope is non-empty and has no internal whitespace - const scopes = value.split(",").map((s) => s.trim()); - return scopes.every((scope) => scope.length > 0 && !/\s/.test(scope)); - }, - ), + .default("okta.apps.read"), +}); + +const TokenValidationSchema = Yup.object().shape({ + orgUrl: Yup.string().required().trim().url().label("Organization URL"), + token: Yup.string() + .required() + .trim() + .matches(/^[^\s]+$/, "Cannot contain spaces") + .label("Token"), }); const AuthenticateOktaForm = () => { const organizationKey = useAppSelector(selectOrganizationFidesKey); const dispatch = useAppDispatch(); const { successAlert } = useAlert(); + const { flags } = useFlags(); const [scannerError, setScannerError] = useState(); + const useOAuth2 = flags.oktaMonitor; + const handleResults = (results: GenerateResponse["generate_results"]) => { const systems: System[] = (results ?? []).filter(isSystem); dispatch(setSystemsForReview(systems)); @@ -124,7 +126,7 @@ const AuthenticateOktaForm = () => { const [generate, { isLoading }] = useGenerateMutation(); - const handleSubmit = async (values: FormValues) => { + const handleOAuth2Submit = async (values: OAuth2FormValues) => { setScannerError(undefined); const config: OktaOAuth2Config = { @@ -148,11 +150,130 @@ const AuthenticateOktaForm = () => { } }; + const handleTokenSubmit = async (values: TokenFormValues) => { + setScannerError(undefined); + + const config: OktaTokenConfig = { + orgUrl: values.orgUrl, + token: values.token, + }; + + const result = await generate({ + organization_key: organizationKey, + generate: { + config: config as OktaConfig, + target: ValidTargets.OKTA, + type: GenerateTypes.SYSTEMS, + }, + }); + + if (isErrorResult(result)) { + handleError(result.error); + } else { + handleResults(result.data.generate_results); + } + }; + + if (useOAuth2) { + return ( + + {({ isValid, isSubmitting, dirty }) => ( +
+ + {isSubmitting ? ( + + ) : null} + + {scannerError ? : null} + {!isSubmitting && !scannerError ? ( + <> + + { + e.preventDefault(); + handleCancel(); + }, + }, + { title: "Authenticate Okta Scanner" }, + ]} + /> + + To use the scanner to inventory systems in Okta, you must + first authenticate using OAuth2 Client Credentials. + You'll need to create an API Services application in + Okta and generate an RSA key pair. + + + + + + + + + + ) : null} + {!isSubmitting ? ( + + + + + ) : null} + +
+ )} +
+ ); + } + return ( {({ isValid, isSubmitting, dirty }) => (
@@ -182,36 +303,23 @@ const AuthenticateOktaForm = () => { { title: "Authenticate Okta Scanner" }, ]} /> - {OKTA_AUTH_DESCRIPTION} + + To use the scanner to inventory systems in Okta, you must + first authenticate to your Okta account by providing the + following information: + - - diff --git a/clients/admin-ui/src/features/connection-type/types.ts b/clients/admin-ui/src/features/connection-type/types.ts index 9cf5dbe5d3c..c20253d0038 100644 --- a/clients/admin-ui/src/features/connection-type/types.ts +++ b/clients/admin-ui/src/features/connection-type/types.ts @@ -24,7 +24,6 @@ export type ConnectionTypeSecretSchemaProperty = { items?: { $ref: string }; sensitive?: boolean; multiselect?: boolean; - multiline?: boolean; options?: string[]; }; diff --git a/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureMonitorModal.tsx b/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureMonitorModal.tsx index 8bbb0e66a60..5522ecf4277 100644 --- a/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureMonitorModal.tsx +++ b/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureMonitorModal.tsx @@ -1,5 +1,6 @@ import { UseDisclosureReturn, useToast } from "fidesui"; +import { useFlags } from "~/features/common/features"; import FidesSpinner from "~/features/common/FidesSpinner"; import { getErrorMessage } from "~/features/common/helpers"; import { useAlert } from "~/features/common/hooks"; @@ -51,7 +52,9 @@ const ConfigureMonitorModal = ({ integrationOption: ConnectionSystemTypeMap; isWebsiteMonitor?: boolean; }) => { + const { flags } = useFlags(); const isOktaIntegration = integration.connection_type === ConnectionType.OKTA; + const useNewOktaEndpoints = flags.oktaMonitor && isOktaIntegration; const [putMonitorMutationTrigger, { isLoading: isSubmittingRegular }] = usePutDiscoveryMonitorMutation(); @@ -67,7 +70,7 @@ const ConfigureMonitorModal = ({ ] = usePutIdentityProviderMonitorMutation(); let isSubmitting: boolean; - if (isOktaIntegration) { + if (useNewOktaEndpoints) { isSubmitting = isEditing ? isSubmittingOktaUpdate : isSubmittingOktaCreate; } else { isSubmitting = isSubmittingRegular; @@ -98,8 +101,8 @@ const ConfigureMonitorModal = ({ } }, TIMEOUT_DELAY); - // Use Identity Provider Monitor endpoint for Okta, otherwise use regular endpoint - if (isOktaIntegration) { + // Use Identity Provider Monitor endpoint for Okta with new auth, otherwise use regular endpoint + if (useNewOktaEndpoints) { // Transform MonitorConfig to IdentityProviderMonitorConfig format const oktaPayload = { name: values.name, diff --git a/clients/admin-ui/src/features/integrations/configure-monitor/MonitorConfigActionsCell.tsx b/clients/admin-ui/src/features/integrations/configure-monitor/MonitorConfigActionsCell.tsx index 5d0fb96a4b5..409615df022 100644 --- a/clients/admin-ui/src/features/integrations/configure-monitor/MonitorConfigActionsCell.tsx +++ b/clients/admin-ui/src/features/integrations/configure-monitor/MonitorConfigActionsCell.tsx @@ -66,10 +66,18 @@ const MonitorConfigActionsCell = ({ }; const handleExecute = async () => { - const result = isOktaMonitor - ? await executeOktaMonitor({ monitor_config_key: monitorId }) - : await executeRegularMonitor({ monitor_config_id: monitorId }); - toastExecuteResult(result); + // Use Identity Provider Monitor endpoint for Okta, otherwise use regular endpoint + if (isOktaMonitor) { + const result = await executeOktaMonitor({ + monitor_config_key: monitorId, + }); + toastExecuteResult(result); + } else { + const result = await executeRegularMonitor({ + monitor_config_id: monitorId, + }); + toastExecuteResult(result); + } }; return ( diff --git a/clients/admin-ui/src/features/integrations/hooks/useMonitorConfigTable.tsx b/clients/admin-ui/src/features/integrations/hooks/useMonitorConfigTable.tsx index 487daf80789..4846732a990 100644 --- a/clients/admin-ui/src/features/integrations/hooks/useMonitorConfigTable.tsx +++ b/clients/admin-ui/src/features/integrations/hooks/useMonitorConfigTable.tsx @@ -6,6 +6,7 @@ import { } from "fidesui"; import { useMemo } from "react"; +import { useFlags } from "~/features/common/features"; import { PRIVACY_NOTICE_REGION_RECORD } from "~/features/common/privacy-notice-regions"; import { useAntTable, useTableState } from "~/features/common/table/hooks"; import { pluralize } from "~/features/common/utils"; @@ -50,12 +51,14 @@ export const useMonitorConfigTable = ({ onEditMonitor, }: UseMonitorConfigTableConfig) => { const tableState = useTableState(); + const { flags } = useFlags(); const { pageIndex, pageSize } = tableState; const isOktaIntegration = integration.connection_type === ConnectionType.OKTA; + const useNewOktaEndpoints = flags.oktaMonitor && isOktaIntegration; - // Use Identity Provider Monitor endpoint for Okta, otherwise use regular endpoint + // Use Identity Provider Monitor endpoint for Okta with new auth, otherwise use regular endpoint const regularMonitorsQuery = useGetMonitorsByIntegrationQuery( { page: pageIndex, @@ -63,7 +66,7 @@ export const useMonitorConfigTable = ({ connection_config_key: integration.key, }, { - skip: isOktaIntegration, + skip: useNewOktaEndpoints, }, ); @@ -74,7 +77,7 @@ export const useMonitorConfigTable = ({ connection_config_key: integration.key, }, { - skip: !isOktaIntegration, + skip: !useNewOktaEndpoints, }, ); @@ -82,7 +85,7 @@ export const useMonitorConfigTable = ({ isLoading, isFetching, data: response, - } = isOktaIntegration ? oktaMonitorsQuery : regularMonitorsQuery; + } = useNewOktaEndpoints ? oktaMonitorsQuery : regularMonitorsQuery; const antTableConfig = useMemo( () => ({ @@ -229,7 +232,7 @@ export const useMonitorConfigTable = ({ onEditMonitor(record)} isWebsiteMonitor={isWebsiteMonitor} - isOktaMonitor={isOktaIntegration} + isOktaMonitor={useNewOktaEndpoints} monitorId={record.key} /> ), @@ -256,7 +259,12 @@ export const useMonitorConfigTable = ({ statusColumn, actionsColumn, ]; - }, [integration.secrets, isWebsiteMonitor, onEditMonitor, isOktaIntegration]); + }, [ + integration.secrets, + isWebsiteMonitor, + onEditMonitor, + useNewOktaEndpoints, + ]); return { // Table state and data diff --git a/clients/admin-ui/src/features/integrations/integration-type-info/oktaInfo.tsx b/clients/admin-ui/src/features/integrations/integration-type-info/oktaInfo.tsx index 9fb89a26062..8c9e84c6059 100644 --- a/clients/admin-ui/src/features/integrations/integration-type-info/oktaInfo.tsx +++ b/clients/admin-ui/src/features/integrations/integration-type-info/oktaInfo.tsx @@ -21,15 +21,14 @@ export const OKTA_DESCRIPTION = ( ); -export const OKTA_AUTH_DESCRIPTION = - "Okta integration uses OAuth2 Client Credentials with private_key_jwt for secure authentication. You will need to generate an RSA key in Okta and copy the JSON key to use in Fides."; - const OktaIntegrationOverview = () => ( <> - {OKTA_DESCRIPTION} - Authentication: {OKTA_AUTH_DESCRIPTION} + SSO providers manage user authentication and can help identify systems + within your infrastructure. Adding an SSO provider as a data source allows + you to detect connected systems, monitor access patterns, and enhance your + data map for better visibility and control. Authentication: Okta integration uses OAuth2 Client diff --git a/clients/admin-ui/src/types/api/models/OktaConfig.ts b/clients/admin-ui/src/types/api/models/OktaConfig.ts index b248a87b2d9..07d7929ec9e 100644 --- a/clients/admin-ui/src/types/api/models/OktaConfig.ts +++ b/clients/admin-ui/src/types/api/models/OktaConfig.ts @@ -3,11 +3,9 @@ /* eslint-disable */ /** - * The model for the connection config for Okta (OAuth2) + * The model for the connection config for Okta */ export type OktaConfig = { orgUrl: string; - clientId: string; - privateKey: string; - scopes?: Array; + token: string; }; diff --git a/docs/fides/docs/config/index.md b/docs/fides/docs/config/index.md index ac989b1098b..297cbe8be59 100644 --- a/docs/fides/docs/config/index.md +++ b/docs/fides/docs/config/index.md @@ -87,7 +87,7 @@ task_always_eager = true ### Credentials -The credentials section uses custom keys which can be referenced in specific commands that take the --credentials-id option. For example, a command that uses a credential might look like `fides scan dataset db --credentials-id app_postgres`. The credential object itself will be validated at the time of use depending on what type of credential is required. For instance if `fides scan system okta` is used, it will expect the object to contain orgUrl, clientId, and privateKey key/value pairs for OAuth2 authentication. In the case of a typical database like postgres, it will only expect a connection_string. The following is an example of what a credentials section might look like in a given deployment with various applications: +The credentials section uses custom keys which can be referenced in specific commands that take the --credentials-id option. For example, a command that uses a credential might look like `fides scan dataset db --credentials-id app_postgres`. The credential object itself will be validated at the time of use depending on what type of credential is required. For instance if `fides scan system okta` is used, it will expect the object to contain orgUrl and token key/value pairs. In the case of a typical database like postgres, it will only expect a connection_string. The following is an example of what a credentials section might look like in a given deployment with various applications: ```toml title="Example Credentials Section" [credentials] diff --git a/noxfiles/run_infrastructure.py b/noxfiles/run_infrastructure.py index c651879ee6b..08711730878 100644 --- a/noxfiles/run_infrastructure.py +++ b/noxfiles/run_infrastructure.py @@ -75,8 +75,7 @@ ], "okta": [ "OKTA_ORG_URL", - "OKTA_CLIENT_ID", - "OKTA_PRIVATE_KEY", + "OKTA_API_TOKEN", ], } EXTERNAL_DATASTORES = list(EXTERNAL_DATASTORE_CONFIG.keys()) diff --git a/noxfiles/setup_tests_nox.py b/noxfiles/setup_tests_nox.py index 555f1270aa9..5a1b103bc2d 100644 --- a/noxfiles/setup_tests_nox.py +++ b/noxfiles/setup_tests_nox.py @@ -73,9 +73,7 @@ def pytest_ctl(session: Session, mark: str, coverage_arg: str) -> None: "-e", "AWS_DEFAULT_REGION", "-e", - "OKTA_CLIENT_ID", - "-e", - "OKTA_PRIVATE_KEY", + "OKTA_CLIENT_TOKEN", "-e", "BIGQUERY_CONFIG", "-e", @@ -225,9 +223,7 @@ def pytest_ops( "-e", "OKTA_ORG_URL", "-e", - "OKTA_CLIENT_ID", - "-e", - "OKTA_PRIVATE_KEY", + "OKTA_API_TOKEN", "-e", "RDS_MYSQL_AWS_ACCESS_KEY_ID", "-e", diff --git a/requirements.txt b/requirements.txt index cabff100e3f..c3d99e6e932 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,6 +37,7 @@ pg8000==1.31.2 nh3==0.2.15 numpy==1.24.4 oauthlib==3.3.1 +okta==2.7.0 openpyxl==3.0.9 networkx==3.1 packaging==23.0 @@ -56,7 +57,6 @@ python-jose[cryptography]==3.3.0 pyyaml==6.0.1 pyahocorasick==2.1.0 redis==3.5.3 -requests-oauth2client>=1.5.0 requests-oauthlib==2.0.0 rich-click==1.6.1 sendgrid==6.9.7 diff --git a/src/fides/api/api/v1/endpoints/generate.py b/src/fides/api/api/v1/endpoints/generate.py index 15fcea469e0..0309840edf9 100644 --- a/src/fides/api/api/v1/endpoints/generate.py +++ b/src/fides/api/api/v1/endpoints/generate.py @@ -163,7 +163,7 @@ async def generate( ) elif generate_target == "okta" and isinstance(generate_config, OktaConfig): - generate_results = generate_okta( + generate_results = await generate_okta( okta_config=generate_config, organization=organization, ) @@ -239,7 +239,7 @@ def generate_dynamodb( return [i.model_dump(exclude_none=True) for i in aws_resources] -def generate_okta( +async def generate_okta( okta_config: OktaConfig, organization: Organization ) -> List[Dict[str, str]]: """ @@ -256,7 +256,7 @@ def generate_okta( detail=str(error), ) log.info("Generating systems from Okta") - okta_systems = generate_okta_systems( + okta_systems = await generate_okta_systems( organization=organization, okta_config=okta_config ) return [i.model_dump(exclude_none=True) for i in okta_systems] diff --git a/src/fides/api/api/v1/endpoints/validate.py b/src/fides/api/api/v1/endpoints/validate.py index 79586040735..c761ef2068d 100644 --- a/src/fides/api/api/v1/endpoints/validate.py +++ b/src/fides/api/api/v1/endpoints/validate.py @@ -128,4 +128,4 @@ async def validate_okta(okta_config: OktaConfig) -> None: """ import fides.connectors.okta as okta_connector - okta_connector.validate_credentials(okta_config=okta_config) + await okta_connector.validate_credentials(okta_config=okta_config) diff --git a/src/fides/api/schemas/connection_configuration/connection_secrets_okta.py b/src/fides/api/schemas/connection_configuration/connection_secrets_okta.py index 1c26897fbae..7cb04abd70b 100644 --- a/src/fides/api/schemas/connection_configuration/connection_secrets_okta.py +++ b/src/fides/api/schemas/connection_configuration/connection_secrets_okta.py @@ -1,135 +1,27 @@ -import json -import re -from typing import ClassVar, List, Optional -from urllib.parse import urlparse +from typing import ClassVar, List -from pydantic import Field, field_validator +from pydantic import Field -from fides.api.custom_types import AnyHttpUrlStringRemovesSlash from fides.api.schemas.base_class import NoValidationSchema from fides.api.schemas.connection_configuration.connection_secrets import ( ConnectionConfigSecretsSchema, ) -# Okta domain validation patterns -# Supports: org.okta.com, org.oktapreview.com, org.okta-emea.com -OKTA_DOMAIN_PATTERN = re.compile( - r"^[a-zA-Z0-9][a-zA-Z0-9\-]*" # Org name (alphanumeric, can contain hyphens) - r"(\.[a-zA-Z0-9][a-zA-Z0-9\-]*)*" # Optional subdomains - r"\.(okta\.com|oktapreview\.com|okta-emea\.com)$" # Okta domains -) - -# Custom domains must be valid hostnames -CUSTOM_DOMAIN_PATTERN = re.compile( - r"^[a-zA-Z0-9]" # Must start with alphanumeric - r"[a-zA-Z0-9\-\.]*" # Can contain alphanumeric, hyphens, dots - r"[a-zA-Z0-9]$" # Must end with alphanumeric -) - class OktaSchema(ConnectionConfigSecretsSchema): - """Schema for Okta OAuth2 connection secrets.""" + """Schema to validate the secrets needed to connect to Okta""" - org_url: AnyHttpUrlStringRemovesSlash = Field( + org_url: str = Field( title="Organization URL", description="The URL of your Okta organization (e.g. https://your-org.okta.com)", ) - client_id: str = Field( - title="OAuth2 Client ID", - description="The OAuth2 client ID from your Okta service application", - ) - private_key: str = Field( - title="Private Key", - description=( - "RSA private key in JWK (JSON) format for signing JWT assertions. " - "Download from Okta: Applications > Your App > Sign On > " - "Client Credentials > Edit > Generate new key." - ), - json_schema_extra={"sensitive": True, "multiline": True}, + api_token: str = Field( + title="API Token", + description="The API token used to authenticate with Okta", + json_schema_extra={"sensitive": True}, ) - scopes: Optional[List[str]] = Field( - default=["okta.apps.read"], - title="OAuth2 Scopes", - description="OAuth2 scopes to request (default: okta.apps.read)", - ) - - _required_components: ClassVar[List[str]] = ["org_url", "client_id", "private_key"] - - @field_validator("org_url") - @classmethod - def validate_okta_org_url(cls, value: str) -> str: - """Validate Okta organization URL. - - Supports standard Okta domains and custom domains: - - org.okta.com (production) - - org.oktapreview.com (sandbox/preview) - - org.okta-emea.com (European instances) - - Custom domains (enterprise feature) - """ - parsed = urlparse(str(value)) - hostname = parsed.hostname or parsed.netloc - - if not hostname: - raise ValueError("Invalid URL: no hostname found") - - # Check if it's a standard Okta domain - is_okta_domain = OKTA_DOMAIN_PATTERN.match(hostname) is not None - - # If not a standard Okta domain, validate as custom domain - if not is_okta_domain: - if not CUSTOM_DOMAIN_PATTERN.match(hostname): - raise ValueError( - f"Invalid Okta domain: {hostname}. " - "Expected format: org.okta.com, org.oktapreview.com, " - "org.okta-emea.com, or a valid custom domain." - ) - - # Reject admin URLs for standard Okta domains - if is_okta_domain: - labels = hostname.split(".") - if len(labels) >= 3 and labels[-3].endswith("-admin"): - raise ValueError( - "Admin organization URLs (-admin.okta.com) are not supported. " - "Use your main organization URL (e.g., https://your-org.okta.com)" - ) - - if parsed.path and parsed.path != "/": - raise ValueError( - f"Okta organization URL should not contain a path (got: {parsed.path})" - ) - - return str(value) - - @field_validator("private_key") - @classmethod - def validate_private_key_format(cls, value: str) -> str: - if not value or not value.strip(): - raise ValueError("Private key cannot be empty") - - value = value.strip() - - try: - jwk_dict = json.loads(value) - except json.JSONDecodeError as exc: - raise ValueError( - "Private key must be in JWK (JSON) format. " - "Download your key from Okta: Applications > Your App > " - "Sign On > Client Credentials > Edit > Generate new key" - ) from exc - - if "d" not in jwk_dict: - raise ValueError( - "JWK is not a private key (missing 'd' parameter). " - "Make sure you're using the private key, not the public key." - ) - - kty = jwk_dict.get("kty") - if kty not in ("RSA", "EC"): - raise ValueError( - f"Unsupported key type: {kty}. Only RSA and EC keys are supported." - ) - return value + _required_components: ClassVar[List[str]] = ["org_url", "api_token"] class OktaDocsSchema(OktaSchema, NoValidationSchema): diff --git a/src/fides/api/service/connectors/okta_connector.py b/src/fides/api/service/connectors/okta_connector.py index bb4103a1773..2e03d6b49a5 100644 --- a/src/fides/api/service/connectors/okta_connector.py +++ b/src/fides/api/service/connectors/okta_connector.py @@ -1,4 +1,7 @@ -from typing import Any, Dict, List, NoReturn, Optional +from typing import Any, Dict, List, Optional + +from okta.client import Client as OktaClient +from okta.exceptions import OktaAPIException from fides.api.common_exceptions import ConnectionException from fides.api.graph.execution import ExecutionNode @@ -6,84 +9,60 @@ from fides.api.models.policy import Policy from fides.api.models.privacy_request import PrivacyRequest, RequestTask from fides.api.service.connectors.base_connector import BaseConnector -from fides.api.service.connectors.okta_http_client import ( - OktaApplication, - OktaHttpClient, -) +from fides.api.service.connectors.query_configs.query_config import QueryConfig from fides.api.util.collection_util import Row +from fides.api.util.wrappers import sync class OktaConnector(BaseConnector): - """Okta connector using OAuth2 private_key_jwt authentication.""" + """ + Okta connector for integrating with Okta's API. + This connector allows for user management and authentication operations. + """ @property def dsr_supported(self) -> bool: return False - def create_client(self) -> OktaHttpClient: - """ - Create and return an OktaHttpClient configured with OAuth2 credentials. - - The connection secrets are validated by OktaSchema before reaching this method, - so we can safely access the required fields. - - Returns: - OktaHttpClient configured for OAuth2 private_key_jwt authentication. - - Raises: - ConnectionException: If client creation fails. - """ - secrets = self.configuration.secrets - + def create_client(self) -> OktaClient: + """Creates and returns an Okta client instance""" try: - scopes = secrets.get("scopes", ["okta.apps.read"]) - if isinstance(scopes, str): - scopes = [s.strip() for s in scopes.split(",")] - - return OktaHttpClient( - org_url=secrets["org_url"], - client_id=secrets["client_id"], - private_key=secrets["private_key"], - scopes=scopes, + return OktaClient( + { + "orgUrl": self.configuration.secrets["org_url"], + "token": self.configuration.secrets["api_token"], + "raiseException": True, + } ) - except ConnectionException: - raise except Exception as e: - raise ConnectionException( - "Failed to create Okta client. Please verify your credentials." - ) from e + raise ConnectionException(f"Failed to create Okta client: {str(e)}") - def query_config(self, node: ExecutionNode) -> NoReturn: - """Return the query config for this connector type. Not implemented for Okta.""" + def query_config(self, node: ExecutionNode) -> QueryConfig[Any]: + """Return the query config that corresponds to this connector type""" raise NotImplementedError("Query config not implemented for Okta") def test_connection(self) -> Optional[ConnectionTestStatus]: """ - Validate the Okta connection by attempting to list applications. - - Returns: - ConnectionTestStatus.succeeded if connection is valid. - - Raises: - ConnectionException: If connection test fails. + Validates the connection to Okta by attempting to list users. """ try: - self._list_applications(limit=1) + self._list_applications() return ConnectionTestStatus.succeeded - except ConnectionException: - raise + except OktaAPIException as e: + error = e.args[0] + raise ConnectionException( + f"Failed to connect to Okta: {error['errorSummary']}" + ) except Exception as e: raise ConnectionException( f"Unexpected error testing Okta connection: {str(e)}" - ) from e + ) - def _list_applications( - self, limit: int = 200, after: Optional[str] = None - ) -> List[OktaApplication]: - """List Okta applications with optional pagination.""" + @sync + async def _list_applications(self) -> List[Dict[str, Any]]: + """List all applications in Okta""" client = self.client() - apps, _ = client.list_applications(limit=limit, after=after) - return apps + return await client.list_applications() def retrieve_data( self, @@ -93,7 +72,7 @@ def retrieve_data( request_task: RequestTask, input_data: Dict[str, List[Any]], ) -> List[Row]: - """DSR data retrieval is not supported for Okta connector.""" + """DSR execution not supported for Okta connector""" return [] def mask_data( @@ -105,8 +84,9 @@ def mask_data( rows: List[Row], input_data: Optional[Dict[str, List[Any]]] = None, ) -> int: - """DSR data masking is not supported for Okta connector.""" + """DSR execution not supported for Okta connector""" return 0 def close(self) -> None: - """Close any held resources. No-op for Okta client.""" + """Close any held resources""" + # No resources to close for Okta client diff --git a/src/fides/api/service/connectors/okta_http_client.py b/src/fides/api/service/connectors/okta_http_client.py deleted file mode 100644 index 6149c505d8b..00000000000 --- a/src/fides/api/service/connectors/okta_http_client.py +++ /dev/null @@ -1,330 +0,0 @@ -import json -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union -from urllib.parse import parse_qs, urlparse - -import requests -from requests.adapters import HTTPAdapter -from typing_extensions import TypedDict -from urllib3.util.retry import Retry - -from fides.api.common_exceptions import ConnectionException -from fides.api.service.connectors.limiter.rate_limiter import ( - RateLimiter, - RateLimiterPeriod, - RateLimiterRequest, -) - -if TYPE_CHECKING: - from requests_oauth2client import DPoPKey, OAuth2Client - - -class _JwkBase(TypedDict, total=False): - """Base JWK fields (all optional).""" - - kty: str # Key type: "RSA" or "EC" - alg: str # Algorithm: RS256, ES256, etc. - # EC-specific - crv: str # Curve: P-256, P-384, P-521 - x: str - y: str - # RSA-specific - n: str # Modulus - e: str # Public exponent - - -class PrivateJwk(_JwkBase): - """JWK private key structure per RFC 7517.""" - - d: str - - -class OktaApplication(TypedDict, total=False): - """Okta Application object from the API.""" - - id: str - name: str - label: str - status: str # ACTIVE, INACTIVE, DELETED - created: str - lastUpdated: str - signOnMode: str - - -DEFAULT_OKTA_SCOPES = ("okta.apps.read",) -DEFAULT_API_LIMIT = 200 -DEFAULT_MAX_PAGES = 100 -DEFAULT_REQUEST_TIMEOUT = 30 - -# Rate limiting: Okta's default is 600 requests/minute for most endpoints -# https://developer.okta.com/docs/reference/rl-global-mgmt/ -DEFAULT_RATE_LIMIT_PER_MINUTE = 500 # Conservative default below Okta's limit - -EC_CURVE_ALG_MAP = { - "P-256": "ES256", - "P-384": "ES384", - "P-521": "ES512", -} - - -class OktaHttpClient: - """ - HTTP client for Okta API with OAuth2 private_key_jwt + DPoP. - - Uses custom implementation instead of Okta SDK because the SDK lacks DPoP support. - - Features: - - Automatic token management via OAuth2ClientCredentialsAuth (10-minute refresh buffer) - - Rate limiting via Redis (gracefully degrades if Redis unavailable) - - Retry with exponential backoff for transient failures (429, 5xx) via urllib3 - """ - - def __init__( - self, - org_url: str, - client_id: str, - private_key: Union[str, PrivateJwk], - scopes: Optional[List[str]] = None, - *, - rate_limit_per_minute: Optional[int] = DEFAULT_RATE_LIMIT_PER_MINUTE, - session: Optional[requests.Session] = None, # For test injection - ): - self.org_url = org_url.rstrip("/") - self.scopes = tuple(scopes) if scopes is not None else DEFAULT_OKTA_SCOPES - - # Rate limiting configuration - self._rate_limit_per_minute = rate_limit_per_minute - - # Allow test injection of a pre-configured session - if session is not None: - self._session = session - return - - try: - from requests_oauth2client import ( - DPoPKey, - OAuth2Client, - OAuth2ClientCredentialsAuth, - PrivateKeyJwt, - ) - - private_jwk = self._parse_jwk(private_key) - alg = self._determine_alg_from_jwk(private_jwk) - - # DPoP (RFC 9449) requires a separate key from client authentication. - # ES256 is used regardless of the client auth key type because: - # 1. It's explicitly recommended by RFC 9449 for DPoP proofs - # 2. It provides strong security with compact signatures - # 3. Okta supports ES256 for DPoP across all configurations - dpop_key = DPoPKey.generate(alg="ES256") - - oauth_client = OAuth2Client( - token_endpoint=f"{self.org_url}/oauth2/v1/token", - auth=PrivateKeyJwt(client_id, private_jwk, alg=alg), - dpop_bound_access_tokens=True, - ) - - # Create session with automatic token management - self._session = requests.Session() - self._session.auth = OAuth2ClientCredentialsAuth( - client=oauth_client, - scope=" ".join(self.scopes), - dpop_key=dpop_key, - leeway=600, # 10 min buffer before expiry (matching TOKEN_EXPIRY_BUFFER_MINUTES) - ) - - # Configure retry strategy via urllib3 - retry_strategy = Retry( - total=3, # 3 retries - backoff_factor=1.0, # Exponential backoff - status_forcelist=[429, 502, 503, 504], # Retryable status codes - respect_retry_after_header=True, # Honor Retry-After header - raise_on_status=False, # Let us handle errors - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self._session.mount("https://", adapter) - self._session.mount("http://", adapter) - - except ImportError as e: - raise ConnectionException( - "The 'requests-oauth2client' library is required for Okta connector. " - "Please install it with: pip install requests-oauth2client" - ) from e - except (ValueError, TypeError) as e: - # Generic message avoids leaking key content - raise ConnectionException( - "Invalid private key format. Ensure the key is a valid JWK with 'd' parameter." - ) from e - - @staticmethod - def _parse_jwk(private_key: Union[str, PrivateJwk]) -> PrivateJwk: - """Parse private key from string or dict. - - Args: - private_key: JWK as either a JSON string or dict - - Returns: - Parsed JWK dictionary - - Raises: - ValueError: If key is not valid JSON or missing required 'd' parameter - """ - if isinstance(private_key, dict): - if "d" not in private_key: - raise ValueError("Private key required (missing 'd' parameter)") - return private_key - - try: - parsed = json.loads(private_key.strip()) - except json.JSONDecodeError as exc: - raise ValueError("Private key must be valid JSON.") from exc - - if "d" not in parsed: - raise ValueError("Private key required (missing 'd' parameter)") - return parsed - - @staticmethod - def _determine_alg_from_jwk(jwk: PrivateJwk) -> str: - if "alg" in jwk: - return jwk["alg"] - - kty = jwk.get("kty") - if kty == "RSA": - return "RS256" - if kty == "EC": - crv = jwk.get("crv", "P-256") - return EC_CURVE_ALG_MAP.get(crv, "ES256") - - return "RS256" - - def _build_rate_limit_requests(self) -> List[RateLimiterRequest]: - """ - Build rate limit request objects for Okta API calls. - - Returns empty list if rate limiting is disabled (rate_limit_per_minute is None). - """ - if self._rate_limit_per_minute is None: - return [] - - return [ - RateLimiterRequest( - key=f"okta:{self.org_url}", - rate_limit=self._rate_limit_per_minute, - period=RateLimiterPeriod.MINUTE, - ) - ] - - def _apply_rate_limit(self) -> None: - """Apply rate limiting before making a request.""" - rate_limit_requests = self._build_rate_limit_requests() - if rate_limit_requests: - RateLimiter().limit(rate_limit_requests) - - def list_applications( - self, limit: int = DEFAULT_API_LIMIT, after: Optional[str] = None - ) -> Tuple[List[OktaApplication], Optional[str]]: - """ - List Okta applications with cursor-based pagination. - - Includes: - - Rate limiting (via Redis if available) - - Retry with exponential backoff for transient failures (via urllib3) - - Automatic token management (via OAuth2ClientCredentialsAuth) - - Args: - limit: Maximum number of applications to return - after: Cursor for next page (from previous response) - - Returns: - Tuple of (apps_list, next_cursor). next_cursor is None if no more pages. - - Raises: - ConnectionException: If request fails after all retries - """ - self._apply_rate_limit() - - params: Dict[str, Union[int, str]] = {"limit": limit} - if after: - params["after"] = after - - try: - response = self._session.get( - f"{self.org_url}/api/v1/apps", - params=params, - timeout=DEFAULT_REQUEST_TIMEOUT, - ) - response.raise_for_status() - except requests.RequestException as e: - raise ConnectionException(f"Okta API request failed: {str(e)}") from e - - next_cursor = self._extract_next_cursor(response.headers.get("Link")) - return response.json(), next_cursor - - def list_all_applications( - self, page_size: int = DEFAULT_API_LIMIT, max_pages: int = DEFAULT_MAX_PAGES - ) -> List[OktaApplication]: - """ - Fetch all Okta applications with automatic pagination. - - Args: - page_size: Number of applications per page - max_pages: Maximum number of pages to fetch (safety limit) - - Returns: - List of all applications - """ - all_apps: List[OktaApplication] = [] - cursor: Optional[str] = None - seen_cursors: set[str] = set() - - for _ in range(max_pages): - apps, next_cursor = self.list_applications(limit=page_size, after=cursor) - all_apps.extend(apps) - - if not next_cursor: - break - if next_cursor in seen_cursors: - break - - seen_cursors.add(next_cursor) - cursor = next_cursor - - return all_apps - - @staticmethod - def _extract_bracketed_url(text: str) -> Optional[str]: - """Extract URL from angle brackets in RFC 8288 Link header format. - - Args: - text: A link entry like '; rel="next"' - - Returns: - The URL string, or None if no bracketed URL found - """ - start = text.find("<") - end = text.find(">", start + 1) if start != -1 else -1 - if start != -1 and end != -1: - return text[start + 1 : end] - return None - - @staticmethod - def _extract_next_cursor(link_header: Optional[str]) -> Optional[str]: - """Extract 'after' cursor from Okta Link header. - - Args: - link_header: The Link header from Okta API response - - Returns: - The 'after' cursor value, or None if no next page - """ - if not link_header: - return None - for link in link_header.split(","): - if 'rel="next"' in link: - url = OktaHttpClient._extract_bracketed_url(link) - if url: - parsed = urlparse(url) - query_params = parse_qs(parsed.query) - after_values = query_params.get("after", []) - if after_values: - return after_values[0] - return None diff --git a/src/fides/cli/commands/generate.py b/src/fides/cli/commands/generate.py index c72edc6aa11..611f84751e1 100644 --- a/src/fides/cli/commands/generate.py +++ b/src/fides/cli/commands/generate.py @@ -10,9 +10,8 @@ connection_string_option, credentials_id_option, include_null_flag, - okta_client_id_option, okta_org_url_option, - okta_private_key_option, + okta_token_option, organization_fides_key_option, ) from fides.cli.utils import ( @@ -180,8 +179,7 @@ def generate_system(ctx: click.Context) -> None: @click.argument("output_filename", type=str) @credentials_id_option @okta_org_url_option -@okta_client_id_option -@okta_private_key_option +@okta_token_option @include_null_flag @organization_fides_key_option @with_analytics @@ -189,22 +187,20 @@ def generate_system_okta( ctx: click.Context, output_filename: str, credentials_id: str, + token: str, org_url: str, - client_id: str, - private_key: str, include_null: bool, org_key: str, ) -> None: """ Generates systems from your Okta applications. Connects via - OAuth2 client credentials. + an Okta admin account. """ config = ctx.obj["CONFIG"] okta_config = handle_okta_credentials_options( fides_config=config, + token=token, org_url=org_url, - client_id=client_id, - private_key=private_key, credentials_id=credentials_id, ) diff --git a/src/fides/cli/commands/scan.py b/src/fides/cli/commands/scan.py index 2fae2c14b46..9572aaa7980 100644 --- a/src/fides/cli/commands/scan.py +++ b/src/fides/cli/commands/scan.py @@ -11,9 +11,8 @@ coverage_threshold_option, credentials_id_option, manifests_dir_argument, - okta_client_id_option, okta_org_url_option, - okta_private_key_option, + okta_token_option, organization_fides_key_option, ) from fides.cli.utils import ( @@ -92,8 +91,7 @@ def scan_system(ctx: click.Context) -> None: @manifests_dir_argument @credentials_id_option @okta_org_url_option -@okta_client_id_option -@okta_private_key_option +@okta_token_option @organization_fides_key_option @coverage_threshold_option @with_analytics @@ -102,8 +100,7 @@ def scan_system_okta( manifests_dir: str, credentials_id: str, org_url: str, - client_id: str, - private_key: str, + token: str, org_key: str, coverage_threshold: int, ) -> None: @@ -113,11 +110,7 @@ def scan_system_okta( config = ctx.obj["CONFIG"] okta_config = handle_okta_credentials_options( - fides_config=config, - org_url=org_url, - client_id=client_id, - private_key=private_key, - credentials_id=credentials_id, + fides_config=config, token=token, org_url=org_url, credentials_id=credentials_id ) _system.scan_system_okta( diff --git a/src/fides/cli/options.py b/src/fides/cli/options.py index 2f7ebd1f9b1..e043d3109fc 100644 --- a/src/fides/cli/options.py +++ b/src/fides/cli/options.py @@ -196,35 +196,23 @@ def connection_string_option(command: Callable) -> Callable: def okta_org_url_option(command: Callable) -> Callable: - "Use org url option to connect to okta. Requires options --org-url, --client-id, and --private-key" + "Use org url option to connect to okta. Requires options --org-url and --token" command = click.option( "--org-url", type=str, - help="Connect to Okta using an 'Org URL'. _Requires options `--org-url`, `--client-id` & `--private-key`._", + help="Connect to Okta using an 'Org URL'. _Requires options `--org-url` & `--token`._", )( command ) # type: ignore return command -def okta_client_id_option(command: Callable) -> Callable: - "Use client id option to connect to okta. Requires options --org-url, --client-id, and --private-key" +def okta_token_option(command: Callable) -> Callable: + "Use token option to connect to okta. Requires options --org-url and --token" command = click.option( - "--client-id", + "--token", type=str, - help="Connect to Okta using an OAuth2 Client ID. _Requires options `--org-url`, `--client-id` & `--private-key`._", - )( - command - ) # type: ignore - return command - - -def okta_private_key_option(command: Callable) -> Callable: - "Use private key option to connect to okta. Requires options --org-url, --client-id, and --private-key" - command = click.option( - "--private-key", - type=str, - help="Connect to Okta using a private key (JWK JSON format). _Requires options `--org-url`, `--client-id` & `--private-key`._", + help="Connect to Okta using a token. _Requires options `--org-url` and `--token`._", )( command ) # type: ignore diff --git a/src/fides/cli/utils.py b/src/fides/cli/utils.py index 2fbef1e55b7..43bcdc2e7a9 100644 --- a/src/fides/cli/utils.py +++ b/src/fides/cli/utils.py @@ -306,29 +306,23 @@ def handle_database_credentials_options( def handle_okta_credentials_options( - fides_config: FidesConfig, - org_url: str, - client_id: str, - private_key: str, - credentials_id: str, + fides_config: FidesConfig, token: str, org_url: str, credentials_id: str ) -> Optional[OktaConfig]: """ - Handles the mutually exclusive okta connections options org-url/client-id/private-key and credentials-id. + Handles the mutually exclusive okta connections options org-url/token and credentials-id. It is allowed to provide neither as there is support for environment variables """ okta_config: Optional[OktaConfig] = None - if client_id or private_key or org_url: - if not client_id or not private_key or not org_url: + if token or org_url: + if not token or not org_url: raise click.UsageError( - "Illegal usage: org-url, client-id, and private-key must be used together" + "Illegal usage: token and org-url must be used together" ) if credentials_id: raise click.UsageError( - "Illegal usage: org-url/client-id/private-key and credentials-id cannot be used together" + "Illegal usage: token/org-url and credentials-id cannot be used together" ) - okta_config = OktaConfig( - org_url=org_url, client_id=client_id, private_key=private_key - ) + okta_config = OktaConfig(orgUrl=org_url, token=token) if credentials_id: okta_config = get_config_okta_credentials( credentials_config=fides_config.credentials, diff --git a/src/fides/connectors/models.py b/src/fides/connectors/models.py index 4cebb1bcc9c..e209c6e529d 100644 --- a/src/fides/connectors/models.py +++ b/src/fides/connectors/models.py @@ -3,7 +3,7 @@ # pylint: disable=C0115,C0116, E0213 from typing import ClassVar, List, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel class AWSConfig(BaseModel): @@ -45,15 +45,12 @@ class BigQueryConfig(BaseModel): class OktaConfig(BaseModel): """ - The model for the connection config for Okta using OAuth2. + The model for the connection config for Okta """ - model_config = ConfigDict(populate_by_name=True) - - org_url: str = Field(alias="orgUrl") - client_id: str = Field(alias="clientId") - private_key: str = Field(alias="privateKey") - scopes: Optional[List[str]] = Field(default=["okta.apps.read"]) + # camel case matches okta client config model + orgUrl: str + token: str class DatabaseConfig(BaseModel): diff --git a/src/fides/connectors/okta.py b/src/fides/connectors/okta.py index 18b3fd50975..57dd82a5c85 100644 --- a/src/fides/connectors/okta.py +++ b/src/fides/connectors/okta.py @@ -1,14 +1,14 @@ -"""Module that adds interactions with okta using OAuth2.""" +"""Module that adds interactions with okta""" -from typing import List, Optional +import ast +from functools import update_wrapper +from typing import Any, Callable, List, Optional from fideslang.models import System, SystemMetadata +from okta.client import Client as OktaClient +from okta.exceptions import OktaAPIException +from okta.models import Application as OktaApplication -from fides.api.common_exceptions import ConnectionException -from fides.api.service.connectors.okta_http_client import ( - OktaApplication, - OktaHttpClient, -) from fides.connectors.models import ( ConnectorAuthFailureException, ConnectorFailureException, @@ -16,123 +16,87 @@ ) -def get_okta_client(okta_config: Optional[OktaConfig]) -> OktaHttpClient: +def get_okta_client(okta_config: Optional[OktaConfig]) -> OktaClient: """ - Returns an OktaHttpClient for the given okta config using OAuth2 authentication. - - Args: - okta_config: Configuration with OAuth2 credentials (orgUrl, clientId, privateKey) + Returns an Okta client for the given okta config. Authentication can + also be handled through environment variables that the okta python sdk support. - Returns: - OktaHttpClient instance configured for OAuth2 - - Raises: - ConnectorAuthFailureException: If credentials are missing or invalid + Enabled raiseException config to facilitate exception handling """ - if not okta_config: - raise ConnectorAuthFailureException( - "Okta configuration is required. Please provide orgUrl, clientId, and privateKey." - ) - - try: - return OktaHttpClient( - org_url=okta_config.org_url, - client_id=okta_config.client_id, - private_key=okta_config.private_key, - scopes=okta_config.scopes, - ) - except ConnectionException as e: - raise ConnectorAuthFailureException(str(e)) from e + config_dict = okta_config.model_dump(mode="json") if okta_config else {} + config_dict["raiseException"] = True + okta_client = OktaClient(config_dict) + return okta_client -def _is_oauth2_auth_error(exc: BaseException) -> bool: - """Check if the exception chain contains an OAuth2 authentication error.""" - try: - from requests_oauth2client.exceptions import InvalidClient, UnauthorizedClient - - cause = exc.__cause__ - while cause is not None: - if isinstance(cause, (InvalidClient, UnauthorizedClient)): - return True - cause = cause.__cause__ - return False - except ImportError: - # Fallback to string matching if library not available - error_str = str(exc).lower() - return "invalid_client" in error_str or "unauthorized" in error_str +def handle_common_okta_errors(func: Callable) -> Callable: + """ + Function decorator which handles common errors for okta calls. + Classifies exceptions based on error codes returned by the client. + For a full list of error codes see https://developer.okta.com/docs/reference/error-codes/ + """ + async def wrapper_func(*args, **kwargs) -> Any: # type: ignore + try: + return await func(*args, **kwargs) + except OktaAPIException as error: + error_json = ast.literal_eval(str(error)) + # E0000004 - Authentication exception + # E0000011 - Invalid token exception + if error_json["errorCode"] in ["E0000004", "E0000011"]: + raise ConnectorAuthFailureException(error_json["errorSummary"]) + raise ConnectorFailureException(error_json["errorSummary"]) -def validate_credentials(okta_config: Optional[OktaConfig]) -> None: - """ - Validates okta credentials by attempting to list applications with limit of 1. + return update_wrapper(wrapper_func, func) - Args: - okta_config: Configuration with OAuth2 credentials - Raises: - ConnectorAuthFailureException: If authentication fails - ConnectorFailureException: If the API request fails for other reasons +@handle_common_okta_errors +async def validate_credentials(okta_config: Optional[OktaConfig]) -> None: """ - try: - client = get_okta_client(okta_config=okta_config) - client.list_applications(limit=1) - except ConnectionException as e: - if _is_oauth2_auth_error(e): - raise ConnectorAuthFailureException( - "Authentication failed. Please verify your OAuth2 credentials." - ) from e - raise ConnectorFailureException(f"Okta API error: {e}") from e - - -def list_okta_applications(okta_client: OktaHttpClient) -> List[OktaApplication]: + Calls the list_applications api with a page limit of 1 to validate + okta credentials. """ - Returns a list of Okta applications using pagination. + client = get_okta_client(okta_config=okta_config) + query_parameters = {"limit": "1"} + await client.list_applications(query_parameters) - Args: - okta_client: Configured OktaHttpClient instance - Returns: - List of OktaApplication objects from Okta API - - Raises: - ConnectorFailureException: If the API request fails +@handle_common_okta_errors +async def list_okta_applications(okta_client: OktaClient) -> List[OktaApplication]: + """ + Returns a list of Okta applications. Iterates through each page returned by + the client. """ - try: - return okta_client.list_all_applications() - except ConnectionException as e: - raise ConnectorFailureException(f"Failed to list Okta applications: {e}") from e + applications = [] + current_applications, resp, _ = await okta_client.list_applications() + while True: + applications.extend(current_applications) + if resp.has_next(): + current_applications, _ = await resp.next() + else: + break + return applications def create_okta_systems( okta_applications: List[OktaApplication], organization_key: str ) -> List[System]: """ - Returns a list of system objects given a list of Okta applications. - - Only includes ACTIVE applications. - - Args: - okta_applications: List of OktaApplication dicts from Okta API. - Note: OktaApplication is a TypedDict (dict at runtime with type hints), - not a Pydantic model, so dict-style access with .get() is used. - organization_key: The organization fides_key to associate with systems - - Returns: - List of System objects for active Okta applications + Returns a list of system objects given a list of Okta applications """ systems = [ System( - fides_key=app.get("id", ""), - name=app.get("name", ""), + fides_key=application.id, + name=application.name, fidesctl_meta=SystemMetadata( - resource_id=app.get("id", ""), + resource_id=application.id, ), - description=f"Fides Generated Description for Okta Application: {app.get('label', app.get('name', 'Unknown'))}", + description=f"Fides Generated Description for Okta Application: {application.label}", system_type="okta_application", organization_fides_key=organization_key, privacy_declarations=[], ) - for app in okta_applications - if app.get("status") == "ACTIVE" + for application in okta_applications + if application.status and application.status == "ACTIVE" ] return systems diff --git a/src/fides/core/system.py b/src/fides/core/system.py index a84c03da571..6a12c1bcf42 100644 --- a/src/fides/core/system.py +++ b/src/fides/core/system.py @@ -1,5 +1,6 @@ """Module that adds functionality for generating or scanning systems.""" +import asyncio from collections import defaultdict from typing import Dict, List, Optional @@ -176,19 +177,20 @@ def generate_system_aws( return file_name -def generate_okta_systems( +async def generate_okta_systems( organization: Organization, okta_config: Optional[OktaConfig] ) -> List[System]: """ Given an okta configuration, calls okta for a list of applications and returns the corresponding systems. - - Uses OAuth2 authentication via OktaHttpClient. """ + import fides.connectors.okta as okta_connector okta_client = okta_connector.get_okta_client(okta_config) - okta_applications = okta_connector.list_okta_applications(okta_client=okta_client) + okta_applications = await okta_connector.list_okta_applications( + okta_client=okta_client + ) okta_systems = okta_connector.create_okta_systems( okta_applications=okta_applications, organization_key=organization.fides_key ) @@ -215,8 +217,8 @@ def generate_system_okta( ) assert organization - okta_systems = generate_okta_systems( - organization=organization, okta_config=okta_config + okta_systems = asyncio.run( + generate_okta_systems(organization=organization, okta_config=okta_config) ) write_system_manifest( @@ -445,8 +447,8 @@ def scan_system_okta( ) assert organization - okta_systems = generate_okta_systems( - organization=organization, okta_config=okta_config + okta_systems = asyncio.run( + generate_okta_systems(organization=organization, okta_config=okta_config) ) if len(okta_systems) < 1: diff --git a/tests/ctl/api/test_generate.py b/tests/ctl/api/test_generate.py index 77dbb62c5b6..de770a37e64 100644 --- a/tests/ctl/api/test_generate.py +++ b/tests/ctl/api/test_generate.py @@ -26,9 +26,8 @@ "connection_string": "postgresql+psycopg2://postgres:postgres@postgres-test:5432/postgres_example?" }, "okta": { - "orgUrl": getenv("OKTA_ORG_URL", "https://dev-78908748.okta.com"), - "clientId": getenv("OKTA_CLIENT_ID", ""), - "privateKey": getenv("OKTA_PRIVATE_KEY", ""), + "orgUrl": "https://dev-78908748.okta.com", + "token": getenv("OKTA_CLIENT_TOKEN", ""), }, "dynamodb": { "region_name": getenv("DYNAMODB_REGION", ""), @@ -54,8 +53,7 @@ }, "okta": { "orgUrl": "https://dev-78908748.okta.com", - "clientId": "INVALID_CLIENT_ID", - "privateKey": '{"kty":"RSA","d":"invalid","n":"invalid","e":"AQAB"}', + "token": "INVALID_TOKEN", }, "dynamodb": { "region_name": getenv("DYNAMODB_REGION", ""), @@ -69,7 +67,7 @@ EXPECTED_FAILURE_MESSAGES = { "aws": "The security token included in the request is invalid.", - "okta": "OAuth2 token acquisition failed", + "okta": "Invalid token provided", "db": 'FATAL: database "INVALID_DB" does not exist\n\n(Background on this error at: https://sqlalche.me/e/14/e3q8)', "bigquery": "Invalid project ID 'INVALID_PROJECT_ID'. Project IDs must contain 6-63 lowercase letters, digits, or dashes. Some project IDs also include domain name separated by a colon. IDs must start with a letter and may not end with a dash.", "dynamodb": "The security token included in the request is invalid.", diff --git a/tests/ctl/api/test_validate.py b/tests/ctl/api/test_validate.py index 4276218e899..7d299e76081 100644 --- a/tests/ctl/api/test_validate.py +++ b/tests/ctl/api/test_validate.py @@ -23,9 +23,8 @@ ), }, "okta": { - "orgUrl": getenv("OKTA_ORG_URL", "https://dev-78908748.okta.com"), - "clientId": getenv("OKTA_CLIENT_ID", ""), - "privateKey": getenv("OKTA_PRIVATE_KEY", ""), + "orgUrl": "https://dev-78908748.okta.com", + "token": getenv("OKTA_CLIENT_TOKEN", ""), }, } @@ -62,8 +61,7 @@ def test_validate_success( }, "okta": { "orgUrl": "https://dev-78908748.okta.com", - "clientId": "INVALID_CLIENT_ID", - "privateKey": '{"kty":"RSA","d":"invalid","n":"invalid","e":"AQAB"}', + "token": "INVALID_TOKEN", }, "bigquery": { "dataset": "fidesopstest", @@ -79,7 +77,7 @@ def test_validate_success( EXPECTED_FAILURE_MESSAGES = { "aws": "Authentication failed validating config. The security token included in the request is invalid.", - "okta": "Authentication failed validating config.", + "okta": "Authentication failed validating config. Invalid token provided", "bigquery": "Unexpected failure validating config. Invalid project ID 'INVALID_PROJECT_ID'. Project IDs must contain 6-63 lowercase letters, digits, or dashes. Some project IDs also include domain name separated by a colon. IDs must start with a letter and may not end with a dash.", } @@ -104,11 +102,5 @@ def test_validate_failure( validate_response = ValidateResponse.parse_raw(response.text) assert validate_response.status == "failure" - # Okta OAuth2 errors can vary, so use startswith for flexibility - if validate_target == "okta": - assert validate_response.message.startswith( - EXPECTED_FAILURE_MESSAGES[validate_target] - ) - else: - assert validate_response.message == EXPECTED_FAILURE_MESSAGES[validate_target] + assert validate_response.message == EXPECTED_FAILURE_MESSAGES[validate_target] assert response.status_code == 200 diff --git a/tests/ctl/cli/test_cli.py b/tests/ctl/cli/test_cli.py index a8d528b17ce..bdb00b33929 100644 --- a/tests/ctl/cli/test_cli.py +++ b/tests/ctl/cli/test_cli.py @@ -755,10 +755,7 @@ def test_scan_system_aws_input_credentials_id( def test_scan_system_okta_input_credential_options( self, test_config_path: str, test_cli_runner: CliRunner ) -> None: - client_id = os.environ.get("OKTA_CLIENT_ID") - private_key = os.environ.get("OKTA_PRIVATE_KEY") - if not all([client_id, private_key]): - pytest.skip("Okta OAuth2 credentials not configured") + token = os.environ["OKTA_CLIENT_TOKEN"] result = test_cli_runner.invoke( cli, [ @@ -769,10 +766,8 @@ def test_scan_system_okta_input_credential_options( "okta", "--org-url", OKTA_URL, - "--client-id", - client_id, - "--private-key", - private_key, + "--token", + token, "--coverage-threshold", "0", ], @@ -786,12 +781,9 @@ def test_scan_system_okta_input_credentials_id( test_config_path: str, test_cli_runner: CliRunner, ) -> None: - client_id = os.environ.get("OKTA_CLIENT_ID") - private_key = os.environ.get("OKTA_PRIVATE_KEY") - if not all([client_id, private_key]): - pytest.skip("Okta OAuth2 credentials not configured") - os.environ["FIDES__CREDENTIALS__OKTA_1__CLIENT_ID"] = client_id - os.environ["FIDES__CREDENTIALS__OKTA_1__PRIVATE_KEY"] = private_key + os.environ["FIDES__CREDENTIALS__OKTA_1__TOKEN"] = os.environ[ + "OKTA_CLIENT_TOKEN" + ] result = test_cli_runner.invoke( cli, [ @@ -968,11 +960,8 @@ def test_generate_system_okta_input_credential_options( test_cli_runner: CliRunner, tmpdir: LocalPath, ) -> None: - client_id = os.environ.get("OKTA_CLIENT_ID") - private_key = os.environ.get("OKTA_PRIVATE_KEY") - if not all([client_id, private_key]): - pytest.skip("Okta OAuth2 credentials not configured") tmp_file = tmpdir.join("system.yml") + token = os.environ["OKTA_CLIENT_TOKEN"] result = test_cli_runner.invoke( cli, [ @@ -984,10 +973,8 @@ def test_generate_system_okta_input_credential_options( f"{tmp_file}", "--org-url", OKTA_URL, - "--client-id", - client_id, - "--private-key", - private_key, + "--token", + token, ], ) print(result.output) @@ -1015,13 +1002,10 @@ def test_generate_system_okta_input_credentials_id( test_cli_runner: CliRunner, tmpdir: LocalPath, ) -> None: - client_id = os.environ.get("OKTA_CLIENT_ID") - private_key = os.environ.get("OKTA_PRIVATE_KEY") - if not all([client_id, private_key]): - pytest.skip("Okta OAuth2 credentials not configured") tmp_file = tmpdir.join("system.yml") - os.environ["FIDES__CREDENTIALS__OKTA_1__CLIENT_ID"] = client_id - os.environ["FIDES__CREDENTIALS__OKTA_1__PRIVATE_KEY"] = private_key + os.environ["FIDES__CREDENTIALS__OKTA_1__TOKEN"] = os.environ[ + "OKTA_CLIENT_TOKEN" + ] result = test_cli_runner.invoke( cli, [ diff --git a/tests/ctl/cli/test_utils.py b/tests/ctl/cli/test_utils.py index fd666edd602..a3fa04b7d64 100644 --- a/tests/ctl/cli/test_utils.py +++ b/tests/ctl/cli/test_utils.py @@ -131,14 +131,12 @@ def test_handle_okta_credentials_options_both_raises( "Check for an exception if both credentials options are supplied." with pytest.raises(click.UsageError): input_org_url = "hello.com" - input_client_id = "test_client_id" - input_private_key = '{"kty":"RSA","d":"test","n":"test","e":"AQAB"}' + input_token = "abcd12345" input_credentials_id = "okta_1" utils.handle_okta_credentials_options( fides_config=test_config, + token=input_token, org_url=input_org_url, - client_id=input_client_id, - private_key=input_private_key, credentials_id=input_credentials_id, ) @@ -152,14 +150,12 @@ def test_config_dne_raises( "Check for an exception if credentials dont exist" with pytest.raises(click.UsageError): input_org_url = "" - input_client_id = "" - input_private_key = "" + input_token = "" input_credentials_id = "UNKNOWN" utils.handle_okta_credentials_options( fides_config=test_config, + token=input_token, org_url=input_org_url, - client_id=input_client_id, - private_key=input_private_key, credentials_id=input_credentials_id, ) @@ -167,41 +163,39 @@ def test_returns_config_dict( self, test_config: FidesConfig, ) -> None: - "Check config credentials are returned" + "Check for an exception if credentials dont exist" input_org_url = "" - input_client_id = "" - input_private_key = "" + input_token = "" input_credentials_id = "okta_1" okta_config = utils.handle_okta_credentials_options( fides_config=test_config, + token=input_token, org_url=input_org_url, - client_id=input_client_id, - private_key=input_private_key, credentials_id=input_credentials_id, ) - assert okta_config.org_url == "https://dev-78908748.okta.com" - assert okta_config.client_id == "redacted_override_in_tests" - assert okta_config.private_key is not None + assert okta_config.model_dump() == { + "orgUrl": "https://dev-78908748.okta.com", + "token": "redacted_override_in_tests", + } def test_returns_input_dict( self, test_config: FidesConfig, ) -> None: - "Check input credentials are returned" + "Check for an exception if credentials dont exist" input_org_url = "hello.com" - input_client_id = "test_client_id" - input_private_key = '{"kty":"RSA","d":"test","n":"test","e":"AQAB"}' + input_token = "abcd12345" input_credentials_id = "" okta_config = utils.handle_okta_credentials_options( fides_config=test_config, + token=input_token, org_url=input_org_url, - client_id=input_client_id, - private_key=input_private_key, credentials_id=input_credentials_id, ) - assert okta_config.org_url == input_org_url - assert okta_config.client_id == input_client_id - assert okta_config.private_key == input_private_key + assert okta_config.model_dump() == { + "orgUrl": input_org_url, + "token": input_token, + } @pytest.mark.unit diff --git a/tests/ctl/connectors/test_okta.py b/tests/ctl/connectors/test_okta.py index 69c775d0696..8a1571b8944 100644 --- a/tests/ctl/connectors/test_okta.py +++ b/tests/ctl/connectors/test_okta.py @@ -1,58 +1,66 @@ # pylint: disable=missing-docstring, redefined-outer-name import os -from typing import Generator, List +from typing import Generator import pytest from fideslang.models import System, SystemMetadata +from okta.models import Application as OktaApplication +from py._path.local import LocalPath import fides.connectors.okta as okta_connector -from fides.api.service.connectors.okta_http_client import OktaApplication +from fides.config import FidesConfig from fides.connectors.models import OktaConfig @pytest.fixture() def okta_list_applications() -> Generator: - """Sample Okta applications matching OktaApplication TypedDict.""" - okta_applications: List[OktaApplication] = [ - { - "id": "okta_id_1", - "name": "okta_id_1", - "label": "okta_label_1", - "status": "ACTIVE", - }, - { - "id": "okta_id_2", - "name": "okta_id_2", - "label": "okta_label_2", - "status": "ACTIVE", - }, + okta_applications = [ + OktaApplication( + config={ + "id": "okta_id_1", + "name": "okta_id_1", + "label": "okta_label_1", + "status": "ACTIVE", + } + ), + OktaApplication( + config={ + "id": "okta_id_2", + "name": "okta_id_2", + "label": "okta_label_2", + "status": "ACTIVE", + } + ), ] yield okta_applications @pytest.fixture() def okta_list_applications_with_inactive() -> Generator: - """Sample Okta applications including inactive ones.""" - okta_applications: List[OktaApplication] = [ - { - "id": "okta_id_1", - "name": "okta_id_1", - "label": "okta_label_1", - "status": "ACTIVE", - }, - { - "id": "okta_id_2", - "name": "okta_id_2", - "label": "okta_label_2", - "status": "INACTIVE", - }, + okta_applications = [ + OktaApplication( + config={ + "id": "okta_id_1", + "name": "okta_id_1", + "label": "okta_label_1", + "status": "ACTIVE", + } + ), + OktaApplication( + config={ + "id": "okta_id_2", + "name": "okta_id_2", + "label": "okta_label_2", + "status": "INACTIVE", + } + ), ] yield okta_applications # Unit @pytest.mark.unit -def test_create_okta_systems(okta_list_applications: List[OktaApplication]) -> None: +def test_create_okta_systems(okta_list_applications: Generator) -> None: expected_result = [ System( fides_key="okta_id_1", @@ -85,8 +93,8 @@ def test_create_okta_systems(okta_list_applications: List[OktaApplication]) -> N @pytest.mark.unit -def test_create_okta_systems_filters_inactive( - okta_list_applications_with_inactive: List[OktaApplication], +def test_create_okta_datasets_filters_inactive( + okta_list_applications_with_inactive: Generator, ) -> None: expected_result = [ System( @@ -108,30 +116,14 @@ def test_create_okta_systems_filters_inactive( assert okta_systems == expected_result -# Integration - requires OAuth2 credentials +# Integration @pytest.mark.external -def test_list_okta_applications() -> None: - """ - Integration test for listing Okta applications. - - Requires environment variables: - - OKTA_ORG_URL: Your Okta org URL (e.g. https://dev-12345.okta.com) - - OKTA_CLIENT_ID: OAuth2 client ID - - OKTA_PRIVATE_KEY: JWK private key (JSON string) - """ - org_url = os.environ.get("OKTA_ORG_URL") - client_id = os.environ.get("OKTA_CLIENT_ID") - private_key = os.environ.get("OKTA_PRIVATE_KEY") - - if not all([org_url, client_id, private_key]): - pytest.skip("Okta OAuth2 credentials not configured") - +def test_list_okta_applications(tmpdir: LocalPath, test_config: FidesConfig) -> None: client = okta_connector.get_okta_client( OktaConfig( - orgUrl=org_url, - clientId=client_id, - privateKey=private_key, + orgUrl="https://dev-78908748.okta.com", + token=os.environ["OKTA_CLIENT_TOKEN"], ) ) actual_result = okta_connector.list_okta_applications(okta_client=client) - assert isinstance(actual_result, list) + assert actual_result diff --git a/tests/ctl/core/test_system.py b/tests/ctl/core/test_system.py index 4f38bb7eb45..bca89c07a99 100644 --- a/tests/ctl/core/test_system.py +++ b/tests/ctl/core/test_system.py @@ -308,24 +308,7 @@ def test_generate_system_aws( assert actual_result -def get_okta_config() -> OktaConfig: - """ - Helper to get OktaConfig from environment variables. - - Requires: - - OKTA_ORG_URL: Your Okta org URL (e.g. https://dev-12345.okta.com) - - OKTA_CLIENT_ID: OAuth2 client ID - - OKTA_PRIVATE_KEY: JWK private key (JSON string) - """ - org_url = os.environ.get("OKTA_ORG_URL", "https://dev-78908748.okta.com") - client_id = os.environ.get("OKTA_CLIENT_ID", "") - private_key = os.environ.get("OKTA_PRIVATE_KEY", "") - - return OktaConfig( - orgUrl=org_url, - clientId=client_id, - privateKey=private_key, - ) +OKTA_ORG_URL = "https://dev-78908748.okta.com" @pytest.mark.usefixtures("default_organization") @@ -334,15 +317,14 @@ class TestSystemOkta: def test_generate_system_okta( self, tmpdir: LocalPath, test_config: FidesConfig ) -> None: - okta_config = get_okta_config() - if not okta_config.clientId or not okta_config.privateKey: - pytest.skip("Okta OAuth2 credentials not configured") - actual_result = _system.generate_system_okta( file_name=f"{tmpdir}/test_file.yml", include_null=False, organization_key="default_organization", - okta_config=okta_config, + okta_config=OktaConfig( + orgUrl=OKTA_ORG_URL, + token=os.environ["OKTA_CLIENT_TOKEN"], + ), url=test_config.cli.server_url, headers=test_config.user.auth_header, ) @@ -352,22 +334,24 @@ def test_generate_system_okta( def test_scan_system_okta_success( self, tmpdir: LocalPath, test_config: FidesConfig ) -> None: - okta_config = get_okta_config() - if not okta_config.clientId or not okta_config.privateKey: - pytest.skip("Okta OAuth2 credentials not configured") - file_name = f"{tmpdir}/test_file.yml" _system.generate_system_okta( file_name=file_name, include_null=False, organization_key="default_organization", - okta_config=okta_config, + okta_config=OktaConfig( + orgUrl=OKTA_ORG_URL, + token=os.environ["OKTA_CLIENT_TOKEN"], + ), url=test_config.cli.server_url, headers=test_config.user.auth_header, ) _system.scan_system_okta( manifest_dir=file_name, - okta_config=okta_config, + okta_config=OktaConfig( + orgUrl=OKTA_ORG_URL, + token=os.environ["OKTA_CLIENT_TOKEN"], + ), organization_key="default_organization", coverage_threshold=100, url=test_config.cli.server_url, @@ -379,14 +363,13 @@ def test_scan_system_okta_success( def test_scan_system_okta_fail( self, tmpdir: LocalPath, test_config: FidesConfig ) -> None: - okta_config = get_okta_config() - if not okta_config.clientId or not okta_config.privateKey: - pytest.skip("Okta OAuth2 credentials not configured") - with pytest.raises(SystemExit): _system.scan_system_okta( manifest_dir="", - okta_config=okta_config, + okta_config=OktaConfig( + orgUrl=OKTA_ORG_URL, + token=os.environ["OKTA_CLIENT_TOKEN"], + ), coverage_threshold=100, organization_key="default_organization", url=test_config.cli.server_url, diff --git a/tests/ctl/test_config.toml b/tests/ctl/test_config.toml index 067bd00ca28..cc7bab4101b 100644 --- a/tests/ctl/test_config.toml +++ b/tests/ctl/test_config.toml @@ -28,7 +28,7 @@ encryption_key = "test_encryption_key" [credentials] postgres_1 = {connection_string="postgresql+psycopg2://postgres:fides@fides-db:5432/fides_test"} -okta_1 = {orgUrl="https://dev-78908748.okta.com", clientId="redacted_override_in_tests", privateKey='{"kty":"RSA","d":"redacted","n":"redacted","e":"AQAB"}'} +okta_1 = {orgUrl="https://dev-78908748.okta.com", token="redacted_override_in_tests"} aws_1 = {aws_access_key_id="redacted_id_override_in_tests", aws_secret_access_key="redacted_override_in_tests", region_name="us-east-1"} [credentials.bigquery_1] type = "service_account" diff --git a/tests/fixtures/okta_fixtures.py b/tests/fixtures/okta_fixtures.py index 8a32d37856b..5d610ec5bc5 100644 --- a/tests/fixtures/okta_fixtures.py +++ b/tests/fixtures/okta_fixtures.py @@ -31,20 +31,19 @@ def okta_connection_config(db: Session) -> Generator: okta_integration_config = integration_config.get("okta", {}) org_url = okta_integration_config.get("org_url") or os.environ.get("OKTA_ORG_URL") - client_id = okta_integration_config.get("client_id") or os.environ.get( - "OKTA_CLIENT_ID" - ) - private_key = okta_integration_config.get("private_key") or os.environ.get( - "OKTA_PRIVATE_KEY" + api_token = okta_integration_config.get("api_token") or os.environ.get( + "OKTA_API_TOKEN" ) - if not all([org_url, client_id, private_key]): - pytest.skip("Okta OAuth2 credentials not configured") + if not org_url: + raise RuntimeError("Missing org_url for Okta") + + if not api_token: + raise RuntimeError("Missing api_token for Okta") schema = OktaSchema( org_url=org_url, - client_id=client_id, - private_key=private_key, + api_token=api_token, ) connection_config.secrets = schema.model_dump() connection_config.save(db=db) diff --git a/tests/fixtures/saas_example_fixtures.py b/tests/fixtures/saas_example_fixtures.py index e5fc4a5cdb0..3e14672cd94 100644 --- a/tests/fixtures/saas_example_fixtures.py +++ b/tests/fixtures/saas_example_fixtures.py @@ -391,6 +391,9 @@ def oauth2_authorization_code_connection_config( connection_config.delete(db) +## TODO: base on the previous connection config to set up a new improved + + @pytest.fixture(scope="function") def oauth2_client_credentials_configuration() -> OAuth2ClientCredentialsConfiguration: return { diff --git a/tests/ops/service/connectors/test_okta_http_client.py b/tests/ops/service/connectors/test_okta_http_client.py deleted file mode 100644 index 5a547a50ebd..00000000000 --- a/tests/ops/service/connectors/test_okta_http_client.py +++ /dev/null @@ -1,558 +0,0 @@ -import json -import sys -from unittest.mock import MagicMock, patch - -import pytest -import requests -from requests.adapters import HTTPAdapter - -from fides.api.common_exceptions import ConnectionException -from fides.api.service.connectors.okta_http_client import ( - DEFAULT_API_LIMIT, - DEFAULT_MAX_PAGES, - DEFAULT_OKTA_SCOPES, - DEFAULT_REQUEST_TIMEOUT, - OktaHttpClient, -) - -RSA_JWK = { - "kty": "RSA", - "kid": "test-kid-rsa", - "n": "test-modulus", - "e": "AQAB", - "d": "test-private-exponent", -} -EC_JWK = { - "kty": "EC", - "crv": "P-256", - "kid": "test-kid-ec", - "x": "test-x", - "y": "test-y", - "d": "test-d-ec", -} - -TEST_ORG_URL = "https://test.okta.com" -TEST_CLIENT_ID = "test-client-id" -TEST_PRIVATE_KEY_STR = "not-used-when-injected" - - -@pytest.fixture -def mock_session(): - """Mock requests.Session for testing.""" - session = MagicMock(spec=requests.Session) - return session - - -@pytest.fixture -def client_with_injection(mock_session): - """Create client with injected session for testing.""" - return OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=TEST_PRIVATE_KEY_STR, - session=mock_session, - ) - - -class TestOktaHttpClientInit: - def test_injected_session_set(self, client_with_injection, mock_session): - assert client_with_injection._session is mock_session - assert client_with_injection.org_url == TEST_ORG_URL - - def test_init_strips_trailing_slash(self, mock_session): - client = OktaHttpClient( - org_url=f"{TEST_ORG_URL}/", - client_id=TEST_CLIENT_ID, - private_key=TEST_PRIVATE_KEY_STR, - session=mock_session, - ) - assert client.org_url == TEST_ORG_URL - - def test_default_scopes_are_tuple(self, client_with_injection): - assert client_with_injection.scopes == DEFAULT_OKTA_SCOPES - assert isinstance(client_with_injection.scopes, tuple) - - def test_custom_scopes_coerced_to_tuple(self, mock_session): - scopes = ["okta.apps.read", "okta.users.read"] - client = OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=TEST_PRIVATE_KEY_STR, - scopes=scopes, - session=mock_session, - ) - assert client.scopes == tuple(scopes) - - @patch( - "fides.api.service.connectors.okta_http_client.OktaHttpClient._determine_alg_from_jwk" - ) - @patch("fides.api.service.connectors.okta_http_client.OktaHttpClient._parse_jwk") - @patch("requests_oauth2client.PrivateKeyJwt") - @patch("requests_oauth2client.OAuth2Client") - @patch("requests_oauth2client.OAuth2ClientCredentialsAuth") - @patch("requests_oauth2client.DPoPKey") - def test_full_initialization_without_injection( - self, - mock_dpop_class, - mock_auth_class, - mock_oauth_class, - mock_private_key_jwt, - mock_parse_jwk, - mock_determine_alg, - ): - mock_parse_jwk.return_value = RSA_JWK - mock_determine_alg.return_value = "RS256" - mock_dpop_instance = MagicMock() - mock_dpop_class.generate.return_value = mock_dpop_instance - mock_oauth_instance = MagicMock() - mock_oauth_class.return_value = mock_oauth_instance - - client = OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=json.dumps(RSA_JWK), - ) - - mock_parse_jwk.assert_called_once_with(json.dumps(RSA_JWK)) - mock_determine_alg.assert_called_once_with(RSA_JWK) - mock_dpop_class.generate.assert_called_once_with(alg="ES256") - mock_private_key_jwt.assert_called_once_with( - TEST_CLIENT_ID, RSA_JWK, alg="RS256" - ) - mock_oauth_class.assert_called_once_with( - token_endpoint=f"{TEST_ORG_URL}/oauth2/v1/token", - auth=mock_private_key_jwt.return_value, - dpop_bound_access_tokens=True, - ) - mock_auth_class.assert_called_once_with( - client=mock_oauth_instance, - scope="okta.apps.read", - dpop_key=mock_dpop_instance, - leeway=600, - ) - # Verify session has auth and adapters configured - assert hasattr(client, "_session") - - def test_invalid_key_raises_connection_exception(self): - with pytest.raises(ConnectionException) as exc_info: - OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key="not-valid-json", - ) - assert "Invalid private key format" in str(exc_info.value) - - def test_public_key_raises_connection_exception(self): - public_jwk = RSA_JWK.copy() - del public_jwk["d"] - with pytest.raises(ConnectionException) as exc_info: - OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=json.dumps(public_jwk), - ) - assert "Invalid private key format" in str(exc_info.value) - - def test_import_error_raises_connection_exception(self): - with patch.dict(sys.modules, {"requests_oauth2client": None}): - with pytest.raises(ConnectionException) as exc_info: - OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=json.dumps(RSA_JWK), - ) - assert "requests-oauth2client' library is required" in str(exc_info.value) - - -class TestOktaHttpClientMethods: - @pytest.fixture - def mock_rate_limiter(self): - """Mock rate limiter to avoid Redis dependency in tests.""" - with patch("fides.api.service.connectors.okta_http_client.RateLimiter") as mock: - mock.return_value.limit.return_value = None - yield mock - - def test_list_applications_single_page( - self, - client_with_injection, - mock_session, - mock_rate_limiter, - ): - mock_apps = [{"id": "app1"}, {"id": "app2"}] - mock_response = MagicMock() - mock_response.headers = {} - mock_response.status_code = 200 - mock_response.json.return_value = mock_apps - mock_session.get.return_value = mock_response - - apps, next_cursor = client_with_injection.list_applications() - - assert apps == mock_apps - assert next_cursor is None - mock_session.get.assert_called_once_with( - f"{TEST_ORG_URL}/api/v1/apps", - params={"limit": DEFAULT_API_LIMIT}, - timeout=DEFAULT_REQUEST_TIMEOUT, - ) - - def test_list_applications_with_pagination( - self, - client_with_injection, - mock_session, - mock_rate_limiter, - ): - link_header = f'<{TEST_ORG_URL}/api/v1/apps?after=cursor123>; rel="next"' - mock_response = MagicMock() - mock_response.headers = {"Link": link_header} - mock_response.status_code = 200 - mock_response.json.return_value = [{"id": "app1"}] - mock_session.get.return_value = mock_response - - apps, next_cursor = client_with_injection.list_applications(limit=1) - - assert apps == [{"id": "app1"}] - assert next_cursor == "cursor123" - mock_session.get.assert_called_once_with( - f"{TEST_ORG_URL}/api/v1/apps", - params={"limit": 1}, - timeout=DEFAULT_REQUEST_TIMEOUT, - ) - - def test_list_applications_http_error( - self, - client_with_injection, - mock_session, - mock_rate_limiter, - ): - mock_session.get.side_effect = requests.RequestException("Network error") - with pytest.raises(ConnectionException) as exc_info: - client_with_injection.list_applications() - assert "Okta API request failed" in str(exc_info.value) - - def test_list_applications_http_status_error( - self, - client_with_injection, - mock_session, - mock_rate_limiter, - ): - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = requests.HTTPError( - "401 Unauthorized" - ) - mock_session.get.return_value = mock_response - - with pytest.raises(ConnectionException) as exc_info: - client_with_injection.list_applications() - assert "Okta API request failed" in str(exc_info.value) - - def test_list_all_applications_multiple_pages(self, client_with_injection): - with patch.object(client_with_injection, "list_applications") as mock_list: - mock_list.side_effect = [ - ([{"id": "app1"}], "cursor1"), - ([{"id": "app2"}], "cursor2"), - ([{"id": "app3"}], None), - ] - apps = client_with_injection.list_all_applications(page_size=1) - assert [a["id"] for a in apps] == ["app1", "app2", "app3"] - assert mock_list.call_count == 3 - - def test_list_all_applications_respects_max_pages(self, client_with_injection): - with patch.object(client_with_injection, "list_applications") as mock_list: - mock_list.side_effect = [ - ([{"id": "app"}], "cursor1"), - ([{"id": "app"}], "cursor2"), - ([{"id": "app"}], "cursor3"), - ] - apps = client_with_injection.list_all_applications(page_size=1, max_pages=3) - assert len(apps) == 3 - assert mock_list.call_count == 3 - - def test_list_all_applications_stops_on_duplicate_cursor( - self, client_with_injection - ): - with patch.object(client_with_injection, "list_applications") as mock_list: - mock_list.side_effect = [ - ([{"id": "app1"}], "same_cursor"), - ([{"id": "app2"}], "same_cursor"), - ] - apps = client_with_injection.list_all_applications(page_size=1) - - assert len(apps) == 2 - assert mock_list.call_count == 2 - - -class TestRateLimiting: - """Tests for rate limiting functionality.""" - - def test_rate_limiter_called(self, mock_session): - """Rate limiter should be called before requests.""" - client = OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=TEST_PRIVATE_KEY_STR, - rate_limit_per_minute=100, - session=mock_session, - ) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = [] - mock_response.headers = {} - mock_session.get.return_value = mock_response - - with patch( - "fides.api.service.connectors.okta_http_client.RateLimiter" - ) as mock_limiter_class: - mock_limiter = MagicMock() - mock_limiter_class.return_value = mock_limiter - client.list_applications() - - mock_limiter.limit.assert_called_once() - # Verify rate limit request was built correctly - rate_limit_requests = mock_limiter.limit.call_args[0][0] - assert len(rate_limit_requests) == 1 - assert rate_limit_requests[0].key == f"okta:{TEST_ORG_URL}" - assert rate_limit_requests[0].rate_limit == 100 - - def test_rate_limiting_disabled_when_none(self, mock_session): - """Rate limiter should not be called when rate_limit_per_minute is None.""" - client = OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=TEST_PRIVATE_KEY_STR, - rate_limit_per_minute=None, - session=mock_session, - ) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = [] - mock_response.headers = {} - mock_session.get.return_value = mock_response - - with patch( - "fides.api.service.connectors.okta_http_client.RateLimiter" - ) as mock_limiter_class: - client.list_applications() - - # RateLimiter should not have been instantiated - mock_limiter_class.assert_not_called() - - def test_build_rate_limit_requests(self, mock_session): - """_build_rate_limit_requests should return correct configuration.""" - client = OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=TEST_PRIVATE_KEY_STR, - rate_limit_per_minute=250, - session=mock_session, - ) - - rate_requests = client._build_rate_limit_requests() - assert len(rate_requests) == 1 - assert rate_requests[0].key == f"okta:{TEST_ORG_URL}" - assert rate_requests[0].rate_limit == 250 - - -class TestStaticHelperMethods: - def test_extract_cursor_from_link_header(self): - link_header = ( - f'<{TEST_ORG_URL}/api/v1/apps?after=abc123>; rel="next", ' - f'<{TEST_ORG_URL}/api/v1/apps?after=prev>; rel="prev"' - ) - cursor = OktaHttpClient._extract_next_cursor(link_header) - assert cursor == "abc123" - - def test_extract_cursor_no_next_link(self): - cursor = OktaHttpClient._extract_next_cursor( - f'<{TEST_ORG_URL}/api/v1/apps>; rel="self"' - ) - assert cursor is None - - def test_extract_cursor_none_header(self): - assert OktaHttpClient._extract_next_cursor(None) is None - - def test_extract_cursor_no_angle_brackets(self): - link_header = 'https://test.okta.com/api/v1/apps?after=abc123; rel="next"' - cursor = OktaHttpClient._extract_next_cursor(link_header) - assert cursor is None - - def test_extract_cursor_no_after_param(self): - link_header = f'<{TEST_ORG_URL}/api/v1/apps?limit=200>; rel="next"' - cursor = OktaHttpClient._extract_next_cursor(link_header) - assert cursor is None - - def test_extract_cursor_url_encoded(self): - link_header = ( - f'<{TEST_ORG_URL}/api/v1/apps?after=abc%3D123&limit=200>; rel="next"' - ) - cursor = OktaHttpClient._extract_next_cursor(link_header) - assert cursor == "abc=123" - - def test_extract_cursor_multiple_params(self): - link_header = f'<{TEST_ORG_URL}/api/v1/apps?limit=200&after=xyz789&filter=active>; rel="next"' - cursor = OktaHttpClient._extract_next_cursor(link_header) - assert cursor == "xyz789" - - def test_parse_jwk_accepts_dict(self): - parsed = OktaHttpClient._parse_jwk(RSA_JWK) - assert parsed["kid"] == RSA_JWK["kid"] - - def test_parse_jwk_rejects_dict_without_d(self): - # _parse_jwk validates 'd' parameter for defense-in-depth - public_jwk = RSA_JWK.copy() - del public_jwk["d"] - with pytest.raises(ValueError) as exc_info: - OktaHttpClient._parse_jwk(public_jwk) - assert "missing 'd' parameter" in str(exc_info.value) - - def test_parse_jwk_rejects_string_without_d(self): - # _parse_jwk validates 'd' parameter for JSON strings too - public_jwk = RSA_JWK.copy() - del public_jwk["d"] - with pytest.raises(ValueError) as exc_info: - OktaHttpClient._parse_jwk(json.dumps(public_jwk)) - assert "missing 'd' parameter" in str(exc_info.value) - - def test_parse_jwk_round_trip(self): - parsed = OktaHttpClient._parse_jwk(json.dumps(RSA_JWK)) - assert parsed["kid"] == RSA_JWK["kid"] - - def test_determine_alg_from_jwk_prefers_alg_field(self): - jwk = {**RSA_JWK, "alg": "RS512"} - assert OktaHttpClient._determine_alg_from_jwk(jwk) == "RS512" - - def test_determine_alg_from_ec_curve(self): - alg = OktaHttpClient._determine_alg_from_jwk(EC_JWK) - assert alg == "ES256" - - def test_determine_alg_default_fallback(self): - jwk = {"kty": "unknown", "d": "value"} - assert OktaHttpClient._determine_alg_from_jwk(jwk) == "RS256" - - -class TestSessionConfiguration: - """Tests for verifying session configuration with library built-ins.""" - - @patch("requests_oauth2client.PrivateKeyJwt") - @patch("requests_oauth2client.OAuth2Client") - @patch("requests_oauth2client.OAuth2ClientCredentialsAuth") - @patch("requests_oauth2client.DPoPKey") - def test_session_has_oauth2_auth( - self, - mock_dpop_class, - mock_auth_class, - mock_oauth_class, - mock_private_key_jwt, - ): - """Session should have OAuth2ClientCredentialsAuth configured.""" - mock_dpop_instance = MagicMock() - mock_dpop_class.generate.return_value = mock_dpop_instance - mock_oauth_instance = MagicMock() - mock_oauth_class.return_value = mock_oauth_instance - mock_auth_instance = MagicMock() - mock_auth_class.return_value = mock_auth_instance - - client = OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=json.dumps(RSA_JWK), - ) - - # Verify OAuth2ClientCredentialsAuth was created with correct params - mock_auth_class.assert_called_once_with( - client=mock_oauth_instance, - scope="okta.apps.read", - dpop_key=mock_dpop_instance, - leeway=600, - ) - # Verify session.auth is set - assert client._session.auth is mock_auth_instance - - @patch("requests_oauth2client.PrivateKeyJwt") - @patch("requests_oauth2client.OAuth2Client") - @patch("requests_oauth2client.OAuth2ClientCredentialsAuth") - @patch("requests_oauth2client.DPoPKey") - def test_session_has_retry_adapter( - self, - mock_dpop_class, - mock_auth_class, - mock_oauth_class, - mock_private_key_jwt, - ): - """Session should have HTTPAdapter with retry strategy configured.""" - mock_dpop_class.generate.return_value = MagicMock() - mock_oauth_class.return_value = MagicMock() - mock_auth_class.return_value = MagicMock() - - client = OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=json.dumps(RSA_JWK), - ) - - # Verify HTTPS adapter is mounted - https_adapter = client._session.get_adapter("https://example.com") - assert isinstance(https_adapter, HTTPAdapter) - - # Verify retry strategy - retry = https_adapter.max_retries - assert retry.total == 3 - assert retry.backoff_factor == 1.0 - assert 429 in retry.status_forcelist - assert 502 in retry.status_forcelist - assert 503 in retry.status_forcelist - assert 504 in retry.status_forcelist - assert retry.respect_retry_after_header is True - - @patch("requests_oauth2client.PrivateKeyJwt") - @patch("requests_oauth2client.OAuth2Client") - @patch("requests_oauth2client.OAuth2ClientCredentialsAuth") - @patch("requests_oauth2client.DPoPKey") - def test_dpop_enabled_on_oauth_client( - self, - mock_dpop_class, - mock_auth_class, - mock_oauth_class, - mock_private_key_jwt, - ): - """OAuth2Client should have dpop_bound_access_tokens=True.""" - mock_dpop_class.generate.return_value = MagicMock() - mock_auth_class.return_value = MagicMock() - - OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=json.dumps(RSA_JWK), - ) - - mock_oauth_class.assert_called_once() - call_kwargs = mock_oauth_class.call_args[1] - assert call_kwargs["dpop_bound_access_tokens"] is True - - @patch("requests_oauth2client.PrivateKeyJwt") - @patch("requests_oauth2client.OAuth2Client") - @patch("requests_oauth2client.OAuth2ClientCredentialsAuth") - @patch("requests_oauth2client.DPoPKey") - def test_custom_scopes_passed_to_auth( - self, - mock_dpop_class, - mock_auth_class, - mock_oauth_class, - mock_private_key_jwt, - ): - """Custom scopes should be passed to OAuth2ClientCredentialsAuth.""" - mock_dpop_class.generate.return_value = MagicMock() - mock_oauth_class.return_value = MagicMock() - mock_auth_class.return_value = MagicMock() - - custom_scopes = ["okta.apps.read", "okta.users.read"] - OktaHttpClient( - org_url=TEST_ORG_URL, - client_id=TEST_CLIENT_ID, - private_key=json.dumps(RSA_JWK), - scopes=custom_scopes, - ) - - call_kwargs = mock_auth_class.call_args[1] - assert call_kwargs["scope"] == "okta.apps.read okta.users.read"