From 6eebfcfe773a6146d1ce673da6d1602484dcd1c7 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Wed, 20 May 2026 10:46:29 +0200 Subject: [PATCH] feat(api): add okta provider support (#11184) --- api/CHANGELOG.md | 8 + .../api/migrations/0093_okta_provider.py | 41 +++++ api/src/backend/api/models.py | 27 +++ api/src/backend/api/specs/v1.yaml | 174 ++++++++++++++++++ api/src/backend/api/tests/test_utils.py | 31 ++++ api/src/backend/api/tests/test_views.py | 111 ++++++++++- api/src/backend/api/utils.py | 20 ++ .../api/v1/serializer_utils/providers.py | 20 ++ api/src/backend/api/v1/serializers.py | 11 ++ api/src/backend/conftest.py | 7 + prowler/CHANGELOG.md | 4 + .../providers/okta/exceptions/exceptions.py | 11 ++ prowler/providers/okta/okta_provider.py | 24 ++- .../okta/exceptions/okta_exceptions_test.py | 2 + tests/providers/okta/okta_provider_test.py | 50 +++++ 15 files changed, 536 insertions(+), 5 deletions(-) create mode 100644 api/src/backend/api/migrations/0093_okta_provider.py diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index cba4f9c594..392b369206 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.29.0] (Prowler UNRELEASED) + +### 🚀 Added + +- `okta` provider support [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184) + +--- + ## [1.28.0] (Prowler v5.27.0) ### 🚀 Added diff --git a/api/src/backend/api/migrations/0093_okta_provider.py b/api/src/backend/api/migrations/0093_okta_provider.py new file mode 100644 index 0000000000..d3e4a9e397 --- /dev/null +++ b/api/src/backend/api/migrations/0093_okta_provider.py @@ -0,0 +1,41 @@ +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0092_findings_arrays_gin_index_parent"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("alibabacloud", "Alibaba Cloud"), + ("cloudflare", "Cloudflare"), + ("openstack", "OpenStack"), + ("image", "Image"), + ("googleworkspace", "Google Workspace"), + ("vercel", "Vercel"), + ("okta", "Okta"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'okta';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 17d38e82f0..3d9a26698e 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -296,6 +296,7 @@ class Provider(RowLevelSecurityProtectedModel): IMAGE = "image", _("Image") GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace") VERCEL = "vercel", _("Vercel") + OKTA = "okta", _("Okta") @staticmethod def validate_aws_uid(value): @@ -354,6 +355,26 @@ class Provider(RowLevelSecurityProtectedModel): pointer="/data/attributes/uid", ) + @staticmethod + def validate_okta_uid(value): + if not re.match( + r"^[a-z0-9][a-z0-9-]*\.(" + r"okta\.com|oktapreview\.com|okta-emea\.com|" + r"okta-gov\.com|okta\.mil|okta-miltest\.com|trex-govcloud\.com" + r")$", + value, + ): + raise ModelValidationError( + detail=( + "Okta provider ID must be a valid Okta-managed org domain " + "(e.g., acme.okta.com, also .oktapreview.com / .okta-emea.com " + "/ .okta-gov.com / .okta.mil / .okta-miltest.com / " + ".trex-govcloud.com), without scheme or path." + ), + code="okta-uid", + pointer="/data/attributes/uid", + ) + @staticmethod def validate_kubernetes_uid(value): if not re.match( @@ -480,6 +501,12 @@ class Provider(RowLevelSecurityProtectedModel): def clean(self): super().clean() + if self.provider == self.ProviderChoices.OKTA and self.uid: + # Mirror the SDK, which lowercases the org domain before connecting. + # Without this the API would reject Acme.okta.com even though the + # SDK would accept it, and stored uids could disagree with the + # authenticated org domain. + self.uid = self.uid.strip().lower() getattr(self, f"validate_{self.provider}_uid")(self.uid) def save(self, *args, **kwargs): diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 658119f1ba..892b425d4d 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -373,6 +373,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -389,6 +390,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -412,6 +414,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -430,6 +433,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -1453,6 +1457,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -1469,6 +1474,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -1491,6 +1497,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -1509,6 +1516,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -1997,6 +2005,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2013,6 +2022,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -2035,6 +2045,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -2053,6 +2064,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -2584,6 +2596,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2600,6 +2613,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -2622,6 +2636,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -2640,6 +2655,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -3134,6 +3150,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3150,6 +3167,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -3173,6 +3191,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -3191,6 +3210,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -3740,6 +3760,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3756,6 +3777,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -3779,6 +3801,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -3797,6 +3820,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -4254,6 +4278,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4270,6 +4295,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -4293,6 +4319,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -4311,6 +4338,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -4766,6 +4794,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4782,6 +4811,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -4805,6 +4835,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -4823,6 +4854,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -5266,6 +5298,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -5282,6 +5315,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -5305,6 +5339,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -5323,6 +5358,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -7156,6 +7192,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7172,6 +7209,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7195,6 +7233,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7213,6 +7252,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7335,6 +7375,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7351,6 +7392,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7374,6 +7416,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7392,6 +7435,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7503,6 +7547,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7519,6 +7564,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7541,6 +7587,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7559,6 +7606,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7702,6 +7750,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7718,6 +7767,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7741,6 +7791,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7759,6 +7810,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -7915,6 +7967,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7931,6 +7984,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7954,6 +8008,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7972,6 +8027,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8122,6 +8178,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8138,6 +8195,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8160,6 +8218,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8178,6 +8237,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -8370,6 +8430,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8386,6 +8447,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8409,6 +8471,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8427,6 +8490,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8548,6 +8612,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8564,6 +8629,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8587,6 +8653,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8605,6 +8672,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8750,6 +8818,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8766,6 +8835,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8789,6 +8859,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8807,6 +8878,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -9593,6 +9665,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9609,6 +9682,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider__in] schema: @@ -9632,6 +9706,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -9650,6 +9725,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -9673,6 +9749,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9689,6 +9766,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -9712,6 +9790,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -9730,6 +9809,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -10400,6 +10480,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -10416,6 +10497,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -10439,6 +10521,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -10457,6 +10540,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -10951,6 +11035,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -10967,6 +11052,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -10990,6 +11076,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11008,6 +11095,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -11315,6 +11403,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11331,6 +11420,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -11354,6 +11444,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11372,6 +11463,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -11685,6 +11777,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11701,6 +11794,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -11724,6 +11818,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11742,6 +11837,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -12580,6 +12676,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -12596,6 +12693,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -12619,6 +12717,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -12637,6 +12736,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -20115,6 +20215,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -21127,6 +21244,7 @@ components: - image - googleworkspace - vercel + - okta type: string description: |- * `aws` - AWS @@ -21144,6 +21262,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta x-spec-enum-id: 91f917e0c3ab97e8 uid: type: string @@ -21265,6 +21384,7 @@ components: - image - googleworkspace - vercel + - okta type: string x-spec-enum-id: 91f917e0c3ab97e8 description: |- @@ -21285,6 +21405,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta uid: type: string title: Unique identifier for the provider, set by the provider @@ -21337,6 +21458,7 @@ components: - image - googleworkspace - vercel + - okta type: string x-spec-enum-id: 91f917e0c3ab97e8 description: |- @@ -21357,6 +21479,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta uid: type: string minLength: 3 @@ -22206,6 +22329,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -22631,6 +22771,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -23066,6 +23223,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 86da5567da..3a7f030a9c 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -31,6 +31,7 @@ from prowler.providers.image.image_provider import ImageProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider from prowler.providers.m365.m365_provider import M365Provider from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider +from prowler.providers.okta.okta_provider import OktaProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider from prowler.providers.vercel.vercel_provider import VercelProvider @@ -130,6 +131,7 @@ class TestReturnProwlerProvider: (Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider), (Provider.ProviderChoices.IMAGE.value, ImageProvider), (Provider.ProviderChoices.VERCEL.value, VercelProvider), + (Provider.ProviderChoices.OKTA.value, OktaProvider), ], ) def test_return_prowler_provider(self, provider_type, expected_provider): @@ -238,6 +240,31 @@ class TestProwlerProviderConnectionTest: raise_on_exception=False, ) + @patch("api.utils.return_prowler_provider") + def test_prowler_provider_connection_test_okta_provider( + self, mock_return_prowler_provider + ): + """Test connection test for Okta provider passes org domain and provider_id.""" + provider = MagicMock() + provider.uid = "acme.okta.com" + provider.provider = Provider.ProviderChoices.OKTA.value + provider.secret.secret = { + "okta_client_id": "0oa123456789abcdef", + "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "okta_scopes": ["okta.policies.read"], + } + mock_return_prowler_provider.return_value = MagicMock() + + prowler_provider_connection_test(provider) + mock_return_prowler_provider.return_value.test_connection.assert_called_once_with( + okta_client_id="0oa123456789abcdef", + okta_private_key="-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + okta_scopes=["okta.policies.read"], + okta_org_domain="acme.okta.com", + provider_id="acme.okta.com", + raise_on_exception=False, + ) + @patch("api.utils.return_prowler_provider") def test_prowler_provider_connection_test_image_provider_no_creds( self, mock_return_prowler_provider @@ -308,6 +335,10 @@ class TestGetProwlerProviderKwargs: Provider.ProviderChoices.VERCEL.value, {"team_id": "provider_uid"}, ), + ( + Provider.ProviderChoices.OKTA.value, + {"okta_org_domain": "provider_uid"}, + ), ], ) def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs): diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 97796c97ca..008189e313 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -1625,6 +1625,21 @@ class TestProviderViewSet: "uid": "C12", "alias": "Google Workspace Minimum Length", }, + { + "provider": "okta", + "uid": "acme.okta.com", + "alias": "Okta Org", + }, + { + "provider": "okta", + "uid": "agency.okta-gov.com", + "alias": "Okta Gov Org", + }, + { + "provider": "okta", + "uid": "agency.okta.mil", + "alias": "Okta Mil Org", + }, ] ), ) @@ -2143,6 +2158,24 @@ class TestProviderViewSet: "googleworkspace-uid", "uid", ), + ( + { + "provider": "okta", + "uid": "https://acme.okta.com", + "alias": "test", + }, + "okta-uid", + "uid", + ), + ( + { + "provider": "okta", + "uid": "acme.example.com", + "alias": "test", + }, + "okta-uid", + "uid", + ), ] ), ) @@ -2163,6 +2196,25 @@ class TestProviderViewSet: == f"/data/attributes/{error_pointer}" ) + @pytest.mark.parametrize( + "input_uid,stored_uid", + [ + ("Acme.okta.com", "acme.okta.com"), + (" ACME.OKTA.COM ", "acme.okta.com"), + ("Agency.Okta-Gov.com", "agency.okta-gov.com"), + ], + ) + def test_providers_create_okta_uid_normalized( + self, authenticated_client, input_uid, stored_uid + ): + response = authenticated_client.post( + reverse("provider-list"), + data={"provider": "okta", "uid": input_uid, "alias": "Okta"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert Provider.objects.get().uid == stored_uid + def test_providers_partial_update(self, authenticated_client, providers_fixture): provider1, *_ = providers_fixture new_alias = "This is the new name" @@ -2320,17 +2372,17 @@ class TestProviderViewSet: ), ("alias", "aws_testing_1", 1), ("alias.icontains", "aws", 2), - ("inserted_at", TODAY, 13), + ("inserted_at", TODAY, 14), ( "inserted_at.gte", "2024-01-01", - 13, + 14, ), ("inserted_at.lte", "2024-01-01", 0), ( "updated_at.gte", "2024-01-01", - 13, + 14, ), ("updated_at.lte", "2024-01-01", 0), ] @@ -2963,6 +3015,19 @@ class TestProviderSecretViewSet: "api_token": "fake-vercel-api-token-for-testing", }, ), + # Okta with inline private key credentials + ( + Provider.ProviderChoices.OKTA.value, + ProviderSecret.TypeChoices.STATIC, + { + "okta_client_id": "0oa123456789abcdef", + "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "okta_scopes": [ + "okta.policies.read", + "okta.groups.read", + ], + }, + ), ], ) def test_provider_secrets_create_valid( @@ -3075,6 +3140,46 @@ class TestProviderSecretViewSet: == f"/data/attributes/{error_pointer}" ) + def test_provider_secrets_invalid_create_okta_missing_private_key( + self, + providers_fixture, + authenticated_client, + ): + okta_provider = next( + provider + for provider in providers_fixture + if provider.provider == Provider.ProviderChoices.OKTA.value + ) + data = { + "data": { + "type": "provider-secrets", + "attributes": { + "name": "Okta Secret", + "secret_type": ProviderSecret.TypeChoices.STATIC, + "secret": { + "okta_client_id": "0oa123456789abcdef", + }, + }, + "relationships": { + "provider": { + "data": {"type": "providers", "id": str(okta_provider.id)} + } + }, + } + } + + response = authenticated_client.post( + reverse("providersecret-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["errors"][0]["code"] == "required" + assert response.json()["errors"][0]["source"]["pointer"] == ( + "/data/attributes/secret/okta_private_key" + ) + def test_provider_secrets_partial_update( self, authenticated_client, provider_secret_fixture ): diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index b80d54b08a..8da8d91226 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -37,6 +37,7 @@ if TYPE_CHECKING: from prowler.providers.mongodbatlas.mongodbatlas_provider import ( MongodbatlasProvider, ) + from prowler.providers.okta.okta_provider import OktaProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider from prowler.providers.vercel.vercel_provider import VercelProvider @@ -93,6 +94,7 @@ def return_prowler_provider( | KubernetesProvider | M365Provider | MongodbatlasProvider + | OktaProvider | OpenstackProvider | OraclecloudProvider | VercelProvider @@ -181,6 +183,10 @@ def return_prowler_provider( from prowler.providers.vercel.vercel_provider import VercelProvider prowler_provider = VercelProvider + case Provider.ProviderChoices.OKTA.value: + from prowler.providers.okta.okta_provider import OktaProvider + + prowler_provider = OktaProvider case _: raise ValueError(f"Provider type {provider.provider} not supported") return prowler_provider @@ -246,6 +252,11 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "team_id": provider.uid, } + elif provider.provider == Provider.ProviderChoices.OKTA.value: + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "okta_org_domain": provider.uid, + } elif provider.provider == Provider.ProviderChoices.IMAGE.value: # Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or # a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest"). @@ -290,6 +301,7 @@ def initialize_prowler_provider( | KubernetesProvider | M365Provider | MongodbatlasProvider + | OktaProvider | OpenstackProvider | OraclecloudProvider | VercelProvider @@ -351,6 +363,14 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: "raise_on_exception": False, } return prowler_provider.test_connection(**vercel_kwargs) + elif provider.provider == Provider.ProviderChoices.OKTA.value: + okta_kwargs = { + **prowler_provider_kwargs, + "okta_org_domain": provider.uid, + "provider_id": provider.uid, + "raise_on_exception": False, + } + return prowler_provider.test_connection(**okta_kwargs) elif provider.provider == Provider.ProviderChoices.IMAGE.value: image_kwargs = { "image": provider.uid, diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index f8d67f0e3b..0b8b4eacf4 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -404,6 +404,26 @@ from rest_framework_json_api import serializers }, "required": ["clouds_yaml_content", "clouds_yaml_cloud"], }, + { + "type": "object", + "title": "Okta OAuth Credentials", + "properties": { + "okta_client_id": { + "type": "string", + "description": "Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication.", + }, + "okta_private_key": { + "type": "string", + "description": "PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app.", + }, + "okta_scopes": { + "type": "array", + "items": {"type": "string"}, + "description": "OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks.", + }, + }, + "required": ["okta_client_id", "okta_private_key"], + }, { "type": "object", "title": "Vercel API Token", diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 51cb95fbff..18971452e8 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1543,6 +1543,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): serializer = GCPProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GOOGLEWORKSPACE.value: serializer = GoogleWorkspaceProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.OKTA.value: + serializer = OktaProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GITHUB.value: serializer = GithubProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.IAC.value: @@ -1688,6 +1690,15 @@ class GoogleWorkspaceProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class OktaProviderSecret(serializers.Serializer): + okta_client_id = serializers.CharField() + okta_private_key = serializers.CharField() + okta_scopes = serializers.ListField(child=serializers.CharField(), required=False) + + class Meta: + resource_name = "provider-secrets" + + class MongoDBAtlasProviderSecret(serializers.Serializer): atlas_public_key = serializers.CharField() atlas_private_key = serializers.CharField() diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index c5228c77be..eb73b0c51f 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -571,6 +571,12 @@ def providers_fixture(tenants_fixture): alias="vercel_testing", tenant_id=tenant.id, ) + provider14 = Provider.objects.create( + provider="okta", + uid="acme.okta.com", + alias="okta_testing", + tenant_id=tenant.id, + ) return ( provider1, @@ -586,6 +592,7 @@ def providers_fixture(tenants_fixture): provider11, provider12, provider13, + provider14, ) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index ce0b5cb581..8f7b868530 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_app_registration_client_secret_unused` check for M365 provider [(#11232)](https://github.com/prowler-cloud/prowler/pull/11232) - `cloudsql_instance_cmek_encryption_enabled` check for GCP provider [(#11023)](https://github.com/prowler-cloud/prowler/pull/11023) +### 🔄 Changed + +- `OktaProvider.test_connection` accepts an optional `provider_id` (org domain) and raises `OktaInvalidProviderIdError` (14007) when it doesn't match the authenticated org — guards against stored UID drifting from the credentials' org [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184) + --- ## [5.27.1] (Prowler UNRELEASED) diff --git a/prowler/providers/okta/exceptions/exceptions.py b/prowler/providers/okta/exceptions/exceptions.py index 20a20c1b93..04dd71bef0 100644 --- a/prowler/providers/okta/exceptions/exceptions.py +++ b/prowler/providers/okta/exceptions/exceptions.py @@ -34,6 +34,10 @@ class OktaBaseException(ProwlerException): "message": "Okta service app is missing required scopes", "remediation": "Have a Super Admin grant the required *.read scopes to the service app and assign the Read-Only Administrator role.", }, + (14007, "OktaInvalidProviderIdError"): { + "message": "The provided provider_id does not match the credentials org domain", + "remediation": "Check the provider_id (Okta org domain) and ensure it matches the org the service app credentials were issued for.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -110,3 +114,10 @@ class OktaInsufficientPermissionsError(OktaCredentialsError): super().__init__( 14006, file=file, original_exception=original_exception, message=message ) + + +class OktaInvalidProviderIdError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14007, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index d26cfaee5c..34958caab2 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -22,6 +22,7 @@ from prowler.providers.okta.exceptions.exceptions import ( OktaInsufficientPermissionsError, OktaInvalidCredentialsError, OktaInvalidOrgDomainError, + OktaInvalidProviderIdError, OktaPrivateKeyFileError, OktaSetUpIdentityError, OktaSetUpSessionError, @@ -348,8 +349,17 @@ class OktaProvider(Provider): okta_private_key_file: str = "", okta_scopes: Optional[Union[str, list[str]]] = None, raise_on_exception: bool = True, + provider_id: str = None, ) -> Connection: - """Test the connection to Okta with the provided OAuth credentials.""" + """Test the connection to Okta with the provided OAuth credentials. + + Args: + provider_id: The provider ID (Okta org domain). When supplied, the + authenticated org domain must match it — guards against the + stored provider UID drifting from the org the credentials were + actually issued for. Compared case-insensitively, matching the + normalization applied during session setup. + """ try: OktaProvider.validate_arguments( okta_org_domain=okta_org_domain, @@ -364,7 +374,17 @@ class OktaProvider(Provider): private_key_file=okta_private_key_file, scopes=okta_scopes, ) - OktaProvider.setup_identity(session) + identity = OktaProvider.setup_identity(session) + + if provider_id and provider_id.strip().lower() != identity.org_domain: + raise OktaInvalidProviderIdError( + file=os.path.basename(__file__), + message=( + f"The provider ID '{provider_id}' does not match the " + f"authenticated Okta org domain '{identity.org_domain}'." + ), + ) + return Connection(is_connected=True) except Exception as error: logger.critical( diff --git a/tests/providers/okta/exceptions/okta_exceptions_test.py b/tests/providers/okta/exceptions/okta_exceptions_test.py index 20f06ba96e..28ea120d4e 100644 --- a/tests/providers/okta/exceptions/okta_exceptions_test.py +++ b/tests/providers/okta/exceptions/okta_exceptions_test.py @@ -7,6 +7,7 @@ from prowler.providers.okta.exceptions.exceptions import ( OktaInsufficientPermissionsError, OktaInvalidCredentialsError, OktaInvalidOrgDomainError, + OktaInvalidProviderIdError, OktaPrivateKeyFileError, OktaSetUpIdentityError, OktaSetUpSessionError, @@ -20,6 +21,7 @@ EXPECTED_CODES = { OktaInvalidOrgDomainError: 14004, OktaPrivateKeyFileError: 14005, OktaInsufficientPermissionsError: 14006, + OktaInvalidProviderIdError: 14007, } diff --git a/tests/providers/okta/okta_provider_test.py b/tests/providers/okta/okta_provider_test.py index 8a9efbdcc8..da325d84ca 100644 --- a/tests/providers/okta/okta_provider_test.py +++ b/tests/providers/okta/okta_provider_test.py @@ -7,6 +7,7 @@ from prowler.providers.okta.exceptions.exceptions import ( OktaInsufficientPermissionsError, OktaInvalidCredentialsError, OktaInvalidOrgDomainError, + OktaInvalidProviderIdError, OktaPrivateKeyFileError, OktaSetUpIdentityError, ) @@ -396,6 +397,55 @@ class Test_OktaProvider_test_connection: with pytest.raises(OktaEnvironmentVariableError): OktaProvider.test_connection() + def test_provider_id_match_succeeds(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id=OKTA_ORG_DOMAIN, + ) + assert connection.is_connected is True + assert connection.error is None + + def test_provider_id_match_is_case_insensitive(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id=OKTA_ORG_DOMAIN.upper(), + ) + assert connection.is_connected is True + + def test_provider_id_mismatch_raises(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + with pytest.raises(OktaInvalidProviderIdError): + OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id="other.okta.com", + ) + + def test_provider_id_mismatch_returns_error_when_raise_disabled( + self, _clear_okta_env, tmp_path + ): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id="other.okta.com", + raise_on_exception=False, + ) + assert connection.is_connected is False + assert isinstance(connection.error, OktaInvalidProviderIdError) + class Test_OktaProvider_print_credentials: def test_invokes_print_boxes_with_org_and_client(self, _clear_okta_env, tmp_path):