Browse Source

feat(api): Allow auth-provider configuration via API (#47279)

Allow configuring auth-provider through GET api, currently walled behind a feature flag as well. Combine changes from [earlier PR](https://github.com/getsentry/sentry/pull/46489) and [feature-flag PR (https://github.com/getsentry/sentry/pull/46883) due to rebasing troubles
Seiji Chew 1 year ago
parent
commit
2eaa801895

+ 54 - 1
src/sentry/api/endpoints/organization_details.py

@@ -9,7 +9,7 @@ from pytz import UTC
 from rest_framework import serializers, status
 
 from bitfield.types import BitHandler
-from sentry import audit_log, roles
+from sentry import audit_log, features, roles
 from sentry.api.base import ONE_DAY, region_silo_endpoint
 from sentry.api.bases.organization import OrganizationEndpoint
 from sentry.api.decorators import sudo_required
@@ -175,6 +175,8 @@ class OrganizationSerializer(BaseOrganizationSerializer):
     allowJoinRequests = serializers.BooleanField(required=False)
     relayPiiConfig = serializers.CharField(required=False, allow_blank=True, allow_null=True)
     apdexThreshold = serializers.IntegerField(min_value=1, required=False)
+    providerKey = serializers.CharField(required=False, allow_blank=True, allow_null=True)
+    providerConfig = serializers.JSONField(required=False, allow_null=True)
 
     @memoize
     def _has_legacy_rate_limits(self):
@@ -187,6 +189,11 @@ class OrganizationSerializer(BaseOrganizationSerializer):
         org = self.context["organization"]
         return AuthProvider.objects.filter(organization_id=org.id).exists()
 
+    @property
+    def has_api_auth_provider(self):
+        org = self.context["organization"]
+        return features.has("organizations:api-auth-provider", org)
+
     def validate_relayPiiConfig(self, value):
         organization = self.context["organization"]
         return validate_pii_config_update(organization, value)
@@ -268,6 +275,24 @@ class OrganizationSerializer(BaseOrganizationSerializer):
             )
         return value
 
+    def validate_providerKey(self, value):
+        from sentry.auth import manager
+
+        if not self.has_api_auth_provider:
+            raise serializers.ValidationError(
+                "Organization does not have the api-auth-provider feature flag enabled"
+            )
+        if not manager.exists(value):
+            raise serializers.ValidationError("Invalid providerKey")
+        return value
+
+    def validate_providerConfig(self, value):
+        if not self.has_api_auth_provider:
+            raise serializers.ValidationError(
+                "Organization does not have the api-auth-provider feature flag enabled"
+            )
+        return value
+
     def validate(self, attrs):
         attrs = super().validate(attrs)
         if attrs.get("avatarType") == "upload":
@@ -278,6 +303,14 @@ class OrganizationSerializer(BaseOrganizationSerializer):
                 raise serializers.ValidationError(
                     {"avatarType": "Cannot set avatarType to upload without avatar"}
                 )
+        # Both providerKey and providerConfig are required to configure an auth provider
+        if self.has_api_auth_provider and (("providerKey" in attrs) != ("providerConfig" in attrs)):
+            raise serializers.ValidationError(
+                {
+                    "providerKey": "providerKey and providerConfig are required together to config an auth provider",
+                    "providerConfig": "providerKey and providerConfig are required together to config an auth provider",
+                }
+            )
         return attrs
 
     def save_trusted_relays(self, incoming, changed_data, organization):
@@ -387,6 +420,21 @@ class OrganizationSerializer(BaseOrganizationSerializer):
             org.name = data["name"]
         if "slug" in data:
             org.slug = data["slug"]
+        if self.has_api_auth_provider and "providerKey" in data and "providerConfig" in data:
+            provider_key = data["providerKey"]
+            provider_config = data["providerConfig"]
+            with transaction.atomic():
+                auth_provider = AuthProvider.objects.update_or_create(
+                    organization_id=org.id,
+                    defaults={"provider": provider_key},
+                )[0]
+                provider = auth_provider.get_provider()
+                try:
+                    config = provider.build_config(provider_config)
+                    config["sentry-source"] = "api-organization-details"
+                except KeyError as err:
+                    raise KeyError(f"Invalid key {str(err)} in providerConfig")
+                auth_provider.update(config=config)
 
         org_tracked_field = {
             "name": org.name,
@@ -542,6 +590,11 @@ class OrganizationDetailsEndpoint(OrganizationEndpoint):
                     {"slug": ["An organization with this slug already exists."]},
                     status=status.HTTP_409_CONFLICT,
                 )
+            except KeyError as err:
+                return self.respond(
+                    {"providerConfig": [err.args[0]]},
+                    status=status.HTTP_400_BAD_REQUEST,
+                )
 
             # Send outbox message to clean up mappings after organization
             # creation transaction

+ 1 - 1
src/sentry/features/__init__.py

@@ -66,6 +66,7 @@ default_manager.add("organizations:javascript-console-error-tag", OrganizationFe
 default_manager.add("organizations:alert-allow-indexed", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
 default_manager.add("organizations:alert-crash-free-metrics", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
 default_manager.add("organizations:alert-filters", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
+default_manager.add("organizations:api-auth-provider", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
 default_manager.add("organizations:api-keys", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
 default_manager.add("organizations:auto-enable-codecov", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
 default_manager.add("organizations:crash-rate-alerts", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
@@ -195,7 +196,6 @@ default_manager.add("organizations:u2f-superuser-form", OrganizationFeature, Fea
 # also be listed in SubscriptionPlanFeatureHandler in getsentry so that sentry.io
 # behaves correctly.
 default_manager.add("organizations:advanced-search", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
-default_manager.add("organizations:api-auth-provider", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
 default_manager.add("organizations:app-store-connect-multiple", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
 default_manager.add("organizations:change-alerts", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
 default_manager.add('organizations:commit-context', OrganizationFeature, FeatureHandlerStrategy.INTERNAL)

+ 78 - 0
tests/sentry/api/endpoints/test_organization_details.py

@@ -13,6 +13,7 @@ from sentry import audit_log
 from sentry import options as sentry_options
 from sentry.api.endpoints.organization_details import ERR_NO_2FA, ERR_SSO_ENABLED
 from sentry.auth.authenticators import TotpInterface
+from sentry.auth.providers.google.constants import DATA_VERSION
 from sentry.constants import RESERVED_ORGANIZATION_SLUGS
 from sentry.db.postgres.roles import in_test_psql_role_override
 from sentry.models import (
@@ -30,6 +31,7 @@ from sentry.models import (
 from sentry.models.organizationmapping import OrganizationMapping
 from sentry.signals import project_created
 from sentry.testutils import APITestCase, TwoFactorAPITestCase, pytest
+from sentry.testutils.helpers.features import with_feature
 from sentry.testutils.silo import exempt_from_silo_limits, region_silo_test
 from sentry.utils import json
 
@@ -317,6 +319,7 @@ class OrganizationUpdateTest(OrganizationDetailsTestBase):
         assert avatar.get_avatar_type_display() == "upload"
         assert avatar.file_id
 
+    @with_feature("organizations:api-auth-provider")
     @responses.activate
     @patch(
         "sentry.integrations.github.GitHubAppsClient.get_repositories",
@@ -353,6 +356,8 @@ class OrganizationUpdateTest(OrganizationDetailsTestBase):
             "defaultRole": "owner",
             "require2FA": True,
             "allowJoinRequests": False,
+            "providerKey": "google",
+            "providerConfig": {"domain": "foo.com"},
         }
 
         # needed to set require2FA
@@ -807,6 +812,79 @@ class OrganizationUpdateTest(OrganizationDetailsTestBase):
         OrganizationMapping.objects.create(organization_id=999, slug="taken", region_name="us")
         self.get_error_response(self.organization.slug, slug="taken", status_code=409)
 
+    @with_feature("organizations:api-auth-provider")
+    def test_configure_auth_provider(self):
+        old_config = {"domain": "foo.com"}
+        new_config = {"domain": "bar.com"}
+        provider = "google"
+
+        self.get_success_response(
+            self.organization.slug,
+            method="put",
+            providerKey=provider,
+            providerConfig=old_config,
+        )
+        auth_provider = AuthProvider.objects.get(organization_id=self.organization.id)
+        assert auth_provider.provider == provider
+        assert auth_provider.config == {
+            "domains": ["foo.com"],
+            "version": DATA_VERSION,
+            "sentry-source": "api-organization-details",
+        }
+
+        self.get_success_response(
+            self.organization.slug,
+            method="put",
+            providerKey=provider,
+            providerConfig=new_config,
+        )
+        auth_provider = AuthProvider.objects.get(organization_id=self.organization.id)
+        assert auth_provider.provider == provider
+        assert auth_provider.config == {
+            "domains": ["bar.com"],
+            "version": DATA_VERSION,
+            "sentry-source": "api-organization-details",
+        }
+
+    def test_invalid_auth_provider_missing_feature_flag(self):
+        self.get_error_response(
+            self.organization.slug,
+            method="put",
+            providerKey="google",
+            providerConfig={"domain": "foo.com"},
+            status_code=400,
+        )
+        assert AuthProvider.objects.count() == 0
+
+    @with_feature("organizations:api-auth-provider")
+    def test_invalid_auth_provider_configuration(self):
+        self.get_error_response(
+            self.organization.slug, method="put", providerKey="google", status_code=400
+        )
+        self.get_error_response(
+            self.organization.slug,
+            method="put",
+            providerConfig={"domain": "foo.com"},
+            status_code=400,
+        )
+        self.get_error_response(
+            self.organization.slug,
+            method="put",
+            providerKey="not_valid",
+            providerConfig={"domain": "foo.com"},
+            status_code=400,
+        )
+
+        response = self.get_error_response(
+            self.organization.slug,
+            method="put",
+            providerKey="google",
+            providerConfig={"invalid_domain": "foo.com"},
+            status_code=400,
+        )
+        assert response.data == {"providerConfig": ["Invalid key 'domain' in providerConfig"]}
+        assert AuthProvider.objects.count() == 0
+
 
 @region_silo_test
 class OrganizationDeleteTest(OrganizationDetailsTestBase):