diff --git a/Multitenancy.md b/Multitenancy.md index 032c43be2a..f99566b29e 100644 --- a/Multitenancy.md +++ b/Multitenancy.md @@ -29,6 +29,7 @@ This allows ACA-Py to be used for a wider range of use cases. One use case could - [Tenant Management](#tenant-management) - [Update a tenant](#update-a-tenant) - [Remove a tenant](#remove-a-tenant) + - [Per tenant settings](#per-tenant-settings) ## General Concept @@ -375,3 +376,81 @@ curl -X POST "${ACAPY_ADMIN_URL}/multitenancy/wallet/{wallet_id}/remove" \ ```jsonc {} ``` + +### Per tenant settings + +To allow configurablity of ACA-Py startup parameters/environment variables at a tenant/subwallet level. [PR#2233](https://github.com/hyperledger/aries-cloudagent-python/pull/2233) will provide the ability to update the following subset of settings when creating or updating the subwallet: + +| Labels | | Setting | +|---|---|---| +| ACAPY_LOG_LEVEL | log-level | log.level | +| ACAPY_INVITE_PUBLIC | invite-public | debug.invite_public | +| ACAPY_PUBLIC_INVITES | public-invites | public_invites | +| ACAPY_AUTO_ACCEPT_INVITES | auto-accept-invites | debug.auto_accept_invites | +| ACAPY_AUTO_ACCEPT_REQUESTS | auto-accept-requests | debug.auto_accept_requests | +| ACAPY_AUTO_PING_CONNECTION | auto-ping-connection | auto_ping_connection | +| ACAPY_MONITOR_PING | monitor-ping | debug.monitor_ping | +| ACAPY_AUTO_RESPOND_MESSAGES | auto-respond-messages | debug.auto_respond_messages | +| ACAPY_AUTO_RESPOND_CREDENTIAL_OFFER | auto-respond-credential-offer | debug.auto_resopnd_credential_offer | +| ACAPY_AUTO_RESPOND_CREDENTIAL_REQUEST | auto-respond-credential-request | debug.auto_respond_credential_request | +| ACAPY_AUTO_VERIFY_PRESENTATION | auto-verify-presentation | debug.auto_verify_presentation | +| ACAPY_NOTIFY_REVOCATION | notify-revocation | revocation.notify | +| ACAPY_AUTO_REQUEST_ENDORSEMENT | auto-request-endorsement | endorser.auto_request | +| ACAPY_AUTO_WRITE_TRANSACTIONS | auto-write-transactions | endorser.auto_write | +| ACAPY_CREATE_REVOCATION_TRANSACTIONS | auto-create-revocation-transactions | endorser.auto_create_rev_reg | +| ACAPY_ENDORSER_ROLE | endorser-protocol-role | endorser.protocol_role | + +- `POST /multitenancy/wallet` + + Added `extra_settings` dict field to request schema. `extra_settings` can be configured in the request body as below: + + **`Example Request`** + ``` + { + "wallet_name": " ... ", + "default_label": " ... ", + "wallet_type": " ... ", + "wallet_key": " ... ", + "key_management_mode": "managed", + "wallet_webhook_urls": [], + "wallet_dispatch_type": "base", + "extra_settings": { + "ACAPY_LOG_LEVEL": "INFO", + "ACAPY_INVITE_PUBLIC": true, + "public-invites": true + }, + } + ``` + + ```sh + echo $new_tenant | curl -X POST "${ACAPY_ADMIN_URL}/multitenancy/wallet" \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $ACAPY_ADMIN_URL_API_KEY" \ + -d @- + ``` + +- `PUT /multitenancy/wallet/{wallet_id}` + + Added `extra_settings` dict field to request schema. + + **`Example Request`** + ``` + { + "wallet_webhook_urls": [ ... ], + "wallet_dispatch_type": "default", + "label": " ... ", + "image_url": " ... ", + "extra_settings": { + "ACAPY_LOG_LEVEL": "INFO", + "ACAPY_INVITE_PUBLIC": true, + "ACAPY_PUBLIC_INVITES": false + }, + } + ``` + + ```sh + echo $update_tenant | curl -X PUT "${ACAPY_ADMIN_URL}/multitenancy/wallet/${WALLET_ID}" \ + -H "Content-Type: application/json" \ + -H "x-api-key: $ACAPY_ADMIN_URL_API_KEY" \ + -d @- + ``` diff --git a/aries_cloudagent/admin/request_context.py b/aries_cloudagent/admin/request_context.py index 1fe7f79076..c7a64a11b0 100644 --- a/aries_cloudagent/admin/request_context.py +++ b/aries_cloudagent/admin/request_context.py @@ -23,11 +23,15 @@ def __init__( profile: Profile, *, context: InjectionContext = None, - settings: Mapping[str, object] = None + settings: Mapping[str, object] = None, + root_profile: Profile = None, + metadata: dict = None ): """Initialize an instance of AdminRequestContext.""" self._context = (context or profile.context).start_scope("admin", settings) self._profile = profile + self._root_profile = root_profile + self._metadata = metadata @property def injector(self) -> Injector: @@ -39,6 +43,16 @@ def profile(self) -> Profile: """Accessor for the associated `Profile` instance.""" return self._profile + @property + def root_profile(self) -> Optional[Profile]: + """Accessor for the associated root_profile instance.""" + return self._root_profile + + @property + def metadata(self) -> dict: + """Accessor for the associated metadata.""" + return self._metadata + @property def settings(self) -> Settings: """Accessor for the context settings.""" diff --git a/aries_cloudagent/admin/server.py b/aries_cloudagent/admin/server.py index 544bc8f585..91dcaf94a7 100644 --- a/aries_cloudagent/admin/server.py +++ b/aries_cloudagent/admin/server.py @@ -384,7 +384,7 @@ async def check_multitenant_authorization(request: web.Request, handler): async def setup_context(request: web.Request, handler): authorization_header = request.headers.get("Authorization") profile = self.root_profile - + meta_data = {} # Multitenancy context setup if self.multitenant_manager and authorization_header: try: @@ -397,6 +397,16 @@ async def setup_context(request: web.Request, handler): profile = await self.multitenant_manager.get_profile_for_token( self.context, token ) + ( + walletid, + walletkey, + ) = self.multitenant_manager.get_wallet_details_from_token( + token=token + ) + meta_data = { + "wallet_id": walletid, + "wallet_key": walletkey, + } except MultitenantManagerError as err: raise web.HTTPUnauthorized(reason=err.roll_up) except (jwt.InvalidTokenError, StorageNotFoundError): @@ -411,7 +421,16 @@ async def setup_context(request: web.Request, handler): # TODO may dynamically adjust the profile used here according to # headers or other parameters - admin_context = AdminRequestContext(profile) + if self.multitenant_manager and authorization_header: + admin_context = AdminRequestContext( + profile=profile, + root_profile=self.root_profile, + metadata=meta_data, + ) + else: + admin_context = AdminRequestContext( + profile=profile, + ) request["context"] = admin_context request["outbound_message_router"] = responder.send diff --git a/aries_cloudagent/admin/tests/test_request_context.py b/aries_cloudagent/admin/tests/test_request_context.py index 7775262638..e74763004e 100644 --- a/aries_cloudagent/admin/tests/test_request_context.py +++ b/aries_cloudagent/admin/tests/test_request_context.py @@ -12,12 +12,26 @@ def setUp(self): self.ctx = test_module.AdminRequestContext(InMemoryProfile.test_profile()) assert self.ctx.__class__.__name__ in str(self.ctx) + self.ctx_with_added_attrs = test_module.AdminRequestContext( + profile=InMemoryProfile.test_profile(), + root_profile=InMemoryProfile.test_profile(), + metadata={"test_attrib_key": "test_attrib_value"}, + ) + assert self.ctx_with_added_attrs.__class__.__name__ in str( + self.ctx_with_added_attrs + ) + def test_session_transaction(self): sesn = self.ctx.session() assert isinstance(sesn, ProfileSession) txn = self.ctx.transaction() assert isinstance(txn, ProfileSession) + sesn = self.ctx_with_added_attrs.session() + assert isinstance(sesn, ProfileSession) + txn = self.ctx_with_added_attrs.transaction() + assert isinstance(txn, ProfileSession) + async def test_session_inject_x(self): test_ctx = test_module.AdminRequestContext.test_context({Collector: None}) async with test_ctx.session() as test_sesn: diff --git a/aries_cloudagent/config/base.py b/aries_cloudagent/config/base.py index cdc30ba507..00d0c4a31c 100644 --- a/aries_cloudagent/config/base.py +++ b/aries_cloudagent/config/base.py @@ -97,6 +97,10 @@ def copy(self) -> "BaseSettings": def extend(self, other: Mapping[str, Any]) -> "BaseSettings": """Merge another mapping to produce a new settings instance.""" + @abstractmethod + def to_dict(self) -> dict: + """Return a dict of the settings instance.""" + def __repr__(self) -> str: """Provide a human readable representation of this object.""" items = ("{}={}".format(k, self[k]) for k in self) diff --git a/aries_cloudagent/config/default_context.py b/aries_cloudagent/config/default_context.py index 203aaac65b..a99c3b44a9 100644 --- a/aries_cloudagent/config/default_context.py +++ b/aries_cloudagent/config/default_context.py @@ -133,6 +133,7 @@ async def load_plugins(self, context: InjectionContext): plugin_registry.register_plugin("aries_cloudagent.messaging.jsonld") plugin_registry.register_plugin("aries_cloudagent.revocation") plugin_registry.register_plugin("aries_cloudagent.resolver") + plugin_registry.register_plugin("aries_cloudagent.settings") plugin_registry.register_plugin("aries_cloudagent.wallet") if context.settings.get("multitenant.admin_enabled"): diff --git a/aries_cloudagent/config/logging.py b/aries_cloudagent/config/logging.py index 18bbdd1dc0..bbd91ef9d2 100644 --- a/aries_cloudagent/config/logging.py +++ b/aries_cloudagent/config/logging.py @@ -634,11 +634,11 @@ def get_logger_with_handlers( logger.addHandler(std_out_handler) if did_ident: logger = logging.LoggerAdapter(logger, {"did": did_ident}) - # set log level - logger_level = ( - (settings.get("log.level")).upper() - if settings.get("log.level") - else logging.INFO - ) - logger.setLevel(logger_level) + # set log level + logger_level = ( + (settings.get("log.level")).upper() + if settings.get("log.level") + else logging.INFO + ) + logger.setLevel(logger_level) return logger diff --git a/aries_cloudagent/config/plugin_settings.py b/aries_cloudagent/config/plugin_settings.py index d1e78cdb74..1da1392ef0 100644 --- a/aries_cloudagent/config/plugin_settings.py +++ b/aries_cloudagent/config/plugin_settings.py @@ -55,6 +55,13 @@ def extend(self, other: Mapping[str, Any]) -> BaseSettings: vals.update(other) return PluginSettings(vals) + def to_dict(self) -> dict: + """Return a dict of the settings instance.""" + setting_dict = {} + for k in self: + setting_dict[k] = self[k] + return setting_dict + def get_value(self, *var_names: str, default: Any = None): """Fetch a setting. diff --git a/aries_cloudagent/config/settings.py b/aries_cloudagent/config/settings.py index d4a7677d13..386e434a49 100644 --- a/aries_cloudagent/config/settings.py +++ b/aries_cloudagent/config/settings.py @@ -97,6 +97,13 @@ def extend(self, other: Mapping[str, Any]) -> BaseSettings: vals.update(other) return Settings(vals) + def to_dict(self) -> dict: + """Return a dict of the settings instance.""" + setting_dict = {} + for k in self: + setting_dict[k] = self[k] + return setting_dict + def update(self, other: Mapping[str, Any]): """Update the settings in place.""" self._values.update(other) diff --git a/aries_cloudagent/ledger/multiple_ledger/base_manager.py b/aries_cloudagent/ledger/multiple_ledger/base_manager.py index b421119d22..346f36af77 100644 --- a/aries_cloudagent/ledger/multiple_ledger/base_manager.py +++ b/aries_cloudagent/ledger/multiple_ledger/base_manager.py @@ -37,6 +37,10 @@ async def _get_ledger_by_did( ) -> Optional[Tuple[str, BaseLedger, bool]]: """Build and submit GET_NYM request and process response.""" + @abstractmethod + async def get_ledger_inst_by_id(self, ledger_id: str) -> Optional[BaseLedger]: + """Return ledger instance by identifier.""" + @abstractmethod async def lookup_did_in_configured_ledgers( self, did: str, cache_did: bool diff --git a/aries_cloudagent/ledger/multiple_ledger/indy_manager.py b/aries_cloudagent/ledger/multiple_ledger/indy_manager.py index e8f86e8577..9ba534b19f 100644 --- a/aries_cloudagent/ledger/multiple_ledger/indy_manager.py +++ b/aries_cloudagent/ledger/multiple_ledger/indy_manager.py @@ -60,6 +60,12 @@ async def get_write_ledger(self) -> Optional[Tuple[str, IndySdkLedger]]: else: return None + async def get_ledger_inst_by_id(self, ledger_id: str) -> Optional[BaseLedger]: + """Return BaseLedger instance.""" + return self.production_ledgers.get( + ledger_id + ) or self.non_production_ledgers.get(ledger_id) + async def get_prod_ledgers(self) -> Mapping: """Return production ledgers mapping.""" return self.production_ledgers diff --git a/aries_cloudagent/ledger/multiple_ledger/indy_vdr_manager.py b/aries_cloudagent/ledger/multiple_ledger/indy_vdr_manager.py index 8cb16f6b5a..0c4d09aa26 100644 --- a/aries_cloudagent/ledger/multiple_ledger/indy_vdr_manager.py +++ b/aries_cloudagent/ledger/multiple_ledger/indy_vdr_manager.py @@ -9,6 +9,7 @@ from ...cache.base import BaseCache from ...core.profile import Profile +from ...ledger.base import BaseLedger from ...ledger.error import LedgerError from ...wallet.crypto import did_is_self_certified @@ -63,6 +64,12 @@ async def get_nonprod_ledgers(self) -> Mapping: """Return non_production ledgers mapping.""" return self.non_production_ledgers + async def get_ledger_inst_by_id(self, ledger_id: str) -> Optional[BaseLedger]: + """Return BaseLedger instance.""" + return self.production_ledgers.get( + ledger_id + ) or self.non_production_ledgers.get(ledger_id) + async def _get_ledger_by_did( self, ledger_id: str, diff --git a/aries_cloudagent/ledger/multiple_ledger/ledger_requests_executor.py b/aries_cloudagent/ledger/multiple_ledger/ledger_requests_executor.py index a56d93dfef..0cf0b4dcb8 100644 --- a/aries_cloudagent/ledger/multiple_ledger/ledger_requests_executor.py +++ b/aries_cloudagent/ledger/multiple_ledger/ledger_requests_executor.py @@ -61,3 +61,8 @@ async def get_ledger_for_identifier( except (MultipleLedgerManagerError, InjectionError): pass return (None, self.profile.inject_or(BaseLedger)) + + async def get_ledger_inst(self, ledger_id: str) -> Optional[BaseLedger]: + """Return ledger instance from ledger_id set in config.""" + multiledger_mgr = self.profile.inject(BaseMultipleLedgerManager) + return await multiledger_mgr.get_ledger_inst_by_id(ledger_id=ledger_id) diff --git a/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_ledger_requests.py b/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_ledger_requests.py index 52e6d52f41..f087660fa5 100644 --- a/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_ledger_requests.py +++ b/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_ledger_requests.py @@ -38,6 +38,9 @@ async def setUp(self): lookup_did_in_configured_ledgers=async_mock.CoroutineMock( return_value=("test_prod_1", self.ledger) ), + get_ledger_inst_by_id=async_mock.CoroutineMock( + return_value=self.ledger + ), ), ) self.profile.context.injector.bind_instance(BaseLedger, self.ledger) @@ -53,6 +56,10 @@ async def test_get_ledger_for_identifier(self): assert ledger_id == "test_prod_1" assert ledger_inst.pool.name == "test_prod_1" + async def test_get_ledger_inst(self): + ledger_inst = await self.indy_ledger_requestor.get_ledger_inst("test_prod_1") + assert ledger_inst + async def test_get_ledger_for_identifier_is_digit(self): ledger_id, ledger = await self.indy_ledger_requestor.get_ledger_for_identifier( "123", 0 diff --git a/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_manager.py b/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_manager.py index 572992a313..fc3f972999 100644 --- a/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_manager.py +++ b/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_manager.py @@ -60,6 +60,14 @@ async def test_get_write_ledger(self): assert ledger_id == "test_prod_1" assert ledger_inst.pool.name == "test_prod_1" + async def test_get_ledger_inst_by_id(self): + ledger_inst = await self.manager.get_ledger_inst_by_id("test_prod_2") + assert ledger_inst + ledger_inst = await self.manager.get_ledger_inst_by_id("test_non_prod_2") + assert ledger_inst + ledger_inst = await self.manager.get_ledger_inst_by_id("test_invalid") + assert not ledger_inst + @async_mock.patch("aries_cloudagent.ledger.indy.IndySdkLedgerPool.context_open") @async_mock.patch("aries_cloudagent.ledger.indy.IndySdkLedgerPool.context_close") @async_mock.patch("indy.ledger.build_get_nym_request") diff --git a/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_vdr_manager.py b/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_vdr_manager.py index 86aa27a3d5..4c4798750d 100644 --- a/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_vdr_manager.py +++ b/aries_cloudagent/ledger/multiple_ledger/tests/test_indy_vdr_manager.py @@ -88,6 +88,14 @@ async def test_get_write_ledger(self): assert ledger_id == "test_prod_1" assert ledger_inst.pool.name == "test_prod_1" + async def test_get_ledger_inst_by_id(self): + ledger_inst = await self.manager.get_ledger_inst_by_id("test_prod_2") + assert ledger_inst + ledger_inst = await self.manager.get_ledger_inst_by_id("test_non_prod_2") + assert ledger_inst + ledger_inst = await self.manager.get_ledger_inst_by_id("test_invalid") + assert not ledger_inst + @async_mock.patch("aries_cloudagent.ledger.indy_vdr.IndyVdrLedgerPool.context_open") @async_mock.patch( "aries_cloudagent.ledger.indy_vdr.IndyVdrLedgerPool.context_close" diff --git a/aries_cloudagent/multitenant/admin/routes.py b/aries_cloudagent/multitenant/admin/routes.py index e95bac1f59..b9c78f9e20 100644 --- a/aries_cloudagent/multitenant/admin/routes.py +++ b/aries_cloudagent/multitenant/admin/routes.py @@ -23,6 +23,54 @@ from ..error import WalletKeyMissingError +ACAPY_LIFECYCLE_CONFIG_FLAG_MAP = { + "ACAPY_LOG_LEVEL": "log.level", + "ACAPY_INVITE_PUBLIC": "debug.invite_public", + "ACAPY_PUBLIC_INVITES": "public_invites", + "ACAPY_AUTO_ACCEPT_INVITES": "debug.auto_accept_invites", + "ACAPY_AUTO_ACCEPT_REQUESTS": "debug.auto_accept_requests", + "ACAPY_AUTO_PING_CONNECTION": "auto_ping_connection", + "ACAPY_MONITOR_PING": "debug.monitor_ping", + "ACAPY_AUTO_RESPOND_MESSAGES": "debug.auto_respond_messages", + "ACAPY_AUTO_RESPOND_CREDENTIAL_OFFER": "debug.auto_respond_credential_offer", + "ACAPY_AUTO_RESPOND_CREDENTIAL_REQUEST": "debug.auto_respond_credential_request", + "ACAPY_AUTO_VERIFY_PRESENTATION": "debug.auto_verify_presentation", + "ACAPY_NOTIFY_REVOCATION": "revocation.notify", + "ACAPY_AUTO_REQUEST_ENDORSEMENT": "endorser.auto_request", + "ACAPY_AUTO_WRITE_TRANSACTIONS": "endorser.auto_write", + "ACAPY_CREATE_REVOCATION_TRANSACTIONS": "endorser.auto_create_rev_reg", + "ACAPY_ENDORSER_ROLE": "endorser.protocol_role", +} + +ACAPY_LIFECYCLE_CONFIG_FLAG_ARGS_MAP = { + "log-level": "log.level", + "invite-public": "debug.invite_public", + "public-invites": "public_invites", + "auto-accept-invites": "debug.auto_accept_invites", + "auto-accept-requests": "debug.auto_accept_requests", + "auto-ping-connection": "auto_ping_connection", + "monitor-ping": "debug.monitor_ping", + "auto-respond-messages": "debug.auto_respond_messages", + "auto-respond-credential-offer": "debug.auto_respond_credential_offer", + "auto-respond-credential-request": "debug.auto_respond_credential_request", + "auto-verify-presentation": "debug.auto_verify_presentation", + "notify-revocation": "revocation.notify", + "auto-request-endorsement": "endorser.auto_request", + "auto-write-transactions": "endorser.auto_write", + "auto-create-revocation-transactions": "endorser.auto_create_rev_reg", + "endorser-protocol-role": "endorser.protocol_role", +} + +ACAPY_ENDORSER_FLAGS_DEPENDENT_ON_AUTHOR_ROLE = [ + "ACAPY_AUTO_REQUEST_ENDORSEMENT", + "ACAPY_AUTO_WRITE_TRANSACTIONS", + "ACAPY_CREATE_REVOCATION_TRANSACTIONS", + "auto-request-endorsement", + "auto-write-transactions", + "auto-create-revocation-transactions", +] + + def format_wallet_record(wallet_record: WalletRecord): """Serialize a WalletRecord object.""" @@ -35,6 +83,35 @@ def format_wallet_record(wallet_record: WalletRecord): return wallet_info +def get_extra_settings_dict_per_tenant(tenant_settings: dict) -> dict: + """Get per tenant settings to be applied when creating wallet.""" + + endorser_role_flag = tenant_settings.get( + "ACAPY_ENDORSER_ROLE" + ) or tenant_settings.get("endorser_protocol_role") + extra_settings = {} + if endorser_role_flag and endorser_role_flag == "author": + extra_settings["endorser.author"] = True + elif endorser_role_flag and endorser_role_flag == "endorser": + extra_settings["endorser.endorser"] = True + for flag in tenant_settings.keys(): + if ( + flag in ACAPY_ENDORSER_FLAGS_DEPENDENT_ON_AUTHOR_ROLE + and endorser_role_flag != "author" + ): + # These flags require endorser role as author, if not set as author then + # this setting will be ignored. + continue + if flag != "ACAPY_ENDORSER_ROLE": + map_flag = ACAPY_LIFECYCLE_CONFIG_FLAG_MAP.get( + flag + ) or ACAPY_LIFECYCLE_CONFIG_FLAG_ARGS_MAP.get(flag) + if not map_flag: + continue + extra_settings[map_flag] = tenant_settings[flag] + return extra_settings + + class MultitenantModuleResponseSchema(OpenAPISchema): """Response schema for multitenant module.""" @@ -56,6 +133,11 @@ class CreateWalletRequestSchema(OpenAPISchema): description="Master key used for key derivation.", example="MySecretKey123" ) + extra_settings = fields.Dict( + description="Agent config key-value pairs", + required=False, + ) + wallet_key_derivation = fields.Str( description="Key derivation", required=False, @@ -142,6 +224,10 @@ class UpdateWalletRequestSchema(OpenAPISchema): default="default", validate=validate.OneOf(["default", "both", "base"]), ) + extra_settings = fields.Dict( + description="Agent config key-value pairs", + required=False, + ) wallet_webhook_urls = fields.List( fields.Str( description="Optional webhook URL to receive webhook messages", @@ -294,6 +380,7 @@ async def wallet_create(request: web.BaseRequest): wallet_key = body.get("wallet_key") wallet_webhook_urls = body.get("wallet_webhook_urls") or [] wallet_dispatch_type = body.get("wallet_dispatch_type") or "default" + extra_settings = body.get("extra_settings") or {} # If no webhooks specified, then dispatch only to base webhook targets if wallet_webhook_urls == []: wallet_dispatch_type = "base" @@ -305,6 +392,8 @@ async def wallet_create(request: web.BaseRequest): "wallet.webhook_urls": wallet_webhook_urls, "wallet.dispatch_type": wallet_dispatch_type, } + extra_subwallet_setting = get_extra_settings_dict_per_tenant(extra_settings) + settings.update(extra_subwallet_setting) label = body.get("label") image_url = body.get("image_url") @@ -354,6 +443,7 @@ async def wallet_update(request: web.BaseRequest): wallet_dispatch_type = body.get("wallet_dispatch_type") label = body.get("label") image_url = body.get("image_url") + extra_settings = body.get("extra_settings") or {} if all( v is None for v in (wallet_webhook_urls, wallet_dispatch_type, label, image_url) @@ -376,6 +466,8 @@ async def wallet_update(request: web.BaseRequest): settings["default_label"] = label if image_url is not None: settings["image_url"] = image_url + extra_subwallet_setting = get_extra_settings_dict_per_tenant(extra_settings) + settings.update(extra_subwallet_setting) try: multitenant_mgr = context.profile.inject(BaseMultitenantManager) diff --git a/aries_cloudagent/multitenant/admin/tests/test_routes.py b/aries_cloudagent/multitenant/admin/tests/test_routes.py index 98ad47dca2..312997c4b6 100644 --- a/aries_cloudagent/multitenant/admin/tests/test_routes.py +++ b/aries_cloudagent/multitenant/admin/tests/test_routes.py @@ -139,6 +139,63 @@ async def test_wallets_list_query(self): } ) + async def test_wallet_create_tenant_settings(self): + body = { + "wallet_name": "test", + "default_label": "test_label", + "wallet_type": "indy", + "wallet_key": "test", + "key_management_mode": "managed", + "wallet_webhook_urls": [], + "wallet_dispatch_type": "base", + "extra_settings": { + "ACAPY_LOG_LEVEL": "INFO", + "ACAPY_INVITE_PUBLIC": True, + "ACAPY_PUBLIC_INVITES": True, + }, + } + self.request.json = async_mock.CoroutineMock(return_value=body) + + with async_mock.patch.object(test_module.web, "json_response") as mock_response: + wallet_mock = async_mock.MagicMock( + serialize=async_mock.MagicMock( + return_value={ + "wallet_id": "test", + "settings": {}, + "key_management_mode": body["key_management_mode"], + } + ) + ) # wallet_record + self.mock_multitenant_mgr.create_wallet = async_mock.CoroutineMock( + return_value=wallet_mock + ) + + self.mock_multitenant_mgr.create_auth_token = async_mock.CoroutineMock( + return_value="test_token" + ) + print(self.request["context"]) + await test_module.wallet_create(self.request) + + self.mock_multitenant_mgr.create_wallet.assert_called_once_with( + { + "wallet.name": body["wallet_name"], + "wallet.type": body["wallet_type"], + "wallet.key": body["wallet_key"], + "wallet.webhook_urls": body["wallet_webhook_urls"], + "wallet.dispatch_type": body["wallet_dispatch_type"], + "log.level": "INFO", + "debug.invite_public": True, + "public_invites": True, + }, + body["key_management_mode"], + ) + self.mock_multitenant_mgr.create_auth_token.assert_called_once_with( + wallet_mock, body["wallet_key"] + ) + mock_response.assert_called_once_with( + {**test_module.format_wallet_record(wallet_mock), "token": "test_token"} + ) + async def test_wallet_create(self): body = { "wallet_name": "test", @@ -259,6 +316,53 @@ async def test_wallet_create_raw_key_derivation(self): WalletRecord.MODE_MANAGED, ) + async def test_wallet_update_tenant_settings(self): + self.request.match_info = {"wallet_id": "test-wallet-id"} + body = { + "wallet_webhook_urls": ["test-webhook-url"], + "wallet_dispatch_type": "default", + "label": "test-label", + "image_url": "test-image-url", + "extra_settings": { + "ACAPY_LOG_LEVEL": "INFO", + "ACAPY_INVITE_PUBLIC": True, + "ACAPY_PUBLIC_INVITES": True, + }, + } + self.request.json = async_mock.CoroutineMock(return_value=body) + + with async_mock.patch.object(test_module.web, "json_response") as mock_response: + settings = { + "wallet.webhook_urls": body["wallet_webhook_urls"], + "wallet.dispatch_type": body["wallet_dispatch_type"], + "default_label": body["label"], + "image_url": body["image_url"], + "log.level": "INFO", + "debug.invite_public": True, + "public_invites": True, + } + wallet_mock = async_mock.MagicMock( + serialize=async_mock.MagicMock( + return_value={ + "wallet_id": "test-wallet-id", + "settings": settings, + } + ) + ) + self.mock_multitenant_mgr.update_wallet = async_mock.CoroutineMock( + return_value=wallet_mock + ) + + await test_module.wallet_update(self.request) + + self.mock_multitenant_mgr.update_wallet.assert_called_once_with( + "test-wallet-id", + settings, + ) + mock_response.assert_called_once_with( + {"wallet_id": "test-wallet-id", "settings": settings} + ) + async def test_wallet_update(self): self.request.match_info = {"wallet_id": "test-wallet-id"} body = { diff --git a/aries_cloudagent/multitenant/base.py b/aries_cloudagent/multitenant/base.py index 2afec03b00..03fbb7a515 100644 --- a/aries_cloudagent/multitenant/base.py +++ b/aries_cloudagent/multitenant/base.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from datetime import datetime import logging -from typing import Iterable, List, Optional, cast +from typing import Iterable, List, Optional, cast, Tuple import jwt @@ -318,6 +318,28 @@ async def create_auth_token( return token + def get_wallet_details_from_token(self, token: str) -> Tuple[str, str]: + """Get the wallet_id and wallet_key from provided token.""" + jwt_secret = self._profile.context.settings.get("multitenant.jwt_secret") + token_body = jwt.decode(token, jwt_secret, algorithms=["HS256"]) + wallet_id = token_body.get("wallet_id") + wallet_key = token_body.get("wallet_key") + return wallet_id, wallet_key + + async def get_wallet_and_profile( + self, context: InjectionContext, wallet_id: str, wallet_key: str + ) -> Tuple[WalletRecord, Profile]: + """Get the wallet_record and profile associated with wallet id and key.""" + extra_settings = {} + async with self._profile.session() as session: + wallet = await WalletRecord.retrieve_by_id(session, wallet_id) + if wallet.requires_external_key: + if not wallet_key: + raise WalletKeyMissingError() + extra_settings["wallet.key"] = wallet_key + profile = await self.get_wallet_profile(context, wallet, extra_settings) + return (wallet, profile) + async def get_profile_for_token( self, context: InjectionContext, token: str ) -> Profile: diff --git a/aries_cloudagent/multitenant/tests/test_base.py b/aries_cloudagent/multitenant/tests/test_base.py index dd1c5fba31..1e28b90b18 100644 --- a/aries_cloudagent/multitenant/tests/test_base.py +++ b/aries_cloudagent/multitenant/tests/test_base.py @@ -418,6 +418,64 @@ async def test_create_auth_token_unmanaged(self): assert wallet_record.jwt_iat == iat assert expected_token == token + async def test_get_wallet_details_from_token(self): + self.profile.settings["multitenant.jwt_secret"] = "very_secret_jwt" + wallet_record = WalletRecord( + key_management_mode=WalletRecord.MODE_MANAGED, + settings={"wallet.type": "indy", "wallet.key": "wallet_key"}, + jwt_iat=100, + ) + session = await self.profile.session() + await wallet_record.save(session) + token = jwt.encode( + {"wallet_id": wallet_record.wallet_id, "iat": 100}, + "very_secret_jwt", + algorithm="HS256", + ) + ret_wallet_id, ret_wallet_key = self.manager.get_wallet_details_from_token( + token + ) + assert ret_wallet_id == wallet_record.wallet_id + assert not ret_wallet_key + + token = jwt.encode( + { + "wallet_id": wallet_record.wallet_id, + "iat": 100, + "wallet_key": "wallet_key", + }, + "very_secret_jwt", + algorithm="HS256", + ) + ret_wallet_id, ret_wallet_key = self.manager.get_wallet_details_from_token( + token + ) + assert ret_wallet_id == wallet_record.wallet_id + assert ret_wallet_key == "wallet_key" + + async def test_get_wallet_and_profile(self): + self.profile.settings["multitenant.jwt_secret"] = "very_secret_jwt" + wallet_record = WalletRecord( + key_management_mode=WalletRecord.MODE_MANAGED, + settings={"wallet.type": "indy", "wallet.key": "wallet_key"}, + jwt_iat=100, + ) + + session = await self.profile.session() + await wallet_record.save(session) + + with async_mock.patch.object( + self.manager, "get_wallet_profile" + ) as get_wallet_profile: + mock_profile = InMemoryProfile.test_profile() + get_wallet_profile.return_value = mock_profile + + wallet, profile = await self.manager.get_wallet_and_profile( + self.profile.context, wallet_record.wallet_id, "wallet_key" + ) + assert wallet == wallet_record + assert profile == mock_profile + async def test_get_profile_for_token_invalid_token_raises(self): self.profile.settings["multitenant.jwt_secret"] = "very_secret_jwt" diff --git a/aries_cloudagent/settings/__init__.py b/aries_cloudagent/settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/settings/routes.py b/aries_cloudagent/settings/routes.py new file mode 100644 index 0000000000..9a576da73d --- /dev/null +++ b/aries_cloudagent/settings/routes.py @@ -0,0 +1,176 @@ +"""Settings routes.""" + +import logging + +from aiohttp import web +from aiohttp_apispec import docs, request_schema, response_schema +from marshmallow import fields + +from ..admin.request_context import AdminRequestContext +from ..multitenant.base import BaseMultitenantManager +from ..core.error import BaseError +from ..core.profile import Profile +from ..messaging.models.openapi import OpenAPISchema +from ..multitenant.admin.routes import ( + get_extra_settings_dict_per_tenant, + ACAPY_LIFECYCLE_CONFIG_FLAG_ARGS_MAP, +) + +LOGGER = logging.getLogger(__name__) + + +class UpdateProfileSettingsSchema(OpenAPISchema): + """Schema to update profile settings.""" + + extra_settings = fields.Dict( + description="Agent config key-value pairs", + required=False, + example={ + "log-level": "INFO", + "ACAPY_INVITE_PUBLIC": True, + "public-invites": False, + }, + ) + + +class ProfileSettingsSchema(OpenAPISchema): + """Profile settings response schema.""" + + settings = fields.Dict( + description="Profile settings dict", + example={ + "log.level": "INFO", + "debug.invite_public": True, + "public_invites": False, + }, + ) + + +def _get_filtered_settings_dict(wallet_settings: dict): + """Get filtered settings dict to display.""" + filter_param_list = list(ACAPY_LIFECYCLE_CONFIG_FLAG_ARGS_MAP.values()) + settings_dict = {} + for param in filter_param_list: + if param in wallet_settings: + settings_dict[param] = wallet_settings.get(param) + return settings_dict + + +def _get_multitenant_settings_dict( + profile_settings: dict, + wallet_settings: dict, +): + """Get filtered settings dict when multitenant manager is present.""" + all_settings = {**profile_settings, **wallet_settings} + settings_dict = _get_filtered_settings_dict(all_settings) + return settings_dict + + +def _get_settings_dict( + profile: Profile, +): + """Get filtered settings dict when multitenant manager is not present.""" + settings_dict = _get_filtered_settings_dict((profile.settings).to_dict()) + return settings_dict + + +@docs( + tags=["settings"], + summary="Update configurable settings associated with the profile.", +) +@request_schema(UpdateProfileSettingsSchema()) +@response_schema(ProfileSettingsSchema(), 200, description="") +async def update_profile_settings(request: web.BaseRequest): + """ + Request handler for updating setting associated with profile. + + Args: + request: aiohttp request object + """ + context: AdminRequestContext = request["context"] + root_profile = context.root_profile or context.profile + try: + body = await request.json() + extra_settings = get_extra_settings_dict_per_tenant( + body.get("extra_settings") or {} + ) + async with root_profile.session() as session: + multitenant_mgr = session.inject_or(BaseMultitenantManager) + if multitenant_mgr: + wallet_id = context.metadata.get("wallet_id") + wallet_key = context.metadata.get("wallet_key") + wallet_record = await multitenant_mgr.update_wallet( + wallet_id, extra_settings + ) + wallet_record, profile = await multitenant_mgr.get_wallet_and_profile( + root_profile.context, wallet_id, wallet_key + ) + settings_dict = _get_multitenant_settings_dict( + profile_settings=profile.settings.to_dict(), + wallet_settings=wallet_record.settings, + ) + else: + root_profile.context.update_settings(extra_settings) + settings_dict = _get_settings_dict(profile=root_profile) + except BaseError as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + return web.json_response(settings_dict) + + +@docs( + tags=["settings"], + summary="Get the configurable settings associated with the profile.", +) +@response_schema(ProfileSettingsSchema(), 200, description="") +async def get_profile_settings(request: web.BaseRequest): + """ + Request handler for getting setting associated with profile. + + Args: + request: aiohttp request object + """ + context: AdminRequestContext = request["context"] + root_profile = context.root_profile or context.profile + try: + async with root_profile.session() as session: + multitenant_mgr = session.inject_or(BaseMultitenantManager) + if multitenant_mgr: + wallet_id = context.metadata.get("wallet_id") + wallet_key = context.metadata.get("wallet_key") + wallet_record, profile = await multitenant_mgr.get_wallet_and_profile( + root_profile.context, wallet_id, wallet_key + ) + settings_dict = _get_multitenant_settings_dict( + profile_settings=profile.settings.to_dict(), + wallet_settings=wallet_record.settings, + ) + else: + settings_dict = _get_settings_dict(profile=root_profile) + except BaseError as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + return web.json_response(settings_dict) + + +async def register(app: web.Application): + """Register routes.""" + + app.add_routes( + [ + web.put("/settings", update_profile_settings), + web.get("/settings", get_profile_settings, allow_head=False), + ] + ) + + +def post_process_routes(app: web.Application): + """Amend swagger API.""" + + # Add top-level tags description + if "tags" not in app._state["swagger_dict"]: + app._state["swagger_dict"]["tags"] = [] + app._state["swagger_dict"]["tags"].append( + { + "name": "settings", + "description": "Agent settings interface.", + } + ) diff --git a/aries_cloudagent/settings/tests/__init__.py b/aries_cloudagent/settings/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/settings/tests/test_routes.py b/aries_cloudagent/settings/tests/test_routes.py new file mode 100644 index 0000000000..b17774c050 --- /dev/null +++ b/aries_cloudagent/settings/tests/test_routes.py @@ -0,0 +1,220 @@ +"""Test settings routes.""" + +# pylint: disable=redefined-outer-name + +import pytest +from asynctest import mock as async_mock + +from ...admin.request_context import AdminRequestContext +from ...core.in_memory import InMemoryProfile +from ...multitenant.base import BaseMultitenantManager +from ...multitenant.manager import MultitenantManager + +from .. import routes as test_module + + +@pytest.fixture +def mock_response(): + json_response = async_mock.MagicMock() + temp_value = test_module.web.json_response + test_module.web.json_response = json_response + yield json_response + test_module.web.json_response = temp_value + + +@pytest.mark.asyncio +async def test_get_profile_settings(mock_response): + profile = InMemoryProfile.test_profile() + profile.settings.update( + { + "admin.admin_client_max_request_size": 1, + "debug.auto_respond_credential_offer": True, + "debug.auto_respond_credential_request": True, + "debug.auto_respond_presentation_proposal": True, + "debug.auto_verify_presentation": True, + "debug.auto_accept_invites": True, + "debug.auto_accept_requests": True, + } + ) + request_dict = { + "context": AdminRequestContext( + profile=profile, + ), + } + request = async_mock.MagicMock( + query={}, + json=async_mock.CoroutineMock(return_value={}), + __getitem__=lambda _, k: request_dict[k], + ) + await test_module.get_profile_settings(request) + assert mock_response.call_args[0][0] == { + "debug.auto_respond_credential_offer": True, + "debug.auto_respond_credential_request": True, + "debug.auto_verify_presentation": True, + "debug.auto_accept_invites": True, + "debug.auto_accept_requests": True, + } + # Multitenant + profile = InMemoryProfile.test_profile() + multi_tenant_manager = MultitenantManager(profile) + profile.context.injector.bind_instance( + BaseMultitenantManager, + multi_tenant_manager, + ) + request_dict = { + "context": AdminRequestContext( + profile=profile, + root_profile=profile, + metadata={ + "wallet_id": "walletid", + "wallet_key": "walletkey", + }, + ), + } + request = async_mock.MagicMock( + query={}, + json=async_mock.CoroutineMock(return_value={}), + __getitem__=lambda _, k: request_dict[k], + ) + with async_mock.patch.object( + multi_tenant_manager, "get_wallet_and_profile" + ) as get_wallet_and_profile: + get_wallet_and_profile.return_value = ( + async_mock.MagicMock( + settings={ + "admin.admin_client_max_request_size": 1, + "debug.auto_respond_credential_offer": True, + "debug.auto_respond_credential_request": True, + "debug.auto_respond_presentation_proposal": True, + "debug.auto_verify_presentation": True, + "debug.auto_accept_invites": True, + "debug.auto_accept_requests": True, + } + ), + profile, + ) + await test_module.get_profile_settings(request) + assert mock_response.call_args[0][0] == { + "debug.auto_respond_credential_offer": True, + "debug.auto_respond_credential_request": True, + "debug.auto_verify_presentation": True, + "debug.auto_accept_invites": True, + "debug.auto_accept_requests": True, + } + + +@pytest.mark.asyncio +async def test_update_profile_settings(mock_response): + profile = InMemoryProfile.test_profile() + profile.settings.update( + { + "public_invites": True, + "debug.invite_public": True, + "debug.auto_accept_invites": True, + "debug.auto_accept_requests": True, + "auto_ping_connection": True, + } + ) + request_dict = { + "context": AdminRequestContext( + profile=profile, + ), + } + request = async_mock.MagicMock( + query={}, + json=async_mock.CoroutineMock( + return_value={ + "extra_settings": { + "ACAPY_INVITE_PUBLIC": False, + "ACAPY_PUBLIC_INVITES": False, + "ACAPY_AUTO_ACCEPT_INVITES": False, + "ACAPY_AUTO_ACCEPT_REQUESTS": False, + "ACAPY_AUTO_PING_CONNECTION": False, + } + } + ), + __getitem__=lambda _, k: request_dict[k], + ) + await test_module.update_profile_settings(request) + assert mock_response.call_args[0][0] == { + "public_invites": False, + "debug.invite_public": False, + "debug.auto_accept_invites": False, + "debug.auto_accept_requests": False, + "auto_ping_connection": False, + } + # Multitenant + profile = InMemoryProfile.test_profile() + multi_tenant_manager = MultitenantManager(profile) + profile.context.injector.bind_instance( + BaseMultitenantManager, + multi_tenant_manager, + ) + + request_dict = { + "context": AdminRequestContext( + profile=profile, + root_profile=profile, + metadata={ + "wallet_id": "walletid", + "wallet_key": "walletkey", + }, + ), + } + request = async_mock.MagicMock( + query={}, + json=async_mock.CoroutineMock( + return_value={ + "extra_settings": { + "ACAPY_INVITE_PUBLIC": False, + "ACAPY_PUBLIC_INVITES": False, + "ACAPY_AUTO_ACCEPT_INVITES": False, + "ACAPY_AUTO_ACCEPT_REQUESTS": False, + "ACAPY_AUTO_PING_CONNECTION": False, + } + } + ), + __getitem__=lambda _, k: request_dict[k], + ) + with async_mock.patch.object( + multi_tenant_manager, "update_wallet" + ) as update_wallet, async_mock.patch.object( + multi_tenant_manager, "get_wallet_and_profile" + ) as get_wallet_and_profile: + get_wallet_and_profile.return_value = ( + async_mock.MagicMock( + settings={ + "admin.admin_client_max_request_size": 1, + "debug.auto_respond_credential_offer": True, + "debug.auto_respond_credential_request": True, + "debug.auto_respond_presentation_proposal": True, + "debug.auto_verify_presentation": True, + "public_invites": False, + "debug.invite_public": False, + "debug.auto_accept_invites": False, + "debug.auto_accept_requests": False, + "auto_ping_connection": False, + } + ), + profile, + ) + update_wallet.return_value = async_mock.MagicMock( + settings={ + "public_invites": False, + "debug.invite_public": False, + "debug.auto_accept_invites": False, + "debug.auto_accept_requests": False, + "auto_ping_connection": False, + } + ) + await test_module.update_profile_settings(request) + assert mock_response.call_args[0][0] == { + "public_invites": False, + "debug.invite_public": False, + "debug.auto_accept_invites": False, + "debug.auto_accept_requests": False, + "auto_ping_connection": False, + "debug.auto_respond_credential_offer": True, + "debug.auto_respond_credential_request": True, + "debug.auto_verify_presentation": True, + } diff --git a/open-api/openapi.json b/open-api/openapi.json index 87a283d1e7..b27af39ad6 100644 --- a/open-api/openapi.json +++ b/open-api/openapi.json @@ -5300,6 +5300,41 @@ "tags" : [ "schema" ] } }, + "/settings" : { + "get" : { + "tags" : [ "settings" ], + "summary" : "Get profile settings or config", + "produces" : [ "application/json" ], + "parameters" : [ ], + "responses" : { + "200" : { + "type" : "object", + "description" : "Settings for this wallet.", + "properties" : { } + } + } + }, + "put" : { + "tags" : [ "settings" ], + "summary" : "Update profile settings or config", + "produces" : [ "application/json" ], + "parameters" : [ { + "in" : "body", + "name" : "body", + "required" : false, + "schema" : { + "$ref" : "#/definitions/UpdateProfileSettingsRequest" + } + } ], + "responses" : { + "200" : { + "type" : "object", + "description" : "Settings for this wallet.", + "properties" : { } + } + } + } + }, "/shutdown" : { "get" : { "responses" : { @@ -12988,6 +13023,16 @@ }, "type" : "object" }, + "UpdateProfileSettingsRequest" : { + "type" : "object", + "properties" : { + "extra_settings" : { + "type" : "object", + "description" : "Settings or config to update.", + "properties" : { } + } + } + }, "ActionMenuFetchResult_result" : { "allOf" : [ { "$ref" : "#/components/schemas/Menu" diff --git a/open-api/swagger.json b/open-api/swagger.json index b6b4493c15..a0f84c7ed3 100644 --- a/open-api/swagger.json +++ b/open-api/swagger.json @@ -4334,6 +4334,41 @@ } } }, + "/settings" : { + "get" : { + "tags" : [ "settings" ], + "summary" : "Get profile settings or config", + "produces" : [ "application/json" ], + "parameters" : [ ], + "responses" : { + "200" : { + "type" : "object", + "description" : "Settings for this wallet.", + "properties" : { } + } + } + }, + "put" : { + "tags" : [ "settings" ], + "summary" : "Update profile settings or config", + "produces" : [ "application/json" ], + "parameters" : [ { + "in" : "body", + "name" : "body", + "required" : false, + "schema" : { + "$ref" : "#/definitions/UpdateProfileSettingsRequest" + } + } ], + "responses" : { + "200" : { + "type" : "object", + "description" : "Settings for this wallet.", + "properties" : { } + } + } + } + }, "/shutdown" : { "get" : { "tags" : [ "server" ], @@ -11914,6 +11949,16 @@ } } }, + "UpdateProfileSettingsRequest" : { + "type" : "object", + "properties" : { + "extra_settings" : { + "type" : "object", + "description" : "Settings or config to update.", + "properties" : { } + } + } + }, "ActionMenuFetchResult_result" : { "type" : "object", "description" : "Action menu"