Repository: getsentry/sentry

Issue:
feat(api): allow the configuration of a provider
This MR fixes the ability to provide boolean values to the organization detail API and also adds the ablity to add a provider to the organization via API.

It's working. I know that it's not the prettiest way (probably) to solve this, but I'm very much open to improvements and changes!


Discussion:
Not sure what's wrong with `security/snyk (sentry)`. I can't access it.
Hi @max-wittig, thanks for the PR! Ideally this would be split up into multiple PRs, but let's address them here for now.

* re: `defaultRole` - this is already handled here: https://github.com/getsentry/sentry/blob/be3ffa6ce61d40274f432b1612355673cbbe3fe3/src/sentry/api/endpoints/organization_details.py#L322-L334 - only owners of the organization are allowed to set the default role.

* I'm not sure what you mean IRT boolean values as organization details already supports boolean values.

* provider - can you explain further what you're trying to accomplish here?

 
Hi @billyvg thanks for your review!

Sure. I will split these two commits up into multiple PRs. No problem.

* Ah I see, `defaultRole` is handled then. Guess I have to be owner. Weird that you don't have to be owner for the other permissions. For example `require2FA`, but okay.

* Seems like boolean is not supported. When I tried to set these values using the API before, they were just ignored. For example, when I tried to set `openMembership` to `true` or `True` via API, the value was ignored and just stayed like it was. After my fix here, it worked as expected.

* I want to accomplish that you can setup an SSO provider (like GitHub or Google) for an organization via API, not just via the UI.
Is there already an existing API endpoint to do something like this? The API docs are outdated and looking through `urls.py`, I haven't found an endpoint that would make this possible today.
@max-wittig testing localling, this works for me: `curl 'http://localhost:8000/api/0/organizations/sentry/' -X PUT -H 'Accept: application/json; charset=utf-8' -H 'Content-Type: application/json' --data-binary '{"isEarlyAdopter":true}' --compressed`

Is it possible you're sending the boolean value as a string?
@billyvg Yes I was using multi-part forms instead of `application/json`. That's why I couldn't send booleans. 
Thanks for clearing that up!

So I'm just missing the last puzzle piece then 😄 
How do I connect a SSO provider via API to an organization? Is there already a way that I missed?

E.g. `PUT /api/0/organizations/my-org/`

```
{
   "provider": "github"
}
```
@max-wittig I don't really have enough context around this to be helpful here. I'm not sure if this is something we want to expose via APIs -- @leedongwei I know you've been doing work with SSO recently, any thoughts here?
@billyvg I think it should be exposed via API, as you already expose the rest of the config. Why not the SSO setting? 😄 

Resolving patch:
diff --git a/src/sentry/api/endpoints/organization_details.py b/src/sentry/api/endpoints/organization_details.py
--- a/src/sentry/api/endpoints/organization_details.py
+++ b/src/sentry/api/endpoints/organization_details.py
@@ -9,7 +9,7 @@
 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 @@ def _has_sso_enabled(self):
         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 @@ def validate_projectRateLimit(self, value):
             )
         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 @@ def validate(self, attrs):
                 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 @@ def save(self):
             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 @@ def put(self, request: Request, organization) -> Response:
                     {"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
diff --git a/src/sentry/features/__init__.py b/src/sentry/features/__init__.py
--- a/src/sentry/features/__init__.py
+++ b/src/sentry/features/__init__.py
@@ -66,6 +66,7 @@
 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)
@@ -200,7 +201,6 @@
 # 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)


Test patch:
diff --git a/tests/sentry/api/endpoints/test_organization_details.py b/tests/sentry/api/endpoints/test_organization_details.py
--- a/tests/sentry/api/endpoints/test_organization_details.py
+++ b/tests/sentry/api/endpoints/test_organization_details.py
@@ -13,6 +13,7 @@
 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.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 @@ def test_upload_avatar(self):
         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 @@ def test_various_options(self, mock_get_repositories):
             "defaultRole": "owner",
             "require2FA": True,
             "allowJoinRequests": False,
+            "providerKey": "google",
+            "providerConfig": {"domain": "foo.com"},
         }
 
         # needed to set require2FA
@@ -807,6 +812,79 @@ def test_org_mapping_already_taken(self):
         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):