From 568c329b6aa7998de6b193f0badc965cfe22d44b Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Tue, 23 Jul 2024 13:23:13 -0700 Subject: [PATCH] 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) --- migrations_lockfile.txt | 2 +- .../api/endpoints/organization_details.py | 4 ++ .../api/serializers/models/organization.py | 1 + ...45_add_prevent_superuser_access_bitflag.py | 48 +++++++++++++++++++ src/sentry/models/organization.py | 5 +- .../endpoints/test_organization_details.py | 5 ++ tests/sentry/models/test_organization.py | 34 ++++++++----- 7 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 src/sentry/migrations/0745_add_prevent_superuser_access_bitflag.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index cf9def7ff9bc5a..9987c215a17b24 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -10,6 +10,6 @@ hybridcloud: 0016_add_control_cacheversion nodestore: 0002_nodestore_no_dictfield remote_subscriptions: 0003_drop_remote_subscription 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 uptime: 0006_projectuptimesubscription_name_owner diff --git a/src/sentry/api/endpoints/organization_details.py b/src/sentry/api/endpoints/organization_details.py index 64575ce5798f84..7b16fde820ca89 100644 --- a/src/sentry/api/endpoints/organization_details.py +++ b/src/sentry/api/endpoints/organization_details.py @@ -246,6 +246,7 @@ class OrganizationSerializer(BaseOrganizationSerializer): openMembership = serializers.BooleanField(required=False) allowSharedIssues = serializers.BooleanField(required=False) allowMemberProjectCreation = serializers.BooleanField(required=False) + allowSuperuserAccess = serializers.BooleanField(required=False) enhancedPrivacy = serializers.BooleanField(required=False) dataScrubber = serializers.BooleanField(required=False) dataScrubberDefaults = serializers.BooleanField(required=False) @@ -511,6 +512,8 @@ def save(self): org.flags.require_email_verification = data["requireEmailVerification"] if "allowMemberProjectCreation" in data: 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: org.name = data["name"] if "slug" in data: @@ -528,6 +531,7 @@ def save(self): "require_2fa": org.flags.require_2fa.is_set, "codecov_access": org.flags.codecov_access.is_set, "disable_member_project_creation": org.flags.disable_member_project_creation.is_set, + "prevent_superuser_access": org.flags.prevent_superuser_access.is_set, }, } diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index cc8166c1b0451b..32dc0fb53cc093 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -353,6 +353,7 @@ def serialize( ), "avatar": avatar, "allowMemberProjectCreation": not obj.flags.disable_member_project_creation, + "allowSuperuserAccess": not obj.flags.prevent_superuser_access, "links": { "organizationUrl": generate_organization_url(obj.slug), "regionUrl": generate_region_url(), diff --git a/src/sentry/migrations/0745_add_prevent_superuser_access_bitflag.py b/src/sentry/migrations/0745_add_prevent_superuser_access_bitflag.py new file mode 100644 index 00000000000000..cd3ccc40971738 --- /dev/null +++ b/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, + ), + ), + ] diff --git a/src/sentry/models/organization.py b/src/sentry/models/organization.py index fc188fe108f46f..222803594e2bbc 100644 --- a/src/sentry/models/organization.py +++ b/src/sentry/models/organization.py @@ -198,6 +198,9 @@ class flags(TypedClassBitField): # Disable org-members from creating new projects disable_member_project_creation: bool + # Prevent superuser access to an organization + prevent_superuser_access: bool + bitfield_default = 1 objects: ClassVar[OrganizationManager] = OrganizationManager(cache_fields=("pk", "slug")) @@ -354,7 +357,7 @@ def _get_bulk_owner_ids(cls, organizations: Collection[Organization]) -> dict[in organization_id__in=org_ids_to_query, role=roles.get_top_dog().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 # `get_default_owner`, which is to use the first one in the query # result's iteration order. diff --git a/tests/sentry/api/endpoints/test_organization_details.py b/tests/sentry/api/endpoints/test_organization_details.py index ba1e51ac3c17b9..2e1e73d54b61f2 100644 --- a/tests/sentry/api/endpoints/test_organization_details.py +++ b/tests/sentry/api/endpoints/test_organization_details.py @@ -428,6 +428,7 @@ def test_various_options(self, mock_get_repositories): "openMembership": False, "isEarlyAdopter": True, "codecovAccess": True, + "allowSuperuserAccess": False, "aiSuggestedSolution": False, "githubOpenPRBot": False, "githubNudgeInvite": False, @@ -470,6 +471,7 @@ def test_various_options(self, mock_get_repositories): assert org.flags.early_adopter assert org.flags.codecov_access + assert org.flags.prevent_superuser_access assert not org.flags.allow_joinleave assert org.flags.disable_shared_issues assert org.flags.enhanced_privacy @@ -501,6 +503,9 @@ def test_various_options(self, mock_get_repositories): assert "to {}".format(data["openMembership"]) in log.data["allow_joinleave"] assert "to {}".format(data["isEarlyAdopter"]) in log.data["early_adopter"] 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(not data["allowSharedIssues"]) in log.data["disable_shared_issues"] assert "to {}".format(data["require2FA"]) in log.data["require_2fa"] diff --git a/tests/sentry/models/test_organization.py b/tests/sentry/models/test_organization.py index 82a22ee1fe77c4..eca01a672ef61c 100644 --- a/tests/sentry/models/test_organization.py +++ b/tests/sentry/models/test_organization.py @@ -95,11 +95,13 @@ def test_flags_have_changed(self): org.flags.codecov_access = True org.flags.require_2fa = 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, "early_adopter") assert flag_has_changed(org, "codecov_access") assert flag_has_changed(org, "require_2fa") assert flag_has_changed(org, "disable_member_project_creation") + assert flag_has_changed(org, "prevent_superuser_access") def test_has_changed(self): org = self.create_organization() @@ -228,9 +230,11 @@ def test_handle_2fa_required__compliant_and_non_compliant_members(self): self.assert_org_member_mapping(org_member=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.is_organization_member(compliant_user.id, compliant_member.id) @@ -280,9 +284,11 @@ def test_handle_2fa_required__non_compliant_members(self): self.assert_org_member_mapping(org_member=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) for user, member in non_compliant: @@ -340,9 +346,11 @@ def test_handle_2fa_required__no_actor_and_api_key__ok(self, auth_log): 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): api_key = ApiKey.objects.create( organization_id=self.org.id, @@ -373,9 +381,11 @@ def test_handle_2fa_required__no_ip_address__ok(self, auth_log): user, member = self._create_user_and_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.META["REMOTE_ADDR"] = None self.org.handle_2fa_required(request)