diff --git a/docs/hosting/administration/erase_organization.rst b/docs/hosting/administration/erase_organization.rst new file mode 100644 index 00000000000..4e5ff1db4e8 --- /dev/null +++ b/docs/hosting/administration/erase_organization.rst @@ -0,0 +1,98 @@ +.. Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +.. _doc_hosting_erase_organization: + +Erase an organization +===================== + +Where & how the data are stored +------------------------------- + +For any given organization, data are split as follow: + +- A PostgreSQL database containing the certificates (e.g. user/devices/workspaces) and + encrypted workspaces metadata (e.g. content of folder, list of blocks for each file version). +- A blockstore (e.g. S3) containing the blocks (i.e. encrypted pieces of data that compose the files). +- On top of that, each Parsec client having access to the organization has an encrypted local database + containing a copy of the certificates, metadata for the workspaces it has access to, and a subset + of the blocks (depending on local cache configuration). + +Erasing data +------------ + +When no longer in use (or for legal reason) an organization can be erased from the Parsec server. + +In practice this means: + +1. Removing everything (certificates & metadata) related to the organization in the PostgreSQL database. +2. Removing the blocks from the blockstore. +3. Removing the remaining data from the clients. + +Step 1: remove from PostgreSQL +------------------------------ + +Erasing an organization from Parsec is both an uncommon and (obviously !) a destructive operation. +As such it is not available from the Administration API but instead must be triggered from the server CLI directly. + +.. code-block:: bash + + # On Parsec server + parsec erase_organization --organization --db + +.. warning:: + + This operation cannot be undone. Make sure you have a backup of any data you may need before + proceeding. + +.. note:: + + After erasing an organization, it is possible to create a new organization with the same name + since no trace of the previous one remains. + + Even if they share the same name, the erased and the new organization are strictly unrelated + since they have a different root verify key (i.e. root key used to verify all certificates + in the organization). + + Typically this means a Parsec client trying to access the erased organization will complain + it doesn't exist on the server even if a new organization with the same name exist. + +Step 2: Blockstore cleanup +-------------------------- + +Once step 1 done, the blocks' decryption keys has been lost. In other words, everything +stored in the blockstore and related to the organization is irrecoverable. +Hence removing those data from the blockstore should be seen as an optional step to reclaim +needlessly occupied space. + +This can be done by manually removing from the blockstore the top level directory named after the organization. +For example, if the organization was named ``CoolOrg``, remove the ``CoolOrg/`` prefix from the bucket. + +.. note:: + + Blockstores have their own backup strategy. Typically AWS S3 allows for a bucket to + have an history so that a data removal can be cancelled. + You should pay attention to this to ensure the blocks have actually been removed. + +Step 3: Clients cleanup +----------------------- + +Once the organization erased from the server, the Parsec client will display an error +about the organization not being found on the server. +However the client can still work in offline mode (if the server is not reachable, the +client cannot know the organization has been erased from the server!), + +For this reason, an end-user is still able to use his Parsec client to work on the +organization using the local cache (e.g. creating a new file in a workspace or +reading an existing file that is in cache). + +To prevent the users from accessing the local cache, the local configuration and data should be manually removed: + +- Linux: + - config: ``$XDG_DATA_HOME or $HOME/.local/share/parsec3/`` (e.g. ``/home/alice/.local/share/parsec3/e68b7131394749a4bbd279bd087e6ae6``) + - data: ``$XDG_CONFIG_HOME or $HOME/.config/parsec3/libparsec/devices/`` (e.g. ``/home/alice/.config/parsec3/libparsec/devices/e68b7131394749a4bbd279bd087e6ae6``) +- macOS: + - config: ``$HOME/Library/Application Support/parsec3/`` (e.g. ``/Users/Alice/Library/Application Support/parsec3/e68b7131394749a4bbd279bd087e6ae6``) + - data: ``$HOME/Library/Application Support/parsec3/libparsec/devices/`` (e.g. ``/Users/Alice/Library/Application Support/parsec3/libparsec/devices/e68b7131394749a4bbd279bd087e6ae6``) +- Windows: + - config: ``{FOLDERID_RoamingAppData}\parsec3\`` (e.g. ``C:\Users\Alice\AppData\Roaming\parsec3\e68b7131394749a4bbd279bd087e6ae6``) + - data: ``{FOLDERID_RoamingAppData}\parsec3\libparsec\devices\`` (e.g. ``C:\Users\Alice\AppData\Roaming/parsec3/libparsec/devices/e68b7131394749a4bbd279bd087e6ae6``) diff --git a/docs/hosting/administration/index.rst b/docs/hosting/administration/index.rst index f206a216920..218a35a8d67 100644 --- a/docs/hosting/administration/index.rst +++ b/docs/hosting/administration/index.rst @@ -13,3 +13,4 @@ Server Administration stats_organization freeze_users shared_recovery + erase_organization diff --git a/server/parsec/backend.py b/server/parsec/backend.py index 687ea95ca9a..35cd3d6b317 100644 --- a/server/parsec/backend.py +++ b/server/parsec/backend.py @@ -156,9 +156,6 @@ async def test_customize_organization( skip_events_offset=len(template.events), ) - async def test_drop_organization(self, id: OrganizationID) -> None: - await self.organization.test_drop_organization(id) - async def test_load_template(self, template: TestbedTemplateContent) -> OrganizationID: org_id = OrganizationID(f"{template.id.title().replace('_', '')}OrgTemplate") match await self.organization.create( diff --git a/server/parsec/cli/__init__.py b/server/parsec/cli/__init__.py index 9db64a3cff6..5671211c52d 100644 --- a/server/parsec/cli/__init__.py +++ b/server/parsec/cli/__init__.py @@ -8,6 +8,7 @@ import click +from parsec.cli.erase_organization import erase_organization from parsec.cli.export import export_realm from parsec.cli.export_email import export_email from parsec.cli.inspect import human_accesses @@ -45,6 +46,7 @@ def cli() -> None: pass +cli.add_command(erase_organization, "erase_organization") cli.add_command(run_cmd, "run") cli.add_command(migrate, "migrate") cli.add_command(export_realm, "export_realm") diff --git a/server/parsec/cli/erase_organization.py b/server/parsec/cli/erase_organization.py new file mode 100644 index 00000000000..1517f1fea73 --- /dev/null +++ b/server/parsec/cli/erase_organization.py @@ -0,0 +1,139 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS +from __future__ import annotations + +import asyncio +from typing import Any + +import click + +from parsec._parsec import ( + OrganizationID, +) +from parsec.cli.options import ( + db_server_options, + debug_config_options, + logging_config_options, +) +from parsec.cli.testbed import if_testbed_available +from parsec.cli.utils import cli_exception_handler, spinner, start_backend +from parsec.components.organization import OrganizationEraseBadOutcome +from parsec.config import ( + BaseDatabaseConfig, + DisabledBlockStoreConfig, + LogLevel, + MockedBlockStoreConfig, +) + + +class DevOption(click.Option): + def handle_parse_result( + self, ctx: click.Context, opts: Any, args: list[str] + ) -> tuple[Any, list[str]]: + value, args = super().handle_parse_result(ctx, opts, args) + if value: + for key, value in ( + ("debug", True), + ("db", "MOCKED"), + ("with_testbed", "coolorg"), + ("organization", "CoolorgOrgTemplate"), + ): + if key not in opts: + opts[key] = value + + return value, args + + +@click.command(short_help="Erase an organization from the database") +@click.option("--organization", type=OrganizationID, help="Organization ID", required=True) +@click.option("--yes", is_flag=True, help="Don't ask for confirmation before proceeding") +@db_server_options +# Add --log-level/--log-format/--log-file +@logging_config_options(default_log_level="INFO") +# Add --debug & --version +@debug_config_options +@if_testbed_available( + click.option("--with-testbed", help="Start by populating with a testbed template") +) +@if_testbed_available( + click.option( + "--dev", + cls=DevOption, + is_flag=True, + is_eager=True, + help=( + "Equivalent to `--debug --db=MOCKED --with-testbed=coolorg --organization CoolorgOrgTemplate`" + ), + ) +) +def erase_organization( + organization: OrganizationID, + db: BaseDatabaseConfig, + db_max_connections: int, + db_min_connections: int, + log_level: LogLevel, + log_format: str, + log_file: str | None, + yes: bool, + debug: bool, + with_testbed: str | None = None, + dev: bool = False, +) -> None: + with cli_exception_handler(debug): + asyncio.run( + _erase_organization( + yes=yes, + db_config=db, + debug=debug, + with_testbed=with_testbed, + organization_id=organization, + ) + ) + + +async def _erase_organization( + db_config: BaseDatabaseConfig, + yes: bool, + debug: bool, + with_testbed: str | None, + organization_id: OrganizationID, +) -> None: + # Can use a dummy blockstore config since we are not going to query it + if with_testbed is None: + blockstore_config = DisabledBlockStoreConfig() + else: + # Testbed template might need to create some blocks + blockstore_config = MockedBlockStoreConfig() + + display_org = click.style(organization_id.str, fg="yellow") + click.echo( + f"You are about to entirely erase the {display_org} organization from the database, this action cannot be undone." + ) + + display_bucket_path = click.style(f"{organization_id.str}/", fg="yellow") + click.echo("Notes:") + click.echo( + "- No trace of the organization will remain, so it will be possible to re-create another organization with the same name." + ) + click.echo( + f"- The organization's blocks won't be erased from the blockstore, you should manually remove the {display_bucket_path} top level directory from it." + ) + click.echo("") + + if not yes: + confirmation = click.prompt("To confirm, type the name of the organization") + if confirmation != organization_id.str: + raise RuntimeError("Organization name does not match, aborting") + + async with start_backend( + db_config=db_config, + blockstore_config=blockstore_config, + debug=debug, + populate_with_template=with_testbed, + ) as backend: + async with spinner("Removing from database..."): + outcome = await backend.organization.erase(id=organization_id) + match outcome: + case None: + pass + case OrganizationEraseBadOutcome.ORGANIZATION_NOT_FOUND: + raise RuntimeError("Organization doesn't exist") diff --git a/server/parsec/cli/testbed.py b/server/parsec/cli/testbed.py index 20d230fbb48..63363803dd0 100644 --- a/server/parsec/cli/testbed.py +++ b/server/parsec/cli/testbed.py @@ -179,9 +179,10 @@ async def customize_organization(self, id: OrganizationID, customization: bytes) cooked_customization = testbed.test_load_testbed_customization(template, customization) # pyright: ignore [reportPossiblyUnboundVariable] await self.backend.test_customize_organization(id, template, cooked_customization) - async def drop_organization(self, id: OrganizationID) -> None: - await self.backend.test_drop_organization(id) - del self.template_per_org[id] + async def drop_organization_idempotent(self, id: OrganizationID) -> None: + # Ignore errors (in case the organization doesn't exist) to be idempotent + await self.backend.organization.erase(id) + self.template_per_org.pop(id, None) testbed_router = APIRouter(tags=["testbed"]) @@ -244,8 +245,7 @@ async def test_new(template: str, request: Request, background_tasks: Background async def _organization_garbage_collector(): await asyncio.sleep(orga_life_limit) logger.info("Dropping testbed org due to time limit", organization=new_org_id.str) - # Dropping is idempotent, so no need for error handling - await testbed.backend.test_drop_organization(new_org_id) + await testbed.drop_organization_idempotent(new_org_id) background_tasks.add_task(_organization_garbage_collector) @@ -279,8 +279,7 @@ async def test_drop(raw_organization_id: str, request: Request) -> Response: except ValueError: return Response(status_code=400, content=b"") - # Dropping is idempotent, so no need for error handling - await testbed.drop_organization(organization_id) + await testbed.drop_organization_idempotent(organization_id) return Response(status_code=200, content=b"") diff --git a/server/parsec/components/memory/organization.py b/server/parsec/components/memory/organization.py index 1aaa9492222..2adf0d1798f 100644 --- a/server/parsec/components/memory/organization.py +++ b/server/parsec/components/memory/organization.py @@ -31,6 +31,7 @@ OrganizationCreateBadOutcome, OrganizationDump, OrganizationDumpTopics, + OrganizationEraseBadOutcome, OrganizationGetBadOutcome, OrganizationGetTosBadOutcome, OrganizationStats, @@ -383,6 +384,16 @@ async def update( if tos is not Unset: await self._event_bus.send(EventOrganizationTosUpdated(organization_id=id)) + @override + async def erase( + self, + id: OrganizationID, + ) -> None | OrganizationEraseBadOutcome: + try: + del self._data.organizations[id] + except KeyError: + return OrganizationEraseBadOutcome.ORGANIZATION_NOT_FOUND + @override async def get_tos( self, id: OrganizationID @@ -437,10 +448,6 @@ async def test_dump_topics(self, id: OrganizationID) -> OrganizationDumpTopics: shamir_recovery=org.per_topic_last_timestamp.get("shamir_recovery"), ) - @override - async def test_drop_organization(self, id: OrganizationID) -> None: - self._data.organizations.pop(id, None) - @override async def test_duplicate_organization( self, source_id: OrganizationID, target_id: OrganizationID diff --git a/server/parsec/components/organization.py b/server/parsec/components/organization.py index 4b3397427b6..f6ed6d15d13 100644 --- a/server/parsec/components/organization.py +++ b/server/parsec/components/organization.py @@ -227,6 +227,10 @@ class OrganizationUpdateBadOutcome(BadOutcomeEnum): ORGANIZATION_NOT_FOUND = auto() +class OrganizationEraseBadOutcome(BadOutcomeEnum): + ORGANIZATION_NOT_FOUND = auto() + + @dataclass(slots=True) class OrganizationDumpTopics: common: DateTime @@ -316,6 +320,12 @@ async def update( ) -> None | OrganizationUpdateBadOutcome: raise NotImplementedError + async def erase( + self, + id: OrganizationID, + ) -> None | OrganizationEraseBadOutcome: + raise NotImplementedError + async def get_tos( self, id: OrganizationID ) -> TermsOfService | None | OrganizationGetTosBadOutcome: @@ -329,9 +339,6 @@ async def test_dump_organizations( async def test_dump_topics(self, id: OrganizationID) -> OrganizationDumpTopics: raise NotImplementedError - async def test_drop_organization(self, id: OrganizationID) -> None: - raise NotImplementedError - async def test_duplicate_organization( self, source_id: OrganizationID, target_id: OrganizationID ) -> None: diff --git a/server/parsec/components/postgresql/organization.py b/server/parsec/components/postgresql/organization.py index 43be338e9cd..d19390758f7 100644 --- a/server/parsec/components/postgresql/organization.py +++ b/server/parsec/components/postgresql/organization.py @@ -24,6 +24,7 @@ OrganizationCreateBadOutcome, OrganizationDump, OrganizationDumpTopics, + OrganizationEraseBadOutcome, OrganizationGetBadOutcome, OrganizationGetTosBadOutcome, OrganizationStats, @@ -37,6 +38,7 @@ from parsec.components.postgresql import AsyncpgConnection, AsyncpgPool from parsec.components.postgresql.organization_bootstrap import organization_bootstrap from parsec.components.postgresql.organization_create import organization_create +from parsec.components.postgresql.organization_erase import organization_erase from parsec.components.postgresql.organization_get_tos import organization_get_tos from parsec.components.postgresql.organization_stats import ( organization_server_stats, @@ -48,7 +50,6 @@ from parsec.components.postgresql.organization_test_dump_topics import organization_test_dump_topics from parsec.components.postgresql.organization_update import organization_update from parsec.components.postgresql.test_queries import ( - q_test_drop_organization, q_test_duplicate_organization, ) from parsec.components.postgresql.user import PGUserComponent @@ -342,6 +343,15 @@ async def update( tos, ) + @override + @transaction + async def erase( + self, + conn: AsyncpgConnection, + id: OrganizationID, + ) -> None | OrganizationEraseBadOutcome: + return await organization_erase(conn, id) + @override @no_transaction async def get_tos( @@ -363,11 +373,6 @@ async def test_dump_topics( ) -> OrganizationDumpTopics: return await organization_test_dump_topics(conn, id) - @override - @transaction - async def test_drop_organization(self, conn: AsyncpgConnection, id: OrganizationID) -> None: - await conn.execute(*q_test_drop_organization(organization_id=id.str)) - @override @transaction async def test_duplicate_organization( diff --git a/server/parsec/components/postgresql/organization_erase.py b/server/parsec/components/postgresql/organization_erase.py new file mode 100644 index 00000000000..84ce4ffa30f --- /dev/null +++ b/server/parsec/components/postgresql/organization_erase.py @@ -0,0 +1,182 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS +from __future__ import annotations + +from parsec._parsec import OrganizationID +from parsec.components.organization import OrganizationEraseBadOutcome +from parsec.components.postgresql import AsyncpgConnection +from parsec.components.postgresql.utils import Q + +_q_erase_organization = Q( + """ +WITH +deleted_organizations AS ( + DELETE FROM organization + WHERE organization_id = $organization_id + RETURNING _id +), + +deleted_sequester_service AS ( + DELETE FROM sequester_service + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_human AS ( + DELETE FROM human + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_users AS ( + DELETE FROM user_ + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_devices AS ( + DELETE FROM device + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_profiles AS ( + DELETE FROM profile + WHERE user_ IN (SELECT * FROM deleted_users) + RETURNING _id +), + +deleted_invitations AS ( + DELETE FROM invitation + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_realms AS ( + DELETE FROM realm + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_realm_user_roles AS ( + DELETE FROM realm_user_role + WHERE realm IN (SELECT * FROM deleted_realms) + RETURNING _id +), + +deleted_vlob_atoms AS ( + DELETE FROM vlob_atom + WHERE realm IN (SELECT * FROM deleted_realms) + RETURNING _id +), + +deleted_blocks AS ( + DELETE FROM block + WHERE realm IN (SELECT * FROM deleted_realms) + RETURNING _id, block_id +), + +deleted_realm_keys_bundle AS ( + DELETE FROM realm_keys_bundle + WHERE realm IN (SELECT * FROM deleted_realms) + RETURNING _id +), + +deleted_realm_keys_bundle_access AS ( + DELETE FROM realm_keys_bundle_access + WHERE realm IN (SELECT * FROM deleted_realms) + RETURNING _id +), + +deleted_realm_sequester_keys_bundle_access AS ( + DELETE FROM realm_sequester_keys_bundle_access + WHERE realm_keys_bundle IN (SELECT * FROM deleted_realm_keys_bundle) + RETURNING _id +), + +deleted_realm_names AS ( + DELETE FROM realm_name + WHERE realm IN (SELECT * FROM deleted_realms) + RETURNING _id +), + +deleted_realm_vlob_updates AS ( + DELETE FROM realm_vlob_update + WHERE realm IN (SELECT * FROM deleted_realms) + RETURNING _id +), + +deleted_block_data AS ( + DELETE FROM block_data + WHERE organization_id = $organization_id + RETURNING _id +), + +deleted_topics_common AS ( + DELETE FROM common_topic + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_topics_sequester AS ( + DELETE FROM sequester_topic + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_topics_shamir_recovery AS ( + DELETE FROM shamir_recovery_topic + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_topics_realm AS ( + DELETE FROM realm_topic + WHERE + organization IN (SELECT * FROM deleted_organizations) + OR realm IN (SELECT * FROM deleted_realms) + RETURNING _id +), + +deleted_greeting_sessions AS ( + DELETE FROM greeting_session + WHERE invitation IN (SELECT * FROM deleted_invitations) + RETURNING _id +), + +deleted_greeting_attempts AS ( + DELETE FROM greeting_attempt + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_greeting_steps AS ( + DELETE FROM greeting_step + WHERE greeting_attempt IN (SELECT * FROM deleted_greeting_attempts) + RETURNING _id +), + +deleted_shamir_recovery_setups AS ( + DELETE FROM shamir_recovery_setup + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +), + +deleted_shamir_recovery_shares AS ( + DELETE FROM shamir_recovery_share + WHERE organization IN (SELECT * FROM deleted_organizations) + RETURNING _id +) + +SELECT (SELECT COUNT(*) FROM deleted_organizations) AS deleted_count +""" +) + + +async def organization_erase( + conn: AsyncpgConnection, + organization_id: OrganizationID, +) -> None | OrganizationEraseBadOutcome: + row = await conn.fetchrow(*_q_erase_organization(organization_id=organization_id.str)) + assert row is not None + if row["deleted_count"] == 0: + return OrganizationEraseBadOutcome.ORGANIZATION_NOT_FOUND diff --git a/server/parsec/components/postgresql/test_queries.py b/server/parsec/components/postgresql/test_queries.py index 3d3f5be17d0..96077791a3e 100644 --- a/server/parsec/components/postgresql/test_queries.py +++ b/server/parsec/components/postgresql/test_queries.py @@ -12,172 +12,6 @@ q_user, ) -q_test_drop_organization = Q( - """ -WITH -deleted_organizations AS ( - DELETE FROM organization - WHERE organization_id = $organization_id - RETURNING _id -), - -deleted_sequester_service AS ( - DELETE FROM sequester_service - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_human AS ( - DELETE FROM human - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_users AS ( - DELETE FROM user_ - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_devices AS ( - DELETE FROM device - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_profiles AS ( - DELETE FROM profile - WHERE user_ IN (SELECT * FROM deleted_users) - RETURNING _id -), - -deleted_invitations AS ( - DELETE FROM invitation - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_realms AS ( - DELETE FROM realm - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_realm_user_roles AS ( - DELETE FROM realm_user_role - WHERE realm IN (SELECT * FROM deleted_realms) - RETURNING _id -), - -deleted_vlob_atoms AS ( - DELETE FROM vlob_atom - WHERE realm IN (SELECT * FROM deleted_realms) - RETURNING _id -), - -deleted_blocks AS ( - DELETE FROM block - WHERE realm IN (SELECT * FROM deleted_realms) - RETURNING _id, block_id -), - -deleted_realm_keys_bundle AS ( - DELETE FROM realm_keys_bundle - WHERE realm IN (SELECT * FROM deleted_realms) - RETURNING _id -), - -deleted_realm_keys_bundle_access AS ( - DELETE FROM realm_keys_bundle_access - WHERE realm IN (SELECT * FROM deleted_realms) - RETURNING _id -), - -deleted_realm_sequester_keys_bundle_access AS ( - DELETE FROM realm_sequester_keys_bundle_access - WHERE realm_keys_bundle IN (SELECT * FROM deleted_realm_keys_bundle) - RETURNING _id -), - -deleted_realm_names AS ( - DELETE FROM realm_name - WHERE realm IN (SELECT * FROM deleted_realms) - RETURNING _id -), - -deleted_realm_vlob_updates AS ( - DELETE FROM realm_vlob_update - WHERE realm IN (SELECT * FROM deleted_realms) - RETURNING _id -), - -deleted_block_data AS ( - DELETE FROM block_data - WHERE organization_id = $organization_id - RETURNING _id -), - -deleted_topics_common AS ( - DELETE FROM common_topic - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_topics_sequester AS ( - DELETE FROM sequester_topic - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_topics_shamir_recovery AS ( - DELETE FROM shamir_recovery_topic - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_topics_realm AS ( - DELETE FROM realm_topic - WHERE - organization IN (SELECT * FROM deleted_organizations) - OR realm IN (SELECT * FROM deleted_realms) - RETURNING _id -), - -deleted_greeting_sessions AS ( - DELETE FROM greeting_session - WHERE invitation IN (SELECT * FROM deleted_invitations) - RETURNING _id -), - -deleted_greeting_attempts AS ( - DELETE FROM greeting_attempt - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_greeting_steps AS ( - DELETE FROM greeting_step - WHERE greeting_attempt IN (SELECT * FROM deleted_greeting_attempts) - RETURNING _id -), - -deleted_shamir_recovery_setups AS ( - DELETE FROM shamir_recovery_setup - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -), - -deleted_shamir_recovery_shares AS ( - DELETE FROM shamir_recovery_share - WHERE organization IN (SELECT * FROM deleted_organizations) - RETURNING _id -) - -SELECT 1 -""" -) - - q_test_duplicate_organization = Q( f""" WITH diff --git a/server/tests/api_v5/authenticated/test_events_listen.py b/server/tests/api_v5/authenticated/test_events_listen.py index 0d585bde50b..d77bce027f6 100644 --- a/server/tests/api_v5/authenticated/test_events_listen.py +++ b/server/tests/api_v5/authenticated/test_events_listen.py @@ -263,7 +263,7 @@ async def test_conn_closed_on_bad_outcome( ) ) - await backend.organization.test_drop_organization(minimalorg.organization_id) + await backend.organization.erase(minimalorg.organization_id) with pytest.raises(StopAsyncIteration): async with minimalorg.alice.events_listen() as alice_sse: diff --git a/server/tests/cli/test_erase_organization.py b/server/tests/cli/test_erase_organization.py new file mode 100644 index 00000000000..7cb2ae18c46 --- /dev/null +++ b/server/tests/cli/test_erase_organization.py @@ -0,0 +1,48 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS +from __future__ import annotations + +import anyio +from click.testing import CliRunner + +from parsec.cli import cli + + +async def test_erase_organization_ok() -> None: + runner = CliRunner() + result = await anyio.to_thread.run_sync( + lambda: runner.invoke( + cli, + "erase_organization --organization MinimalOrgTemplate --db MOCKED --with-testbed minimal", + input="MinimalOrgTemplate\n", + env={"DEBUG": "1"}, + ) + ) + assert result.exit_code == 0, result.output + assert "Removing from database..." in result.output + + +async def test_erase_organization_not_found() -> None: + runner = CliRunner() + result = await anyio.to_thread.run_sync( + lambda: runner.invoke( + cli, + "erase_organization --organization NonExistentOrg --db MOCKED", + input="NonExistentOrg\n", + env={"DEBUG": "1"}, + ) + ) + assert result.exit_code != 0 + + +async def test_erase_organization_confirmation_mismatch() -> None: + runner = CliRunner() + result = await anyio.to_thread.run_sync( + lambda: runner.invoke( + cli, + "erase_organization --organization MinimalOrgTemplate --db MOCKED --with-testbed minimal", + input="WrongName\n", + env={"DEBUG": "1"}, + ) + ) + assert result.exit_code != 0 + assert "does not match" in result.output diff --git a/server/tests/test_erase_organization.py b/server/tests/test_erase_organization.py new file mode 100644 index 00000000000..ee58dd3c0c8 --- /dev/null +++ b/server/tests/test_erase_organization.py @@ -0,0 +1,92 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS +from __future__ import annotations + +import pytest + +from parsec._parsec import ( + BootstrapToken, + DateTime, + DeviceLabel, + OrganizationID, + SigningKey, + UserProfile, +) +from parsec.components.organization import OrganizationEraseBadOutcome + +from .common import ( + Backend, + CoolorgRpcClients, + RpcTransportError, + generate_new_device_certificates, + generate_new_user_certificates, +) + + +async def test_erase_organization_ok(backend: Backend, coolorg: CoolorgRpcClients) -> None: + org_id = coolorg.organization_id + + dump = await backend.organization.test_dump_organizations() + assert org_id in dump + + outcome = await backend.organization.erase(id=org_id) + assert outcome is None + + dump = await backend.organization.test_dump_organizations() + assert org_id not in dump + + with pytest.raises(RpcTransportError) as exc: + await coolorg.alice.ping(ping="hello") + assert exc.value.rep.status_code == 404 + + # Try to re-erase the organization + + outcome = await backend.organization.erase(id=org_id) + assert outcome == OrganizationEraseBadOutcome.ORGANIZATION_NOT_FOUND + + # Recreate an organization with the same name is now possible + + bootstrap_token = await backend.organization.create( + now=DateTime.now(), + id=coolorg.organization_id, + ) + assert isinstance(bootstrap_token, BootstrapToken) + + root_key = SigningKey.generate() + + alice_user_certificates = generate_new_user_certificates( + timestamp=DateTime.now(), + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + profile=UserProfile.ADMIN, + author_device_id=None, + author_signing_key=root_key, + ) + + alice_device_certificates = generate_new_device_certificates( + timestamp=alice_user_certificates.certificate.timestamp, + user_id=coolorg.alice.user_id, + device_id=coolorg.alice.device_id, + device_label=DeviceLabel("Dev1"), + author_device_id=None, + author_signing_key=root_key, + ) + + outcome = await backend.organization.bootstrap( + id=coolorg.organization_id, + now=DateTime.now(), + bootstrap_token=bootstrap_token, + root_verify_key=root_key.verify_key, + user_certificate=alice_user_certificates.signed_certificate, + device_certificate=alice_device_certificates.signed_certificate, + redacted_user_certificate=alice_user_certificates.signed_redacted_certificate, + redacted_device_certificate=alice_device_certificates.signed_redacted_certificate, + sequester_authority_certificate=None, + ) + assert isinstance(outcome, tuple) + + +async def test_erase_organization_not_found(backend: Backend) -> None: + dummy_id = OrganizationID("NonExistentOrg") + + outcome = await backend.organization.erase(id=dummy_id) + assert outcome == OrganizationEraseBadOutcome.ORGANIZATION_NOT_FOUND