diff --git a/askar_tools/README.md b/askar_tools/README.md index cb98e23..8ffb8e3 100644 --- a/askar_tools/README.md +++ b/askar_tools/README.md @@ -10,7 +10,6 @@ poetry install ``` - ### Export Wallet: * Exports a wallet into a file with a readable json format. This can be useful for debugging or for sharing wallet information with others. @@ -22,11 +21,13 @@ poetry install poetry run askar-tools \ --strategy export \ --uri postgres://:@:/ \ - --base-wallet-name \ - --base-wallet-key + --wallet-name \ + --wallet-key \ + --wallet-key-derivation-method \ + --export-filename ``` -### Multitenant Wallet - Switch from single wallet to multi wallet: +### Multi-tenant Wallet - Switch from single wallet to multi wallet: ##### Prerequisites: Backup sub-wallet. This operation will delete the sub-wallet when finished. If the wallet is broken for some reason you will not be able to recover it without a backup. @@ -34,7 +35,7 @@ poetry install * Converts the profiles in the sub-wallet to individual wallets and databases. * After completion, the sub-wallet will be deleted and the deployment should no longer use the `--multitenancy-config '{"wallet_type": "single-wallet-askar"}'` configuration. -- `export` (Output the contents of a wallet to a json file): +- `mt-convert-to-mw` (Convert from single wallet to multi-wallet multi-tenant agent): ``` poetry run askar-tools \ @@ -42,5 +43,31 @@ poetry install --uri postgres://:@:/ \ --wallet-name \ --wallet-key \ + --wallet-key-derivation-method \ --multitenant-sub-wallet-name + ``` + +### Import Wallet: + + * Imports a wallet from a database location into a multi-tenant multi-wallet admin and database location. + +- `tenant-import` (Import a wallet into a multi-wallet multi-tenant agent): + + ``` + poetry run askar-tools \ + --strategy tenant-import \ + --uri postgres://:@:/ \ + --wallet-name \ + --wallet-key \ + --wallet-key-derivation-method \ + --tenant-uri postgres://:@:/ \ + --tenant-wallet-name \ + --tenant-wallet-key \ + --tenant-wallet-key-derivation-method \ + --tenant-wallet-type \ + --tenant-label \ + --tenant-image-url \ + --tenant-webhook-urls \ + --tenant-extra-settings \ + --tenant-dispatch-type ``` \ No newline at end of file diff --git a/askar_tools/__main__.py b/askar_tools/__main__.py index ffa8cef..810d677 100644 --- a/askar_tools/__main__.py +++ b/askar_tools/__main__.py @@ -17,6 +17,8 @@ def config(): """Parse command line arguments.""" parser = argparse.ArgumentParser("askar-wallet-tools") + + # Strategy parser.add_argument( "--strategy", required=True, @@ -26,6 +28,8 @@ def config(): "management mode, and agent type." ), ) + + # Main wallet parser.add_argument( "--uri", required=True, @@ -49,6 +53,23 @@ def config(): "be migrated for database per wallet (export) migration strategy." ), ) + parser.add_argument( + "--wallet-key-derivation-method", + type=str, + help=("Specify key derivation method for the wallet. Default is 'ARGON2I_MOD'."), + ) + + # Export + parser.add_argument( + "--export-filename", + type=str, + help=( + "Specify the filename to export the data to. Default is 'wallet_export.json'." + ), + default="wallet_export.json", + ) + + # Multiwallet conversion parser.add_argument( "--multitenant-sub-wallet-name", type=str, @@ -59,7 +80,7 @@ def config(): default="multitenant_sub_wallet", ) - # Add arguments for tenant import + # Tenant import parser.add_argument( "--tenant-uri", help=("Specify URI of the tenant database to be imported."), @@ -75,9 +96,44 @@ def config(): help=("Specify key corresponding of the tenant wallet to be imported."), ) parser.add_argument( - "--export-filename", + "--tenant-wallet-key-derivation-method", + type=str, + help=( + "Specify key derivation method for the tenant wallet. Default is 'ARGON2I_MOD'." + ), + ) + parser.add_argument( + "--tenant-wallet-type", + type=str, + help=( + """Specify the wallet type of the tenant wallet. Either 'askar' + or 'askar-anoncreds'. Default is 'askar'.""" + ), + ) + parser.add_argument( + "--tenant-label", + type=str, + help=("Specify the label for the tenant wallet."), + ) + parser.add_argument( + "--tenant-image-url", + type=str, + help=("Specify the image URL for the tenant wallet."), + ) + parser.add_argument( + "--tenant-webhook-urls", + type=list, + help=("Specify the webhook URLs for the tenant wallet."), + ) + parser.add_argument( + "--tenant-extra-settings", + type=dict, + help=("Specify extra settings for the tenant wallet."), + ) + parser.add_argument( + "--tenant-dispatch-type", type=str, - help=("Specify the filename to export the data to."), + help=("Specify the dispatch type for the tenant wallet."), ) args, _ = parser.parse_known_args(sys.argv[1:]) @@ -101,11 +157,19 @@ async def main( uri: str, wallet_name: Optional[str] = None, wallet_key: Optional[str] = None, + wallet_key_derivation_method: Optional[str] = "ARGON2I_MOD", multitenant_sub_wallet_name: Optional[str] = "multitenant_sub_wallet", tenant_uri: Optional[str] = None, tenant_wallet_name: Optional[str] = None, tenant_wallet_key: Optional[str] = None, - tenant_export_filename: Optional[str] = "wallet_export.json", + tenant_wallet_type: Optional[str] = "askar", + tenant_wallet_key_derivation_method: Optional[str] = "ARGON2I_MOD", + tenant_label: Optional[str] = None, + tenant_image_url: Optional[str] = None, + tenant_webhook_urls: Optional[list] = None, + tenant_extra_settings: Optional[dict] = None, + tenant_dispatch_type: Optional[str] = "default", + export_filename: Optional[str] = "wallet_export.json", ): """Run the main function.""" logging.basicConfig(level=logging.WARN) @@ -122,12 +186,12 @@ async def main( # Strategy setup if strategy == "export": await conn.connect() - print("wallet_name", wallet_name) method = Exporter( conn=conn, wallet_name=wallet_name, wallet_key=wallet_key, - export_filename=tenant_export_filename, + wallet_key_derivation_method=wallet_key_derivation_method, + export_filename=export_filename, ) elif strategy == "mt-convert-to-mw": await conn.connect() @@ -135,6 +199,7 @@ async def main( conn=conn, wallet_name=wallet_name, wallet_key=wallet_key, + wallet_key_derivation_method=wallet_key_derivation_method, sub_wallet_name=multitenant_sub_wallet_name, ) elif strategy == "tenant-import": @@ -152,9 +217,17 @@ async def main( admin_conn=conn, admin_wallet_name=wallet_name, admin_wallet_key=wallet_key, + admin_wallet_key_derivation_method=wallet_key_derivation_method, tenant_conn=tenant_conn, tenant_wallet_name=tenant_wallet_name, tenant_wallet_key=tenant_wallet_key, + tenant_wallet_type=tenant_wallet_type, + tenant_wallet_key_derivation_method=tenant_wallet_key_derivation_method, + tenant_label=tenant_label, + tenant_image_url=tenant_image_url, + tenant_webhook_urls=tenant_webhook_urls, + tenant_extra_settings=tenant_extra_settings, + tenant_dispatch_type=tenant_dispatch_type, ) else: raise Exception("Invalid strategy") diff --git a/askar_tools/exporter.py b/askar_tools/exporter.py index 8d6377d..d64e44a 100644 --- a/askar_tools/exporter.py +++ b/askar_tools/exporter.py @@ -5,6 +5,7 @@ from aries_askar import Store +from .key_methods import KEY_METHODS from .pg_connection import PgConnection from .sqlite_connection import SqliteConnection @@ -17,6 +18,7 @@ def __init__( conn: SqliteConnection | PgConnection, wallet_name: str, wallet_key: str, + wallet_key_derivation_method: str = "ARGON2I_MOD", export_filename: str = "wallet_export.json", ): """Initialize the Exporter object. @@ -25,10 +27,13 @@ def __init__( conn: The connection object. wallet_name: The name of the wallet. wallet_key: The key for the wallet. + wallet_key_derivation_method: The key derivation method for the wallet. + export_filename: The name of the export file. """ self.conn = conn self.wallet_name = wallet_name self.wallet_key = wallet_key + self.wallet_key_derivation_method = wallet_key_derivation_method self.export_filename = export_filename async def _get_decoded_items_and_tags(self, store): @@ -53,10 +58,14 @@ async def _get_decoded_items_and_tags(self, store): async def export(self): """Export the wallet data.""" - print("Exporting wallet to wallet_export.json...") + print(f"Exporting wallet to {self.export_filename}...") tables = {"config": {}, "items": {}, "profiles": {}} - store = await Store.open(self.conn.uri, pass_key=self.wallet_key) + store = await Store.open( + self.conn.uri, + pass_key=self.wallet_key, + key_method=KEY_METHODS[self.wallet_key_derivation_method], + ) tables["items"] = await self._get_decoded_items_and_tags(store) diff --git a/askar_tools/key_methods.py b/askar_tools/key_methods.py new file mode 100644 index 0000000..fd737ae --- /dev/null +++ b/askar_tools/key_methods.py @@ -0,0 +1,7 @@ +""".Key methods for Askar wallet.""" + +KEY_METHODS = { + "RAW": "RAW", + "ARGON2I_INT": "kdf:argon2i:int", + "ARGON2I_MOD": "kdf:argon2i:mod", +} diff --git a/askar_tools/multi_wallet_converter.py b/askar_tools/multi_wallet_converter.py index 4f30177..942ee6c 100644 --- a/askar_tools/multi_wallet_converter.py +++ b/askar_tools/multi_wallet_converter.py @@ -3,15 +3,10 @@ from aries_askar import Store from .error import ConversionError +from .key_methods import KEY_METHODS from .pg_connection import PgConnection from .sqlite_connection import SqliteConnection -KEY_METHODS = { - "KEY_DERIVATION_RAW": "RAW", - "KEY_DERIVATION_ARGON2I_INT": "kdf:argon2i:int", - "KEY_DERIVATION_ARGON2I_MOD": "kdf:argon2i:mod", -} - class MultiWalletConverter: """Util class for converting multi-tenant wallets between single wallet and multi wallet.""" # noqa: E501 @@ -21,6 +16,7 @@ def __init__( conn: SqliteConnection | PgConnection, wallet_name: str, wallet_key: str, + wallet_key_derivation_method: str, sub_wallet_name: str, ): """Initialize the MultiWalletConverter instance. @@ -29,11 +25,13 @@ def __init__( conn (SqliteConnection): The SQLite connection object. wallet_name (str): The name of the wallet. wallet_key (str): The key for the wallet. + wallet_key_derivation_method (str): The key derivation method for the wallet. sub_wallet_name (str): The name of the sub wallet. """ self.conn = conn self.admin_wallet_name = wallet_name self.admin_wallet_key = wallet_key + self.wallet_key_derivation_method = wallet_key_derivation_method self.sub_wallet_name = sub_wallet_name def get_wallet_records(self, entries): @@ -87,7 +85,7 @@ async def convert_single_wallet_to_multi_wallet(self): ) key_method = KEY_METHODS.get( wallet_record["settings"].get( - "wallet.key_derivation_method", "KEY_DERIVATION_ARGON2I_MOD" + "wallet.key_derivation_method", "ARGON2I_MOD" ) ) print( diff --git a/askar_tools/tenant_importer.py b/askar_tools/tenant_importer.py index 508446c..584d923 100644 --- a/askar_tools/tenant_importer.py +++ b/askar_tools/tenant_importer.py @@ -5,6 +5,7 @@ from aries_askar import Store +from .key_methods import KEY_METHODS from .pg_connection import PgConnection from .sqlite_connection import SqliteConnection @@ -20,6 +21,14 @@ def __init__( tenant_conn: SqliteConnection | PgConnection, tenant_wallet_name: str, tenant_wallet_key: str, + tenant_wallet_type: str = "askar", + tenant_label: str = None, + tenant_image_url: str = None, + tenant_webhook_urls: list = None, + tenant_extra_settings: dict = None, + tenant_dispatch_type: str = "default", + admin_wallet_key_derivation_method: str = "ARGON2I_MOD", + tenant_wallet_key_derivation_method: str = "ARGON2I_MOD", ): """Initialize the Tenant Importer object. @@ -30,6 +39,16 @@ def __init__( tenant_conn: The tenant connection object. tenant_wallet_name: The name of the tenant wallet. tenant_wallet_key: The key for the tenant wallet. + tenant_wallet_type: The type of the tenant wallet. + tenant_label: The label for the tenant wallet. + tenant_image_url: The image URL for the tenant wallet. + tenant_webhook_urls: The webhook URLs for the tenant wallet. + tenant_extra_settings: Extra settings for the tenant wallet. + tenant_dispatch_type: The dispatch type for the tenant wallet. + admin_wallet_key_derivation_method: The key derivation method for the admin + wallet. + tenant_wallet_key_derivation_method: The key derivation method for the tenant + wallet. """ self.admin_conn = admin_conn self.admin_wallet_name = admin_wallet_name @@ -37,26 +56,52 @@ def __init__( self.tenant_conn = tenant_conn self.tenant_wallet_name = tenant_wallet_name self.tenant_wallet_key = tenant_wallet_key + self.tenant_wallet_type = tenant_wallet_type + self.tenant_label = tenant_label + self.tenant_image_url = tenant_image_url + self.tenant_webhook_urls = tenant_webhook_urls + self.tenant_extra_settings = tenant_extra_settings + self.tenant_dispatch_type = tenant_dispatch_type + self.admin_wallet_key_derivation_method = admin_wallet_key_derivation_method + self.tenant_wallet_key_derivation_method = tenant_wallet_key_derivation_method async def _create_tenant(self, wallet_id: str, admin_txn, current_time: str): # Create wallet record in admin wallet + + value_json = { + "wallet_name": self.tenant_wallet_name, + "created_at": current_time, + "updated_at": current_time, + "settings": { + "wallet.type": self.tenant_wallet_type, + "wallet.name": self.tenant_wallet_name, + "wallet.key": self.tenant_wallet_key, + "wallet.id": wallet_id, + "wallet.key_derivation_method": KEY_METHODS[ + self.tenant_wallet_key_derivation_method + ], + "wallet.dispatch_type": self.tenant_dispatch_type, + }, + "key_management_mode": "managed", + "jwt_iat": current_time, + } + + if self.tenant_label: + value_json["settings"]["default_label"] = self.tenant_label + + if self.tenant_image_url: + value_json["settings"]["image_url"] = self.tenant_image_url + + if self.tenant_extra_settings: + value_json["settings"].update(self.tenant_extra_settings) + + if self.tenant_webhook_urls: + value_json["settings"]["wallet.webhook_urls"] = self.tenant_webhook_urls + await admin_txn.insert( category="wallet_record", name=wallet_id, - value_json={ - "wallet_name": self.tenant_wallet_name, - "created_at": current_time, - "updated_at": current_time, - "settings": { - "wallet.type": "askar", - "wallet.name": self.tenant_wallet_name, - "wallet.key": self.tenant_wallet_key, - "wallet.id": wallet_id, - "wallet.dispatch_type": "base", - }, - "key_management_mode": "managed", - "jwt_iat": current_time, - }, + value_json=value_json, tags={ "wallet_name": self.tenant_wallet_name, }, @@ -140,18 +185,21 @@ async def import_tenant(self): tenant_wallet = await Store.open( uri=self.tenant_conn.uri, pass_key=self.tenant_wallet_key, + key_method=KEY_METHODS[self.tenant_wallet_key_derivation_method], ) await tenant_wallet.copy_to( target_uri=self.admin_conn.uri.replace( self.admin_wallet_name, self.tenant_wallet_name ), pass_key=self.tenant_wallet_key, + key_method=KEY_METHODS[self.tenant_wallet_key_derivation_method], ) # Import the tenant wallet into the admin wallet admin_store = await Store.open( uri=self.admin_conn.uri, pass_key=self.admin_wallet_key, + key_method=KEY_METHODS[self.admin_wallet_key_derivation_method], ) async with admin_store.transaction() as admin_txn: wallet_id = str(uuid.uuid4()) diff --git a/askar_tools/tests/e2e/containers.py b/askar_tools/tests/e2e/containers.py index 3a48980..8af0716 100644 --- a/askar_tools/tests/e2e/containers.py +++ b/askar_tools/tests/e2e/containers.py @@ -174,8 +174,9 @@ def acapy_sqlite( self, name: str, wallet_key: str, - admin_port: int, + wallet_key_derivation_method: str, wallet_type: str, + admin_port: int, volume_src: str, volume_dst: str, sub_wallet_src: Optional[str] = None, @@ -198,6 +199,7 @@ def acapy_sqlite( --wallet-type {wallet_type} --wallet-name {name} --wallet-key {wallet_key} + --wallet-key-derivation-method {wallet_key_derivation_method} --preserve-exchange-records --auto-provision """ @@ -239,8 +241,9 @@ def acapy_postgres( self, name: str, wallet_key: str, - admin_port: int, + wallet_key_derivation_method: str, wallet_type: str, + admin_port: int, postgres: Container, mwst: bool = False, mt: bool = False, @@ -261,6 +264,7 @@ def acapy_postgres( --wallet-type {wallet_type} --wallet-name {name} --wallet-key {wallet_key} + --wallet-key-derivation-method {wallet_key_derivation_method} --wallet-storage-type postgres_storage --preserve-exchange-records --auto-provision diff --git a/askar_tools/tests/e2e/test_export.py b/askar_tools/tests/e2e/test_export.py index 5bc1aa2..4258647 100644 --- a/askar_tools/tests/e2e/test_export.py +++ b/askar_tools/tests/e2e/test_export.py @@ -16,10 +16,15 @@ async def test_export_pg(self, containers: Containers): # Prepare postgres = containers.postgres(5432) alice_container = containers.acapy_postgres( - "alice", "insecure", 3001, "askar", postgres + "alice", + "3cAZj1hPvUhKeBkzCKPTHhTxRRmYv5abDbjmaYwtk6Nf", + "RAW", + "askar", + 3001, + postgres, ) bob_container = containers.acapy_postgres( - "bob", "insecure", 3002, "askar", postgres + "bob", "insecure", "kdf:argon2i:mod", "askar", 3002, postgres ) containers.wait_until_healthy(alice_container) containers.wait_until_healthy(bob_container) @@ -35,21 +40,33 @@ async def test_export_pg(self, containers: Containers): strategy="export", uri="postgres://postgres:mysecretpassword@localhost:5432/alice", wallet_name="alice", + wallet_key="3cAZj1hPvUhKeBkzCKPTHhTxRRmYv5abDbjmaYwtk6Nf", + wallet_key_derivation_method="RAW", + export_filename="wallet_export_alice.json", + ) + await main( + strategy="export", + uri="postgres://postgres:mysecretpassword@localhost:5432/bob", + wallet_name="bob", wallet_key="insecure", + export_filename="wallet_export_bob.json", ) - found_export_file = False + found_alice_export_file = False + found_bob_export_file = False for root, dirs, files in os.walk("../"): - if "wallet_export.json" in files: - found_export_file = True - # TODO: Delete the file + if "wallet_export_alice.json" in files: + found_alice_export_file = True + if "wallet_export_bob.json" in files: + found_bob_export_file = True containers.stop(alice_container) containers.stop(bob_container) containers.stop(postgres) # Assert: TODO: check file contents - assert found_export_file + assert found_alice_export_file + assert found_bob_export_file @pytest.mark.asyncio @pytest.mark.e2e @@ -58,9 +75,10 @@ async def test_export_sqlite(self, containers: Containers, tmp_path_factory): containers.fix_permissions(alice_volume_path, user=1001, group=1001) alice_container = containers.acapy_sqlite( "alice", - "insecure", - 3001, + "3cAZj1hPvUhKeBkzCKPTHhTxRRmYv5abDbjmaYwtk6Nf", + "RAW", "askar", + 3001, alice_volume_path, "/home/aries/.aries_cloudagent/wallet/alice", ) @@ -69,8 +87,9 @@ async def test_export_sqlite(self, containers: Containers, tmp_path_factory): bob_container = containers.acapy_sqlite( "bob", "insecure", - 3002, + "kdf:argon2i:mod", "askar", + 3002, bob_volume_path, "/home/aries/.aries_cloudagent/wallet/bob", ) @@ -92,17 +111,29 @@ async def test_export_sqlite(self, containers: Containers, tmp_path_factory): strategy="export", uri=f"sqlite://{alice_volume_path}/sqlite.db", wallet_name="alice", + wallet_key="3cAZj1hPvUhKeBkzCKPTHhTxRRmYv5abDbjmaYwtk6Nf", + wallet_key_derivation_method="RAW", + export_filename="wallet_export_alice.json", + ) + await main( + strategy="export", + uri=f"sqlite://{bob_volume_path}/sqlite.db", + wallet_name="bob", wallet_key="insecure", + export_filename="wallet_export_alice.json", ) - found_export_file = False + found_alice_export_file = False + found_bob_export_file = False for root, dirs, files in os.walk("../"): - if "wallet_export.json" in files: - found_export_file = True - # TODO: Delete the file + if "wallet_export_alice.json" in files: + found_alice_export_file = True + if "wallet_export_bob.json" in files: + found_bob_export_file = True containers.stop(alice_container) containers.stop(bob_container) # Assert: TODO: check file contents - assert found_export_file + assert found_alice_export_file + assert found_bob_export_file diff --git a/askar_tools/tests/e2e/test_mt_convert_to_mw.py b/askar_tools/tests/e2e/test_mt_convert_to_mw.py index fc44cc3..8d36ea3 100644 --- a/askar_tools/tests/e2e/test_mt_convert_to_mw.py +++ b/askar_tools/tests/e2e/test_mt_convert_to_mw.py @@ -18,8 +18,9 @@ async def test_conversion_pg(self, containers: Containers): admin_container = containers.acapy_postgres( "admin", "insecure", - 3001, + "kdf:argon2i:mod", "askar", + 3001, postgres, mwst=True, mt=True, @@ -35,7 +36,8 @@ async def test_conversion_pg(self, containers: Containers): json={ "label": "Alice", "wallet_name": "alice", - "wallet_key": "alice_insecure1", + "wallet_key": "3cAZj1hPvUhKeBkzCKPTHhTxRRmYv5abDbjmaYwtk6Nf", + "wallet_key_derivation": "RAW", "wallet_type": "askar", }, response=CreateWalletResponse, @@ -77,8 +79,9 @@ async def test_conversion_pg(self, containers: Containers): admin_container = containers.acapy_postgres( "admin", "insecure", - 3001, + "kdf:argon2i:mod", "askar", + 3001, postgres, mt=True, askar_profile=False, @@ -114,8 +117,9 @@ async def test_conversion_sqlite(self, containers: Containers, tmp_path_factory) admin_container = containers.acapy_sqlite( "admin", "insecure", - 3001, + "kdf:argon2i:mod", "askar", + 3001, admin_volume_path, "/home/aries/.aries_cloudagent/wallet/admin", sub_wallet_volume_path, @@ -133,7 +137,8 @@ async def test_conversion_sqlite(self, containers: Containers, tmp_path_factory) json={ "label": "Alice", "wallet_name": "alice", - "wallet_key": "alice_insecure1", + "wallet_key": "3cAZj1hPvUhKeBkzCKPTHhTxRRmYv5abDbjmaYwtk6Nf", + "wallet_key_derivation": "RAW", "wallet_type": "askar", }, response=CreateWalletResponse, diff --git a/askar_tools/tests/e2e/test_tenant_import.py b/askar_tools/tests/e2e/test_tenant_import.py index 7adf1e3..3f338e3 100644 --- a/askar_tools/tests/e2e/test_tenant_import.py +++ b/askar_tools/tests/e2e/test_tenant_import.py @@ -19,8 +19,9 @@ async def test_tenant_import_pg(self, containers: Containers): admin_container = containers.acapy_postgres( "admin", "insecure", - 3001, + "kdf:argon2i:mod", "askar", + 3001, admin_postgres, mwst=True, mt=True, @@ -28,9 +29,10 @@ async def test_tenant_import_pg(self, containers: Containers): ) tenant_container = containers.acapy_postgres( "tenant", - "insecure", - 3002, + "3cAZj1hPvUhKeBkzCKPTHhTxRRmYv5abDbjmaYwtk6Nf", + "RAW", "askar", + 3002, tenant_postgres, mwst=False, mt=False, @@ -70,7 +72,13 @@ async def test_tenant_import_pg(self, containers: Containers): wallet_key="insecure", tenant_uri="postgres://postgres:mysecretpassword@localhost:5433/tenant", tenant_wallet_name="tenant", - tenant_wallet_key="insecure", + tenant_wallet_key="3cAZj1hPvUhKeBkzCKPTHhTxRRmYv5abDbjmaYwtk6Nf", + tenant_label="Tenant", + tenant_image_url="https://example.com/image.png", + tenant_extra_settings={"extra": "settings"}, + tenant_webhook_urls=["http://example.com/webhook"], + tenant_dispatch_type="default", + tenant_wallet_key_derivation_method="RAW", ) async with Controller("http://localhost:3001") as admin: