|
@@ -1,21 +1,37 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
from copy import deepcopy
|
|
|
+from io import BytesIO
|
|
|
from pathlib import Path
|
|
|
from tempfile import TemporaryDirectory
|
|
|
from typing import Any
|
|
|
|
|
|
+from orjson import JSONDecodeError, dumps
|
|
|
+
|
|
|
+from sentry.backup.crypto import (
|
|
|
+ DecryptionError,
|
|
|
+ EncryptorDecryptorPair,
|
|
|
+ LocalFileDecryptor,
|
|
|
+ LocalFileEncryptor,
|
|
|
+ create_encrypted_export_tarball,
|
|
|
+ decrypt_encrypted_tarball,
|
|
|
+)
|
|
|
from sentry.backup.dependencies import NormalizedModelName, get_model, get_model_name
|
|
|
+from sentry.backup.exports import ExportCheckpointer, ExportCheckpointerError
|
|
|
+from sentry.backup.helpers import Printer
|
|
|
from sentry.backup.scopes import ExportScope
|
|
|
+from sentry.backup.services.import_export.model import RpcExportOk
|
|
|
from sentry.db import models
|
|
|
from sentry.models.options.option import Option
|
|
|
from sentry.models.organization import Organization
|
|
|
from sentry.models.organizationmember import OrganizationMember
|
|
|
from sentry.models.orgauthtoken import OrgAuthToken
|
|
|
from sentry.testutils.helpers.backups import (
|
|
|
+ NOOP_PRINTER,
|
|
|
BackupTransactionTestCase,
|
|
|
export_to_encrypted_tarball,
|
|
|
export_to_file,
|
|
|
+ generate_rsa_key_pair,
|
|
|
)
|
|
|
from sentry.testutils.helpers.datetime import freeze_time
|
|
|
from sentry.users.models.email import Email
|
|
@@ -26,6 +42,65 @@ from sentry.users.models.userrole import UserRole, UserRoleUser
|
|
|
from tests.sentry.backup import get_matching_exportable_models
|
|
|
|
|
|
|
|
|
+class FakeExportCheckpointer(ExportCheckpointer):
|
|
|
+ cache_hits: int = 0
|
|
|
+ cache_misses: int = 0
|
|
|
+ cache_writes: int = 0
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ crypto: EncryptorDecryptorPair | None,
|
|
|
+ printer: Printer,
|
|
|
+ tmp_dir: str,
|
|
|
+ test_method_name: str,
|
|
|
+ ):
|
|
|
+ self.__crypto = crypto
|
|
|
+ self.__printer = printer
|
|
|
+ self.__tmp_dir = tmp_dir
|
|
|
+ self.__test_method_name = test_method_name
|
|
|
+
|
|
|
+ def _get_file_name(self, model_name: NormalizedModelName) -> Path:
|
|
|
+ if self.__crypto is None:
|
|
|
+ return Path(self.__tmp_dir).joinpath(
|
|
|
+ f"_{self.__test_method_name}.checkpoint.{str(model_name)}.json"
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ return Path(self.__tmp_dir).joinpath(
|
|
|
+ f"_{self.__test_method_name}.checkpoint.{str(model_name)}.enc.tar"
|
|
|
+ )
|
|
|
+
|
|
|
+ def get(self, model_name: NormalizedModelName) -> RpcExportOk | None:
|
|
|
+ file_name = self._get_file_name(model_name)
|
|
|
+ try:
|
|
|
+ with open(file_name, "rb") as fp:
|
|
|
+ json_data = (
|
|
|
+ decrypt_encrypted_tarball(fp, self.__crypto.decryptor)
|
|
|
+ if self.__crypto is not None
|
|
|
+ else fp.read()
|
|
|
+ )
|
|
|
+ parsed_json = self._parse_cached_json(json_data)
|
|
|
+ if parsed_json is None:
|
|
|
+ self.cache_misses += 1
|
|
|
+ else:
|
|
|
+ self.cache_hits += 1
|
|
|
+
|
|
|
+ return parsed_json
|
|
|
+ except (FileNotFoundError, DecryptionError, JSONDecodeError, ExportCheckpointerError):
|
|
|
+ self.cache_misses += 1
|
|
|
+ return None
|
|
|
+
|
|
|
+ def add(self, model_name: NormalizedModelName, json_export: Any) -> None:
|
|
|
+ file_name = self._get_file_name(model_name)
|
|
|
+ with open(file_name, "wb") as fp:
|
|
|
+ out_bytes = (
|
|
|
+ create_encrypted_export_tarball(json_export, self.__crypto.encryptor).getvalue()
|
|
|
+ if self.__crypto is not None
|
|
|
+ else dumps(json_export)
|
|
|
+ )
|
|
|
+ fp.write(out_bytes)
|
|
|
+ self.cache_writes += 1
|
|
|
+
|
|
|
+
|
|
|
class ExportTestCase(BackupTransactionTestCase):
|
|
|
@staticmethod
|
|
|
def count(data: Any, model: type[models.base.BaseModel]) -> int:
|
|
@@ -48,23 +123,32 @@ class ExportTestCase(BackupTransactionTestCase):
|
|
|
|
|
|
def export(
|
|
|
self,
|
|
|
- tmp_dir,
|
|
|
+ tmp_dir: str,
|
|
|
*,
|
|
|
scope: ExportScope,
|
|
|
filter_by: set[str] | None = None,
|
|
|
+ checkpointer: ExportCheckpointer | None = None,
|
|
|
) -> Any:
|
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
|
- return export_to_file(tmp_path, scope=scope, filter_by=filter_by)
|
|
|
+ return export_to_file(tmp_path, scope=scope, filter_by=filter_by, checkpointer=checkpointer)
|
|
|
|
|
|
def export_and_encrypt(
|
|
|
self,
|
|
|
- tmp_dir,
|
|
|
+ tmp_dir: str,
|
|
|
*,
|
|
|
scope: ExportScope,
|
|
|
+ rsa_key_pair: tuple[bytes, bytes],
|
|
|
filter_by: set[str] | None = None,
|
|
|
+ checkpointer: ExportCheckpointer | None = None,
|
|
|
) -> Any:
|
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.enc.tar")
|
|
|
- return export_to_encrypted_tarball(tmp_path, scope=scope, filter_by=filter_by)
|
|
|
+ return export_to_encrypted_tarball(
|
|
|
+ tmp_path,
|
|
|
+ scope=scope,
|
|
|
+ filter_by=filter_by,
|
|
|
+ checkpointer=checkpointer,
|
|
|
+ rsa_key_pair=rsa_key_pair,
|
|
|
+ )
|
|
|
|
|
|
|
|
|
class ScopingTests(ExportTestCase):
|
|
@@ -101,17 +185,131 @@ class ScopingTests(ExportTestCase):
|
|
|
def test_user_export_scoping(self):
|
|
|
self.create_exhaustive_instance(is_superadmin=True)
|
|
|
with TemporaryDirectory() as tmp_dir:
|
|
|
- unencrypted = self.export(tmp_dir, scope=ExportScope.User)
|
|
|
+ unencrypted_checkpointer = FakeExportCheckpointer(
|
|
|
+ crypto=None,
|
|
|
+ printer=NOOP_PRINTER,
|
|
|
+ tmp_dir=tmp_dir,
|
|
|
+ test_method_name=self._testMethodName,
|
|
|
+ )
|
|
|
+ unencrypted = self.export(
|
|
|
+ tmp_dir, scope=ExportScope.User, checkpointer=unencrypted_checkpointer
|
|
|
+ )
|
|
|
self.verify_model_inclusion(unencrypted, ExportScope.User)
|
|
|
- assert unencrypted == self.export_and_encrypt(tmp_dir, scope=ExportScope.User)
|
|
|
+
|
|
|
+ first_pass_cache_writes = unencrypted_checkpointer.cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_hits == 0
|
|
|
+ assert unencrypted_checkpointer.cache_misses > 0
|
|
|
+ assert unencrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+
|
|
|
+ # The following re-run of `self.export` should only use the checkpoint cache. After
|
|
|
+ # completion, we should have no new writes, and no new misses; only hits should be
|
|
|
+ # incremented.
|
|
|
+ self.export(tmp_dir, scope=ExportScope.User, checkpointer=unencrypted_checkpointer)
|
|
|
+ assert unencrypted_checkpointer.cache_hits == first_pass_cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
+
|
|
|
+ rsa_key_pair = generate_rsa_key_pair()
|
|
|
+ (private_key_pem, public_key_pem) = rsa_key_pair
|
|
|
+ encrypted_checkpointer = FakeExportCheckpointer(
|
|
|
+ crypto=EncryptorDecryptorPair(
|
|
|
+ encryptor=LocalFileEncryptor(BytesIO(public_key_pem)),
|
|
|
+ decryptor=LocalFileDecryptor(BytesIO(private_key_pem)),
|
|
|
+ ),
|
|
|
+ printer=NOOP_PRINTER,
|
|
|
+ tmp_dir=tmp_dir,
|
|
|
+ test_method_name=self._testMethodName,
|
|
|
+ )
|
|
|
+ encrypted = self.export_and_encrypt(
|
|
|
+ tmp_dir,
|
|
|
+ scope=ExportScope.User,
|
|
|
+ rsa_key_pair=rsa_key_pair,
|
|
|
+ checkpointer=encrypted_checkpointer,
|
|
|
+ )
|
|
|
+ assert unencrypted == encrypted
|
|
|
+
|
|
|
+ assert encrypted_checkpointer.cache_hits == 0
|
|
|
+ assert encrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
+
|
|
|
+ # The following re-run of `self.export_and_encrypt` should only use the checkpoint
|
|
|
+ # cache. After completion, we should have no new writes, and no new misses; only hits
|
|
|
+ # should be incremented.
|
|
|
+ self.export_and_encrypt(
|
|
|
+ tmp_dir,
|
|
|
+ scope=ExportScope.User,
|
|
|
+ rsa_key_pair=rsa_key_pair,
|
|
|
+ checkpointer=encrypted_checkpointer,
|
|
|
+ )
|
|
|
+ assert encrypted_checkpointer.cache_hits == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
|
|
|
@freeze_time("2023-10-11 18:00:00")
|
|
|
def test_organization_export_scoping(self):
|
|
|
self.create_exhaustive_instance(is_superadmin=True)
|
|
|
with TemporaryDirectory() as tmp_dir:
|
|
|
- unencrypted = self.export(tmp_dir, scope=ExportScope.Organization)
|
|
|
+ unencrypted_checkpointer = FakeExportCheckpointer(
|
|
|
+ crypto=None,
|
|
|
+ printer=NOOP_PRINTER,
|
|
|
+ tmp_dir=tmp_dir,
|
|
|
+ test_method_name=self._testMethodName,
|
|
|
+ )
|
|
|
+ unencrypted = self.export(
|
|
|
+ tmp_dir, scope=ExportScope.Organization, checkpointer=unencrypted_checkpointer
|
|
|
+ )
|
|
|
self.verify_model_inclusion(unencrypted, ExportScope.Organization)
|
|
|
- assert unencrypted == self.export_and_encrypt(tmp_dir, scope=ExportScope.Organization)
|
|
|
+
|
|
|
+ first_pass_cache_writes = unencrypted_checkpointer.cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_hits == 0
|
|
|
+ assert unencrypted_checkpointer.cache_misses > 0
|
|
|
+ assert unencrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+
|
|
|
+ # The following re-run of `self.export` should only use the checkpoint cache. After
|
|
|
+ # completion, we should have no new writes, and no new misses; only hits should be
|
|
|
+ # incremented.
|
|
|
+ self.export(
|
|
|
+ tmp_dir, scope=ExportScope.Organization, checkpointer=unencrypted_checkpointer
|
|
|
+ )
|
|
|
+ assert unencrypted_checkpointer.cache_hits == first_pass_cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
+
|
|
|
+ rsa_key_pair = generate_rsa_key_pair()
|
|
|
+ (private_key_pem, public_key_pem) = rsa_key_pair
|
|
|
+ encrypted_checkpointer = FakeExportCheckpointer(
|
|
|
+ crypto=EncryptorDecryptorPair(
|
|
|
+ encryptor=LocalFileEncryptor(BytesIO(public_key_pem)),
|
|
|
+ decryptor=LocalFileDecryptor(BytesIO(private_key_pem)),
|
|
|
+ ),
|
|
|
+ printer=NOOP_PRINTER,
|
|
|
+ tmp_dir=tmp_dir,
|
|
|
+ test_method_name=self._testMethodName,
|
|
|
+ )
|
|
|
+ encrypted = self.export_and_encrypt(
|
|
|
+ tmp_dir,
|
|
|
+ scope=ExportScope.Organization,
|
|
|
+ rsa_key_pair=rsa_key_pair,
|
|
|
+ checkpointer=encrypted_checkpointer,
|
|
|
+ )
|
|
|
+ assert unencrypted == encrypted
|
|
|
+
|
|
|
+ assert encrypted_checkpointer.cache_hits == 0
|
|
|
+ assert encrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
+
|
|
|
+ # The following re-run of `self.export_and_encrypt` should only use the checkpoint
|
|
|
+ # cache. After completion, we should have no new writes, and no new misses; only hits
|
|
|
+ # should be incremented.
|
|
|
+ self.export_and_encrypt(
|
|
|
+ tmp_dir,
|
|
|
+ scope=ExportScope.Organization,
|
|
|
+ rsa_key_pair=rsa_key_pair,
|
|
|
+ checkpointer=encrypted_checkpointer,
|
|
|
+ )
|
|
|
+ assert encrypted_checkpointer.cache_hits == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
|
|
|
@freeze_time("2023-10-11 18:00:00")
|
|
|
def test_config_export_scoping(self):
|
|
@@ -123,17 +321,129 @@ class ScopingTests(ExportTestCase):
|
|
|
self.create_exhaustive_api_keys_for_user(staff)
|
|
|
self.create_exhaustive_api_keys_for_user(superuser)
|
|
|
with TemporaryDirectory() as tmp_dir:
|
|
|
- unencrypted = self.export(tmp_dir, scope=ExportScope.Config)
|
|
|
+ unencrypted_checkpointer = FakeExportCheckpointer(
|
|
|
+ crypto=None,
|
|
|
+ printer=NOOP_PRINTER,
|
|
|
+ tmp_dir=tmp_dir,
|
|
|
+ test_method_name=self._testMethodName,
|
|
|
+ )
|
|
|
+ unencrypted = self.export(
|
|
|
+ tmp_dir, scope=ExportScope.Config, checkpointer=unencrypted_checkpointer
|
|
|
+ )
|
|
|
self.verify_model_inclusion(unencrypted, ExportScope.Config)
|
|
|
- assert unencrypted == self.export_and_encrypt(tmp_dir, scope=ExportScope.Config)
|
|
|
+
|
|
|
+ first_pass_cache_writes = unencrypted_checkpointer.cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_hits == 0
|
|
|
+ assert unencrypted_checkpointer.cache_misses > 0
|
|
|
+ assert unencrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+
|
|
|
+ # The following re-run of `self.export` should only use the checkpoint cache. After
|
|
|
+ # completion, we should have no new writes, and no new misses; only hits should be
|
|
|
+ # incremented.
|
|
|
+ self.export(tmp_dir, scope=ExportScope.Config, checkpointer=unencrypted_checkpointer)
|
|
|
+ assert unencrypted_checkpointer.cache_hits == first_pass_cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
+
|
|
|
+ rsa_key_pair = generate_rsa_key_pair()
|
|
|
+ (private_key_pem, public_key_pem) = rsa_key_pair
|
|
|
+ encrypted_checkpointer = FakeExportCheckpointer(
|
|
|
+ crypto=EncryptorDecryptorPair(
|
|
|
+ encryptor=LocalFileEncryptor(BytesIO(public_key_pem)),
|
|
|
+ decryptor=LocalFileDecryptor(BytesIO(private_key_pem)),
|
|
|
+ ),
|
|
|
+ printer=NOOP_PRINTER,
|
|
|
+ tmp_dir=tmp_dir,
|
|
|
+ test_method_name=self._testMethodName,
|
|
|
+ )
|
|
|
+ encrypted = self.export_and_encrypt(
|
|
|
+ tmp_dir,
|
|
|
+ scope=ExportScope.Config,
|
|
|
+ rsa_key_pair=rsa_key_pair,
|
|
|
+ checkpointer=encrypted_checkpointer,
|
|
|
+ )
|
|
|
+ assert unencrypted == encrypted
|
|
|
+
|
|
|
+ assert encrypted_checkpointer.cache_hits == 0
|
|
|
+ assert encrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
+
|
|
|
+ # The following re-run of `self.export_and_encrypt` should only use the checkpoint
|
|
|
+ # cache. After completion, we should have no new writes, and no new misses; only hits
|
|
|
+ # should be incremented.
|
|
|
+ self.export_and_encrypt(
|
|
|
+ tmp_dir,
|
|
|
+ scope=ExportScope.Config,
|
|
|
+ rsa_key_pair=rsa_key_pair,
|
|
|
+ checkpointer=encrypted_checkpointer,
|
|
|
+ )
|
|
|
+ assert encrypted_checkpointer.cache_hits == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
|
|
|
@freeze_time("2023-10-11 18:00:00")
|
|
|
def test_global_export_scoping(self):
|
|
|
self.create_exhaustive_instance(is_superadmin=True)
|
|
|
with TemporaryDirectory() as tmp_dir:
|
|
|
- unencrypted = self.export(tmp_dir, scope=ExportScope.Global)
|
|
|
+ unencrypted_checkpointer = FakeExportCheckpointer(
|
|
|
+ crypto=None,
|
|
|
+ printer=NOOP_PRINTER,
|
|
|
+ tmp_dir=tmp_dir,
|
|
|
+ test_method_name=self._testMethodName,
|
|
|
+ )
|
|
|
+ unencrypted = self.export(
|
|
|
+ tmp_dir, scope=ExportScope.Global, checkpointer=unencrypted_checkpointer
|
|
|
+ )
|
|
|
self.verify_model_inclusion(unencrypted, ExportScope.Global)
|
|
|
- assert unencrypted == self.export_and_encrypt(tmp_dir, scope=ExportScope.Global)
|
|
|
+
|
|
|
+ first_pass_cache_writes = unencrypted_checkpointer.cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_hits == 0
|
|
|
+ assert unencrypted_checkpointer.cache_misses > 0
|
|
|
+ assert unencrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+
|
|
|
+ # The following re-run of `self.export` should only use the checkpoint cache. After
|
|
|
+ # completion, we should have no new writes, and no new misses; only hits should be
|
|
|
+ # incremented.
|
|
|
+ self.export(tmp_dir, scope=ExportScope.Global, checkpointer=unencrypted_checkpointer)
|
|
|
+ assert unencrypted_checkpointer.cache_hits == first_pass_cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert unencrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
+
|
|
|
+ rsa_key_pair = generate_rsa_key_pair()
|
|
|
+ (private_key_pem, public_key_pem) = rsa_key_pair
|
|
|
+ encrypted_checkpointer = FakeExportCheckpointer(
|
|
|
+ crypto=EncryptorDecryptorPair(
|
|
|
+ encryptor=LocalFileEncryptor(BytesIO(public_key_pem)),
|
|
|
+ decryptor=LocalFileDecryptor(BytesIO(private_key_pem)),
|
|
|
+ ),
|
|
|
+ printer=NOOP_PRINTER,
|
|
|
+ tmp_dir=tmp_dir,
|
|
|
+ test_method_name=self._testMethodName,
|
|
|
+ )
|
|
|
+ encrypted = self.export_and_encrypt(
|
|
|
+ tmp_dir,
|
|
|
+ scope=ExportScope.Global,
|
|
|
+ rsa_key_pair=rsa_key_pair,
|
|
|
+ checkpointer=encrypted_checkpointer,
|
|
|
+ )
|
|
|
+ assert unencrypted == encrypted
|
|
|
+
|
|
|
+ assert encrypted_checkpointer.cache_hits == 0
|
|
|
+ assert encrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
+
|
|
|
+ # The following re-run of `self.export_and_encrypt` should only use the checkpoint
|
|
|
+ # cache. After completion, we should have no new writes, and no new misses; only hits
|
|
|
+ # should be incremented.
|
|
|
+ self.export_and_encrypt(
|
|
|
+ tmp_dir,
|
|
|
+ scope=ExportScope.Global,
|
|
|
+ rsa_key_pair=rsa_key_pair,
|
|
|
+ checkpointer=encrypted_checkpointer,
|
|
|
+ )
|
|
|
+ assert encrypted_checkpointer.cache_hits == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_misses == first_pass_cache_writes
|
|
|
+ assert encrypted_checkpointer.cache_writes == first_pass_cache_writes
|
|
|
|
|
|
|
|
|
# Filters should work identically in both silo and monolith modes, so no need to repeat the tests
|