Просмотр исходного кода

feat(data-secrecy): Migration to Add `prevent_superuser_access` Bit Flag (#74700)

Created migration to add bit flag to Organization. Will need to follow
this up with a migration to add the flag so Hybrid Cloud services can
handle syncing.

**Glossary**
**Data secrecy mode:** Disallows any kind of superuser access into an
organization

**Enable/Disable Data secrecy mode:** Persistently enable/disable data
secrecy for an organization

**Waive Data secrecy mode:** Temporarily disable data secrecy for an
organizations
**Reinstate Data secrecy mode:** Re-enable data secrecy after a
temporary waiver

This flag handles the enable/disable function.


[spec](https://www.notion.so/sentry/Superuser-Data-Secrecy-Mode-b9f7fdfd8b564615ae1f91d3d981bc1a)
Raj Joshi 7 месяцев назад
Родитель
Сommit
568c329b6a

+ 1 - 1
migrations_lockfile.txt

@@ -10,6 +10,6 @@ hybridcloud: 0016_add_control_cacheversion
 nodestore: 0002_nodestore_no_dictfield
 nodestore: 0002_nodestore_no_dictfield
 remote_subscriptions: 0003_drop_remote_subscription
 remote_subscriptions: 0003_drop_remote_subscription
 replays: 0004_index_together
 replays: 0004_index_together
-sentry: 0744_add_dataset_source_field_to_dashboards
+sentry: 0745_add_prevent_superuser_access_bitflag
 social_auth: 0002_default_auto_field
 social_auth: 0002_default_auto_field
 uptime: 0006_projectuptimesubscription_name_owner
 uptime: 0006_projectuptimesubscription_name_owner

+ 4 - 0
src/sentry/api/endpoints/organization_details.py

@@ -246,6 +246,7 @@ class OrganizationSerializer(BaseOrganizationSerializer):
     openMembership = serializers.BooleanField(required=False)
     openMembership = serializers.BooleanField(required=False)
     allowSharedIssues = serializers.BooleanField(required=False)
     allowSharedIssues = serializers.BooleanField(required=False)
     allowMemberProjectCreation = serializers.BooleanField(required=False)
     allowMemberProjectCreation = serializers.BooleanField(required=False)
+    allowSuperuserAccess = serializers.BooleanField(required=False)
     enhancedPrivacy = serializers.BooleanField(required=False)
     enhancedPrivacy = serializers.BooleanField(required=False)
     dataScrubber = serializers.BooleanField(required=False)
     dataScrubber = serializers.BooleanField(required=False)
     dataScrubberDefaults = serializers.BooleanField(required=False)
     dataScrubberDefaults = serializers.BooleanField(required=False)
@@ -511,6 +512,8 @@ class OrganizationSerializer(BaseOrganizationSerializer):
             org.flags.require_email_verification = data["requireEmailVerification"]
             org.flags.require_email_verification = data["requireEmailVerification"]
         if "allowMemberProjectCreation" in data:
         if "allowMemberProjectCreation" in data:
             org.flags.disable_member_project_creation = not data["allowMemberProjectCreation"]
             org.flags.disable_member_project_creation = not data["allowMemberProjectCreation"]
+        if "allowSuperuserAccess" in data:
+            org.flags.prevent_superuser_access = not data["allowSuperuserAccess"]
         if "name" in data:
         if "name" in data:
             org.name = data["name"]
             org.name = data["name"]
         if "slug" in data:
         if "slug" in data:
@@ -528,6 +531,7 @@ class OrganizationSerializer(BaseOrganizationSerializer):
                 "require_2fa": org.flags.require_2fa.is_set,
                 "require_2fa": org.flags.require_2fa.is_set,
                 "codecov_access": org.flags.codecov_access.is_set,
                 "codecov_access": org.flags.codecov_access.is_set,
                 "disable_member_project_creation": org.flags.disable_member_project_creation.is_set,
                 "disable_member_project_creation": org.flags.disable_member_project_creation.is_set,
+                "prevent_superuser_access": org.flags.prevent_superuser_access.is_set,
             },
             },
         }
         }
 
 

+ 1 - 0
src/sentry/api/serializers/models/organization.py

@@ -353,6 +353,7 @@ class OrganizationSerializer(Serializer):
             ),
             ),
             "avatar": avatar,
             "avatar": avatar,
             "allowMemberProjectCreation": not obj.flags.disable_member_project_creation,
             "allowMemberProjectCreation": not obj.flags.disable_member_project_creation,
