diff --git a/.gitignore b/.gitignore index c3198ddee..7c7bc7757 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +junit.xml # Translations *.mo diff --git a/app/event_handling/sse_listener.py b/app/event_handling/sse_listener.py index 456e9bbe4..72513727a 100644 --- a/app/event_handling/sse_listener.py +++ b/app/event_handling/sse_listener.py @@ -1,4 +1,5 @@ import json +from typing import Any, Dict import httpx @@ -26,7 +27,7 @@ def __init__( self.wallet_id = wallet_id self.topic = topic - async def wait_for_state(self, desired_state, timeout: int = 120): + async def wait_for_state(self, desired_state, timeout: int = 120) -> Dict[str, Any]: """ Start listening for SSE events. When an event is received that matches the specified parameters. """ @@ -46,7 +47,9 @@ async def wait_for_state(self, desired_state, timeout: int = 120): raise SseListenerTimeout("Event with request state was not returned by server.") - async def wait_for_event(self, field, field_id, desired_state, timeout: int = 120): + async def wait_for_event( + self, field, field_id, desired_state, timeout: int = 120 + ) -> Dict[str, Any]: """ Start listening for SSE events. When an event is received that matches the specified parameters. """ diff --git a/app/routes/admin/tenants.py b/app/routes/admin/tenants.py index 9d9c372b7..617fe7ef4 100644 --- a/app/routes/admin/tenants.py +++ b/app/routes/admin/tenants.py @@ -25,7 +25,7 @@ UpdateTenantRequest, WalletListWithGroups, ) -from app.services.onboarding import handle_tenant_update, onboard_tenant +from app.services.onboarding.tenants import handle_tenant_update, onboard_tenant from app.services.trust_registry import ( Actor, TrustRegistryException, diff --git a/app/services/onboarding.py b/app/services/onboarding.py deleted file mode 100644 index e801ecd44..000000000 --- a/app/services/onboarding.py +++ /dev/null @@ -1,436 +0,0 @@ -from typing import List - -from aries_cloudcontroller import AcaPyClient, InvitationCreateRequest, InvitationRecord -from aries_cloudcontroller.model.create_wallet_token_request import ( - CreateWalletTokenRequest, -) -from fastapi.exceptions import HTTPException - -from app.dependencies.acapy_clients import ( - get_governance_controller, - get_tenant_controller, -) -from app.event_handling.sse_listener import SseListener -from app.exceptions.cloud_api_error import CloudApiException -from app.models.tenants import OnboardResult, UpdateTenantRequest -from app.services import acapy_ledger, acapy_wallet -from app.services.trust_registry import TrustRegistryRole, actor_by_id, update_actor -from app.util.assert_connection_metadata import ( - assert_author_role_set, - assert_endorser_info_set, - assert_endorser_role_set, -) -from app.util.did import qualified_did_sov -from shared import ACAPY_ENDORSER_ALIAS -from shared.log_config import get_logger - -logger = get_logger(__name__) - - -def create_sse_listener(wallet_id: str, topic: str) -> SseListener: - # Helper method for passing MockListener to class - return SseListener(topic=topic, wallet_id=wallet_id) - - -async def handle_tenant_update( - admin_controller: AcaPyClient, - tenant_id: str, - update: UpdateTenantRequest, -): - bound_logger = logger.bind(body={"tenant_id": tenant_id}) - bound_logger.bind(body=update).info("Handling tenant update") - - bound_logger.debug("Retrieving the wallet") - wallet = await admin_controller.multitenancy.get_wallet(wallet_id=tenant_id) - if not wallet: - bound_logger.error("Bad request: Wallet not found.") - raise HTTPException(404, f"Wallet for tenant id `{tenant_id}` not found.") - - bound_logger.debug("Retrieving tenant from trust registry") - actor = await actor_by_id(wallet.wallet_id) - if not actor: - bound_logger.error( - "Tenant not found in trust registry. " - "Holder tenants cannot be updated with new roles." - ) - raise HTTPException(409, "Holder tenants cannot be updated with new roles.") - - updated_actor = actor.copy() - if update.name: - updated_actor["name"] = update.name - - if update.roles: - bound_logger.info("Updating tenant roles") - # We only care about the added roles, as that's what needs the setup. - # Teardown is not required at the moment, besides from removing it from - # the trust registry - added_roles = list(set(update.roles) - set(actor["roles"])) - - # We need to pose as the tenant to onboard for the specified role - token_response = await admin_controller.multitenancy.get_auth_token( - wallet_id=tenant_id, body=CreateWalletTokenRequest() - ) - - onboard_result = await onboard_tenant( - name=updated_actor["name"], - roles=added_roles, - tenant_auth_token=token_response.token, - tenant_id=tenant_id, - ) - - # Remove duplicates from the role list - updated_actor["roles"] = list(set(update.roles)) - updated_actor["did"] = onboard_result.did - updated_actor["didcomm_invitation"] = onboard_result.didcomm_invitation - - await update_actor(updated_actor) - bound_logger.info("Tenant update handled successfully.") - - -async def onboard_tenant( - *, name: str, roles: List[TrustRegistryRole], tenant_auth_token: str, tenant_id: str -) -> OnboardResult: - bound_logger = logger.bind( - body={"name": name, "roles": roles, "tenant_id": tenant_id} - ) - bound_logger.bind(body=roles).info("Start onboarding tenant") - - if "issuer" in roles: - bound_logger.debug("Tenant has 'issuer' role, onboarding as issuer") - # Get governance and tenant controllers, onboard issuer - async with get_governance_controller() as governance_controller, get_tenant_controller( - tenant_auth_token - ) as tenant_controller: - onboard_result = await onboard_issuer( - name=name, - endorser_controller=governance_controller, - issuer_controller=tenant_controller, - issuer_wallet_id=tenant_id, - ) - bound_logger.info("Onboarding as issuer completed successfully.") - return onboard_result - - elif "verifier" in roles: - bound_logger.debug("Tenant has 'verifier' role, onboarding as verifier") - async with get_tenant_controller(tenant_auth_token) as tenant_controller: - onboard_result = await onboard_verifier( - name=name, verifier_controller=tenant_controller - ) - bound_logger.info("Onboarding as verifier completed successfully.") - return onboard_result - - bound_logger.error("Tenant request does not have valid role(s) for onboarding.") - raise CloudApiException("Unable to onboard tenant without role(s).") - - -async def onboard_issuer( - *, - name: str = None, - endorser_controller: AcaPyClient, - issuer_controller: AcaPyClient, - issuer_wallet_id: str, -): - """Onboard the controller as issuer. - - The onboarding will take care of the following: - - make sure the issuer has a public did - - make sure the issuer has a connection with the endorser - - make sure the issuer has set up endorsement with the endorser connection - - Args: - name (str): name of the issuer - issuer_controller (AcaPyClient): authenticated ACA-Py client for issuer - endorser_controller (AcaPyClient): authenticated ACA-Py client for endorser - """ - bound_logger = logger.bind(body={"issuer_wallet_id": issuer_wallet_id}) - bound_logger.info("Onboarding issuer") - - try: - issuer_did = await acapy_wallet.get_public_did(controller=issuer_controller) - bound_logger.debug("Obtained public DID for the to-be issuer") - except CloudApiException: - bound_logger.debug("No public DID for the to-be issuer") - issuer_did: acapy_wallet.Did = await onboard_issuer_no_public_did( - name, endorser_controller, issuer_controller, issuer_wallet_id - ) - - bound_logger.debug("Creating OOB invitation on behalf of issuer") - invitation = await issuer_controller.out_of_band.create_invitation( - auto_accept=True, - multi_use=True, - body=InvitationCreateRequest( - alias=f"Trust Registry {name}", - handshake_protocols=["https://didcomm.org/didexchange/1.0"], - ), - ) - - return OnboardResult( - did=qualified_did_sov(issuer_did.did), - didcomm_invitation=invitation.invitation_url, - ) - - -async def onboard_issuer_no_public_did( - name: str, - endorser_controller: AcaPyClient, - issuer_controller: AcaPyClient, - issuer_wallet_id: str, -): - """ - Onboard an issuer without a public DID. - - This function handles the case where the issuer does not have a public DID. - It takes care of the following steps: - - Create an endorser invitation using the endorser_controller - - Wait for the connection between issuer and endorser to complete - - Set roles for both issuer and endorser - - Configure endorsement for the connection - - Register the issuer DID on the ledger - - Args: - name (str): Name of the issuer - endorser_controller (AcaPyClient): Authenticated ACA-Py client for endorser - issuer_controller (AcaPyClient): Authenticated ACA-Py client for issuer - issuer_wallet_id (str): Wallet id of the issuer - - Returns: - issuer_did (DID): The issuer's DID after completing the onboarding process - """ - bound_logger = logger.bind(body={"issuer_wallet_id": issuer_wallet_id}) - bound_logger.info("Onboarding issuer that has no public DID") - - async def create_endorser_invitation(): - bound_logger.debug("Create OOB invitation on behalf of endorser") - invitation = await endorser_controller.out_of_band.create_invitation( - auto_accept=True, - body=InvitationCreateRequest( - alias=name, - handshake_protocols=["https://didcomm.org/didexchange/1.0"], - use_public_did=True, - ), - ) - bound_logger.debug("Created OOB invitation") - return invitation - - async def wait_for_connection_completion(invitation): - connections_listener = create_sse_listener( - topic="connections", wallet_id="admin" - ) - - bound_logger.debug("Receive invitation from endorser on behalf of issuer") - connection_record = await issuer_controller.out_of_band.receive_invitation( - auto_accept=True, - use_existing_connection=False, - body=invitation.invitation, - alias=ACAPY_ENDORSER_ALIAS, - ) - - try: - bound_logger.debug("Waiting for event signalling invitation complete") - endorser_connection = await connections_listener.wait_for_event( - field="invitation_msg_id", - field_id=invitation.invi_msg_id, - desired_state="completed", - ) - except TimeoutError as e: - bound_logger.error("Waiting for invitation complete event has timed out.") - raise CloudApiException( - "Timeout occurred while waiting for connection with endorser to complete.", - 504, - ) from e - - bound_logger.info("Connection complete between issuer and endorser.") - return endorser_connection, connection_record - - async def set_endorser_roles(endorser_connection, connection_record): - endorser_connection_id = endorser_connection["connection_id"] - issuer_connection_id = connection_record.connection_id - - bound_logger.debug("Setting roles for endorser") - await endorser_controller.endorse_transaction.set_endorser_role( - conn_id=endorser_connection_id, - transaction_my_job="TRANSACTION_ENDORSER", - ) - - bound_logger.debug("Assert that the endorser role is set") - await assert_endorser_role_set(endorser_controller, endorser_connection_id) - - await issuer_controller.endorse_transaction.set_endorser_role( - conn_id=issuer_connection_id, - transaction_my_job="TRANSACTION_AUTHOR", - ) - - bound_logger.debug("Assert that the author role is set") - await assert_author_role_set(issuer_controller, issuer_connection_id) - - bound_logger.debug("Successfully set roles for connection.") - - async def configure_endorsement(connection_record, endorser_did): - # Make sure endorsement has been configured - # There is currently no way to retrieve endorser info. We'll just set it - # to make sure the endorser info is set. - bound_logger.debug("Setting endorser info") - await issuer_controller.endorse_transaction.set_endorser_info( - conn_id=connection_record.connection_id, - endorser_did=endorser_did.did, - ) - - bound_logger.debug("Assert that the endorser info is set") - await assert_endorser_info_set( - issuer_controller, connection_record.connection_id, endorser_did.did - ) - bound_logger.debug("Successfully set endorser info.") - - async def register_issuer_did(): - bound_logger.info("Creating DID for issuer") - issuer_did = await acapy_wallet.create_did(issuer_controller) - - await acapy_ledger.register_nym_on_ledger( - endorser_controller, - did=issuer_did.did, - verkey=issuer_did.verkey, - alias=name, - ) - - bound_logger.debug("Accepting TAA on behalf of issuer") - await acapy_ledger.accept_taa_if_required(issuer_controller) - # NOTE: Still needs endorsement in 0.7.5 release - # Otherwise did has no associated services. - bound_logger.debug("Setting public DID for issuer") - await acapy_wallet.set_public_did( - issuer_controller, - did=issuer_did.did, - create_transaction_for_endorser=True, - ) - - endorsements_listener = create_sse_listener( - topic="endorsements", wallet_id="admin" - ) - - try: - bound_logger.debug("Waiting for endorsement request received") - txn_record = await endorsements_listener.wait_for_state( - desired_state="request-received" - ) - except TimeoutError as e: - bound_logger.error("Waiting for endorsement request has timed out.") - raise CloudApiException( - "Timeout occurred while waiting for endorsement request.", 504 - ) from e - - bound_logger.bind(body=txn_record["transaction_id"]).debug( - "Endorsing transaction" - ) - await endorser_controller.endorse_transaction.endorse_transaction( - tran_id=txn_record["transaction_id"] - ) - - bound_logger.debug("Issuer DID registered and endorsed successfully.") - return issuer_did - - async def create_connection_with_endorser(endorser_did): - invitation = await create_endorser_invitation() - endorser_connection, connection_record = await wait_for_connection_completion( - invitation - ) - await set_endorser_roles(endorser_connection, connection_record) - await configure_endorsement(connection_record, endorser_did) - - try: - logger.debug("Getting public DID for endorser") - endorser_did = await acapy_wallet.get_public_did(controller=endorser_controller) - except Exception as e: - logger.critical("Endorser has no public DID.") - raise CloudApiException("Unable to get endorser public DID.") from e - - try: - bound_logger.info("Creating connection with endorser") - await create_connection_with_endorser(endorser_did) - issuer_did = await register_issuer_did() - except Exception as e: - bound_logger.exception("Could not create connection with endorser.") - raise CloudApiException( - f"Error creating connection with endorser: {str(e)}", - ) from e - - bound_logger.info("Successfully registered DID for issuer.") - return issuer_did - - -async def onboard_verifier(*, name: str, verifier_controller: AcaPyClient): - """Onboard the controller as verifier. - - The onboarding will take care of the following: - - create a multi_use invitation to use in the - - Args: - verifier_controller (AcaPyClient): authenticated ACA-Py client for verifier - """ - bound_logger = logger.bind(body={"name": name}) - bound_logger.info("Onboarding verifier") - - onboarding_result = {} - - # If the verifier already has a public did it doesn't need an invitation. The invitation - # is just to bypass having to pay for a public did for every verifier - try: - bound_logger.debug("Getting public DID for to-be verifier") - public_did = await acapy_wallet.get_public_did(controller=verifier_controller) - - onboarding_result["did"] = qualified_did_sov(public_did.did) - except CloudApiException: - bound_logger.info( - "No public DID found for to-be verifier. " - "Creating OOB invitation on their behalf." - ) - # create a multi_use invitation from the did - invitation: InvitationRecord = ( - await verifier_controller.out_of_band.create_invitation( - auto_accept=True, - multi_use=True, - body=InvitationCreateRequest( - use_public_did=False, - alias=f"Trust Registry {name}", - handshake_protocols=["https://didcomm.org/didexchange/1.0"], - ), - ) - ) - - # check if invitation and necessary attributes exist - if invitation and invitation.invitation and invitation.invitation.services: - try: - # Because we're not creating an invitation with a public did the invitation will always - # contain a did:key as the first recipientKey in the first service - bound_logger.debug("Getting DID from verifier's invitation") - service = invitation.invitation.services[0] - if ( - service - and "recipientKeys" in service - and len(service["recipientKeys"]) > 0 - ): - onboarding_result["did"] = service["recipientKeys"][0] - else: - raise KeyError( - f"RecipientKeys not present in the invitation service: `{service}`." - ) - onboarding_result["didcomm_invitation"] = invitation.invitation_url - except (KeyError, IndexError) as e: - bound_logger.error( - "Created invitation does not contain expected keys: {}", e - ) - raise CloudApiException( - "Error onboarding verifier: No public DID found. " - "Tried to create invitation, but found no service/recipientKeys." - ) from e - else: - bound_logger.error( - "Created invitation does not have necessary attributes. Got: `{}`.", - invitation, - ) - raise CloudApiException( - "Error onboarding verifier: No public DID found. " - "Tried and failed to create invitation on their behalf." - ) - - bound_logger.info("Returning verifier onboard result.") - return OnboardResult(**onboarding_result) diff --git a/app/services/onboarding/__init__.py b/app/services/onboarding/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/services/onboarding/issuer.py b/app/services/onboarding/issuer.py new file mode 100644 index 000000000..103305c4a --- /dev/null +++ b/app/services/onboarding/issuer.py @@ -0,0 +1,114 @@ +from aries_cloudcontroller import AcaPyClient, InvitationCreateRequest + +from app.exceptions.cloud_api_error import CloudApiException +from app.models.tenants import OnboardResult +from app.services import acapy_wallet +from app.services.onboarding.util.register_issuer_did import ( + create_connection_with_endorser, + register_issuer_did, +) +from app.util.did import qualified_did_sov +from shared.log_config import get_logger + +logger = get_logger(__name__) + + +async def onboard_issuer( + *, + name: str = None, + endorser_controller: AcaPyClient, + issuer_controller: AcaPyClient, + issuer_wallet_id: str, +): + """Onboard the controller as issuer. + + The onboarding will take care of the following: + - make sure the issuer has a public did + - make sure the issuer has a connection with the endorser + - make sure the issuer has set up endorsement with the endorser connection + + Args: + name (str): name of the issuer + issuer_controller (AcaPyClient): authenticated ACA-Py client for issuer + endorser_controller (AcaPyClient): authenticated ACA-Py client for endorser + """ + bound_logger = logger.bind(body={"issuer_wallet_id": issuer_wallet_id}) + bound_logger.info("Onboarding issuer") + + try: + issuer_did = await acapy_wallet.get_public_did(controller=issuer_controller) + bound_logger.debug("Obtained public DID for the to-be issuer") + except CloudApiException: + bound_logger.debug("No public DID for the to-be issuer") + issuer_did: acapy_wallet.Did = await onboard_issuer_no_public_did( + name, endorser_controller, issuer_controller, issuer_wallet_id + ) + + bound_logger.debug("Creating OOB invitation on behalf of issuer") + invitation = await issuer_controller.out_of_band.create_invitation( + auto_accept=True, + multi_use=True, + body=InvitationCreateRequest( + alias=f"Trust Registry {name}", + handshake_protocols=["https://didcomm.org/didexchange/1.0"], + ), + ) + + return OnboardResult( + did=qualified_did_sov(issuer_did.did), + didcomm_invitation=invitation.invitation_url, + ) + + +async def onboard_issuer_no_public_did( + name: str, + endorser_controller: AcaPyClient, + issuer_controller: AcaPyClient, + issuer_wallet_id: str, +): + """ + Onboard an issuer without a public DID. + + This function handles the case where the issuer does not have a public DID. + It takes care of the following steps: + - Create an endorser invitation using the endorser_controller + - Wait for the connection between issuer and endorser to complete + - Set roles for both issuer and endorser + - Configure endorsement for the connection + - Register the issuer DID on the ledger + + Args: + name (str): Name of the issuer + endorser_controller (AcaPyClient): Authenticated ACA-Py client for endorser + issuer_controller (AcaPyClient): Authenticated ACA-Py client for issuer + issuer_wallet_id (str): Wallet id of the issuer + + Returns: + issuer_did (DID): The issuer's DID after completing the onboarding process + """ + bound_logger = logger.bind(body={"issuer_wallet_id": issuer_wallet_id}) + bound_logger.info("Onboarding issuer that has no public DID") + + try: + bound_logger.debug("Getting public DID for endorser") + endorser_did = await acapy_wallet.get_public_did(controller=endorser_controller) + except Exception as e: + bound_logger.critical("Endorser has no public DID.") + raise CloudApiException("Unable to get endorser public DID.") from e + + try: + bound_logger.info("Creating connection with endorser") + await create_connection_with_endorser( + endorser_controller, issuer_controller, endorser_did, name, bound_logger + ) + issuer_did = await register_issuer_did( + endorser_controller, issuer_controller, name, bound_logger + ) + except Exception as e: + bound_logger.exception("Could not create connection with endorser.") + raise CloudApiException( + f"Error creating connection with endorser: {str(e)}", + ) from e + + bound_logger.info("Successfully registered DID for issuer.") + return issuer_did diff --git a/app/services/onboarding/tenants.py b/app/services/onboarding/tenants.py new file mode 100644 index 000000000..e711314be --- /dev/null +++ b/app/services/onboarding/tenants.py @@ -0,0 +1,111 @@ +from typing import List + +from aries_cloudcontroller import AcaPyClient +from aries_cloudcontroller.model.create_wallet_token_request import ( + CreateWalletTokenRequest, +) +from fastapi.exceptions import HTTPException + +from app.dependencies.acapy_clients import ( + get_governance_controller, + get_tenant_controller, +) +from app.exceptions.cloud_api_error import CloudApiException +from app.models.tenants import OnboardResult, UpdateTenantRequest +from app.services.onboarding.issuer import onboard_issuer +from app.services.onboarding.verifier import onboard_verifier +from app.services.trust_registry import TrustRegistryRole, actor_by_id, update_actor +from shared.log_config import get_logger + +logger = get_logger(__name__) + + +async def handle_tenant_update( + admin_controller: AcaPyClient, + tenant_id: str, + update: UpdateTenantRequest, +): + bound_logger = logger.bind(body={"tenant_id": tenant_id}) + bound_logger.bind(body=update).info("Handling tenant update") + + bound_logger.debug("Retrieving the wallet") + wallet = await admin_controller.multitenancy.get_wallet(wallet_id=tenant_id) + if not wallet: + bound_logger.error("Bad request: Wallet not found.") + raise HTTPException(404, f"Wallet for tenant id `{tenant_id}` not found.") + + bound_logger.debug("Retrieving tenant from trust registry") + actor = await actor_by_id(wallet.wallet_id) + if not actor: + bound_logger.error( + "Tenant not found in trust registry. " + "Holder tenants cannot be updated with new roles." + ) + raise HTTPException(409, "Holder tenants cannot be updated with new roles.") + + updated_actor = actor.copy() + if update.name: + updated_actor["name"] = update.name + + if update.roles: + bound_logger.info("Updating tenant roles") + # We only care about the added roles, as that's what needs the setup. + # Teardown is not required at the moment, besides from removing it from + # the trust registry + added_roles = list(set(update.roles) - set(actor["roles"])) + + # We need to pose as the tenant to onboard for the specified role + token_response = await admin_controller.multitenancy.get_auth_token( + wallet_id=tenant_id, body=CreateWalletTokenRequest() + ) + + onboard_result = await onboard_tenant( + name=updated_actor["name"], + roles=added_roles, + tenant_auth_token=token_response.token, + tenant_id=tenant_id, + ) + + # Remove duplicates from the role list + updated_actor["roles"] = list(set(update.roles)) + updated_actor["did"] = onboard_result.did + updated_actor["didcomm_invitation"] = onboard_result.didcomm_invitation + + await update_actor(updated_actor) + bound_logger.info("Tenant update handled successfully.") + + +async def onboard_tenant( + *, name: str, roles: List[TrustRegistryRole], tenant_auth_token: str, tenant_id: str +) -> OnboardResult: + bound_logger = logger.bind( + body={"name": name, "roles": roles, "tenant_id": tenant_id} + ) + bound_logger.bind(body=roles).info("Start onboarding tenant") + + if "issuer" in roles: + bound_logger.debug("Tenant has 'issuer' role, onboarding as issuer") + # Get governance and tenant controllers, onboard issuer + async with get_governance_controller() as governance_controller, get_tenant_controller( + tenant_auth_token + ) as tenant_controller: + onboard_result = await onboard_issuer( + name=name, + endorser_controller=governance_controller, + issuer_controller=tenant_controller, + issuer_wallet_id=tenant_id, + ) + bound_logger.info("Onboarding as issuer completed successfully.") + return onboard_result + + elif "verifier" in roles: + bound_logger.debug("Tenant has 'verifier' role, onboarding as verifier") + async with get_tenant_controller(tenant_auth_token) as tenant_controller: + onboard_result = await onboard_verifier( + name=name, verifier_controller=tenant_controller + ) + bound_logger.info("Onboarding as verifier completed successfully.") + return onboard_result + + bound_logger.error("Tenant request does not have valid role(s) for onboarding.") + raise CloudApiException("Unable to onboard tenant without role(s).") diff --git a/app/services/onboarding/util/__init__.py b/app/services/onboarding/util/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/services/onboarding/util/register_issuer_did.py b/app/services/onboarding/util/register_issuer_did.py new file mode 100644 index 000000000..9ff4b5f28 --- /dev/null +++ b/app/services/onboarding/util/register_issuer_did.py @@ -0,0 +1,173 @@ +from logging import Logger + +from aries_cloudcontroller import AcaPyClient, InvitationCreateRequest, InvitationRecord + +from app.event_handling.sse_listener import SseListener +from app.exceptions.cloud_api_error import CloudApiException +from app.services import acapy_ledger, acapy_wallet +from app.services.acapy_wallet import Did +from app.services.onboarding.util.set_endorser_metadata import ( + set_author_role, + set_endorser_info, + set_endorser_role, +) +from shared import ACAPY_ENDORSER_ALIAS + + +async def create_connection_with_endorser( + endorser_controller: AcaPyClient, + issuer_controller: AcaPyClient, + endorser_did: Did, + name: str, + logger: Logger, +): + invitation = await create_endorser_invitation(endorser_controller, name, logger) + endorser_connection_id, issuer_connection_id = await wait_for_connection_completion( + issuer_controller, invitation, logger + ) + await set_endorser_roles( + endorser_controller, + issuer_controller, + endorser_connection_id, + issuer_connection_id, + logger, + ) + await configure_endorsement( + issuer_controller, issuer_connection_id, endorser_did.did, logger + ) + + +async def create_endorser_invitation( + endorser_controller: AcaPyClient, name: str, logger: Logger +): + logger.debug("Create OOB invitation on behalf of endorser") + invitation = await endorser_controller.out_of_band.create_invitation( + auto_accept=True, + body=InvitationCreateRequest( + alias=name, + handshake_protocols=["https://didcomm.org/didexchange/1.0"], + use_public_did=True, + ), + ) + logger.debug("Created OOB invitation") + return invitation + + +async def wait_for_connection_completion( + issuer_controller: AcaPyClient, invitation: InvitationRecord, logger: Logger +) -> tuple[str, str]: + connections_listener = create_sse_listener(topic="connections", wallet_id="admin") + + logger.debug("Receive invitation from endorser on behalf of issuer") + connection_record = await issuer_controller.out_of_band.receive_invitation( + auto_accept=True, + use_existing_connection=False, + body=invitation.invitation, + alias=ACAPY_ENDORSER_ALIAS, + ) + + try: + logger.debug("Waiting for event signalling invitation complete") + endorser_connection = await connections_listener.wait_for_event( + field="invitation_msg_id", + field_id=invitation.invi_msg_id, + desired_state="completed", + ) + except TimeoutError as e: + logger.error("Waiting for invitation complete event has timed out.") + raise CloudApiException( + "Timeout occurred while waiting for connection with endorser to complete.", + 504, + ) from e + + logger.info("Connection complete between issuer and endorser.") + return endorser_connection["connection_id"], connection_record.connection_id + + +async def set_endorser_roles( + endorser_controller: AcaPyClient, + issuer_controller: AcaPyClient, + endorser_connection_id: str, + issuer_connection_id: str, + logger: Logger, +): + logger.debug("Setting roles for endorser") + await set_endorser_role(endorser_controller, endorser_connection_id, logger) + + logger.debug("Setting roles for author") + await set_author_role(issuer_controller, issuer_connection_id, logger) + + logger.debug("Successfully set roles for connection.") + + +async def configure_endorsement( + issuer_controller: AcaPyClient, + issuer_connection_id: str, + endorser_did: str, + logger: Logger, +): + # Make sure endorsement has been configured + # There is currently no way to retrieve endorser info. We'll just set it + # to make sure the endorser info is set. + logger.debug("Setting endorser info") + await set_endorser_info( + issuer_controller, + issuer_connection_id, + endorser_did, + logger, + ) + logger.debug("Successfully set endorser info.") + + +async def register_issuer_did( + endorser_controller: AcaPyClient, + issuer_controller: AcaPyClient, + name: str, + logger: Logger, +): + logger.info("Creating DID for issuer") + issuer_did = await acapy_wallet.create_did(issuer_controller) + + await acapy_ledger.register_nym_on_ledger( + endorser_controller, + did=issuer_did.did, + verkey=issuer_did.verkey, + alias=name, + ) + + logger.debug("Accepting TAA on behalf of issuer") + await acapy_ledger.accept_taa_if_required(issuer_controller) + # NOTE: Still needs endorsement in 0.7.5 release + # Otherwise did has no associated services. + logger.debug("Setting public DID for issuer") + await acapy_wallet.set_public_did( + issuer_controller, + did=issuer_did.did, + create_transaction_for_endorser=True, + ) + + endorsements_listener = create_sse_listener(topic="endorsements", wallet_id="admin") + + try: + logger.debug("Waiting for endorsement request received") + txn_record = await endorsements_listener.wait_for_state( + desired_state="request-received" + ) + except TimeoutError as e: + logger.error("Waiting for endorsement request has timed out.") + raise CloudApiException( + "Timeout occurred while waiting for endorsement request.", 504 + ) from e + + logger.bind(body=txn_record["transaction_id"]).debug("Endorsing transaction") + await endorser_controller.endorse_transaction.endorse_transaction( + tran_id=txn_record["transaction_id"] + ) + + logger.debug("Issuer DID registered and endorsed successfully.") + return issuer_did + + +def create_sse_listener(wallet_id: str, topic: str) -> SseListener: + # Helper method for passing a MockListener to this module in tests + return SseListener(topic=topic, wallet_id=wallet_id) diff --git a/app/services/onboarding/util/set_endorser_metadata.py b/app/services/onboarding/util/set_endorser_metadata.py new file mode 100644 index 000000000..ba7b2c757 --- /dev/null +++ b/app/services/onboarding/util/set_endorser_metadata.py @@ -0,0 +1,194 @@ +import asyncio +import os +from logging import Logger +from typing import Callable + +from aiohttp import ClientResponseError +from aries_cloudcontroller import AcaPyClient + +from app.exceptions.cloud_api_error import CloudApiException + +DEFAULT_NUM_TRIES = 1 +DEFAULT_DELAY = float(os.environ.get("SET_ENDORSER_INFO_DELAY", "1.5")) + + +async def set_endorser_role( + endorser_controller: AcaPyClient, endorser_connection_id: str, logger: Logger +): + try: + logger.debug("Setting roles for endorser on endorser-issuer connection.") + await endorser_controller.endorse_transaction.set_endorser_role( + conn_id=endorser_connection_id, + transaction_my_job="TRANSACTION_ENDORSER", + ) + logger.debug("Successfully set endorser role.") + await asyncio.sleep(DEFAULT_DELAY) # Allow ACA-Py records to update + except ClientResponseError as e: + logger.error("Failed to set endorser role: {}.", e) + raise CloudApiException( + "Failed to set the endorser role in the endorser-issuer connection, " + f"with connection id {endorser_connection_id}. " + "This is a known bug in ACA-Py. Please retry." + ) from e + + +async def set_author_role( + issuer_controller: AcaPyClient, issuer_connection_id: str, logger: Logger +): + try: + logger.debug("Setting roles for author on issuer-endorser connection") + await issuer_controller.endorse_transaction.set_endorser_role( + conn_id=issuer_connection_id, + transaction_my_job="TRANSACTION_AUTHOR", + ) + logger.debug("Successfully set author role.") + await asyncio.sleep(DEFAULT_DELAY) # Allow ACA-Py records to update + except ClientResponseError as e: + logger.error("Failed to set author role: {}.", e) + raise CloudApiException( + "Failed to set the author role in the issuer-endorser connection, " + f"with connection id {issuer_connection_id}. " + "This is a known bug in ACA-Py. Please retry." + ) from e + + +async def set_endorser_info( + issuer_controller: AcaPyClient, + issuer_connection_id: str, + endorser_did: str, + logger: Logger, +): + try: + logger.debug("Setting endorser info on issuer-endorser connection") + await issuer_controller.endorse_transaction.set_endorser_info( + conn_id=issuer_connection_id, + endorser_did=endorser_did, + ) + logger.debug("Successfully set endorser info.") + await asyncio.sleep(DEFAULT_DELAY) # Allow ACA-Py records to update + except ClientResponseError as e: + logger.error("Failed to set endorser info: {}.", e) + raise CloudApiException( + "Failed to set the endorser info in the issuer-endorser connection, " + f"with connection id {issuer_connection_id}. " + "This is a known bug in ACA-Py. Please retry." + ) from e + + +# Unused code at the moment: may be useful in avoiding ACA-Py delays resulting in duplicate record bug + + +async def assert_metadata_set( + controller: AcaPyClient, + conn_id: str, + check_fn: Callable, + logger: Logger, + num_tries=DEFAULT_NUM_TRIES, + delay=DEFAULT_DELAY, +): + """Checks if connection record metadata has been set according to a custom check function. + + Args: + controller: The AcaPyClient instance for the respective agent + conn_id: Connection id of the connection you're interested in + check_fn: A function that takes the metadata and returns True if it meets the desired condition + logger: A logger instance + num_tries: Number of num_tries before failing + delay: Delay in seconds between each retry + + Returns: + True if condition is met, raises an exception otherwise. + """ + for _ in range(num_tries): + # Delay is placed at the start to avoid race condition in ACA-Py, where reading metadata causes duplicate + # record error if metadata is still due to be updated + logger.debug(f"Sleep {delay}s before trying to fetch metadata") + await asyncio.sleep(delay) + try: + logger.debug("Fetching connection metadata") + connection_metadata = await controller.connection.get_metadata( + conn_id=conn_id + ) + logger.debug("Successfully fetched metadata") + metadata_dict = connection_metadata.results + if check_fn(metadata_dict): + return True + except ClientResponseError as e: + logger.error("Exception occurred when getting metadata: {}", e) + + raise SettingMetadataException( + f"Failed to assert that metadata meets the desired condition after {num_tries} attempts." + ) + + +async def assert_endorser_role_set( + controller: AcaPyClient, + conn_id: str, + logger: Logger, + num_tries=1, + delay=DEFAULT_DELAY, +): + check_fn = ( + lambda metadata: metadata.get("transaction_jobs", {}).get("transaction_my_job") + == "TRANSACTION_ENDORSER" + ) + try: + await assert_metadata_set( + controller, conn_id, check_fn, logger, num_tries, delay + ) + except Exception as e: + raise SettingMetadataException( + "Failed to assert that the endorser role has been set in the connection metadata." + ) from e + + +async def assert_author_role_set( + controller: AcaPyClient, + conn_id: str, + logger: Logger, + num_tries=1, + delay=DEFAULT_DELAY, +): + check_fn = ( + lambda metadata: metadata.get("transaction_jobs", {}).get("transaction_my_job") + == "TRANSACTION_AUTHOR" + and metadata.get("transaction_jobs", {}).get("transaction_their_job") + == "TRANSACTION_ENDORSER" + ) + try: + await assert_metadata_set( + controller, conn_id, check_fn, logger, num_tries, delay + ) + except Exception as e: + raise SettingMetadataException( + "Failed to assert that the author role has been set in the connection metadata." + ) from e + + +async def assert_endorser_info_set( + controller: AcaPyClient, + conn_id: str, + endorser_did: str, + logger: Logger, + num_tries=1, + delay=DEFAULT_DELAY, +): + check_fn = ( + lambda metadata: metadata.get("transaction_jobs", {}).get("transaction_my_job") + == "TRANSACTION_AUTHOR" + and metadata.get("transaction_jobs", {}).get("transaction_their_job") + == "TRANSACTION_ENDORSER" + and metadata.get("endorser_info", {}).get("endorser_did") == endorser_did + ) + try: + await assert_metadata_set( + controller, conn_id, check_fn, logger, num_tries, delay + ) + except Exception as e: + raise SettingMetadataException( + "Failed to assert that the endorser info has been set in the connection metadata." + ) from e + + +class SettingMetadataException(CloudApiException): + pass diff --git a/app/services/onboarding/verifier.py b/app/services/onboarding/verifier.py new file mode 100644 index 000000000..75b79efc9 --- /dev/null +++ b/app/services/onboarding/verifier.py @@ -0,0 +1,88 @@ +from aries_cloudcontroller import AcaPyClient, InvitationCreateRequest, InvitationRecord + +from app.exceptions.cloud_api_error import CloudApiException +from app.models.tenants import OnboardResult +from app.services import acapy_wallet +from app.util.did import qualified_did_sov +from shared.log_config import get_logger + +logger = get_logger(__name__) + + +async def onboard_verifier(*, name: str, verifier_controller: AcaPyClient): + """Onboard the controller as verifier. + + The onboarding will take care of the following: + - create a multi_use invitation to use in the + + Args: + verifier_controller (AcaPyClient): authenticated ACA-Py client for verifier + """ + bound_logger = logger.bind(body={"name": name}) + bound_logger.info("Onboarding verifier") + + onboarding_result = {} + + # If the verifier already has a public did it doesn't need an invitation. The invitation + # is just to bypass having to pay for a public did for every verifier + try: + bound_logger.debug("Getting public DID for to-be verifier") + public_did = await acapy_wallet.get_public_did(controller=verifier_controller) + + onboarding_result["did"] = qualified_did_sov(public_did.did) + except CloudApiException: + bound_logger.info( + "No public DID found for to-be verifier. " + "Creating OOB invitation on their behalf." + ) + # create a multi_use invitation from the did + invitation: InvitationRecord = ( + await verifier_controller.out_of_band.create_invitation( + auto_accept=True, + multi_use=True, + body=InvitationCreateRequest( + use_public_did=False, + alias=f"Trust Registry {name}", + handshake_protocols=["https://didcomm.org/didexchange/1.0"], + ), + ) + ) + + # check if invitation and necessary attributes exist + if invitation and invitation.invitation and invitation.invitation.services: + try: + # Because we're not creating an invitation with a public did the invitation will always + # contain a did:key as the first recipientKey in the first service + bound_logger.debug("Getting DID from verifier's invitation") + service = invitation.invitation.services[0] + if ( + service + and "recipientKeys" in service + and len(service["recipientKeys"]) > 0 + ): + onboarding_result["did"] = service["recipientKeys"][0] + else: + raise KeyError( + f"RecipientKeys not present in the invitation service: `{service}`." + ) + onboarding_result["didcomm_invitation"] = invitation.invitation_url + except (KeyError, IndexError) as e: + bound_logger.error( + "Created invitation does not contain expected keys: {}", e + ) + raise CloudApiException( + "Error onboarding verifier: No public DID found. " + "Tried to create invitation, but found no service/recipientKeys." + ) from e + else: + bound_logger.error( + "Created invitation does not have necessary attributes. Got: `{}`.", + invitation, + ) + raise CloudApiException( + "Error onboarding verifier: No public DID found. " + "Tried and failed to create invitation on their behalf." + ) + + bound_logger.info("Returning verifier onboard result.") + return OnboardResult(**onboarding_result) diff --git a/app/tests/admin/test_onboarding.py b/app/tests/admin/test_onboarding.py index ad0bb0b55..30650dc07 100644 --- a/app/tests/admin/test_onboarding.py +++ b/app/tests/admin/test_onboarding.py @@ -13,9 +13,10 @@ from app.event_handling.sse_listener import SseListener from app.exceptions.cloud_api_error import CloudApiException -from app.services import onboarding +from app.services import acapy_ledger, acapy_wallet from app.services.acapy_wallet import Did -from app.services.onboarding import acapy_ledger, acapy_wallet +from app.services.onboarding import issuer, verifier +from app.services.onboarding.util import register_issuer_did from app.tests.util.mock import to_async from shared.util.mock_agent_controller import get_mock_agent_controller @@ -57,10 +58,10 @@ async def test_onboard_issuer_public_did_exists( ) # Mock event listeners - when(onboarding).create_sse_listener( + when(register_issuer_did).create_sse_listener( topic="connections", wallet_id="admin" ).thenReturn(MockSseListener(topic="connections", wallet_id="admin")) - when(onboarding).create_sse_listener( + when(register_issuer_did).create_sse_listener( topic="endorsements", wallet_id="admin" ).thenReturn( MockListenerEndorserConnectionId(topic="endorsements", wallet_id="admin") @@ -76,7 +77,7 @@ async def test_onboard_issuer_public_did_exists( ) ) - onboard_result = await onboarding.onboard_issuer( + onboard_result = await issuer.onboard_issuer( name="issuer_name", endorser_controller=endorser_controller, issuer_controller=mock_agent_controller, @@ -105,12 +106,12 @@ async def test_onboard_issuer_no_public_did( ) # Mock event listeners - when(onboarding).create_sse_listener( + when(register_issuer_did).create_sse_listener( topic="connections", wallet_id="admin" ).thenReturn( MockListenerEndorserConnectionId(topic="connections", wallet_id="admin") ) - when(onboarding).create_sse_listener( + when(register_issuer_did).create_sse_listener( topic="endorsements", wallet_id="admin" ).thenReturn(MockListenerRequestReceived(topic="endorsements", wallet_id="admin")) @@ -137,8 +138,8 @@ async def test_onboard_issuer_no_public_did( when(endorser_controller.endorse_transaction).set_endorser_role(...).thenReturn( to_async() ) - when(mock_agent_controller.endorse_transaction).set_endorser_info(...).thenReturn( - to_async() + when(mock_agent_controller.endorse_transaction).set_endorser_info(...).thenAnswer( + lambda conn_id, endorser_did: to_async() ) # Expanding the test scenario: we want to ensure that the coroutine is successfully retried in the @@ -212,7 +213,7 @@ def __init__(self): ) ) - onboard_result = await onboarding.onboard_issuer( + onboard_result = await issuer.onboard_issuer( name="issuer_name", endorser_controller=endorser_controller, issuer_controller=mock_agent_controller, @@ -246,7 +247,7 @@ async def test_onboard_verifier_public_did_exists(mock_agent_controller: AcaPyCl ) ) - onboard_result = await onboarding.onboard_verifier( + onboard_result = await verifier.onboard_verifier( name="verifier_name", verifier_controller=mock_agent_controller ) @@ -272,7 +273,7 @@ async def test_onboard_verifier_no_public_did(mock_agent_controller: AcaPyClient ) ) - onboard_result = await onboarding.onboard_verifier( + onboard_result = await verifier.onboard_verifier( name="verifier_name", verifier_controller=mock_agent_controller ) @@ -303,7 +304,7 @@ async def test_onboard_verifier_no_recipient_keys(mock_agent_controller: AcaPyCl ) with pytest.raises(CloudApiException): - await onboarding.onboard_verifier( + await verifier.onboard_verifier( name="verifier_name", verifier_controller=mock_agent_controller ) diff --git a/app/tests/e2e/issuer/did_key_bbs/test_ld_bbs.py b/app/tests/e2e/issuer/did_key_bbs/test_ld_bbs.py index 268902678..421987967 100644 --- a/app/tests/e2e/issuer/did_key_bbs/test_ld_bbs.py +++ b/app/tests/e2e/issuer/did_key_bbs/test_ld_bbs.py @@ -15,7 +15,7 @@ CREDENTIALS_BASE_PATH = issuer_router.prefix OOB_BASE_PATH = oob_router.prefix -CON = con_router.prefix +CONNECTIONS_BASE_PATH = con_router.prefix credential_ = SendCredential( @@ -162,7 +162,7 @@ async def test_send_jsonld_bbs_oob( assert_that(accept_response.status_code).is_equal_to(200) assert_that(oob_record).contains("created_at", "oob_id", "invitation") - faber_con = await faber_client.get(CON) + faber_con = await faber_client.get(CONNECTIONS_BASE_PATH) faber_connections = faber_con.json() for con in faber_connections: diff --git a/app/tests/e2e/issuer/did_key_ed/test_ld_ed25519.py b/app/tests/e2e/issuer/did_key_ed/test_ld_ed25519.py index 6f811e24e..615b179ec 100644 --- a/app/tests/e2e/issuer/did_key_ed/test_ld_ed25519.py +++ b/app/tests/e2e/issuer/did_key_ed/test_ld_ed25519.py @@ -8,7 +8,6 @@ from app.routes.connections import router as con_router from app.routes.issuer import router as issuer_router from app.routes.oob import router as oob_router -from app.routes.wallet.dids import router as wallet_router from app.tests.util.ecosystem_connections import FaberAliceConnect from app.tests.util.trust_registry import DidKey from app.tests.util.webhooks import check_webhook_state @@ -16,8 +15,7 @@ CREDENTIALS_BASE_PATH = issuer_router.prefix OOB_BASE_PATH = oob_router.prefix -WALLET = wallet_router.prefix -CON = con_router.prefix +CONNECTIONS_BASE_PATH = con_router.prefix credential_ = SendCredential( type="ld_proof", @@ -165,7 +163,7 @@ async def test_send_jsonld_oob( assert_that(accept_response.status_code).is_equal_to(200) assert_that(oob_record).contains("created_at", "oob_id", "invitation") - faber_con = await faber_client.get(CON) + faber_con = await faber_client.get(CONNECTIONS_BASE_PATH) faber_connections = faber_con.json() for con in faber_connections: diff --git a/app/util/assert_connection_metadata.py b/app/util/assert_connection_metadata.py deleted file mode 100644 index 237ce3420..000000000 --- a/app/util/assert_connection_metadata.py +++ /dev/null @@ -1,100 +0,0 @@ -import asyncio -from typing import Callable - -from aiohttp import ClientResponseError -from aries_cloudcontroller import AcaPyClient - -from app.exceptions.cloud_api_error import CloudApiException - -DEFAULT_RETRIES = 10 -DEFAULT_DELAY = 0.2 - - -async def assert_metadata_set( - controller: AcaPyClient, - conn_id: str, - check_fn: Callable, - retries=DEFAULT_RETRIES, - delay=DEFAULT_DELAY, -): - """Checks if connection record metadata has been set according to a custom check function. - - Args: - controller: The AcaPyClient instance for the respective agent - conn_id: Connection id of the connection you're interested in - check_fn: A function that takes the metadata and returns True if it meets the desired condition - retries: Number of retries before failing - delay: Delay in seconds between each retry - - Returns: - True if condition is met, raises an exception otherwise. - """ - for _ in range(retries): - # Delay is placed at the start to avoid race condition in ACA-Py, where reading metadata causes duplicate - # record error if metadata is still due to be updated - await asyncio.sleep(delay) - try: - connection_metadata = await controller.connection.get_metadata( - conn_id=conn_id - ) - metadata_dict = connection_metadata.results - if check_fn(metadata_dict): - return True - except ClientResponseError: - # A duplicate record error (aries_cloudagent.storage.error.StorageDuplicateError) may occur in ACA-Py - # if we fetch metadata while it's being updated - pass - - raise CloudApiException( - f"Failed to assert that metadata meets the desired condition after {retries} attempts." - ) - - -async def assert_endorser_role_set( - controller, conn_id, retries=DEFAULT_RETRIES, delay=DEFAULT_DELAY -): - check_fn = ( - lambda metadata: metadata.get("transaction_jobs", {}).get("transaction_my_job") - == "TRANSACTION_ENDORSER" - ) - try: - await assert_metadata_set(controller, conn_id, check_fn, retries, delay) - except Exception as e: - raise CloudApiException( - "Failed to assert that the endorser role has been set in the connection metadata." - ) from e - - -async def assert_author_role_set( - controller, conn_id, retries=DEFAULT_RETRIES, delay=DEFAULT_DELAY -): - check_fn = ( - lambda metadata: metadata.get("transaction_jobs", {}).get("transaction_my_job") - == "TRANSACTION_AUTHOR" - and metadata.get("transaction_jobs", {}).get("transaction_their_job") - == "TRANSACTION_ENDORSER" - ) - try: - await assert_metadata_set(controller, conn_id, check_fn, retries, delay) - except Exception as e: - raise CloudApiException( - "Failed to assert that the author role has been set in the connection metadata." - ) from e - - -async def assert_endorser_info_set( - controller, conn_id, endorser_did, retries=DEFAULT_RETRIES, delay=DEFAULT_DELAY -): - check_fn = ( - lambda metadata: metadata.get("transaction_jobs", {}).get("transaction_my_job") - == "TRANSACTION_AUTHOR" - and metadata.get("transaction_jobs", {}).get("transaction_their_job") - == "TRANSACTION_ENDORSER" - and metadata.get("endorser_info", {}).get("endorser_did") == endorser_did - ) - try: - await assert_metadata_set(controller, conn_id, check_fn, retries, delay) - except Exception as e: - raise CloudApiException( - "Failed to assert that the endorser info has been set in the connection metadata." - ) from e