diff --git a/aries_cloudagent/revocation/manager.py b/aries_cloudagent/revocation/manager.py index 3fc8b81741..824efe79c0 100644 --- a/aries_cloudagent/revocation/manager.py +++ b/aries_cloudagent/revocation/manager.py @@ -2,11 +2,12 @@ import json import logging -from typing import Mapping, Sequence, Text, Tuple +from typing import Mapping, Optional, Sequence, Text, Tuple from ..protocols.revocation_notification.v1_0.models.rev_notification_record import ( RevNotificationRecord, ) +from ..connections.models.conn_record import ConnRecord from ..core.error import BaseError from ..core.profile import Profile from ..indy.issuer import IndyIssuer @@ -47,7 +48,9 @@ async def revoke_credential_by_cred_ex_id( notify_version: str = None, thread_id: str = None, connection_id: str = None, + endorser_conn_id: str = None, comment: str = None, + write_ledger: bool = True, ): """Revoke a credential by its credential exchange identifier at issue. @@ -79,7 +82,9 @@ async def revoke_credential_by_cred_ex_id( notify_version=notify_version, thread_id=thread_id, connection_id=connection_id, + endorser_conn_id=endorser_conn_id, comment=comment, + write_ledger=write_ledger, ) async def revoke_credential( @@ -91,8 +96,10 @@ async def revoke_credential( notify_version: str = None, thread_id: str = None, connection_id: str = None, + endorser_conn_id: str = None, comment: str = None, - ): + write_ledger: bool = True, + ) -> Optional[dict]: """Revoke a credential. Optionally, publish the corresponding revocation registry delta to the ledger. @@ -147,15 +154,38 @@ async def revoke_credential( await txn.commit() await self.set_cred_revoked_state(rev_reg_id, crids) if delta_json: - await issuer_rr_upd.send_entry(self._profile) - await notify_revocation_published_event( - self._profile, rev_reg_id, [cred_rev_id] - ) - + if write_ledger: + rev_entry_resp = await issuer_rr_upd.send_entry(self._profile) + await notify_revocation_published_event( + self._profile, rev_reg_id, [cred_rev_id] + ) + return rev_entry_resp + else: + async with self._profile.session() as session: + try: + connection_record = await ConnRecord.retrieve_by_id( + session, endorser_conn_id + ) + except StorageNotFoundError: + raise RevocationManagerError( + "No endorser connection record found " + f"for id: {endorser_conn_id}" + ) + endorser_info = await connection_record.metadata_get( + session, "endorser_info" + ) + endorser_did = endorser_info["endorser_did"] + rev_entry_resp = await issuer_rr_upd.send_entry( + self._profile, + write_ledger=write_ledger, + endorser_did=endorser_did, + ) + return rev_entry_resp else: async with self._profile.transaction() as txn: await issuer_rr_rec.mark_pending(txn, cred_rev_id) await txn.commit() + return None async def update_rev_reg_revoked_state( self, @@ -182,7 +212,9 @@ async def update_rev_reg_revoked_state( async def publish_pending_revocations( self, rrid2crid: Mapping[Text, Sequence[Text]] = None, - ) -> Mapping[Text, Sequence[Text]]: + write_ledger: bool = True, + connection_id: str = None, + ) -> Tuple[Optional[dict], Mapping[Text, Sequence[Text]]]: """Publish pending revocations to the ledger. Args: @@ -202,12 +234,13 @@ async def publish_pending_revocations( - all pending revocations from all revocation registry tagged 0 - pending ["1", "2"] from revocation registry tagged 1 - no pending revocations from any other revocation registries. + connection_id: connection identifier for endorser connection to use Returns: mapping from each revocation registry id to its cred rev ids published. """ result = {} issuer = self._profile.inject(IndyIssuer) - + rev_entry_resp = None async with self._profile.session() as session: issuer_rr_recs = await IssuerRevRegRecord.query_by_pending(session) @@ -239,14 +272,36 @@ async def publish_pending_revocations( await txn.commit() await self.set_cred_revoked_state(issuer_rr_rec.revoc_reg_id, crids) if delta_json: - await issuer_rr_upd.send_entry(self._profile) + if connection_id: + async with self._profile.session() as session: + try: + connection_record = await ConnRecord.retrieve_by_id( + session, connection_id + ) + except StorageNotFoundError: + raise RevocationManagerError( + "No endorser connection record found " + f"for id: {connection_id}" + ) + endorser_info = await connection_record.metadata_get( + session, "endorser_info" + ) + endorser_did = endorser_info["endorser_did"] + rev_entry_resp = await issuer_rr_upd.send_entry( + self._profile, + write_ledger=write_ledger, + endorser_did=endorser_did, + ) + else: + rev_entry_resp = await issuer_rr_upd.send_entry(self._profile) published = sorted(crid for crid in crids if crid not in failed_crids) result[issuer_rr_rec.revoc_reg_id] = published - await notify_revocation_published_event( - self._profile, issuer_rr_rec.revoc_reg_id, crids - ) + if not connection_id: + await notify_revocation_published_event( + self._profile, issuer_rr_rec.revoc_reg_id, crids + ) - return result + return rev_entry_resp, result async def clear_pending_revocations( self, purge: Mapping[Text, Sequence[Text]] = None diff --git a/aries_cloudagent/revocation/routes.py b/aries_cloudagent/revocation/routes.py index 383bb5b02a..228aeb1cd5 100644 --- a/aries_cloudagent/revocation/routes.py +++ b/aries_cloudagent/revocation/routes.py @@ -504,6 +504,8 @@ class RevRegConnIdMatchInfoSchema(OpenAPISchema): summary="Revoke an issued credential", ) @request_schema(RevokeRequestSchema()) +@querystring_schema(CreateRevRegTxnForEndorserOptionSchema()) +@querystring_schema(RevRegConnIdMatchInfoSchema()) @response_schema(RevocationModuleResponseSchema(), description="") async def revoke(request: web.BaseRequest): """Request handler for storing a credential revocation. @@ -519,27 +521,58 @@ async def revoke(request: web.BaseRequest): body = await request.json() cred_ex_id = body.get("cred_ex_id") body["notify"] = body.get("notify", context.settings.get("revocation.notify")) - notify = body.get("notify") + notify = body.get("notify", False) connection_id = body.get("connection_id") body["notify_version"] = body.get("notify_version", "v1_0") notify_version = body["notify_version"] + create_transaction_for_endorser = json.loads( + request.query.get("create_transaction_for_endorser", "false") + ) + endorser_conn_id = request.query.get("conn_id") + rev_manager = RevocationManager(context.profile) + profile = context.profile + outbound_handler = request["outbound_message_router"] + write_ledger = not create_transaction_for_endorser + if is_author_role(profile): + write_ledger = False + create_transaction_for_endorser = True + if not endorser_conn_id: + endorser_conn_id = await get_endorser_connection_id(profile) + if not endorser_conn_id: + raise web.HTTPBadRequest(reason="No endorser connection found") if notify and not connection_id: raise web.HTTPBadRequest(reason="connection_id must be set when notify is true") if notify and not notify_version: raise web.HTTPBadRequest( reason="Request must specify notify_version if notify is true" ) - - rev_manager = RevocationManager(context.profile) try: if cred_ex_id: - # rev_reg_id and cred_rev_id should not be present so we can - # safely splat the body - await rev_manager.revoke_credential_by_cred_ex_id(**body) + rev_entry_resp = await rev_manager.revoke_credential_by_cred_ex_id( + cred_ex_id=cred_ex_id, + publish=body.get("publish", False), + notify=notify, + notify_version=notify_version, + thread_id=body.get("thread_id"), + connection_id=connection_id, + endorser_conn_id=endorser_conn_id, + comment=body.get("comment"), + write_ledger=write_ledger, + ) else: - # no cred_ex_id so we can safely splat the body - await rev_manager.revoke_credential(**body) + rev_entry_resp = await rev_manager.revoke_credential( + rev_reg_id=body.get("rev_reg_id"), + cred_rev_id=body.get("cred_rev_id"), + publish=body.get("publish", False), + notify=notify, + notify_version=notify_version, + thread_id=body.get("thread_id"), + connection_id=connection_id, + endorser_conn_id=endorser_conn_id, + comment=body.get("comment"), + write_ledger=write_ledger, + ) except ( RevocationManagerError, RevocationError, @@ -549,11 +582,37 @@ async def revoke(request: web.BaseRequest): ) as err: raise web.HTTPBadRequest(reason=err.roll_up) from err + if create_transaction_for_endorser and rev_entry_resp: + transaction_mgr = TransactionManager(profile) + try: + transaction = await transaction_mgr.create_record( + messages_attach=rev_entry_resp["result"], connection_id=endorser_conn_id + ) + 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, + ) + except (StorageError, TransactionManagerError) as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + await outbound_handler(transaction_request, connection_id=endorser_conn_id) + + return web.json_response({"txn": transaction.serialize()}) return web.json_response({}) @docs(tags=["revocation"], summary="Publish pending revocations to ledger") @request_schema(PublishRevocationsSchema()) +@querystring_schema(CreateRevRegTxnForEndorserOptionSchema()) +@querystring_schema(RevRegConnIdMatchInfoSchema()) @response_schema(TxnOrPublishRevocationsResultSchema(), 200, description="") async def publish_revocations(request: web.BaseRequest): """Request handler for publishing pending revocations to the ledger. @@ -568,17 +627,55 @@ async def publish_revocations(request: web.BaseRequest): context: AdminRequestContext = request["context"] body = await request.json() rrid2crid = body.get("rrid2crid") - + create_transaction_for_endorser = json.loads( + request.query.get("create_transaction_for_endorser", "false") + ) + write_ledger = not create_transaction_for_endorser + endorser_conn_id = request.query.get("conn_id") rev_manager = RevocationManager(context.profile) + profile = context.profile + outbound_handler = request["outbound_message_router"] + if is_author_role(profile): + write_ledger = False + create_transaction_for_endorser = True + endorser_conn_id = await get_endorser_connection_id(profile) + if not endorser_conn_id: + raise web.HTTPBadRequest(reason="No endorser connection found") try: - rev_reg_resp = await rev_manager.publish_pending_revocations( - rrid2crid, + rev_reg_resp, result = await rev_manager.publish_pending_revocations( + rrid2crid=rrid2crid, + write_ledger=write_ledger, + connection_id=endorser_conn_id, ) except (RevocationError, StorageError, IndyIssuerError, LedgerError) as err: raise web.HTTPBadRequest(reason=err.roll_up) from err - return web.json_response({"rrid2crid": rev_reg_resp}) + if create_transaction_for_endorser and rev_reg_resp: + transaction_mgr = TransactionManager(profile) + try: + transaction = await transaction_mgr.create_record( + messages_attach=rev_reg_resp["result"], connection_id=endorser_conn_id + ) + 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, + ) + except (StorageError, TransactionManagerError) as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + await outbound_handler(transaction_request, connection_id=endorser_conn_id) + + return web.json_response({"txn": transaction.serialize()}) + return web.json_response({"rrid2crid": result}) @docs(tags=["revocation"], summary="Clear pending revocations") diff --git a/aries_cloudagent/revocation/tests/test_manager.py b/aries_cloudagent/revocation/tests/test_manager.py index f48cb70693..4d0689664f 100644 --- a/aries_cloudagent/revocation/tests/test_manager.py +++ b/aries_cloudagent/revocation/tests/test_manager.py @@ -7,6 +7,7 @@ IssuerCredRevRecord, ) +from ...connections.models.conn_record import ConnRecord from ...core.in_memory import InMemoryProfile from ...indy.issuer import IndyIssuer from ...protocols.issue_credential.v1_0.models.credential_exchange import ( @@ -96,6 +97,153 @@ async def test_revoke_credential_publish(self): ["2", "1"], ) + async def test_revoke_credential_publish_endorser(self): + conn_record = ConnRecord( + their_label="Hello", + their_role=ConnRecord.Role.RESPONDER.rfc160, + alias="Bob", + ) + session = await self.profile.session() + await conn_record.save(session) + await conn_record.metadata_set( + session, + key="endorser_info", + value={ + "endorser_did": "test_endorser_did", + "endorser_name": "test_endorser_name", + }, + ) + conn_id = conn_record.connection_id + assert conn_id is not None + manager = RevocationManager(self.profile) + CRED_EX_ID = "dummy-cxid" + CRED_REV_ID = "1" + mock_issuer_rev_reg_record = mock.MagicMock( + revoc_reg_id=REV_REG_ID, + tails_local_path=TAILS_LOCAL, + send_entry=mock.CoroutineMock(), + clear_pending=mock.CoroutineMock(), + pending_pub=["2"], + ) + issuer = mock.MagicMock(IndyIssuer, autospec=True) + issuer.revoke_credentials = mock.CoroutineMock( + return_value=( + json.dumps( + { + "ver": "1.0", + "value": { + "prevAccum": "1 ...", + "accum": "21 ...", + "issued": [1], + }, + } + ), + [], + ) + ) + self.profile.context.injector.bind_instance(IndyIssuer, issuer) + + with mock.patch.object( + test_module.IssuerCredRevRecord, + "retrieve_by_cred_ex_id", + mock.CoroutineMock(), + ) as mock_retrieve, mock.patch.object( + test_module, "IndyRevocation", autospec=True + ) as revoc, mock.patch.object( + test_module.IssuerRevRegRecord, + "retrieve_by_id", + mock.CoroutineMock(return_value=mock_issuer_rev_reg_record), + ), mock.patch.object( + test_module.ConnRecord, + "retrieve_by_id", + mock.CoroutineMock(return_value=conn_record), + ): + mock_retrieve.return_value = mock.MagicMock( + rev_reg_id="dummy-rr-id", cred_rev_id=CRED_REV_ID + ) + mock_rev_reg = mock.MagicMock( + get_or_fetch_local_tails_path=mock.CoroutineMock() + ) + revoc.return_value.get_issuer_rev_reg_record = mock.CoroutineMock( + return_value=mock_issuer_rev_reg_record + ) + revoc.return_value.get_ledger_registry = mock.CoroutineMock( + return_value=mock_rev_reg + ) + + await self.manager.revoke_credential_by_cred_ex_id( + cred_ex_id=CRED_EX_ID, + publish=True, + connection_id=conn_id, + write_ledger=False, + ) + + issuer.revoke_credentials.assert_awaited_once_with( + mock_issuer_rev_reg_record.cred_def_id, + mock_issuer_rev_reg_record.revoc_reg_id, + mock_issuer_rev_reg_record.tails_local_path, + ["2", "1"], + ) + + async def test_revoke_credential_publish_endorser_x(self): + CRED_EX_ID = "dummy-cxid" + CRED_REV_ID = "1" + mock_issuer_rev_reg_record = mock.MagicMock( + revoc_reg_id=REV_REG_ID, + tails_local_path=TAILS_LOCAL, + send_entry=mock.CoroutineMock(), + clear_pending=mock.CoroutineMock(), + pending_pub=["2"], + ) + issuer = mock.MagicMock(IndyIssuer, autospec=True) + issuer.revoke_credentials = mock.CoroutineMock( + return_value=( + json.dumps( + { + "ver": "1.0", + "value": { + "prevAccum": "1 ...", + "accum": "21 ...", + "issued": [1], + }, + } + ), + [], + ) + ) + self.profile.context.injector.bind_instance(IndyIssuer, issuer) + + with mock.patch.object( + test_module.IssuerCredRevRecord, + "retrieve_by_cred_ex_id", + mock.CoroutineMock(), + ) as mock_retrieve, mock.patch.object( + test_module, "IndyRevocation", autospec=True + ) as revoc, mock.patch.object( + test_module.IssuerRevRegRecord, + "retrieve_by_id", + mock.CoroutineMock(return_value=mock_issuer_rev_reg_record), + ): + mock_retrieve.return_value = mock.MagicMock( + rev_reg_id="dummy-rr-id", cred_rev_id=CRED_REV_ID + ) + mock_rev_reg = mock.MagicMock( + get_or_fetch_local_tails_path=mock.CoroutineMock() + ) + revoc.return_value.get_issuer_rev_reg_record = mock.CoroutineMock( + return_value=mock_issuer_rev_reg_record + ) + revoc.return_value.get_ledger_registry = mock.CoroutineMock( + return_value=mock_rev_reg + ) + with self.assertRaises(RevocationManagerError): + await self.manager.revoke_credential_by_cred_ex_id( + cred_ex_id=CRED_EX_ID, + publish=True, + connection_id="invalid_conn_id", + write_ledger=False, + ) + async def test_revoke_cred_by_cxid_not_found(self): CRED_EX_ID = "dummy-cxid" @@ -165,6 +313,145 @@ async def test_revoke_credential_pend(self): issuer.revoke_credentials.assert_not_awaited() + async def test_publish_pending_revocations_endorser(self): + deltas = [ + { + "ver": "1.0", + "value": {"prevAccum": "1 ...", "accum": "21 ...", "issued": [1, 2, 3]}, + }, + { + "ver": "1.0", + "value": { + "prevAccum": "21 ...", + "accum": "36 ...", + "issued": [1, 2, 3], + }, + }, + ] + + mock_issuer_rev_reg_records = [ + mock.MagicMock( + record_id=0, + revoc_reg_id=REV_REG_ID, + tails_local_path=TAILS_LOCAL, + pending_pub=["1", "2"], + send_entry=mock.CoroutineMock(), + clear_pending=mock.CoroutineMock(), + ), + mock.MagicMock( + record_id=1, + revoc_reg_id=f"{TEST_DID}:4:{CRED_DEF_ID}:CL_ACCUM:tag2", + tails_local_path=TAILS_LOCAL, + pending_pub=["9", "99"], + send_entry=mock.CoroutineMock(), + clear_pending=mock.CoroutineMock(), + ), + ] + conn_record = ConnRecord( + their_label="Hello", + their_role=ConnRecord.Role.RESPONDER.rfc160, + alias="Bob", + ) + session = await self.profile.session() + await conn_record.save(session) + await conn_record.metadata_set( + session, + key="endorser_info", + value={ + "endorser_did": "test_endorser_did", + "endorser_name": "test_endorser_name", + }, + ) + conn_id = conn_record.connection_id + assert conn_id is not None + with mock.patch.object( + test_module.IssuerRevRegRecord, + "query_by_pending", + mock.CoroutineMock(return_value=mock_issuer_rev_reg_records), + ), mock.patch.object( + test_module.IssuerRevRegRecord, + "retrieve_by_id", + mock.CoroutineMock( + side_effect=lambda _, id, **args: mock_issuer_rev_reg_records[id] + ), + ): + issuer = mock.MagicMock(IndyIssuer, autospec=True) + issuer.merge_revocation_registry_deltas = mock.CoroutineMock( + side_effect=deltas + ) + + issuer.revoke_credentials = mock.CoroutineMock( + side_effect=[(json.dumps(delta), []) for delta in deltas] + ) + self.profile.context.injector.bind_instance(IndyIssuer, issuer) + manager = RevocationManager(self.profile) + _, result = await manager.publish_pending_revocations( + rrid2crid={REV_REG_ID: "2"}, connection_id=conn_id + ) + assert result == {REV_REG_ID: ["2"]} + mock_issuer_rev_reg_records[0].clear_pending.assert_called_once() + mock_issuer_rev_reg_records[1].clear_pending.assert_not_called() + + async def test_publish_pending_revocations_endorser_x(self): + deltas = [ + { + "ver": "1.0", + "value": {"prevAccum": "1 ...", "accum": "21 ...", "issued": [1, 2, 3]}, + }, + { + "ver": "1.0", + "value": { + "prevAccum": "21 ...", + "accum": "36 ...", + "issued": [1, 2, 3], + }, + }, + ] + + mock_issuer_rev_reg_records = [ + mock.MagicMock( + record_id=0, + revoc_reg_id=REV_REG_ID, + tails_local_path=TAILS_LOCAL, + pending_pub=["1", "2"], + send_entry=mock.CoroutineMock(), + clear_pending=mock.CoroutineMock(), + ), + mock.MagicMock( + record_id=1, + revoc_reg_id=f"{TEST_DID}:4:{CRED_DEF_ID}:CL_ACCUM:tag2", + tails_local_path=TAILS_LOCAL, + pending_pub=["9", "99"], + send_entry=mock.CoroutineMock(), + clear_pending=mock.CoroutineMock(), + ), + ] + with mock.patch.object( + test_module.IssuerRevRegRecord, + "query_by_pending", + mock.CoroutineMock(return_value=mock_issuer_rev_reg_records), + ), mock.patch.object( + test_module.IssuerRevRegRecord, + "retrieve_by_id", + mock.CoroutineMock( + side_effect=lambda _, id, **args: mock_issuer_rev_reg_records[id] + ), + ): + issuer = mock.MagicMock(IndyIssuer, autospec=True) + issuer.merge_revocation_registry_deltas = mock.CoroutineMock( + side_effect=deltas + ) + + issuer.revoke_credentials = mock.CoroutineMock( + side_effect=[(json.dumps(delta), []) for delta in deltas] + ) + self.profile.context.injector.bind_instance(IndyIssuer, issuer) + manager = RevocationManager(self.profile) + with self.assertRaises(RevocationManagerError): + result = await manager.publish_pending_revocations( + rrid2crid={REV_REG_ID: "2"}, connection_id="invalid_conn_id" + ) + async def test_publish_pending_revocations_basic(self): deltas = [ { @@ -207,7 +494,7 @@ async def test_publish_pending_revocations_basic(self): ) self.profile.context.injector.bind_instance(IndyIssuer, issuer) - result = await self.manager.publish_pending_revocations() + _, result = await self.manager.publish_pending_revocations() assert result == {REV_REG_ID: ["1", "2"]} mock_issuer_rev_reg_record.clear_pending.assert_called_once() @@ -266,7 +553,9 @@ async def test_publish_pending_revocations_1_rev_reg_all(self): ) self.profile.context.injector.bind_instance(IndyIssuer, issuer) - result = await self.manager.publish_pending_revocations({REV_REG_ID: None}) + _, result = await self.manager.publish_pending_revocations( + {REV_REG_ID: None} + ) assert result == {REV_REG_ID: ["1", "2"]} mock_issuer_rev_reg_records[0].clear_pending.assert_called_once() mock_issuer_rev_reg_records[1].clear_pending.assert_not_called() @@ -326,7 +615,9 @@ async def test_publish_pending_revocations_1_rev_reg_some(self): ) self.profile.context.injector.bind_instance(IndyIssuer, issuer) - result = await self.manager.publish_pending_revocations({REV_REG_ID: "2"}) + _, result = await self.manager.publish_pending_revocations( + {REV_REG_ID: "2"} + ) assert result == {REV_REG_ID: ["2"]} mock_issuer_rev_reg_records[0].clear_pending.assert_called_once() mock_issuer_rev_reg_records[1].clear_pending.assert_not_called() diff --git a/aries_cloudagent/revocation/tests/test_routes.py b/aries_cloudagent/revocation/tests/test_routes.py index 45bfa0e4c9..0a384bc2b3 100644 --- a/aries_cloudagent/revocation/tests/test_routes.py +++ b/aries_cloudagent/revocation/tests/test_routes.py @@ -31,6 +31,21 @@ def setUp(self): self.test_did = "sample-did" + self.author_profile = InMemoryProfile.test_profile() + self.author_profile.settings.set_value("endorser.author", True) + self.author_context = self.author_profile.context + setattr(self.author_context, "profile", self.author_profile) + self.author_request_dict = { + "context": self.author_context, + "outbound_message_router": mock.CoroutineMock(), + } + self.author_request = mock.MagicMock( + app={}, + match_info={}, + query={}, + __getitem__=lambda _, k: self.author_request_dict[k], + ) + async def test_validate_cred_rev_rec_qs_and_revoke_req(self): for req in ( test_module.CredRevRecordQueryStringSchema(), @@ -94,6 +109,124 @@ async def test_revoke(self): mock_response.assert_called_once_with({}) + async def test_revoke_endorser_no_conn_id_by_cred_ex_id(self): + self.author_request.json = mock.CoroutineMock( + return_value={ + "rev_reg_id": "rr_id", + "cred_rev_id": "23", + "publish": "false", + } + ) + + with mock.patch.object( + test_module, "RevocationManager", autospec=True + ) as mock_mgr, mock.patch.object( + test_module, + "get_endorser_connection_id", + mock.CoroutineMock(return_value="dummy-conn-id"), + ), mock.patch.object( + test_module.web, "json_response" + ): + mock_mgr.return_value.revoke_credential = mock.CoroutineMock( + return_value={"result": "..."} + ) + + await test_module.revoke(self.author_request) + + async def test_revoke_endorser_by_cred_ex_id(self): + self.author_request.json = mock.CoroutineMock( + return_value={ + "rev_reg_id": "rr_id", + "cred_rev_id": "23", + "publish": "false", + "connection_id": "dummy-conn-id", + } + ) + + with mock.patch.object( + test_module, "RevocationManager", autospec=True + ) as mock_mgr, mock.patch.object( + test_module.web, "json_response" + ), mock.patch.object( + test_module, + "get_endorser_connection_id", + mock.CoroutineMock(return_value="test_conn_id"), + ): + mock_mgr.return_value.revoke_credential = mock.CoroutineMock( + return_value={"result": "..."} + ) + + await test_module.revoke(self.author_request) + + async def test_revoke_endorser_no_conn_id(self): + self.author_request.json = mock.CoroutineMock( + return_value={ + "rev_reg_id": "rr_id", + "cred_rev_id": "23", + "publish": "false", + } + ) + + with mock.patch.object( + test_module, "RevocationManager", autospec=True + ) as mock_mgr, mock.patch.object( + test_module, + "get_endorser_connection_id", + mock.CoroutineMock(return_value="dummy-conn-id"), + ), mock.patch.object( + test_module.web, "json_response" + ): + mock_mgr.return_value.revoke_credential = mock.CoroutineMock( + return_value={"result": "..."} + ) + + await test_module.revoke(self.author_request) + + async def test_revoke_endorser(self): + self.author_request.json = mock.CoroutineMock( + return_value={ + "rev_reg_id": "rr_id", + "cred_rev_id": "23", + "publish": "false", + "connection_id": "dummy-conn-id", + } + ) + + with mock.patch.object( + test_module, "RevocationManager", autospec=True + ) as mock_mgr, mock.patch.object( + test_module.web, "json_response" + ), mock.patch.object( + test_module, + "get_endorser_connection_id", + mock.CoroutineMock(return_value="test_conn_id"), + ): + mock_mgr.return_value.revoke_credential = mock.CoroutineMock( + return_value={"result": "..."} + ) + + await test_module.revoke(self.author_request) + + async def test_revoke_endorser_x(self): + self.author_request.json = mock.CoroutineMock( + return_value={ + "rev_reg_id": "rr_id", + "cred_rev_id": "23", + "publish": "false", + } + ) + + with mock.patch.object( + test_module, "RevocationManager", autospec=True + ) as mock_mgr, mock.patch.object( + test_module, + "get_endorser_connection_id", + mock.CoroutineMock(return_value=None), + ): + mock_mgr.return_value.revoke_credential = mock.CoroutineMock() + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.revoke(self.author_request) + async def test_revoke_by_cred_ex_id(self): self.request.json = mock.CoroutineMock( return_value={ @@ -143,7 +276,9 @@ async def test_publish_revocations(self): test_module.web, "json_response" ) as mock_response: pub_pending = mock.CoroutineMock() - mock_mgr.return_value.publish_pending_revocations = pub_pending + mock_mgr.return_value.publish_pending_revocations = mock.CoroutineMock( + return_value=({}, pub_pending.return_value) + ) await test_module.publish_revocations(self.request) @@ -163,6 +298,46 @@ async def test_publish_revocations_x(self): with self.assertRaises(test_module.web.HTTPBadRequest): await test_module.publish_revocations(self.request) + async def test_publish_revocations_endorser(self): + self.author_request.json = mock.CoroutineMock() + + with mock.patch.object( + test_module, "RevocationManager", autospec=True + ) as mock_mgr, mock.patch.object( + test_module, + "get_endorser_connection_id", + mock.CoroutineMock(return_value="dummy-conn-id"), + ), mock.patch.object( + test_module.web, "json_response" + ) as mock_response: + pub_pending = mock.CoroutineMock() + mock_mgr.return_value.publish_pending_revocations = mock.CoroutineMock( + return_value=({}, pub_pending.return_value) + ) + + await test_module.publish_revocations(self.author_request) + + mock_response.assert_called_once_with( + {"rrid2crid": pub_pending.return_value} + ) + + async def test_publish_revocations_endorser_x(self): + self.author_request.json = mock.CoroutineMock() + + with mock.patch.object( + test_module, "RevocationManager", autospec=True + ) as mock_mgr, mock.patch.object( + test_module, + "get_endorser_connection_id", + mock.CoroutineMock(return_value=None), + ), mock.patch.object( + test_module.web, "json_response" + ) as mock_response: + pub_pending = mock.CoroutineMock() + mock_mgr.return_value.publish_pending_revocations = pub_pending + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.publish_revocations(self.author_request) + async def test_clear_pending_revocations(self): self.request.json = mock.CoroutineMock() diff --git a/aries_cloudagent/wallet/routes.py b/aries_cloudagent/wallet/routes.py index 58903a2963..d27d77d4e5 100644 --- a/aries_cloudagent/wallet/routes.py +++ b/aries_cloudagent/wallet/routes.py @@ -2,7 +2,7 @@ import json import logging -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union from aiohttp import web from aiohttp_apispec import docs, querystring_schema, request_schema, response_schema @@ -10,6 +10,7 @@ from marshmallow import fields, validate from ..admin.request_context import AdminRequestContext +from ..config.injection_context import InjectionContext from ..connections.models.conn_record import ConnRecord from ..core.event_bus import Event, EventBus from ..core.profile import Profile @@ -722,9 +723,10 @@ async def wallet_set_public_did(request: web.BaseRequest): async def promote_wallet_public_did( - context: AdminRequestContext, + context: Union[AdminRequestContext, InjectionContext], did: str, write_ledger: bool = False, + profile: Profile = None, connection_id: str = None, routing_keys: List[str] = None, mediator_endpoint: str = None, @@ -736,8 +738,22 @@ async def promote_wallet_public_did( is_indy_did = bool(IndyDID.PATTERN.match(did)) # write only Indy DID write_ledger = is_indy_did and write_ledger - - ledger = context.profile.inject_or(BaseLedger) + is_ctx_admin_request = True + if isinstance(context, InjectionContext): + is_ctx_admin_request = False + if not profile: + raise web.HTTPForbidden( + reason=( + "InjectionContext is provided but no profile is provided. " + "InjectionContext does not have profile attribute but " + "AdminRequestContext does." + ) + ) + ledger = ( + context.profile.inject_or(BaseLedger) + if is_ctx_admin_request + else profile.inject_or(BaseLedger) + ) if is_indy_did: if not ledger: @@ -750,18 +766,29 @@ async def promote_wallet_public_did( if not await ledger.get_key_for_did(did): raise LookupError(f"DID {did} is not posted to the ledger") + is_author_profile = ( + is_author_role(context.profile) + if is_ctx_admin_request + else is_author_role(profile) + ) # check if we need to endorse - if is_author_role(context.profile): + if is_author_profile: # authors cannot write to the ledger write_ledger = False # author has not provided a connection id, so determine which to use if not connection_id: - connection_id = await get_endorser_connection_id(context.profile) + connection_id = ( + await get_endorser_connection_id(context.profile) + if is_ctx_admin_request + else await get_endorser_connection_id(profile) + ) if not connection_id: raise web.HTTPBadRequest(reason="No endorser connection found") if not write_ledger: - async with context.session() as session: + async with ( + context.session() if is_ctx_admin_request else profile.session() + ) as session: try: connection_record = await ConnRecord.retrieve_by_id( session, connection_id @@ -792,7 +819,9 @@ async def promote_wallet_public_did( did_info: DIDInfo = None attrib_def = None - async with context.session() as session: + async with ( + context.session() if is_ctx_admin_request else profile.session() + ) as session: wallet = session.inject_or(BaseWallet) did_info = await wallet.get_local_did(did) info = await wallet.set_public_did(did_info) @@ -814,8 +843,16 @@ async def promote_wallet_public_did( if info: # Route the public DID - route_manager = context.profile.inject(RouteManager) - await route_manager.route_verkey(context.profile, info.verkey) + route_manager = ( + context.profile.inject(RouteManager) + if is_ctx_admin_request + else profile.inject(RouteManager) + ) + await route_manager.route_verkey( + context.profile, info.verkey + ) if is_ctx_admin_request else await route_manager.route_verkey( + profile, info.verkey + ) return info, attrib_def @@ -1157,7 +1194,10 @@ async def on_register_nym_event(profile: Profile, event: Event): connection_id = event.payload.get("connection_id") try: _info, attrib_def = await promote_wallet_public_did( - profile.context, did, connection_id + context=profile.context, + did=did, + connection_id=connection_id, + profile=profile, ) except Exception as err: # log the error, but continue diff --git a/demo/features/0586-sign-transaction.feature b/demo/features/0586-sign-transaction.feature index 5069f2974e..b7427d9c20 100644 --- a/demo/features/0586-sign-transaction.feature +++ b/demo/features/0586-sign-transaction.feature @@ -157,8 +157,8 @@ Feature: RFC 0586 Aries sign (endorse) transactions functions And "Bob" has written the revocation registry definition to the ledger And "Bob" has written the revocation registry entry transaction to the ledger And "Acme" has an issued credential from "Bob" - And "Bob" revokes the credential without publishing the entry - And "Bob" authors a revocation registry entry publishing transaction + And "Bob" revokes the credential without publishing the entry with txn endorsement + And "Bob" authors a revocation registry entry publishing transaction with txn endorsement Then "Acme" can verify the credential from "Bob" was revoked Examples: @@ -187,8 +187,8 @@ Feature: RFC 0586 Aries sign (endorse) transactions functions And "Bob" has written the revocation registry definition to the ledger And "Bob" has written the revocation registry entry transaction to the ledger And "Acme" has an issued credential from "Bob" - And "Bob" revokes the credential without publishing the entry - And "Bob" authors a revocation registry entry publishing transaction + And "Bob" revokes the credential without publishing the entry with txn endorsement + And "Bob" authors a revocation registry entry publishing transaction with txn endorsement Then "Acme" can verify the credential from "Bob" was revoked Examples: diff --git a/demo/features/steps/0586-sign-transaction.py b/demo/features/steps/0586-sign-transaction.py index 61702e84a8..29dc2b29a8 100644 --- a/demo/features/steps/0586-sign-transaction.py +++ b/demo/features/steps/0586-sign-transaction.py @@ -467,13 +467,46 @@ def step_impl(context, agent_name): async_sleep(3.0) -@when('"{agent_name}" authors a revocation registry entry publishing transaction') -@then('"{agent_name}" authors a revocation registry entry publishing transaction') +@when( + '"{agent_name}" revokes the credential without publishing the entry with txn endorsement' +) +@then( + '"{agent_name}" revokes the credential without publishing the entry with txn endorsement' +) def step_impl(context, agent_name): agent = context.active_agents[agent_name] + # get the required revocation info from the last credential exchange + cred_exchange = context.cred_exchange + + cred_exchange = agent_container_GET( + agent["agent"], "/issue-credential-2.0/records/" + cred_exchange["cred_ex_id"] + ) + context.cred_exchange = cred_exchange connection_id = agent["agent"].agent.connection_id + # revoke the credential + agent_container_POST( + agent["agent"], + "/revocation/revoke", + data={ + "rev_reg_id": cred_exchange["indy"]["rev_reg_id"], + "cred_rev_id": cred_exchange["indy"]["cred_rev_id"], + "publish": False, + "connection_id": cred_exchange["cred_ex_record"]["connection_id"], + }, + params={"conn_id": connection_id, "create_transaction_for_endorser": "true"}, + ) + + # pause for a few seconds + async_sleep(3.0) + + +@when('"{agent_name}" authors a revocation registry entry publishing transaction') +@then('"{agent_name}" authors a revocation registry entry publishing transaction') +def step_impl(context, agent_name): + agent = context.active_agents[agent_name] + # create rev_reg entry transaction created_rev_reg = agent_container_POST( agent["agent"], @@ -491,6 +524,38 @@ def step_impl(context, agent_name): assert "rrid2crid" in created_rev_reg +@when( + '"{agent_name}" authors a revocation registry entry publishing transaction with txn endorsement' +) +@then( + '"{agent_name}" authors a revocation registry entry publishing transaction with txn endorsement' +) +def step_impl(context, agent_name): + agent = context.active_agents[agent_name] + + connection_id = agent["agent"].agent.connection_id + + # create rev_reg entry transaction + created_rev_reg = agent_container_POST( + agent["agent"], + f"/revocation/publish-revocations", + data={ + "rrid2crid": { + context.cred_exchange["indy"]["rev_reg_id"]: [ + context.cred_exchange["indy"]["cred_rev_id"] + ] + } + }, + params={"conn_id": connection_id, "create_transaction_for_endorser": "true"}, + ) + + # check that transaction request has been sent + assert created_rev_reg["txn"]["state"] == "request_sent" + + # pause for a few seconds + async_sleep(3.0) + + @then('"{holder_name}" can verify the credential from "{issuer_name}" was revoked') def step_impl(context, holder_name, issuer_name): agent = context.active_agents[holder_name] @@ -507,10 +572,16 @@ def step_impl(context, holder_name, issuer_name): assert len(cred_list["results"]) == 1 cred_id = cred_list["results"][0]["referent"] - # check revocation status for the credential - revocation_status = agent_container_GET( - agent["agent"], - f"/credential/revoked/{cred_id}", - params={"to": int(time.time())}, - ) - assert revocation_status["revoked"] == True + revoc_status_bool = False + counter = 0 + while not revoc_status_bool and counter < 3: + # check revocation status for the credential + revocation_status = agent_container_GET( + agent["agent"], + f"/credential/revoked/{cred_id}", + params={"to": int(time.time())}, + ) + revoc_status_bool = revocation_status["revoked"] + counter = counter + 1 + async_sleep(1.0) + assert revoc_status_bool is True