Browse Source

fix(scim): Use drf-spec for SCIM endpoints (#57887)

Switch from using static JSON to drf-spec for: 
- [Provision a New
Team](https://docs.sentry.io/api/scim/provision-a-new-team/)
- [List an Organization's
Members](https://docs.sentry.io/api/scim/list-an-organizations-members/)
(List is the exact same)

Update existing drf-spec documentation for:
- [Update a Team's
Attributes](https://docs.sentry.io/api/scim/update-a-teams-attributes/)
- [Provision a New Organization
Member](https://docs.sentry.io/api/scim/provision-a-new-organization-member/)
- [Update an Organization Member's
Attributes](http://localhost:3000/api/scim/update-an-organization-members-attributes/)

### Provision a New Team
![Screenshot 2023-10-10 at 8 52 44
PM](https://github.com/getsentry/sentry/assets/67301797/4534c5a5-359d-4f6d-8a4e-7c40a19bbcef)

### Update a Team's Attributes (operations basically moved from
description to actual body param)
<img width="487" alt="Screenshot 2023-10-10 at 9 17 29 PM"
src="https://github.com/getsentry/sentry/assets/67301797/75f1f0d4-f44f-40cf-ac33-e67c6540224e">

### Provision a New Organization Member
![Screenshot 2023-10-11 at 11 06 39
AM](https://github.com/getsentry/sentry/assets/67301797/55cd6d5f-591a-4e32-a515-eadbc34014e8)

### Update an Organization Member's Attributes
![Screenshot 2023-10-11 at 11 35 09
AM](https://github.com/getsentry/sentry/assets/67301797/42f91405-ddd7-446c-9b67-b09c5d319b6c)
Seiji Chew 1 year ago
parent
commit
d085a2eb77

+ 0 - 3
api-docs/openapi.json

@@ -216,9 +216,6 @@
     "/api/0/sentry-app-installations/{uuid}/external-issues/{external_issue_id}/": {
       "$ref": "paths/integration-platform/sentry-app-external-issue-details.json"
     },
-    "/api/0/organizations/{organization_slug}/scim/v2/Groups": {
-      "$ref": "paths/scim/team_index.json"
-    },
     "/api/0/organizations/{organization_slug}/spike-protections/": {
       "$ref": "paths/projects/spike-protection.json"
     }

+ 0 - 175
api-docs/paths/scim/team_index.json

@@ -1,175 +0,0 @@
-{
-    "get": {
-        "tags": [
-            "SCIM"
-        ],
-        "description": "Returns a paginated list of teams bound to a organization with a SCIM Groups GET Request.\n- Note that the members field will only contain up to 10000 members.",
-        "operationId": "List an Organization's Paginated Teams",
-        "parameters": [
-            {
-                "name": "organization_slug",
-                "description": "The slug of the organization.",
-                "in": "path",
-                "required": true,
-                "schema": {
-                    "type": "string"
-                }
-            },
-            {
-                "$ref": "../../components/parameters/scim.json#/startIndex"
-            },
-            {
-                "$ref": "../../components/parameters/scim.json#/filter"
-            },
-            {
-                "$ref": "../../components/parameters/scim.json#/count"
-            },
-            {
-                "name": "excludedAttributes",
-                "in": "query",
-                "required": false,
-                "description": "Fields that should be left off of return values. Right now the only supported field for this query is `members`.",
-                "schema": {
-                    "type": "string"
-                }
-            }
-        ],
-        "responses": {
-            "200": {
-                "description": "Success",
-                "content": {
-                    "application/json": {
-                        "schema": {
-                            "$ref": "../../components/schemas/scim/group_list.json#/GroupList"
-                        },
-                        "example": {
-                            "schemas": [
-                                "urn:ietf:params:scim:api:messages:2.0:ListResponse"
-                            ],
-                            "totalResults": 1,
-                            "startIndex": 1,
-                            "itemsPerPage": 1,
-                            "Resources": [
-                                {
-                                    "schemas": [
-                                        "urn:ietf:params:scim:schemas:core:2.0:Group"
-                                    ],
-                                    "id": "23232",
-                                    "displayName": "test-scimv2",
-                                    "members": [],
-                                    "meta": {
-                                        "resourceType": "Group"
-                                    }
-                                }
-                            ]
-                        }
-                    }
-                }
-            },
-            "401": {
-                "description": "Permission Denied"
-            },
-            "403": {
-                "description": "Forbidden"
-            },
-            "404": {
-                "description": "Not Found"
-            }
-        },
-        "security": [
-            {
-                "auth_token": [
-                    "team:read"
-                ]
-            }
-        ]
-    },
-    "post": {
-        "tags": [
-            "SCIM"
-        ],
-        "description": "Create a new team bound to an organization via a SCIM Groups POST Request. Note that teams are always created with an empty member set. The endpoint will also do a normalization of uppercase / spaces to lowercase and dashes.",
-        "operationId": "Provision a New Team",
-        "parameters": [
-            {
-                "name": "organization_slug",
-                "description": "The slug of the organization.",
-                "in": "path",
-                "required": true,
-                "schema": {
-                    "type": "string"
-                }
-            }
-        ],
-        "requestBody": {
-            "content": {
-                "application/json": {
-                    "schema": {
-                        "required": [
-                            "schemas","displayName"
-                        ],
-                        "type": "object",
-                        "properties": {
-                            "schemas": {
-                                "$ref": "../../components/schemas/scim/group.json#/definitions/schemas"
-                            },
-                            "displayName": {
-                                "$ref": "../../components/schemas/scim/group.json#/definitions/displayName"
-                            },
-                            "members": {
-                                "$ref": "../../components/schemas/scim/group.json#/definitions/members"
-                            }
-                        }
-                    },
-                    "example": {
-                        "schemas": [
-                            "urn:ietf:params:scim:schemas:core:2.0:Group"
-                        ],
-                        "displayName": "Test SCIMv2",
-                        "members": []
-                    }
-                }
-            },
-            "required": true
-        },
-        "responses": {
-            "201": {
-                "description": "Success",
-                "content": {
-                    "application/json": {
-                        "schema": {
-                            "$ref": "../../components/schemas/scim/group.json#/Group"
-                        },
-                        "example": {
-                            "schemas": [
-                                "urn:ietf:params:scim:schemas:core:2.0:Group"
-                            ],
-                            "displayName": "Test SCIMv2",
-                            "members": [],
-                            "meta": {
-                                "resourceType": "Group"
-                            },
-                            "id": "123"
-                        }
-                    }
-                }
-            },
-            "400": {
-                "description": "Bad input"
-            },
-            "403": {
-                "description": "Forbidden"
-            },
-            "409": {
-                "description": "Team slug already exists"
-            }
-        },
-        "security": [
-            {
-                "auth_token": [
-                    "team:write"
-                ]
-            }
-        ]
-    }
-}

+ 3 - 3
src/sentry/api/endpoints/organization_member/details.py

@@ -66,9 +66,9 @@ Configures the team role of the member. The two roles are:
 ```
 """
 
-# Required to explictly define roles w/ descriptions because OrganizationMemberSerializer
+# Required to explicitly define roles w/ descriptions because OrganizationMemberSerializer
 # has the wrong descriptions, includes deprecated admin, and excludes billing
-_role_choices = [
+ROLE_CHOICES = [
     ("billing", "Can manage payment and compliance details."),
     (
         "member",
@@ -172,7 +172,7 @@ class OrganizationMemberDetailsEndpoint(OrganizationMemberEndpoint):
             fields={
                 "orgRole": serializers.ChoiceField(
                     help_text="The organization role of the member. The options are:",
-                    choices=_role_choices,
+                    choices=ROLE_CHOICES,
                     required=False,
                 ),
                 "teamRoles": serializers.ListField(

+ 40 - 20
src/sentry/scim/endpoints/members.py

@@ -6,18 +6,23 @@ import sentry_sdk
 from django.conf import settings
 from django.db import router, transaction
 from django.db.models import Q
-from drf_spectacular.utils import extend_schema, extend_schema_field, inline_serializer
+from drf_spectacular.utils import (
+    extend_schema,
+    extend_schema_field,
+    extend_schema_serializer,
+    inline_serializer,
+)
 from rest_framework import serializers
 from rest_framework.exceptions import PermissionDenied, ValidationError
 from rest_framework.fields import Field
 from rest_framework.request import Request
 from rest_framework.response import Response
-from typing_extensions import TypedDict
 
 from sentry import audit_log, roles
 from sentry.api.api_publish_status import ApiPublishStatus
 from sentry.api.base import region_silo_endpoint
 from sentry.api.bases.organizationmember import OrganizationMemberEndpoint
+from sentry.api.endpoints.organization_member.details import ROLE_CHOICES
 from sentry.api.endpoints.organization_member.index import OrganizationMemberSerializer
 from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.paginator import GenericOffsetPaginator
@@ -54,6 +59,7 @@ from .utils import (
     OrganizationSCIMMemberPermission,
     SCIMApiError,
     SCIMEndpoint,
+    SCIMListBaseResponse,
     SCIMQueryParamSerializer,
 )
 
@@ -90,6 +96,7 @@ class OperationValue(Field):
         raise ValidationError("value must be a boolean or object")
 
 
+@extend_schema_serializer(dict)
 class SCIMPatchOperationSerializer(serializers.Serializer):
     op = serializers.CharField(required=True)
     value = OperationValue()
@@ -102,12 +109,27 @@ class SCIMPatchOperationSerializer(serializers.Serializer):
         raise serializers.ValidationError(f'"{value}" is not a valid choice')
 
 
+@extend_schema_serializer(exclude_fields="schemas")
 class SCIMPatchRequestSerializer(serializers.Serializer):
     # we don't actually use "schemas" for anything atm but its part of the spec
     schemas = serializers.ListField(child=serializers.CharField(), required=False)
-
     Operations = serializers.ListField(
-        child=SCIMPatchOperationSerializer(), required=True, source="operations", max_length=100
+        child=SCIMPatchOperationSerializer(),
+        required=True,
+        source="operations",
+        max_length=100,
+        help_text="""A list of operations to perform. Currently, the only valid operation is setting
+a member's `active` attribute to false, after which the member will be permanently deleted.
+```json
+{
+    "Operations": [{
+        "op": "replace",
+        "path": "active",
+        "value": False
+    }]
+}
+```
+""",
     )
 
 
@@ -244,8 +266,6 @@ class OrganizationSCIMMemberDetails(SCIMEndpoint, OrganizationMemberEndpoint):
     def patch(self, request: Request, organization, member):
         """
         Update an organization member's attributes with a SCIM PATCH Request.
-        The only supported attribute is `active`. After setting `active` to false
-        Sentry will permanently delete the Organization Member.
         """
         serializer = SCIMPatchRequestSerializer(data=request.data)
 
@@ -384,11 +404,7 @@ class OrganizationSCIMMemberDetails(SCIMEndpoint, OrganizationMemberEndpoint):
         return Response(context, status=200)
 
 
-class SCIMListResponseDict(TypedDict):
-    schemas: List[str]
-    totalResults: int
-    startIndex: int
-    itemsPerPage: int
+class SCIMListMembersResponse(SCIMListBaseResponse):
     Resources: List[OrganizationMemberSCIMSerializerResponse]
 
 
@@ -403,10 +419,9 @@ class OrganizationSCIMMemberIndex(SCIMEndpoint):
     @extend_schema(
         operation_id="List an Organization's Members",
         parameters=[GlobalParams.ORG_SLUG, SCIMQueryParamSerializer],
-        request=None,
         responses={
             200: inline_sentry_response_serializer(
-                "SCIMListResponseEnvelopeSCIMMemberIndexResponse", SCIMListResponseDict
+                "SCIMListResponseEnvelopeSCIMMemberIndexResponse", SCIMListMembersResponse
             ),
             401: RESPONSE_UNAUTHORIZED,
             403: RESPONSE_FORBIDDEN,
@@ -464,8 +479,16 @@ class OrganizationSCIMMemberIndex(SCIMEndpoint):
         request=inline_serializer(
             name="SCIMMemberProvision",
             fields={
-                "userName": serializers.EmailField(),
-                "sentryOrgRole": serializers.CharField(required=False),
+                "userName": serializers.EmailField(
+                    help_text="The SAML field used for email.",
+                    required=True,
+                ),
+                "sentryOrgRole": serializers.ChoiceField(
+                    help_text="""The organization role of the member. If unspecified, this will be
+                    set to the organization's default role. The options are:""",
+                    choices=[role for role in ROLE_CHOICES if role[0] != "owner"],
+                    required=False,
+                ),
             },
         ),
         responses={
@@ -479,11 +502,8 @@ class OrganizationSCIMMemberIndex(SCIMEndpoint):
     def post(self, request: Request, organization) -> Response:
         """
         Create a new Organization Member via a SCIM Users POST Request.
-        - `userName` should be set to the SAML field used for email, and active should be set to `true`.
-        - `sentryOrgRole` can only be `admin`, `manager`, `billing`, or `member`.
-        - Sentry's SCIM API doesn't currently support setting users to inactive,
-        and the member will be deleted if active is set to `false`.
-        - The API also does not support setting secondary emails.
+
+        Note that this API does not support setting secondary emails.
         """
         update_role = False
 

+ 77 - 74
src/sentry/scim/endpoints/teams.py

@@ -5,12 +5,11 @@ from typing import Any, List
 import sentry_sdk
 from django.db import IntegrityError, router, transaction
 from django.utils.text import slugify
-from drf_spectacular.utils import extend_schema, inline_serializer
+from drf_spectacular.utils import extend_schema, extend_schema_serializer, inline_serializer
 from rest_framework import serializers, status
 from rest_framework.exceptions import ParseError
 from rest_framework.request import Request
 from rest_framework.response import Response
-from typing_extensions import TypedDict
 
 from sentry import audit_log
 from sentry.api.api_publish_status import ApiPublishStatus
@@ -56,6 +55,7 @@ from .utils import (
     SCIMApiError,
     SCIMEndpoint,
     SCIMFilterError,
+    SCIMListBaseResponse,
     SCIMQueryParamSerializer,
     parse_filter_conditions,
 )
@@ -63,6 +63,7 @@ from .utils import (
 delete_logger = logging.getLogger("sentry.deletions.api")
 
 
+@extend_schema_serializer(dict)
 class SCIMTeamPatchOperationSerializer(serializers.Serializer):
     op = serializers.CharField(required=True)
     value = serializers.JSONField(required=False)
@@ -77,12 +78,71 @@ class SCIMTeamPatchOperationSerializer(serializers.Serializer):
         raise serializers.ValidationError(f'"{value}" is not a valid choice')
 
 
+@extend_schema_serializer(exclude_fields="schemas")
 class SCIMTeamPatchRequestSerializer(serializers.Serializer):
     # we don't actually use "schemas" for anything atm but its part of the spec
     schemas = serializers.ListField(child=serializers.CharField(), required=True)
-
     Operations = serializers.ListField(
-        child=SCIMTeamPatchOperationSerializer(), required=True, source="operations"
+        child=SCIMTeamPatchOperationSerializer(),
+        required=True,
+        source="operations",
+        help_text="""The list of operations to perform. Valid operations are:
+* Renaming a team:
+```json
+{
+    "Operations": [{
+        "op": "replace",
+        "value": {
+            "id": 23,
+            "displayName": "newName"
+        }
+    }]
+}
+```
+* Adding a member to a team:
+```json
+{
+    "Operations": [{
+        "op": "add",
+        "path": "members",
+        "value": [
+            {
+                "value": 23,
+                "display": "testexample@example.com"
+            }
+        ]
+    }]
+}
+```
+* Removing a member from a team:
+```json
+{
+    "Operations": [{
+        "op": "remove",
+        "path": "members[value eq \"23\"]"
+    }]
+}
+```
+* Replacing an entire member set of a team:
+```json
+{
+    "Operations": [{
+        "op": "replace",
+        "path": "members",
+        "value": [
+            {
+                "value": 23,
+                "display": "testexample2@sentry.io"
+            },
+            {
+                "value": 24,
+                "display": "testexample3@sentry.io"
+            }
+        ]
+    }]
+}
+```
+""",
     )
 
 
@@ -90,11 +150,7 @@ def _team_expand(excluded_attributes):
     return None if "members" in excluded_attributes else ["members"]
 
 
-class SCIMListResponseDict(TypedDict):
-    schemas: List[str]
-    totalResults: int
-    startIndex: int
-    itemsPerPage: int
+class SCIMListTeamsResponse(SCIMListBaseResponse):
     Resources: List[OrganizationTeamSCIMSerializerResponse]
 
 
@@ -113,7 +169,7 @@ class OrganizationSCIMTeamIndex(SCIMEndpoint):
         request=None,
         responses={
             200: inline_sentry_response_serializer(
-                "SCIMListResponseEnvelopeSCIMTeamIndexResponse", SCIMListResponseDict
+                "SCIMListResponseEnvelopeSCIMTeamIndexResponse", SCIMListTeamsResponse
             ),
             401: RESPONSE_UNAUTHORIZED,
             403: RESPONSE_FORBIDDEN,
@@ -124,9 +180,9 @@ class OrganizationSCIMTeamIndex(SCIMEndpoint):
     def get(self, request: Request, organization: Organization, **kwds: Any) -> Response:
         """
         Returns a paginated list of teams bound to a organization with a SCIM Groups GET Request.
-        - Note that the members field will only contain up to 10000 members.
-        """
 
+        Note that the members field will only contain up to 10,000 members.
+        """
         query_params = self.get_query_parameters(request)
 
         queryset = Team.objects.filter(
@@ -160,9 +216,10 @@ class OrganizationSCIMTeamIndex(SCIMEndpoint):
         request=inline_serializer(
             name="SCIMTeamRequestBody",
             fields={
-                "schemas": serializers.ListField(child=serializers.CharField()),
-                "displayName": serializers.CharField(),
-                "members": serializers.ListField(child=serializers.IntegerField()),
+                "displayName": serializers.CharField(
+                    help_text="The slug of the team that is shown in the UI.",
+                    required=True,
+                ),
             },
         ),
         responses={
@@ -175,9 +232,11 @@ class OrganizationSCIMTeamIndex(SCIMEndpoint):
     )
     def post(self, request: Request, organization: Organization, **kwds: Any) -> Response:
         """
-        Create a new team bound to an organization via a SCIM Groups POST Request.
+        Create a new team bound to an organization via a SCIM Groups POST
+        Request. The slug will have a normalization of uppercases/spaces to
+        lowercases and dashes.
+
         Note that teams are always created with an empty member set.
-        The endpoint will also do a normalization of uppercase / spaces to lowercase and dashes.
         """
         # shim displayName from SCIM api in order to work with
         # our regular team index POST
@@ -355,63 +414,7 @@ class OrganizationSCIMTeamDetails(SCIMEndpoint, TeamDetailsEndpoint):
     )
     def patch(self, request: Request, organization, team):
         """
-        Update a team's attributes with a SCIM Group PATCH Request. Valid operations are:
-
-        * Renaming a team:
-        ```json
-        {
-            "Operations": [{
-                "op": "replace",
-                "value": {
-                    "id": 23,
-                    "displayName": "newName"
-                }
-            }]
-        }
-        ```
-        * Adding a member to a team:
-        ```json
-        {
-            "Operations": [{
-                "op": "add",
-                "path": "members",
-                "value": [
-                    {
-                        "value": 23,
-                        "display": "testexample@example.com"
-                    }
-                ]
-            }]
-        }
-        ```
-        * Removing a member from a team:
-        ```json
-        {
-            "Operations": [{
-                "op": "remove",
-                "path": "members[value eq \"23\"]"
-            }]
-        }
-        ```
-        * Replacing an entire member set of a team:
-        ```json
-        {
-            "Operations": [{
-                "op": "replace",
-                "path": "members",
-                "value": [
-                    {
-                        "value": 23,
-                        "display": "testexample2@sentry.io"
-                    },
-                    {
-                        "value": 24,
-                        "display": "testexample3@sentry.io"
-                    }
-                ]
-            }]
-        }
-        ```
+        Update a team's attributes with a SCIM Group PATCH Request.
         """
 
         serializer = SCIMTeamPatchRequestSerializer(data=request.data)

+ 10 - 0
src/sentry/scim/endpoints/utils.py

@@ -1,9 +1,12 @@
+from typing import List
+
 import sentry_sdk
 from drf_spectacular.utils import extend_schema
 from rest_framework import serializers
 from rest_framework.exceptions import APIException, ParseError
 from rest_framework.negotiation import BaseContentNegotiation
 from rest_framework.request import Request
+from typing_extensions import TypedDict
 
 from sentry.api.api_owners import ApiOwner
 from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
@@ -116,6 +119,13 @@ class OrganizationSCIMTeamPermission(OrganizationSCIMPermission):
     }
 
 
+class SCIMListBaseResponse(TypedDict):
+    schemas: List[str]
+    totalResults: int
+    startIndex: int
+    itemsPerPage: int
+
+
 @extend_schema(tags=["SCIM"])
 class SCIMEndpoint(OrganizationEndpoint):
     owner = ApiOwner.ENTERPRISE