+            "allowSuperuserAccess": not obj.flags.prevent_superuser_access,
             "links": {
             "links": {
                 "organizationUrl": generate_organization_url(obj.slug),
                 "organizationUrl": generate_organization_url(obj.slug),
                 "regionUrl": generate_region_url(),
                 "regionUrl": generate_region_url(),

+ 48 - 0
src/sentry/migrations/0745_add_prevent_superuser_access_bitflag.py

@@ -0,0 +1,48 @@
+# Generated by Django 5.0.6 on 2024-07-23 17:37
+
+from django.db import migrations
+
+import bitfield.models
+from sentry.new_migrations.migrations import CheckedMigration
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production.
+    # This should only be used for operations where it's safe to run the migration after your
+    # code has deployed. So this should not be used for most operations that alter the schema
+    # of a table.
+    # Here are some things that make sense to mark as post deployment:
+    # - Large data migrations. Typically we want these to be run manually so that they can be
+    #   monitored and not block the deploy for a long period of time while they run.
+    # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = False
+
+    dependencies = [
+        ("sentry", "0744_add_dataset_source_field_to_dashboards"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="organization",
+            name="flags",
+            field=bitfield.models.BitField(
+                [
+                    "allow_joinleave",
+                    "enhanced_privacy",
+                    "disable_shared_issues",
+                    "early_adopter",
+                    "require_2fa",
+                    "disable_new_visibility_features",
+                    "require_email_verification",
+                    "codecov_access",
+                    "disable_member_project_creation",
+                    "prevent_superuser_access",
+                ],
+                default=1,
+            ),
+        ),
+    ]

+ 4 - 1
src/sentry/models/organization.py

@@ -198,6 +198,9 @@ class Organization(ReplicatedRegionModel, OrganizationAbsoluteUrlMixin):
         # Disable org-members from creating new projects
         # Disable org-members from creating new projects
         disable_member_project_creation: bool
         disable_member_project_creation: bool
 
 
+        # Prevent superuser access to an organization
+        prevent_superuser_access: bool
+
         bitfield_default = 1
         bitfield_default = 1
 
 
     objects: ClassVar[OrganizationManager] = OrganizationManager(cache_fields=("pk", "slug"))
     objects: ClassVar[OrganizationManager] = OrganizationManager(cache_fields=("pk", "slug"))
@@ -354,7 +357,7 @@ class Organization(ReplicatedRegionModel, OrganizationAbsoluteUrlMixin):
                 organization_id__in=org_ids_to_query, role=roles.get_top_dog().id
                 organization_id__in=org_ids_to_query, role=roles.get_top_dog().id
             ).values_list("organization_id", "user_id")
             ).values_list("organization_id", "user_id")
 
 
-            for (org_id, user_id) in queried_owner_ids:
+            for org_id, user_id in queried_owner_ids:
                 # An org may have multiple owners. Here we mimic the behavior of
                 # An org may have multiple owners. Here we mimic the behavior of
                 # `get_default_owner`, which is to use the first one in the query
                 # `get_default_owner`, which is to use the first one in the query
                 # result's iteration order.
                 # result's iteration order.

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

@@ -428,6 +428,7 @@ class OrganizationUpdateTest(OrganizationDetailsTestBase):
             "openMembership": False,
             "openMembership": False,
             "isEarlyAdopter": True,
             "isEarlyAdopter": True,
             "codecovAccess": True,
             "codecovAccess": True,
+            "allowSuperuserAccess": False,
             "aiSuggestedSolution": False,
             "aiSuggestedSolution": False,
             "githubOpenPRBot": False,
             "githubOpenPRBot": False,
             "githubNudgeInvite": False,
             "githubNudgeInvite": False,
@@ -470,6 +471,7 @@ class OrganizationUpdateTest(OrganizationDetailsTestBase):
 
 
         assert org.flags.early_adopter
         assert org.flags.early_adopter
         assert org.flags.codecov_access
         assert org.flags.codecov_access
+        assert org.flags.prevent_superuser_access
         assert not org.flags.allow_joinleave
         assert not org.flags.allow_joinleave
         assert org.flags.disable_shared_issues
         assert org.flags.disable_shared_issues
         assert org.flags.enhanced_privacy
         assert org.flags.enhanced_privacy
@@ -501,6 +503,9 @@ class OrganizationUpdateTest(OrganizationDetailsTestBase):
         assert "to {}".format(data["openMembership"]) in log.data["allow_joinleave"]
         assert "to {}".format(data["openMembership"]) in log.data["allow_joinleave"]
         assert "to {}".format(data["isEarlyAdopter"]) in log.data["early_adopter"]
         assert "to {}".format(data["isEarlyAdopter"]) in log.data["early_adopter"]
         assert "to {}".format(data["codecovAccess"]) in log.data["codecov_access"]
         assert "to {}".format(data["codecovAccess"]) in log.data["codecov_access"]
