diff --git a/aries_cloudagent/ledger/base.py b/aries_cloudagent/ledger/base.py index 3ceaa7e1f3..68f91992ee 100644 --- a/aries_cloudagent/ledger/base.py +++ b/aries_cloudagent/ledger/base.py @@ -86,8 +86,14 @@ async def update_endpoint_for_did( @abstractmethod async def register_nym( - self, did: str, verkey: str, alias: str = None, role: str = None - ): + self, + did: str, + verkey: str, + alias: str = None, + role: str = None, + write_ledger: bool = True, + endorser_did: str = None, + ) -> Tuple[bool, dict]: """ Register a nym on the ledger. diff --git a/aries_cloudagent/ledger/indy.py b/aries_cloudagent/ledger/indy.py index 21d334a64f..6a8945e4e9 100644 --- a/aries_cloudagent/ledger/indy.py +++ b/aries_cloudagent/ledger/indy.py @@ -928,8 +928,14 @@ async def update_endpoint_for_did( return False async def register_nym( - self, did: str, verkey: str, alias: str = None, role: str = None - ): + self, + did: str, + verkey: str, + alias: str = None, + role: str = None, + write_ledger: bool = True, + endorser_did: str = None, + ) -> Tuple[bool, dict]: """ Register a nym on the ledger. @@ -957,10 +963,15 @@ async def register_nym( request_json = await indy.ledger.build_nym_request( public_info.did, did, verkey, alias, role ) - await self._submit( - request_json + if endorser_did and not write_ledger: + request_json = await indy.ledger.append_request_endorser( + request_json, endorser_did + ) + resp = await self._submit( + request_json, sign=True, sign_did=public_info, write_ledger=write_ledger ) # let ledger raise on insufficient privilege - + if not write_ledger: + return True, {"signed_txn": resp} try: did_info = await wallet.get_local_did(did) except WalletNotFoundError: @@ -968,6 +979,7 @@ async def register_nym( else: metadata = {**did_info.metadata, **DIDPosture.POSTED.metadata} await wallet.replace_local_did_metadata(did, metadata) + return True, None async def get_nym_role(self, did: str) -> Role: """ diff --git a/aries_cloudagent/ledger/indy_vdr.py b/aries_cloudagent/ledger/indy_vdr.py index d8e8910bc1..5f82082bf5 100644 --- a/aries_cloudagent/ledger/indy_vdr.py +++ b/aries_cloudagent/ledger/indy_vdr.py @@ -938,8 +938,14 @@ async def update_endpoint_for_did( return False async def register_nym( - self, did: str, verkey: str, alias: str = None, role: str = None - ): + self, + did: str, + verkey: str, + alias: str = None, + role: str = None, + write_ledger: bool = True, + endorser_did: str = None, + ) -> Tuple[bool, dict]: """ Register a nym on the ledger. @@ -965,8 +971,14 @@ async def register_nym( except VdrError as err: raise LedgerError("Exception when building nym request") from err - await self._submit(nym_req, sign=True, sign_did=public_info) + if endorser_did and not write_ledger: + nym_req.set_endorser(endorser_did) + resp = await self._submit( + nym_req, sign=True, sign_did=public_info, write_ledger=write_ledger + ) + if not write_ledger: + return True, {"signed_txn": resp} async with self.profile.session() as session: wallet = session.inject(BaseWallet) try: @@ -976,6 +988,7 @@ async def register_nym( else: metadata = {**did_info.metadata, **DIDPosture.POSTED.metadata} await wallet.replace_local_did_metadata(did, metadata) + return True, None async def get_nym_role(self, did: str) -> Role: """ diff --git a/aries_cloudagent/ledger/routes.py b/aries_cloudagent/ledger/routes.py index dda91013fd..67aed5e1fc 100644 --- a/aries_cloudagent/ledger/routes.py +++ b/aries_cloudagent/ledger/routes.py @@ -1,10 +1,14 @@ """Ledger admin routes.""" +import json + from aiohttp import web from aiohttp_apispec import docs, querystring_schema, request_schema, response_schema from marshmallow import fields, validate from ..admin.request_context import AdminRequestContext +from ..connections.models.conn_record import ConnRecord +from ..messaging.models.base import BaseModelError from ..messaging.models.openapi import OpenAPISchema from ..messaging.valid import ( ENDPOINT, @@ -12,8 +16,21 @@ INDY_DID, INDY_RAW_PUBLIC_KEY, INT_EPOCH, + UUIDFour, +) + +from ..protocols.endorse_transaction.v1_0.manager import ( + TransactionManager, + TransactionManagerError, +) +from ..protocols.endorse_transaction.v1_0.models.transaction_record import ( + TransactionRecordSchema, +) +from ..protocols.endorse_transaction.v1_0.util import ( + is_author_role, + get_endorser_connection_id, ) -from ..storage.error import StorageError +from ..storage.error import StorageError, StorageNotFoundError from ..wallet.error import WalletError, WalletNotFoundError from .base import BaseLedger, Role as LedgerRole @@ -32,6 +49,7 @@ ) from .endpoint_type import EndpointType from .error import BadLedgerRequestError, LedgerError, LedgerTransactionError +from .util import notify_did_event class LedgerModulesResultSchema(OpenAPISchema): @@ -109,6 +127,23 @@ class RegisterLedgerNymQueryStringSchema(OpenAPISchema): ) +class CreateDidTxnForEndorserOptionSchema(OpenAPISchema): + """Class for user to input whether to create a transaction for endorser or not.""" + + create_transaction_for_endorser = fields.Boolean( + description="Create Transaction For Endorser's signature", + required=False, + ) + + +class SchemaConnIdMatchInfoSchema(OpenAPISchema): + """Path parameters and validators for request taking connection id.""" + + conn_id = fields.Str( + description="Connection identifier", required=False, example=UUIDFour.EXAMPLE + ) + + class QueryStringDIDSchema(OpenAPISchema): """Parameters and validators for query string with DID only.""" @@ -128,7 +163,7 @@ class QueryStringEndpointSchema(OpenAPISchema): ) -class RegisterLedgerNymResponseSchema(OpenAPISchema): +class TxnOrRegisterLedgerNymResponseSchema(OpenAPISchema): """Response schema for ledger nym registration.""" success = fields.Bool( @@ -136,6 +171,12 @@ class RegisterLedgerNymResponseSchema(OpenAPISchema): example=True, ) + txn = fields.Nested( + TransactionRecordSchema(), + required=False, + description="DID transaction to endorse", + ) + class GetNymRoleResponseSchema(OpenAPISchema): """Response schema to get nym role operation.""" @@ -172,7 +213,9 @@ class GetDIDEndpointResponseSchema(OpenAPISchema): summary="Send a NYM registration to the ledger.", ) @querystring_schema(RegisterLedgerNymQueryStringSchema()) -@response_schema(RegisterLedgerNymResponseSchema(), 200, description="") +@querystring_schema(CreateDidTxnForEndorserOptionSchema()) +@querystring_schema(SchemaConnIdMatchInfoSchema()) +@response_schema(TxnOrRegisterLedgerNymResponseSchema(), 200, description="") async def register_ledger_nym(request: web.BaseRequest): """ Request handler for registering a NYM with the ledger. @@ -181,6 +224,7 @@ async def register_ledger_nym(request: web.BaseRequest): request: aiohttp request object """ context: AdminRequestContext = request["context"] + outbound_handler = request["outbound_message_router"] async with context.profile.session() as session: ledger = session.inject_or(BaseLedger) if not ledger: @@ -201,11 +245,63 @@ async def register_ledger_nym(request: web.BaseRequest): if role == "reset": # indy: empty to reset, null for regular user role = "" # visually: confusing - correct 'reset' to empty string here + create_transaction_for_endorser = json.loads( + request.query.get("create_transaction_for_endorser", "false") + ) + write_ledger = not create_transaction_for_endorser + endorser_did = None + connection_id = request.query.get("conn_id") + + # check if we need to endorse + if is_author_role(context.profile): + # authors cannot write to the ledger + write_ledger = False + create_transaction_for_endorser = True + if not connection_id: + # author has not provided a connection id, so determine which to use + connection_id = await get_endorser_connection_id(context.profile) + if not connection_id: + raise web.HTTPBadRequest(reason="No endorser connection found") + + if not write_ledger: + try: + async with context.profile.session() as session: + connection_record = await ConnRecord.retrieve_by_id( + session, connection_id + ) + except StorageNotFoundError as err: + raise web.HTTPNotFound(reason=err.roll_up) from err + except BaseModelError as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + async with context.profile.session() as session: + endorser_info = await connection_record.metadata_get( + session, "endorser_info" + ) + if not endorser_info: + raise web.HTTPForbidden( + reason="Endorser Info is not set up in " + "connection metadata for this connection record" + ) + if "endorser_did" not in endorser_info.keys(): + raise web.HTTPForbidden( + reason=' "endorser_did" is not set in "endorser_info"' + " in connection metadata for this connection record" + ) + endorser_did = endorser_info["endorser_did"] + success = False + txn = None async with ledger: try: - await ledger.register_nym(did, verkey, alias, role) - success = True + (success, txn) = await ledger.register_nym( + did, + verkey, + alias, + role, + write_ledger=write_ledger, + endorser_did=endorser_did, + ) except LedgerTransactionError as err: raise web.HTTPForbidden(reason=err.roll_up) except LedgerError as err: @@ -220,7 +316,37 @@ async def register_ledger_nym(request: web.BaseRequest): ) ) - return web.json_response({"success": success}) + meta_data = {"verkey": verkey, "alias": alias, "role": role} + if not create_transaction_for_endorser: + # Notify event + await notify_did_event(context.profile, did, meta_data) + return web.json_response({"success": success}) + else: + transaction_mgr = TransactionManager(context.profile) + try: + transaction = await transaction_mgr.create_record( + messages_attach=txn["signed_txn"], + connection_id=connection_id, + meta_data=meta_data, + ) + except StorageError as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + # if auto-request, send the request to the endorser + if context.settings.get_value("endorser.auto_request"): + try: + transaction, transaction_request = await transaction_mgr.create_request( + transaction=transaction, + # TODO see if we need to parameterize these params + # expires_time=expires_time, + # endorser_write_txn=endorser_write_txn, + ) + except (StorageError, TransactionManagerError) as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + await outbound_handler(transaction_request, connection_id=connection_id) + + return web.json_response({"success": success, "txn": txn}) @docs( diff --git a/aries_cloudagent/ledger/tests/test_routes.py b/aries_cloudagent/ledger/tests/test_routes.py index 8b2ed92a1a..fb94d5c692 100644 --- a/aries_cloudagent/ledger/tests/test_routes.py +++ b/aries_cloudagent/ledger/tests/test_routes.py @@ -1,4 +1,5 @@ from asynctest import mock as async_mock, TestCase as AsyncTestCase +from typing import Tuple from ...admin.request_context import AdminRequestContext from ...core.in_memory import InMemoryProfile @@ -14,6 +15,7 @@ from .. import routes as test_module from ..indy import Role +from ...connections.models.conn_record import ConnRecord class TestLedgerRoutes(AsyncTestCase): @@ -228,11 +230,11 @@ async def test_register_nym(self): with async_mock.patch.object( test_module.web, "json_response", async_mock.Mock() ) as json_response: - self.ledger.register_nym.return_value = True + success: bool = True + txn: dict = None + self.ledger.register_nym.return_value: Tuple[bool, dict] = (success, txn) result = await test_module.register_ledger_nym(self.request) - json_response.assert_called_once_with( - {"success": self.ledger.register_nym.return_value} - ) + json_response.assert_called_once_with({"success": success}) assert result is json_response.return_value async def test_register_nym_bad_request(self): @@ -266,6 +268,184 @@ async def test_register_nym_wallet_error(self): with self.assertRaises(test_module.web.HTTPBadRequest): await test_module.register_ledger_nym(self.request) + async def test_register_nym_create_transaction_for_endorser(self): + self.request.query = { + "did": "a_test_did", + "verkey": "a_test_verkey", + "alias": "did_alias", + "role": "ENDORSER", + "create_transaction_for_endorser": "true", + "conn_id": "dummy", + } + + with async_mock.patch.object( + ConnRecord, "retrieve_by_id", async_mock.CoroutineMock() + ) as mock_conn_rec_retrieve, async_mock.patch.object( + test_module, "TransactionManager", async_mock.MagicMock() + ) as mock_txn_mgr, async_mock.patch.object( + test_module.web, "json_response", async_mock.MagicMock() + ) as mock_response: + mock_txn_mgr.return_value = async_mock.MagicMock( + create_record=async_mock.CoroutineMock( + return_value=async_mock.MagicMock( + serialize=async_mock.MagicMock(return_value={"...": "..."}) + ) + ) + ) + mock_conn_rec_retrieve.return_value = async_mock.MagicMock( + metadata_get=async_mock.CoroutineMock( + return_value={ + "endorser_did": ("did"), + "endorser_name": ("name"), + } + ) + ) + self.ledger.register_nym.return_value: Tuple[bool, dict] = ( + True, + {"signed_txn": {"...": "..."}}, + ) + + result = await test_module.register_ledger_nym(self.request) + assert result == mock_response.return_value + mock_response.assert_called_once_with( + {"success": True, "txn": {"signed_txn": {"...": "..."}}} + ) + + async def test_register_nym_create_transaction_for_endorser_storage_x(self): + self.request.query = { + "did": "a_test_did", + "verkey": "a_test_verkey", + "alias": "did_alias", + "role": "ENDORSER", + "create_transaction_for_endorser": "true", + "conn_id": "dummy", + } + + with async_mock.patch.object( + ConnRecord, "retrieve_by_id", async_mock.CoroutineMock() + ) as mock_conn_rec_retrieve, async_mock.patch.object( + test_module, "TransactionManager", async_mock.MagicMock() + ) as mock_txn_mgr: + + mock_txn_mgr.return_value = async_mock.MagicMock( + create_record=async_mock.CoroutineMock( + side_effect=test_module.StorageError() + ) + ) + mock_conn_rec_retrieve.return_value = async_mock.MagicMock( + metadata_get=async_mock.CoroutineMock( + return_value={ + "endorser_did": ("did"), + "endorser_name": ("name"), + } + ) + ) + self.ledger.register_nym.return_value: Tuple[bool, dict] = ( + True, + {"signed_txn": {"...": "..."}}, + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.register_ledger_nym(self.request) + + async def test_register_nym_create_transaction_for_endorser_not_found_x(self): + self.request.query = { + "did": "a_test_did", + "verkey": "a_test_verkey", + "alias": "did_alias", + "role": "ENDORSER", + "create_transaction_for_endorser": "true", + "conn_id": "dummy", + } + + with async_mock.patch.object( + ConnRecord, "retrieve_by_id", async_mock.CoroutineMock() + ) as mock_conn_rec_retrieve: + mock_conn_rec_retrieve.side_effect = test_module.StorageNotFoundError() + self.ledger.register_nym.return_value: Tuple[bool, dict] = ( + True, + {"signed_txn": {"...": "..."}}, + ) + + with self.assertRaises(test_module.web.HTTPNotFound): + await test_module.register_ledger_nym(self.request) + + async def test_register_nym_create_transaction_for_endorser_base_model_x(self): + self.request.query = { + "did": "a_test_did", + "verkey": "a_test_verkey", + "alias": "did_alias", + "role": "ENDORSER", + "create_transaction_for_endorser": "true", + "conn_id": "dummy", + } + + with async_mock.patch.object( + ConnRecord, "retrieve_by_id", async_mock.CoroutineMock() + ) as mock_conn_rec_retrieve: + mock_conn_rec_retrieve.side_effect = test_module.BaseModelError() + self.ledger.register_nym.return_value: Tuple[bool, dict] = ( + True, + {"signed_txn": {"...": "..."}}, + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.register_ledger_nym(self.request) + + async def test_register_nym_create_transaction_for_endorser_no_endorser_info_x( + self, + ): + self.request.query = { + "did": "a_test_did", + "verkey": "a_test_verkey", + "alias": "did_alias", + "role": "ENDORSER", + "create_transaction_for_endorser": "true", + "conn_id": "dummy", + } + + with async_mock.patch.object( + ConnRecord, "retrieve_by_id", async_mock.CoroutineMock() + ) as mock_conn_rec_retrieve: + mock_conn_rec_retrieve.return_value = async_mock.MagicMock( + metadata_get=async_mock.CoroutineMock(return_value=None) + ) + self.ledger.register_nym.return_value: Tuple[bool, dict] = ( + True, + {"signed_txn": {"...": "..."}}, + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.register_ledger_nym(self.request) + + async def test_register_nym_create_transaction_for_endorser_no_endorser_did_x(self): + self.request.query = { + "did": "a_test_did", + "verkey": "a_test_verkey", + "alias": "did_alias", + "role": "ENDORSER", + "create_transaction_for_endorser": "true", + "conn_id": "dummy", + } + + with async_mock.patch.object( + ConnRecord, "retrieve_by_id", async_mock.CoroutineMock() + ) as mock_conn_rec_retrieve: + mock_conn_rec_retrieve.return_value = async_mock.MagicMock( + metadata_get=async_mock.CoroutineMock( + return_value={ + "endorser_name": ("name"), + } + ) + ) + self.ledger.register_nym.return_value: Tuple[bool, dict] = ( + True, + {"signed_txn": {"...": "..."}}, + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.register_ledger_nym(self.request) + async def test_get_nym_role_a(self): self.profile.context.injector.bind_instance( IndyLedgerRequestsExecutor, diff --git a/aries_cloudagent/ledger/util.py b/aries_cloudagent/ledger/util.py index 558ea411a9..2a302c1bd3 100644 --- a/aries_cloudagent/ledger/util.py +++ b/aries_cloudagent/ledger/util.py @@ -1,3 +1,19 @@ """Ledger utilities.""" +import re + +from ..core.profile import Profile + + TAA_ACCEPTED_RECORD_TYPE = "taa_accepted" + +DID_EVENT_PREFIX = "acapy::DID::" +EVENT_LISTENER_PATTERN = re.compile(f"^{DID_EVENT_PREFIX}(.*)?$") + + +async def notify_did_event(profile: Profile, did: str, meta_data: dict): + """Send notification for a DID post-process event.""" + await profile.notify( + DID_EVENT_PREFIX + did, + meta_data, + )