diff --git a/fixtures/backup/user-with-maximum-privileges.json b/fixtures/backup/user-with-maximum-privileges.json new file mode 100644 index 00000000000000..32ef6372838264 --- /dev/null +++ b/fixtures/backup/user-with-maximum-privileges.json @@ -0,0 +1,99 @@ +[ + { + "model": "sentry.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$150000$iEvdIknqYjTr$+QsGn0tfIJ1FZLxQI37mVU1gL2KbL/wqjMtG/dFhsMA=", + "last_login": null, + "username": "testing@example.com", + "name": "", + "email": "testing@example.com", + "is_staff": true, + "is_active": true, + "is_superuser": true, + "is_managed": false, + "is_sentry_app": null, + "is_password_expired": false, + "last_password_change": "2023-06-22T22:59:57.023Z", + "flags": "0", + "session_nonce": null, + "date_joined": "2023-06-22T22:59:55.488Z", + "last_active": "2023-06-22T22:59:55.489Z", + "avatar_type": 0, + "avatar_url": null + } + }, + { + "model": "sentry.authenticator", + "pk": 1, + "fields": { + "user": 1, + "created_at": "2023-07-27T16:30:53.325Z", + "last_used_at": null, + "type": 1, + "config": "\"\"" + } + }, + { + "model": "sentry.useremail", + "pk": 1, + "fields": { + "user": 1, + "email": "testing@example.com", + "validation_hash": "mCnWesSVvYQcq7qXQ36AZHwosAd6cghE", + "date_hash_added": "2023-06-22T22:59:55.521Z", + "is_verified": true + } + }, + { + "model": "sentry.userip", + "pk": 1, + "fields": { + "user": 1, + "ip_address": "127.0.0.2", + "country_code": null, + "region_code": null, + "first_seen": "2012-04-05T03:29:45.000Z", + "last_seen": "2012-04-05T03:29:45.000Z" + } + }, + { + "model": "sentry.useroption", + "pk": 1, + "fields": { + "user": 1, + "project_id": null, + "organization_id": null, + "key": "timezone", + "value": "\"Europe/Vienna\"" + } + }, + { + "model": "sentry.userpermission", + "pk": 1, + "fields": { + "user": 1, + "permission": "users.admin" + } + }, + { + "model": "sentry.userrole", + "pk": 1, + "fields": { + "date_updated": "2023-06-22T23:00:00.123Z", + "date_added": "2023-06-22T22:54:27.960Z", + "name": "Super Admin", + "permissions": "['broadcasts.admin', 'users.admin', 'options.admin']" + } + }, + { + "model": "sentry.userroleuser", + "pk": 1, + "fields": { + "date_updated": "2023-06-22T23:00:00.123Z", + "date_added": "2023-06-22T22:59:57.000Z", + "user": 1, + "role": 1 + } + } +] diff --git a/fixtures/backup/user-with-minimum-privileges.json b/fixtures/backup/user-with-minimum-privileges.json new file mode 100644 index 00000000000000..53c3195f5a6da3 --- /dev/null +++ b/fixtures/backup/user-with-minimum-privileges.json @@ -0,0 +1,71 @@ +[ + { + "model": "sentry.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$150000$iEvdIknqYjTr$+QsGn0tfIJ1FZLxQI37mVU1gL2KbL/wqjMtG/dFhsMA=", + "last_login": null, + "username": "testing@example.com", + "name": "", + "email": "testing@example.com", + "is_staff": false, + "is_active": true, + "is_superuser": false, + "is_managed": false, + "is_sentry_app": null, + "is_password_expired": false, + "last_password_change": "2023-06-22T22:59:57.023Z", + "flags": "0", + "session_nonce": null, + "date_joined": "2023-06-22T22:59:55.488Z", + "last_active": "2023-06-22T22:59:55.489Z", + "avatar_type": 0, + "avatar_url": null + } + }, + { + "model": "sentry.authenticator", + "pk": 1, + "fields": { + "user": 1, + "created_at": "2023-07-27T16:30:53.325Z", + "last_used_at": null, + "type": 1, + "config": "\"\"" + } + }, + { + "model": "sentry.useremail", + "pk": 1, + "fields": { + "user": 1, + "email": "testing@example.com", + "validation_hash": "mCnWesSVvYQcq7qXQ36AZHwosAd6cghE", + "date_hash_added": "2023-06-22T22:59:55.521Z", + "is_verified": true + } + }, + { + "model": "sentry.userip", + "pk": 1, + "fields": { + "user": 1, + "ip_address": "127.0.0.2", + "country_code": null, + "region_code": null, + "first_seen": "2012-04-05T03:29:45.000Z", + "last_seen": "2012-04-05T03:29:45.000Z" + } + }, + { + "model": "sentry.useroption", + "pk": 1, + "fields": { + "user": 1, + "project_id": null, + "organization_id": null, + "key": "timezone", + "value": "\"Europe/Vienna\"" + } + } +] diff --git a/src/sentry/backup/imports.py b/src/sentry/backup/imports.py index d1825b72f55b5f..bf2323c067431e 100644 --- a/src/sentry/backup/imports.py +++ b/src/sentry/backup/imports.py @@ -10,6 +10,8 @@ from sentry.backup.dependencies import PrimaryKeyMap, normalize_model_name from sentry.backup.helpers import EXCLUDED_APPS +from sentry.backup.scopes import ImportScope +from sentry.silo import unguarded_write class OldImportConfig(NamedTuple): @@ -27,12 +29,19 @@ class OldImportConfig(NamedTuple): use_natural_foreign_keys: bool = False -def imports(src, old_config: OldImportConfig, printer=click.echo): - """Imports core data for the Sentry installation.""" +def _import(src, scope: ImportScope, old_config: OldImportConfig, printer=click.echo): + """ + Imports core data for a Sentry installation. + + It is generally preferable to avoid calling this function directly, as there are certain combinations of input parameters that should not be used together. Instead, use one of the other wrapper functions in this file, named `import_in_XXX_scope()`. + """ try: # Import / export only works in monolith mode with a consolidated db. - with transaction.atomic("default"): + # TODO(getsentry/team-ospo#185): the `unguarded_write` is temporary until we get and RPC + # service up for writing to control silo models. + with unguarded_write(using="default"), transaction.atomic("default"): + allowed_relocation_scopes = scope.value pk_map = PrimaryKeyMap() for obj in serializers.deserialize( "json", src, stream=True, use_natural_keys=old_config.use_natural_foreign_keys @@ -43,9 +52,9 @@ def imports(src, old_config: OldImportConfig, printer=click.echo): # to roll out the new API to self-hosted. if old_config.use_update_instead_of_create: obj.save() - else: + elif o.get_relocation_scope() in allowed_relocation_scopes: o = obj.object - written = o.write_relocation_import(pk_map, obj) + written = o.write_relocation_import(pk_map, obj, scope) if written is not None: old_pk, new_pk = written model_name = normalize_model_name(o) @@ -72,3 +81,27 @@ def imports(src, old_config: OldImportConfig, printer=click.echo): with connection.cursor() as cursor: cursor.execute(sequence_reset_sql.getvalue()) + + +def import_in_user_scope(src, printer=click.echo): + """ + Perform an import in the `User` scope, meaning that only models with `RelocationScope.User` will be imported from the provided `src` file. + """ + return _import(src, ImportScope.User, OldImportConfig(), printer) + + +def import_in_organization_scope(src, printer=click.echo): + """ + Perform an import in the `Organization` scope, meaning that only models with `RelocationScope.User` or `RelocationScope.Organization` will be imported from the provided `src` file. + """ + return _import(src, ImportScope.Organization, OldImportConfig(), printer) + + +def import_in_global_scope(src, printer=click.echo): + """ + Perform an import in the `Global` scope, meaning that all models will be imported from the + provided source file. Because a `Global` import is really only useful when restoring to a fresh + Sentry instance, some behaviors in this scope are different from the others. In particular, + superuser privileges are not sanitized. + """ + return _import(src, ImportScope.Global, OldImportConfig(), printer) diff --git a/src/sentry/backup/mixins.py b/src/sentry/backup/mixins.py new file mode 100644 index 00000000000000..2abf021a8548e7 --- /dev/null +++ b/src/sentry/backup/mixins.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Optional, Tuple + +from django.core.serializers.base import DeserializedObject + +from sentry.backup.dependencies import PrimaryKeyMap +from sentry.backup.scopes import ImportScope + + +class SanitizeUserImportsMixin: + """ + The only realistic reason to do a `Global`ly-scoped import is when restoring some full-instance + backup to a clean install. In this case, one may want to import so-called "superusers": users + with powerful various instance-wide permissions generally reserved for admins and instance + maintainers. Thus, for security reasons, running this import in any `ImportScope` other than + `Global` will sanitize user imports by ignoring imports of the `UserPermission`, `UserRole`, and + `UserRoleUser` models. + """ + + def write_relocation_import( + self, pk_map: PrimaryKeyMap, obj: DeserializedObject, scope: ImportScope + ) -> Optional[Tuple[int, int]]: + if scope != ImportScope.Global: + return None + + return super().write_relocation_import(pk_map, obj, scope) # type: ignore[misc] diff --git a/src/sentry/backup/scopes.py b/src/sentry/backup/scopes.py index 8f1cc3f4c80fd6..3514b14769357c 100644 --- a/src/sentry/backup/scopes.py +++ b/src/sentry/backup/scopes.py @@ -13,9 +13,23 @@ class RelocationScope(Enum): # to a specific user. Global = auto() + # For all models that transitively depend on either `User` or `Organization` root models, and + # nothing else. + Organization = auto() + # Any `Control`-silo model that is either a `User*` model, or directly owner by one, is in this # scope. User = auto() - # For all `Region`-siloed models tied to a specific `Organization`. - Organization = auto() + +@unique +class ImportScope(Enum): + """ + When executing the `sentry import` command, these scopes specify which of the above + `RelocationScope`s should be included in the final export. The basic idea is that each of these + scopes is inclusive of its predecessor in terms of which `RelocationScope`s it accepts. + """ + + User = {RelocationScope.User} + Organization = {RelocationScope.User, RelocationScope.Organization} + Global = {RelocationScope.User, RelocationScope.Organization, RelocationScope.Global} diff --git a/src/sentry/db/models/base.py b/src/sentry/db/models/base.py index 7f62d03eb30dfa..42e3a5b13fda83 100644 --- a/src/sentry/db/models/base.py +++ b/src/sentry/db/models/base.py @@ -9,7 +9,7 @@ from django.utils import timezone from sentry.backup.dependencies import PrimaryKeyMap, dependencies, normalize_model_name -from sentry.backup.scopes import RelocationScope +from sentry.backup.scopes import ImportScope, RelocationScope from sentry.silo import SiloLimit, SiloMode from .fields.bounded import BoundedBigAutoField @@ -108,7 +108,7 @@ def get_relocation_scope(self) -> RelocationScope: return self.__relocation_scope__ - def _normalize_before_relocation_import(self, pk_map: PrimaryKeyMap) -> int: + def _normalize_before_relocation_import(self, pk_map: PrimaryKeyMap, _: ImportScope) -> int: """ A helper function that normalizes a deserialized model. Note that this modifies the model in place, so it should generally be done inside of the companion `write_relocation_import` method, to avoid data skew or corrupted local state. @@ -137,13 +137,13 @@ def _normalize_before_relocation_import(self, pk_map: PrimaryKeyMap) -> int: return old_pk def write_relocation_import( - self, pk_map: PrimaryKeyMap, obj: DeserializedObject + self, pk_map: PrimaryKeyMap, obj: DeserializedObject, scope: ImportScope ) -> Optional[Tuple[int, int]]: """ Writes a deserialized model to the database. If this write is successful, this method will return a tuple of the old and new `pk`s. """ - old_pk = self._normalize_before_relocation_import(pk_map) + old_pk = self._normalize_before_relocation_import(pk_map, scope) obj.save(force_insert=True) return (old_pk, self.pk) diff --git a/src/sentry/models/actor.py b/src/sentry/models/actor.py index ab932f71af003e..f7f35c881a2bf6 100644 --- a/src/sentry/models/actor.py +++ b/src/sentry/models/actor.py @@ -10,7 +10,7 @@ from rest_framework import serializers from sentry.backup.dependencies import PrimaryKeyMap -from sentry.backup.scopes import RelocationScope +from sentry.backup.scopes import ImportScope, RelocationScope from sentry.db.models import Model, region_silo_only_model from sentry.db.models.fields.foreignkey import FlexibleForeignKey from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey @@ -143,8 +143,8 @@ def get_actor_identifier(self): return self.get_actor_tuple().get_actor_identifier() # TODO(hybrid-cloud): actor refactor. Remove this method when done. - def _normalize_before_relocation_import(self, pk_map: PrimaryKeyMap) -> int: - old_pk = super()._normalize_before_relocation_import(pk_map) + def _normalize_before_relocation_import(self, pk_map: PrimaryKeyMap, scope: ImportScope) -> int: + old_pk = super()._normalize_before_relocation_import(pk_map, scope) # `Actor` and `Team` have a direct circular dependency between them for the time being due # to an ongoing refactor (that is, `Actor` foreign keys directly into `Team`, and `Team` diff --git a/src/sentry/models/team.py b/src/sentry/models/team.py index c75edaa36ed1b3..3d0a066eb61478 100644 --- a/src/sentry/models/team.py +++ b/src/sentry/models/team.py @@ -12,7 +12,7 @@ from sentry.app import env from sentry.backup.dependencies import PrimaryKeyMap -from sentry.backup.scopes import RelocationScope +from sentry.backup.scopes import ImportScope, RelocationScope from sentry.constants import ObjectStatus from sentry.db.models import ( BaseManager, @@ -377,9 +377,9 @@ def get_member_actor_ids(self): # TODO(hybrid-cloud): actor refactor. Remove this method when done. def write_relocation_import( - self, pk_map: PrimaryKeyMap, obj: DeserializedObject + self, pk_map: PrimaryKeyMap, obj: DeserializedObject, scope: ImportScope ) -> Optional[Tuple[int, int]]: - written = super().write_relocation_import(pk_map, obj) + written = super().write_relocation_import(pk_map, obj, scope) if written is not None: (_, new_pk) = written diff --git a/src/sentry/models/user.py b/src/sentry/models/user.py index bdf3e59075ccfb..219a91773df8cc 100644 --- a/src/sentry/models/user.py +++ b/src/sentry/models/user.py @@ -15,7 +15,8 @@ from bitfield import TypedClassBitField from sentry.auth.authenticators import available_authenticators -from sentry.backup.scopes import RelocationScope +from sentry.backup.dependencies import PrimaryKeyMap +from sentry.backup.scopes import ImportScope, RelocationScope from sentry.db.models import ( BaseManager, BaseModel, @@ -369,6 +370,16 @@ def get_orgs_require_2fa(self): def clear_lost_passwords(self): LostPasswordHash.objects.filter(user=self).delete() + def _normalize_before_relocation_import(self, pk_map: PrimaryKeyMap, scope: ImportScope) -> int: + old_pk = super()._normalize_before_relocation_import(pk_map, scope) + if scope != ImportScope.Global: + self.is_staff = False + self.is_superuser = False + + # TODO(getsentry/team-ospo#181): Handle usernames that already exist. + + return old_pk + # HACK(dcramer): last_login needs nullable for Django 1.8 User._meta.get_field("last_login").null = True diff --git a/src/sentry/models/useremail.py b/src/sentry/models/useremail.py index 2cea5f0512d526..6e28e40845a199 100644 --- a/src/sentry/models/useremail.py +++ b/src/sentry/models/useremail.py @@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _ from sentry.backup.dependencies import PrimaryKeyMap -from sentry.backup.scopes import RelocationScope +from sentry.backup.scopes import ImportScope, RelocationScope from sentry.db.models import ( BaseManager, FlexibleForeignKey, @@ -88,9 +88,9 @@ def get_primary_email(cls, user: User) -> UserEmail: # with `sentry.user` simultaneously? Will need to make more robust user handling logic, and to # test what happens when a UserEmail already exists. def write_relocation_import( - self, pk_map: PrimaryKeyMap, _: DeserializedObject + self, pk_map: PrimaryKeyMap, obj: DeserializedObject, scope: ImportScope ) -> Optional[Tuple[int, int]]: - old_pk = super()._normalize_before_relocation_import(pk_map) + old_pk = super()._normalize_before_relocation_import(pk_map, scope) (useremail, _) = self.__class__.objects.get_or_create( user=self.user, email=self.email, defaults=model_to_dict(self) ) diff --git a/src/sentry/models/userpermission.py b/src/sentry/models/userpermission.py index 365f4544738a6e..9c83e384300160 100644 --- a/src/sentry/models/userpermission.py +++ b/src/sentry/models/userpermission.py @@ -2,12 +2,13 @@ from django.db import models +from sentry.backup.mixins import SanitizeUserImportsMixin from sentry.backup.scopes import RelocationScope from sentry.db.models import FlexibleForeignKey, Model, control_silo_only_model, sane_repr @control_silo_only_model -class UserPermission(Model): +class UserPermission(SanitizeUserImportsMixin, Model): """ Permissions are applied to administrative users and control explicit scope-like permissions within the API. diff --git a/src/sentry/models/userrole.py b/src/sentry/models/userrole.py index 316a452096b41e..cdb8f0f6589807 100644 --- a/src/sentry/models/userrole.py +++ b/src/sentry/models/userrole.py @@ -3,6 +3,7 @@ from django.conf import settings from django.db import models +from sentry.backup.mixins import SanitizeUserImportsMixin from sentry.backup.scopes import RelocationScope from sentry.db.models import ArrayField, DefaultFieldsModel, control_silo_only_model, sane_repr from sentry.db.models.fields.foreignkey import FlexibleForeignKey @@ -11,7 +12,7 @@ @control_silo_only_model -class UserRole(DefaultFieldsModel): +class UserRole(SanitizeUserImportsMixin, DefaultFieldsModel): """ Roles are applied to administrative users and apply a set of `UserPermission`. """ @@ -41,7 +42,7 @@ def permissions_for_user(cls, user_id: int) -> FrozenSet[str]: @control_silo_only_model -class UserRoleUser(DefaultFieldsModel): +class UserRoleUser(SanitizeUserImportsMixin, DefaultFieldsModel): __relocation_scope__ = RelocationScope.User user = FlexibleForeignKey("sentry.User") diff --git a/src/sentry/runner/commands/backup.py b/src/sentry/runner/commands/backup.py index 4285a187d1be08..ef5dfc9d7f4a40 100644 --- a/src/sentry/runner/commands/backup.py +++ b/src/sentry/runner/commands/backup.py @@ -3,7 +3,8 @@ import click from sentry.backup.exports import OldExportConfig, exports -from sentry.backup.imports import OldImportConfig, imports +from sentry.backup.imports import OldImportConfig, _import +from sentry.backup.scopes import ImportScope from sentry.runner.decorators import configuration @@ -14,8 +15,9 @@ def import_(src, silent): """Imports core data for a Sentry installation.""" - imports( + _import( src, + ImportScope.Global, OldImportConfig( use_update_instead_of_create=True, use_natural_foreign_keys=True, diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index b2f492979a6c15..22a3f4a7823207 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -16,7 +16,8 @@ from sentry.backup.dependencies import sorted_dependencies from sentry.backup.exports import OldExportConfig, exports from sentry.backup.findings import ComparatorFindings -from sentry.backup.imports import OldImportConfig, imports +from sentry.backup.imports import OldImportConfig, _import +from sentry.backup.scopes import ImportScope from sentry.backup.validate import validate from sentry.db.models.fields.bounded import BoundedBigAutoField from sentry.incidents.models import ( @@ -153,7 +154,7 @@ def import_export_then_validate(method_name: str, *, reset_pks: bool = True) -> clear_database_but_keep_sequences() with open(tmp_expect) as tmp_file: - imports(tmp_file, OldImportConfig(), NOOP_PRINTER) + _import(tmp_file, ImportScope.Global, OldImportConfig(), NOOP_PRINTER) # Validate that the "expected" and "actual" JSON matches. actual = export_to_file(tmp_actual) @@ -197,7 +198,7 @@ def import_export_from_fixture_then_validate( # TODO(Hybrid-Cloud): Review whether this is the correct route to apply in this case. with unguarded_write(using="default"), open(fixture_file_path) as fixture_file: - imports(fixture_file, OldImportConfig(), NOOP_PRINTER) + _import(fixture_file, ImportScope.Global, OldImportConfig(), NOOP_PRINTER) res = validate(expect, export_to_file(tmp_path.joinpath("tmp_test_file.json")), map) if res.findings: diff --git a/tests/sentry/backup/test_imports.py b/tests/sentry/backup/test_imports.py new file mode 100644 index 00000000000000..aa37ccd600a327 --- /dev/null +++ b/tests/sentry/backup/test_imports.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import tempfile +from copy import deepcopy +from functools import cached_property +from pathlib import Path + +import pytest +from django.db import IntegrityError + +from sentry.backup.helpers import get_exportable_final_derivations_of +from sentry.backup.imports import ( + import_in_global_scope, + import_in_organization_scope, + import_in_user_scope, +) +from sentry.backup.scopes import RelocationScope +from sentry.db.models.base import BaseModel +from sentry.models.user import User +from sentry.models.userpermission import UserPermission +from sentry.models.userrole import UserRole, UserRoleUser +from sentry.testutils.factories import get_fixture_path +from sentry.testutils.helpers.backups import ( + NOOP_PRINTER, + BackupTestCase, + clear_database_but_keep_sequences, +) +from sentry.utils import json +from tests.sentry.backup import run_backup_tests_only_on_single_db + + +@run_backup_tests_only_on_single_db +class SanitizationTests(BackupTestCase): + """ + Ensure that potentially damaging data is properly scrubbed at import time. + """ + + @cached_property + def json_of_exhaustive_user_with_maximum_privileges(self) -> json.JSONData: + with open(get_fixture_path("backup", "user-with-maximum-privileges.json")) as backup_file: + return json.load(backup_file) + + @cached_property + def json_of_exhaustive_user_with_minimum_privileges(self) -> json.JSONData: + with open(get_fixture_path("backup", "user-with-minimum-privileges.json")) as backup_file: + return json.load(backup_file) + + @staticmethod + def copy_user(exhaustive_user: json.JSONData, username: str) -> json.JSONData: + user = deepcopy(exhaustive_user) + + for model in user: + if model["model"] == "sentry.user": + model["fields"]["username"] = username + + return user + + def generate_tmp_json_file(self, tmp_path) -> json.JSONData: + """ + Generates a file filled with users with different combinations of admin privileges. + """ + + # A user with the maximal amount of "evil" settings. + max_user = self.copy_user(self.json_of_exhaustive_user_with_maximum_privileges, "max_user") + + # A user with no "evil" settings. + min_user = self.copy_user(self.json_of_exhaustive_user_with_minimum_privileges, "min_user") + + # A copy of the `min_user`, but with a maximal `UserPermissions` attached. + permission_user = self.copy_user(min_user, "permission_user") + deepcopy( + list(filter(lambda mod: mod["model"] == "sentry.userpermission", max_user)) + ) + + # A copy of the `min_user`, but with all of the "evil" flags set to `True`. + superadmin_user = self.copy_user(min_user, "superadmin_user") + for model in superadmin_user: + if model["model"] == "sentry.user": + model["fields"]["is_staff"] = True + model["fields"]["is_superuser"] = True + + data = max_user + min_user + permission_user + superadmin_user + with open(tmp_path, "w+") as tmp_file: + json.dump(data, tmp_file) + + def test_user_sanitized_in_user_scope(self): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json") + self.generate_tmp_json_file(tmp_path) + with open(tmp_path) as tmp_file: + import_in_user_scope(tmp_file, NOOP_PRINTER) + + assert User.objects.count() == 4 + assert User.objects.filter(is_staff=False, is_superuser=False).count() == 4 + + assert User.objects.filter(is_staff=True).count() == 0 + assert User.objects.filter(is_superuser=True).count() == 0 + assert UserPermission.objects.count() == 0 + assert UserRole.objects.count() == 0 + assert UserRoleUser.objects.count() == 0 + + def test_user_sanitized_in_organization_scope(self): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json") + self.generate_tmp_json_file(tmp_path) + with open(tmp_path) as tmp_file: + import_in_organization_scope(tmp_file, NOOP_PRINTER) + + assert User.objects.count() == 4 + assert User.objects.filter(is_staff=False, is_superuser=False).count() == 4 + + assert User.objects.filter(is_staff=True).count() == 0 + assert User.objects.filter(is_superuser=True).count() == 0 + assert UserPermission.objects.count() == 0 + assert UserRole.objects.count() == 0 + assert UserRoleUser.objects.count() == 0 + + def test_users_sanitized_in_global_scope(self): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json") + self.generate_tmp_json_file(tmp_path) + with open(tmp_path) as tmp_file: + import_in_global_scope(tmp_file, NOOP_PRINTER) + + assert User.objects.count() == 4 + assert User.objects.filter(is_staff=True).count() == 2 + assert User.objects.filter(is_superuser=True).count() == 2 + assert User.objects.filter(is_staff=False, is_superuser=False).count() == 2 + + # 1 from `max_user`, 1 from `permission_user`. + assert UserPermission.objects.count() == 2 + + # 1 from `max_user`. + assert UserRole.objects.count() == 1 + assert UserRoleUser.objects.count() == 1 + + # TODO(getsentry/team-ospo#181): Should fix this behavior to handle duplicate + def test_bad_already_taken_username(self): + with tempfile.TemporaryDirectory() as tmp_dir: + self.create_user("testing@example.com") + tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json") + with open(tmp_path, "w+") as tmp_file: + json.dump(self.json_of_exhaustive_user_with_minimum_privileges, tmp_file) + + with open(tmp_path) as tmp_file: + with pytest.raises(IntegrityError): + import_in_user_scope(tmp_file, NOOP_PRINTER) + + +@run_backup_tests_only_on_single_db +class ScopingTests(BackupTestCase): + """ + Ensures that only models with the allowed relocation scopes are actually imported. + """ + + def test_user_import_scoping(self): + with tempfile.TemporaryDirectory() as tmp_dir: + self.create_exhaustive_instance(is_superadmin=True) + data = self.import_export_then_validate(self._testMethodName, reset_pks=True) + tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json") + with open(tmp_path, "w+") as tmp_file: + json.dump(data, tmp_file) + + clear_database_but_keep_sequences() + with open(tmp_path) as tmp_file: + import_in_user_scope(tmp_file, NOOP_PRINTER) + for model in get_exportable_final_derivations_of(BaseModel): + if model.__relocation_scope__ != RelocationScope.User: + assert model.objects.count() == 0 + + def test_organization_import_scoping(self): + with tempfile.TemporaryDirectory() as tmp_dir: + self.create_exhaustive_instance(is_superadmin=True) + data = self.import_export_then_validate(self._testMethodName, reset_pks=True) + tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json") + with open(tmp_path, "w+") as tmp_file: + json.dump(data, tmp_file) + + clear_database_but_keep_sequences() + with open(tmp_path) as tmp_file: + import_in_organization_scope(tmp_file, NOOP_PRINTER) + for model in get_exportable_final_derivations_of(BaseModel): + if model.__relocation_scope__ not in { + RelocationScope.User, + RelocationScope.Organization, + }: + assert model.objects.count() == 0