+        assert (
+            "to {}".format(not data["allowSuperuserAccess"]) in log.data["prevent_superuser_access"]
+        )
         assert "to {}".format(data["enhancedPrivacy"]) in log.data["enhanced_privacy"]
         assert "to {}".format(data["enhancedPrivacy"]) in log.data["enhanced_privacy"]
         assert "to {}".format(not data["allowSharedIssues"]) in log.data["disable_shared_issues"]
         assert "to {}".format(not data["allowSharedIssues"]) in log.data["disable_shared_issues"]
         assert "to {}".format(data["require2FA"]) in log.data["require_2fa"]
         assert "to {}".format(data["require2FA"]) in log.data["require_2fa"]

+ 22 - 12
tests/sentry/models/test_organization.py

@@ -95,11 +95,13 @@ class OrganizationTest(TestCase, HybridCloudTestMixin):
         org.flags.codecov_access = True
         org.flags.codecov_access = True
         org.flags.require_2fa = True
         org.flags.require_2fa = True
         org.flags.disable_member_project_creation = True
         org.flags.disable_member_project_creation = True
+        org.flags.prevent_superuser_access = True
         assert flag_has_changed(org, "allow_joinleave") is False
         assert flag_has_changed(org, "allow_joinleave") is False
         assert flag_has_changed(org, "early_adopter")
         assert flag_has_changed(org, "early_adopter")
         assert flag_has_changed(org, "codecov_access")
         assert flag_has_changed(org, "codecov_access")
         assert flag_has_changed(org, "require_2fa")
         assert flag_has_changed(org, "require_2fa")
         assert flag_has_changed(org, "disable_member_project_creation")
         assert flag_has_changed(org, "disable_member_project_creation")
+        assert flag_has_changed(org, "prevent_superuser_access")
 
 
     def test_has_changed(self):
     def test_has_changed(self):
         org = self.create_organization()
         org = self.create_organization()
@@ -228,9 +230,11 @@ class Require2fa(TestCase, HybridCloudTestMixin):
         self.assert_org_member_mapping(org_member=compliant_member)
         self.assert_org_member_mapping(org_member=compliant_member)
         self.assert_org_member_mapping(org_member=non_compliant_member)
         self.assert_org_member_mapping(org_member=non_compliant_member)
 
 
-        with self.options(
-            {"system.url-prefix": "http://example.com"}
-        ), self.tasks(), outbox_runner():
+        with (
+            self.options({"system.url-prefix": "http://example.com"}),
+            self.tasks(),
+            outbox_runner(),
+        ):
             self.org.handle_2fa_required(self.request)
             self.org.handle_2fa_required(self.request)
 
 
         self.is_organization_member(compliant_user.id, compliant_member.id)
         self.is_organization_member(compliant_user.id, compliant_member.id)
@@ -280,9 +284,11 @@ class Require2fa(TestCase, HybridCloudTestMixin):
             self.assert_org_member_mapping(org_member=member)
             self.assert_org_member_mapping(org_member=member)
             non_compliant.append((user, member))
             non_compliant.append((user, member))
 
 
-        with self.options(
-            {"system.url-prefix": "http://example.com"}
-        ), self.tasks(), outbox_runner():
+        with (
+            self.options({"system.url-prefix": "http://example.com"}),
+            self.tasks(),
+            outbox_runner(),
+        ):
             self.org.handle_2fa_required(self.request)
             self.org.handle_2fa_required(self.request)
 
 
         for user, member in non_compliant:
         for user, member in non_compliant:
@@ -340,9 +346,11 @@ class Require2fa(TestCase, HybridCloudTestMixin):
 
 
         self.assert_org_member_mapping(org_member=member)
         self.assert_org_member_mapping(org_member=member)
 
 
-        with self.options(
-            {"system.url-prefix": "http://example.com"}
-        ), self.tasks(), outbox_runner():
+        with (
+            self.options({"system.url-prefix": "http://example.com"}),
+            self.tasks(),
+            outbox_runner(),
+        ):
             with assume_test_silo_mode(SiloMode.CONTROL):
             with assume_test_silo_mode(SiloMode.CONTROL):
                 api_key = ApiKey.objects.create(
                 api_key = ApiKey.objects.create(
                     organization_id=self.org.id,
                     organization_id=self.org.id,
@@ -373,9 +381,11 @@ class Require2fa(TestCase, HybridCloudTestMixin):
         user, member = self._create_user_and_member()
         user, member = self._create_user_and_member()
         self.assert_org_member_mapping(org_member=member)
         self.assert_org_member_mapping(org_member=member)
 
 
-        with self.options(
-            {"system.url-prefix": "http://example.com"}
-        ), self.tasks(), outbox_runner():
+        with (
+            self.options({"system.url-prefix": "http://example.com"}),
+            self.tasks(),
+            outbox_runner(),
+        ):
             request = copy.deepcopy(self.request)
             request = copy.deepcopy(self.request)
             request.META["REMOTE_ADDR"] = None
             request.META["REMOTE_ADDR"] = None
             self.org.handle_2fa_required(request)
             self.org.handle_2fa_required(request)