diff --git a/aries_cloudagent/anoncreds/default/did_indy/registry.py b/aries_cloudagent/anoncreds/default/did_indy/registry.py index f42cd5137c..bf1480721d 100644 --- a/aries_cloudagent/anoncreds/default/did_indy/registry.py +++ b/aries_cloudagent/anoncreds/default/did_indy/registry.py @@ -5,6 +5,7 @@ from ....config.injection_context import InjectionContext from ....core.profile import Profile +from ...base import BaseAnonCredsRegistrar, BaseAnonCredsResolver from ...models.anoncreds_cred_def import ( CredDef, CredDefResult, @@ -13,13 +14,12 @@ from ...models.anoncreds_revocation import ( GetRevListResult, GetRevRegDefResult, - RevRegDef, - RevRegDefResult, RevList, RevListResult, + RevRegDef, + RevRegDefResult, ) from ...models.anoncreds_schema import AnonCredsSchema, GetSchemaResult, SchemaResult -from ...base import BaseAnonCredsRegistrar, BaseAnonCredsResolver LOGGER = logging.getLogger(__name__) @@ -117,3 +117,7 @@ async def update_revocation_list( ) -> RevListResult: """Update a revocation list on the registry.""" raise NotImplementedError() + + async def txn_submit(self, profile: Profile, ledger_transaction: str) -> str: + """Submit a transaction to the ledger.""" + raise NotImplementedError() diff --git a/aries_cloudagent/anoncreds/default/did_web/registry.py b/aries_cloudagent/anoncreds/default/did_web/registry.py index 70730cfd21..c0480b81c7 100644 --- a/aries_cloudagent/anoncreds/default/did_web/registry.py +++ b/aries_cloudagent/anoncreds/default/did_web/registry.py @@ -112,3 +112,7 @@ async def update_revocation_list( ) -> RevListResult: """Update a revocation list on the registry.""" raise NotImplementedError() + + async def txn_submit(self, profile: Profile, ledger_transaction: str) -> str: + """Submit a transaction to the ledger.""" + raise NotImplementedError() diff --git a/aries_cloudagent/anoncreds/default/legacy_indy/author.py b/aries_cloudagent/anoncreds/default/legacy_indy/author.py new file mode 100644 index 0000000000..f4c9691192 --- /dev/null +++ b/aries_cloudagent/anoncreds/default/legacy_indy/author.py @@ -0,0 +1,54 @@ +"""Author specific for indy legacy.""" + +from typing import Optional + +from aiohttp import web + +from aries_cloudagent.connections.models.conn_record import ConnRecord +from aries_cloudagent.messaging.models.base import BaseModelError +from aries_cloudagent.protocols.endorse_transaction.v1_0.util import ( + get_endorser_connection_id, +) +from aries_cloudagent.storage.error import StorageNotFoundError + + +async def get_endorser_info(profile, options: Optional[dict] = {}): + """Gets the endorser did for the current transaction.""" + endorser_connection_id = options.get("endorser_connection_id", None) + if not endorser_connection_id: + endorser_connection_id = await get_endorser_connection_id(profile) + + if not endorser_connection_id: + raise web.HTTPForbidden(reason="No endorser connection found") + + try: + async with profile.session() as session: + connection_record = await ConnRecord.retrieve_by_id( + session, endorser_connection_id + ) + endorser_info = await connection_record.metadata_get( + session, "endorser_info" + ) + except StorageNotFoundError as err: + raise web.HTTPNotFound( + reason=f"Connection for endorser with id {endorser_connection_id} not found" + ) from err + except BaseModelError as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + 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" + ) + ) + + return endorser_info["endorser_did"], endorser_connection_id diff --git a/aries_cloudagent/anoncreds/default/legacy_indy/registry.py b/aries_cloudagent/anoncreds/default/legacy_indy/registry.py index e3168c8974..9b669c8a73 100644 --- a/aries_cloudagent/anoncreds/default/legacy_indy/registry.py +++ b/aries_cloudagent/anoncreds/default/legacy_indy/registry.py @@ -7,6 +7,14 @@ from base58 import alphabet +from aries_cloudagent.anoncreds.default.legacy_indy.author import get_endorser_info +from aries_cloudagent.protocols.endorse_transaction.v1_0.manager import ( + TransactionManager, + TransactionManagerError, +) +from aries_cloudagent.protocols.endorse_transaction.v1_0.util import is_author_role +from aries_cloudagent.storage.error import StorageError + from ....cache.base import BaseCache from ....config.injection_context import InjectionContext from ....core.profile import Profile @@ -185,6 +193,7 @@ async def register_schema( profile: Profile, schema: AnonCredsSchema, options: Optional[dict] = None, + outbound_handler: Optional[any] = None, ) -> SchemaResult: """Register a schema on the registry.""" @@ -193,11 +202,7 @@ async def register_schema( # Assume endorser role on the network, no option for 3rd-party endorser ledger = profile.inject_or(BaseLedger) if not ledger: - reason = "No ledger available" - if not profile.settings.get_value("wallet.type"): - # TODO is this warning necessary? - reason += ": missing wallet-type?" - raise AnonCredsRegistrationError(reason) + raise AnonCredsRegistrationError("No ledger available") # Translate schema into format expected by Indy LOGGER.debug("Registering schema: %s", schema_id) @@ -211,33 +216,82 @@ async def register_schema( } LOGGER.debug("schema value: %s", indy_schema) + endorser_did = None + create_transaction = options.get("create_transaction_for_endorser", False) + + if is_author_role(profile) or create_transaction: + endorser_did, endorser_connection_id = await get_endorser_info( + profile, options + ) + + write_ledger = ( + True if endorser_did is None and not create_transaction else False + ) + + # Get either the transaction or the seq_no or the created schema async with ledger: try: - seq_no = await shield( - ledger.send_schema_anoncreds(schema_id, indy_schema) + result = await shield( + ledger.send_schema_anoncreds( + schema_id, + indy_schema, + write_ledger=write_ledger, + endorser_did=endorser_did, + ) ) except LedgerObjectAlreadyExistsError as err: - indy_schema = err.obj - schema = AnonCredsSchema( - name=indy_schema["name"], - version=indy_schema["version"], - attr_names=indy_schema["attrNames"], - issuer_id=indy_schema["id"].split(":")[0], - ) raise AnonCredsSchemaAlreadyExists(err.message, err.obj_id, schema) except (AnonCredsIssuerError, LedgerError) as err: raise AnonCredsRegistrationError("Failed to register schema") from err - return SchemaResult( - job_id=None, - schema_state=SchemaState( - state=SchemaState.STATE_FINISHED, - schema_id=schema_id, - schema=schema, - ), - registration_metadata={}, - schema_metadata={"seqNo": seq_no}, - ) + # Didn't need endorsement, so return schema result + if write_ledger: + return SchemaResult( + job_id=None, + schema_state=SchemaState( + state=SchemaState.STATE_FINISHED, + schema_id=schema_id, + schema=schema, + ), + registration_metadata={}, + schema_metadata={"seqNo": result}, + ) + + # Need endorsement, so execute transaction flow + meta_data = { + "context": {"schema_id": schema_id, "schema": schema.serialize()}, + "processing": {}, + } + + (schema_id, schema_def) = result + transaction_manager = TransactionManager(profile) + try: + transaction = await transaction_manager.create_record( + messages_attach=schema_def["signed_txn"], + connection_id=endorser_connection_id, + meta_data=meta_data, + ) + except StorageError: + raise AnonCredsRegistrationError("Failed to store transaction record") + + if profile.settings.get("endorser.auto_request"): + try: + ( + transaction, + transaction_request, + ) = await transaction_manager.create_request(transaction=transaction) + except (StorageError, TransactionManagerError) as err: + raise AnonCredsRegistrationError( + "Transaction manager failed to create request: " + err.roll_up + ) from err + + await outbound_handler( + transaction_request, connection_id=endorser_connection_id + ) + return { + "sent": {"schema_id": schema_id, "schema": schema_def}, + "txn": transaction.serialize(), + } async def get_credential_definition( self, profile: Profile, cred_def_id: str @@ -768,3 +822,17 @@ async def fix_ledger_entry( applied_txn = ledger_response["result"] return (rev_reg_delta, recovery_txn, applied_txn) + + async def txn_submit(self, profile: Profile, ledger_transaction: str) -> str: + """Submit a transaction to the ledger.""" + ledger = profile.inject(BaseLedger) + + if not ledger: + raise LedgerError("No ledger available") + + try: + return await shield( + ledger.txn_submit(ledger_transaction, sign=False, taa_accept=False) + ) + except LedgerError as err: + raise AnonCredsRegistrationError(err.roll_up) from err diff --git a/aries_cloudagent/anoncreds/default/legacy_indy/tests/test_registry.py b/aries_cloudagent/anoncreds/default/legacy_indy/tests/test_registry.py index b29cbbe291..1d59df6551 100644 --- a/aries_cloudagent/anoncreds/default/legacy_indy/tests/test_registry.py +++ b/aries_cloudagent/anoncreds/default/legacy_indy/tests/test_registry.py @@ -1,10 +1,31 @@ """Test LegacyIndyRegistry.""" -import pytest +import json import re -from ..registry import LegacyIndyRegistry + +import pytest +from asynctest import TestCase from base58 import alphabet +from aries_cloudagent.anoncreds.base import ( + AnonCredsRegistrationError, + AnonCredsSchemaAlreadyExists, +) +from aries_cloudagent.anoncreds.models.anoncreds_schema import ( + AnonCredsSchema, + SchemaResult, +) +from aries_cloudagent.askar.profile_anon import AskarAnoncredsProfile +from aries_cloudagent.connections.models.conn_record import ConnRecord +from aries_cloudagent.core.in_memory.profile import InMemoryProfile +from aries_cloudagent.ledger.error import LedgerError, LedgerObjectAlreadyExistsError +from aries_cloudagent.protocols.endorse_transaction.v1_0.manager import ( + TransactionManager, +) +from aries_cloudagent.tests import mock + +from .. import registry as test_module + B58 = alphabet if isinstance(alphabet, str) else alphabet.decode("ascii") INDY_DID = rf"^(did:sov:)?[{B58}]{{21,22}}$" INDY_SCHEMA_ID = rf"^[{B58}]{{21,22}}:2:.+:[0-9.]+$" @@ -33,23 +54,214 @@ "WgWxqztrNooG92RXvxSTWv:4:WgWxqztrNooG92RXvxSTWv:3:CL:20:tag:CL_ACCUM:0" ) +mock_schema = AnonCredsSchema( + issuer_id="did:indy:sovrin:SGrjRL82Y9ZZbzhUDXokvQ", + attr_names=["name", "age", "vmax"], + name="test_schema", + version="1.0", +) -@pytest.fixture -def registry(): - """Registry fixture""" - yield LegacyIndyRegistry() +@pytest.mark.anoncreds +class TestLegacyIndyRegistry(TestCase): + def setUp(self): + self.profile = InMemoryProfile.test_profile( + settings={"wallet-type": "askar-anoncreds"}, + profile_class=AskarAnoncredsProfile, + ) + self.registry = test_module.LegacyIndyRegistry() -@pytest.mark.indy -class TestLegacyIndyRegistry: - @pytest.mark.asyncio - async def test_supported_did_regex(self, registry: LegacyIndyRegistry): + async def test_supported_did_regex(self): """Test the supported_did_regex.""" - assert registry.supported_identifiers_regex == SUPPORTED_ID_REGEX - assert bool(registry.supported_identifiers_regex.match(TEST_INDY_DID)) - assert bool(registry.supported_identifiers_regex.match(TEST_INDY_DID_1)) - assert bool(registry.supported_identifiers_regex.match(TEST_INDY_SCHEMA_ID)) + assert self.registry.supported_identifiers_regex == SUPPORTED_ID_REGEX + assert bool(self.registry.supported_identifiers_regex.match(TEST_INDY_DID)) + assert bool(self.registry.supported_identifiers_regex.match(TEST_INDY_DID_1)) + assert bool( + self.registry.supported_identifiers_regex.match(TEST_INDY_SCHEMA_ID) + ) assert bool( - registry.supported_identifiers_regex.match(TEST_INDY_REV_REG_DEF_ID) + self.registry.supported_identifiers_regex.match(TEST_INDY_REV_REG_DEF_ID) + ) + + async def test_register_schema_no_endorsement(self): + self.profile.inject_or = mock.MagicMock( + return_value=mock.CoroutineMock( + send_schema_anoncreds=mock.CoroutineMock(return_value=1) + ) + ) + + result = await self.registry.register_schema(self.profile, mock_schema, {}) + + assert isinstance(result, SchemaResult) + + @mock.patch.object( + ConnRecord, + "retrieve_by_id", + return_value=mock.CoroutineMock( + metadata_get=mock.CoroutineMock(return_value={"endorser_did": "test_did"}) + ), + ) + async def test_register_schema_with_author_role(self, mock_endorser_conn_record): + self.profile.inject_or = mock.MagicMock( + return_value=mock.CoroutineMock( + send_schema_anoncreds=mock.CoroutineMock( + return_value=( + "test_schema_id", + { + "signed_txn": "test_signed_txn", + }, + ) + ) + ) + ) + self.profile.settings.set_value("endorser.author", True) + + result = await self.registry.register_schema( + self.profile, mock_schema, {"endorser_connection_id": "test_connection_id"} + ) + + assert result["sent"] is not None + assert result["txn"] is not None + assert mock_endorser_conn_record.called + + @mock.patch.object( + ConnRecord, + "retrieve_by_id", + return_value=mock.CoroutineMock( + metadata_get=mock.CoroutineMock(return_value={"endorser_did": "test_did"}) + ), + ) + async def test_register_schema_already_exists(self, mock_endorser_conn_record): + self.profile.inject_or = mock.MagicMock( + return_value=mock.CoroutineMock( + send_schema_anoncreds=mock.CoroutineMock( + side_effect=LedgerObjectAlreadyExistsError( + "test", "test", mock_schema + ) + ) + ) + ) + self.profile.settings.set_value("endorser.author", True) + + with self.assertRaises(AnonCredsSchemaAlreadyExists): + await self.registry.register_schema( + self.profile, + mock_schema, + {"endorser_connection_id": "test_connection_id"}, + ) + + @mock.patch.object( + ConnRecord, + "retrieve_by_id", + return_value=mock.CoroutineMock( + metadata_get=mock.CoroutineMock(return_value={"endorser_did": "test_did"}) + ), + ) + async def test_register_schema_with_create_trasaction_param( + self, mock_endorser_conn_record + ): + self.profile.inject_or = mock.MagicMock( + return_value=mock.CoroutineMock( + send_schema_anoncreds=mock.CoroutineMock( + return_value=( + "test_schema_id", + { + "signed_txn": "test_signed_txn", + }, + ) + ) + ) + ) + + result = await self.registry.register_schema( + self.profile, + mock_schema, + { + "endorser_connection_id": "test_connection_id", + "create_transaction_for_endorser": True, + }, + ) + + assert result["sent"] is not None + assert result["txn"] is not None + assert mock_endorser_conn_record.called + + @mock.patch.object( + ConnRecord, + "retrieve_by_id", + return_value=mock.CoroutineMock( + metadata_get=mock.CoroutineMock(return_value={"endorser_did": "test_did"}) + ), + ) + @mock.patch.object( + TransactionManager, + "create_request", + ) + async def test_register_schema_with_author_role_and_create_request( + self, mock_create_request, mock_endorser_conn_record + ): + class MockTransaction: + def __init__(self, txn): + self.txn = txn + + def serialize(self): + return json.dumps(self.txn) + + self.profile.inject_or = mock.MagicMock( + return_value=mock.CoroutineMock( + send_schema_anoncreds=mock.CoroutineMock( + return_value=( + "test_schema_id", + { + "signed_txn": "test_signed_txn", + }, + ) + ) + ) ) + + mock_create_request.return_value = ( + MockTransaction({"test": "test"}), + "transaction_request", + ) + + self.profile.settings.set_value("endorser.author", True) + self.profile.settings.set_value("endorser.auto_request", True) + + mock_outbound_handler = mock.CoroutineMock() + + result = await self.registry.register_schema( + self.profile, + mock_schema, + {"endorser_connection_id": "test_connection_id"}, + mock_outbound_handler, + ) + + assert result["sent"] is not None + assert result["txn"] is not None + assert mock_create_request.called + assert mock_outbound_handler.called + + async def test_txn_submit(self): + self.profile.inject = mock.MagicMock( + side_effect=[ + None, + mock.CoroutineMock( + txn_submit=mock.CoroutineMock(side_effect=LedgerError("test error")) + ), + mock.CoroutineMock( + txn_submit=mock.CoroutineMock(return_value="transaction response") + ), + ] + ) + + # No ledger + with self.assertRaises(LedgerError): + await self.registry.txn_submit(self.profile, "test_txn") + # Write error + with self.assertRaises(AnonCredsRegistrationError): + await self.registry.txn_submit(self.profile, "test_txn") + + result = await self.registry.txn_submit(self.profile, "test_txn") + assert result == "transaction response" diff --git a/aries_cloudagent/anoncreds/events.py b/aries_cloudagent/anoncreds/events.py index 252307dae9..d1e0b99f47 100644 --- a/aries_cloudagent/anoncreds/events.py +++ b/aries_cloudagent/anoncreds/events.py @@ -3,14 +3,17 @@ import re from typing import NamedTuple +from aries_cloudagent.core.profile import Profile + from ..core.event_bus import Event from .models.anoncreds_revocation import RevRegDef - CRED_DEF_FINISHED_EVENT = "anoncreds::credential-definition::finished" REV_REG_DEF_FINISHED_EVENT = "anoncreds::revocation-registry-definition::finished" REV_LIST_FINISHED_EVENT = "anoncreds::revocation-list::finished" +SCHEMA_EVENT = "acapy::schema-anoncreds::" +SCHEMA_ANONCREDS_LISTENER_PATTERN = re.compile(f"^{SCHEMA_EVENT}(.*)?$") CRED_DEF_FINISHED_PATTERN = re.compile("anoncreds::credential-definition::finished") REV_REG_DEF_FINISHED_PATTERN = re.compile( "anoncreds::revocation-registry-definition::finished" @@ -128,3 +131,13 @@ def __init__(self, payload: RevListFinishedPayload): def payload(self) -> RevListFinishedPayload: """Return payload.""" return self._payload + + +async def notify_anoncreds_schema_event( + profile: Profile, schema_id: str, meta_data: dict +): + """Send notification for a schema post-process event.""" + await profile.notify( + SCHEMA_EVENT + schema_id, + meta_data, + ) diff --git a/aries_cloudagent/anoncreds/issuer.py b/aries_cloudagent/anoncreds/issuer.py index d7c07c3a18..b7e8af4d3c 100644 --- a/aries_cloudagent/anoncreds/issuer.py +++ b/aries_cloudagent/anoncreds/issuer.py @@ -130,7 +130,7 @@ async def _finish_registration( await txn.handle.remove(category, job_id) return entry - async def _store_schema( + async def store_schema( self, result: SchemaResult, ): @@ -162,6 +162,7 @@ async def create_and_register_schema( version: str, attr_names: Sequence[str], options: Optional[dict] = None, + outbound_handler: Optional[any] = None, ) -> SchemaResult: """Create a new credential schema and store it in the wallet. @@ -201,9 +202,14 @@ async def create_and_register_schema( self.profile, AnonCredsSchema.from_native(schema), options, + outbound_handler, ) - await self._store_schema(schema_result) + # Endorsement transaction - don't store in wallet + if not isinstance(schema_result, SchemaResult): + return schema_result + + await self.store_schema(schema_result) return schema_result @@ -211,7 +217,7 @@ async def create_and_register_schema( # If we find that we've previously written a schema that looks like # this one before but that schema is not in our wallet, add it to # the wallet so we can return from our get schema calls - await self._store_schema( + await self.store_schema( SchemaResult( job_id=None, schema_state=SchemaState( diff --git a/aries_cloudagent/anoncreds/registry.py b/aries_cloudagent/anoncreds/registry.py index e205bc0150..74d5144b70 100644 --- a/aries_cloudagent/anoncreds/registry.py +++ b/aries_cloudagent/anoncreds/registry.py @@ -1,9 +1,16 @@ """AnonCreds Registry.""" +import json import logging from typing import List, Optional, Sequence - from ..core.profile import Profile +from .base import ( + AnonCredsRegistrationError, + AnonCredsResolutionError, + BaseAnonCredsHandler, + BaseAnonCredsRegistrar, + BaseAnonCredsResolver, +) from .models.anoncreds_cred_def import ( CredDef, CredDefResult, @@ -12,19 +19,12 @@ from .models.anoncreds_revocation import ( GetRevListResult, GetRevRegDefResult, - RevRegDef, - RevRegDefResult, RevList, RevListResult, + RevRegDef, + RevRegDefResult, ) from .models.anoncreds_schema import AnonCredsSchema, GetSchemaResult, SchemaResult -from .base import ( - AnonCredsRegistrationError, - AnonCredsResolutionError, - BaseAnonCredsHandler, - BaseAnonCredsRegistrar, - BaseAnonCredsResolver, -) LOGGER = logging.getLogger(__name__) @@ -92,10 +92,13 @@ async def register_schema( profile: Profile, schema: AnonCredsSchema, options: Optional[dict] = None, + outbound_handler: Optional[any] = None, ) -> SchemaResult: """Register a schema on the registry.""" registrar = await self._registrar_for_identifier(schema.issuer_id) - return await registrar.register_schema(profile, schema, options) + return await registrar.register_schema( + profile, schema, options, outbound_handler + ) async def get_credential_definition( self, profile: Profile, credential_definition_id: str @@ -182,3 +185,10 @@ async def update_revocation_list( return await registrar.update_revocation_list( profile, rev_reg_def, prev_list, curr_list, revoked, options ) + + async def txn_submit(self, profile: Profile, ledger_transaction: str) -> str: + """Submit a transaction to the ledger.""" + registrar = await self._registrar_for_identifier( + json.loads(ledger_transaction)["endorser"] + ) + return await registrar.txn_submit(profile, ledger_transaction) diff --git a/aries_cloudagent/anoncreds/routes.py b/aries_cloudagent/anoncreds/routes.py index 2320515e10..e4fe21eb51 100644 --- a/aries_cloudagent/anoncreds/routes.py +++ b/aries_cloudagent/anoncreds/routes.py @@ -12,11 +12,13 @@ ) from marshmallow import fields +from aries_cloudagent.anoncreds.events import SCHEMA_ANONCREDS_LISTENER_PATTERN +from aries_cloudagent.core.profile import Profile from aries_cloudagent.ledger.error import LedgerError from ..admin.request_context import AdminRequestContext from ..askar.profile import AskarProfile -from ..core.event_bus import EventBus +from ..core.event_bus import Event, EventBus from ..messaging.models.openapi import OpenAPISchema from ..messaging.valid import ( INDY_CRED_DEF_ID_EXAMPLE, @@ -44,9 +46,12 @@ from .models.anoncreds_cred_def import CredDefResultSchema, GetCredDefResultSchema from .models.anoncreds_revocation import RevListResultSchema, RevRegDefResultSchema from .models.anoncreds_schema import ( + AnonCredsSchema, AnonCredsSchemaSchema, GetSchemaResultSchema, + SchemaResult, SchemaResultSchema, + SchemaState, ) from .registry import AnonCredsRegistry from .revocation import AnonCredsRevocation, AnonCredsRevocationError @@ -125,11 +130,24 @@ class SchemaPostOptionSchema(OpenAPISchema): """Parameters and validators for schema options.""" endorser_connection_id = fields.Str( - description="Connection identifier (optional) (this is an example)", + description=""" + Connection identifier (optional) (this is an example) + You can set this is you know the endorsers connection id you want to use. + If not specified then the agent will attempt to find an endorser connection. + """, required=False, example=UUIDFour.EXAMPLE, ) + create_transaction_for_endorser = fields.Bool( + description=""" + Create transaction for endorser (optional, default false). + Use this for agents who don't specify an author role but want to + create a transaction for an endorser to sign.""", + required=False, + example=False, + ) + class SchemaPostRequestSchema(OpenAPISchema): """Parameters and validators for query string in create schema.""" @@ -179,7 +197,7 @@ async def schemas_post(request: web.BaseRequest): context: AdminRequestContext = request["context"] body = await request.json() - options = body.get("options") + options = body.get("options", {}) schema_data = body.get("schema") if schema_data is None: @@ -193,9 +211,17 @@ async def schemas_post(request: web.BaseRequest): issuer = AnonCredsIssuer(context.profile) try: result = await issuer.create_and_register_schema( - issuer_id, name, version, attr_names, options=options + issuer_id, + name, + version, + attr_names, + options, + request["outbound_message_router"], ) - return web.json_response(result.serialize()) + if isinstance(result, SchemaResult): + return web.json_response(result.serialize()) + # Transaction response + return web.json_response(result) except (AnonCredsIssuerError, AnonCredsRegistrationError) as e: raise web.HTTPBadRequest(reason=e.roll_up) from e @@ -643,6 +669,35 @@ async def publish_revocations(request: web.BaseRequest): raise web.HTTPBadRequest(reason=err.roll_up) from err +def register_events(event_bus: EventBus): + """Register events.""" + # TODO Make this pluggable? + setup_manager = DefaultRevocationSetup() + setup_manager.register_events(event_bus) + event_bus.subscribe(SCHEMA_ANONCREDS_LISTENER_PATTERN, on_schema_event) + + +async def on_schema_event(profile: Profile, event: Event): + """Store the schema in wallet.""" + + await AnonCredsIssuer(profile).store_schema( + SchemaResult( + job_id=None, + schema_state=SchemaState( + state=SchemaState.STATE_FINISHED, + schema_id=event.payload["context"]["schema_id"], + schema=AnonCredsSchema( + attr_names=event.payload["context"]["schema"]["attrNames"], + name=event.payload["context"]["schema"]["name"], + version=event.payload["context"]["schema"]["version"], + issuer_id=event.payload["context"]["schema"]["issuerId"], + ), + ), + registration_metadata={}, + ) + ) + + async def register(app: web.Application): """Register routes.""" @@ -672,13 +727,6 @@ async def register(app: web.Application): ) -def register_events(event_bus: EventBus): - """Register events.""" - # TODO Make this pluggable? - setup_manager = DefaultRevocationSetup() - setup_manager.register_events(event_bus) - - def post_process_routes(app: web.Application): """Amend swagger API.""" diff --git a/aries_cloudagent/anoncreds/tests/test_issuer.py b/aries_cloudagent/anoncreds/tests/test_issuer.py index 78b2656fb8..7e662a9421 100644 --- a/aries_cloudagent/anoncreds/tests/test_issuer.py +++ b/aries_cloudagent/anoncreds/tests/test_issuer.py @@ -297,7 +297,7 @@ async def test_create_and_register_schema_fail_insert(self, mock_session_handle) mock_session_handle.insert.assert_called_once() @mock.patch.object(InMemoryProfileSession, "handle") - async def test_create_and_register_already_exists_but_not_in_wallet( + async def test_create_and_register_schema_already_exists_but_not_in_wallet( self, mock_session_handle ): mock_session_handle.fetch_all = mock.CoroutineMock(return_value=[]) @@ -363,6 +363,28 @@ async def test_create_and_register_schema_without_job_id_or_schema_id_raises_x( attr_names=["attr1", "attr2"], ) + @mock.patch.object(InMemoryProfileSession, "handle") + @mock.patch.object(test_module.AnonCredsIssuer, "store_schema") + async def test_create_and_register_schema_with_endorsed_transaction_response_does_not_store_schema( + self, mock_store_schema, mock_session_handle + ): + mock_session_handle.fetch_all = mock.CoroutineMock(return_value=[]) + mock_session_handle.insert = mock.CoroutineMock(return_value=None) + self.profile.inject = mock.Mock( + return_value=mock.MagicMock( + register_schema=mock.CoroutineMock(return_value={"txn": "transaction"}) + ) + ) + result = await self.issuer.create_and_register_schema( + issuer_id="did:sov:3avoBCqDMFHFaKUHug9s8W", + name="example name", + version="1.0", + attr_names=["attr1", "attr2"], + ) + + assert result is not None + assert not mock_store_schema.called + async def test_finish_schema(self): self.profile.transaction = mock.Mock( return_value=mock.MagicMock( diff --git a/aries_cloudagent/anoncreds/tests/test_routes.py b/aries_cloudagent/anoncreds/tests/test_routes.py index e539a93e73..bf8a614f1a 100644 --- a/aries_cloudagent/anoncreds/tests/test_routes.py +++ b/aries_cloudagent/anoncreds/tests/test_routes.py @@ -10,7 +10,11 @@ from aries_cloudagent.anoncreds.revocation import AnonCredsRevocation from aries_cloudagent.anoncreds.revocation_setup import DefaultRevocationSetup from aries_cloudagent.askar.profile_anon import AskarAnoncredsProfile -from aries_cloudagent.core.in_memory.profile import InMemoryProfile +from aries_cloudagent.core.event_bus import Event, MockEventBus +from aries_cloudagent.core.in_memory.profile import ( + InMemoryProfile, + InMemoryProfileSession, +) from aries_cloudagent.revocation_anoncreds.manager import RevocationManager from aries_cloudagent.tests import mock @@ -64,7 +68,9 @@ async def setUp(self) -> None: @mock.patch.object( AnonCredsIssuer, "create_and_register_schema", - return_value=MockSchema("schemaId"), + return_value={ + "txn": "transaction", + }, ) async def test_schemas_post(self, mock_create_and_register_schema): self.request.json = mock.CoroutineMock( @@ -81,8 +87,9 @@ async def test_schemas_post(self, mock_create_and_register_schema): {"schema": {}}, ] ) + self.request_dict["outbound_message_router"] = mock.MagicMock() result = await test_module.schemas_post(self.request) - assert json.loads(result.body)["schema_id"] == "schemaId" + assert result is not None assert mock_create_and_register_schema.call_count == 1 @@ -358,9 +365,12 @@ async def test_publish_revocations(self, mock_publish): assert mock_publish.call_count == 1 @mock.patch.object(DefaultRevocationSetup, "register_events") - async def test_register_events(self, mock_manager): - test_module.register_events("event_bus") - mock_manager.assert_called_once_with("event_bus") + async def test_register_events(self, mock_revocation_setup_listeners): + mock_event_bus = MockEventBus() + mock_event_bus.subscribe = mock.MagicMock() + test_module.register_events(mock_event_bus) + assert mock_revocation_setup_listeners.call_count == 1 + assert mock_event_bus.subscribe.call_count == 1 async def test_register(self): mock_app = mock.MagicMock() @@ -373,3 +383,26 @@ async def test_post_process_routes(self): mock_app = mock.MagicMock(_state={"swagger_dict": {}}) test_module.post_process_routes(mock_app) assert "tags" in mock_app._state["swagger_dict"] + + @mock.patch.object( + InMemoryProfileSession, + "handle", + ) + async def test_on_schema_event_valid_payload(self, mock_handle): + mock_handle.insert = mock.CoroutineMock(return_value=None) + test_event = Event( + topic="test-topic", + payload={ + "context": { + "schema_id": "schema_id", + "schema": { + "issuerId": "Q4TmbeGPoWeWob4Xf6KetA", + "attrNames": ["score"], + "name": "Example Schema", + "version": "0.0.1", + }, + }, + }, + ) + + await test_module.on_schema_event(self.profile, test_event) diff --git a/aries_cloudagent/ledger/base.py b/aries_cloudagent/ledger/base.py index e2ab4c3d4a..9c1f8efff3 100644 --- a/aries_cloudagent/ledger/base.py +++ b/aries_cloudagent/ledger/base.py @@ -3,8 +3,7 @@ import json import logging import re - -from abc import ABC, abstractmethod, ABCMeta +from abc import ABC, ABCMeta, abstractmethod from enum import Enum from hashlib import sha256 from typing import List, Sequence, Tuple, Union @@ -13,16 +12,14 @@ from ..messaging.valid import IndyDID from ..utils import sentinel from ..wallet.did_info import DIDInfo - +from .endpoint_type import EndpointType from .error import ( BadLedgerRequestError, LedgerError, - LedgerTransactionError, LedgerObjectAlreadyExistsError, + LedgerTransactionError, ) -from .endpoint_type import EndpointType - LOGGER = logging.getLogger(__name__) @@ -638,9 +635,8 @@ async def send_schema_anoncreds( write_ledger=write_ledger, ) - # TODO Clean this up - # if not write_ledger: - # return schema_id, {"signed_txn": resp} + if not write_ledger: + return schema_id, {"signed_txn": resp} try: # parse sequence number out of response diff --git a/aries_cloudagent/protocols/endorse_transaction/v1_0/manager.py b/aries_cloudagent/protocols/endorse_transaction/v1_0/manager.py index 2137f23a5d..60a864db9f 100644 --- a/aries_cloudagent/protocols/endorse_transaction/v1_0/manager.py +++ b/aries_cloudagent/protocols/endorse_transaction/v1_0/manager.py @@ -3,9 +3,11 @@ import json import logging import uuid - from asyncio import shield +from aries_cloudagent.anoncreds.events import notify_anoncreds_schema_event +from aries_cloudagent.anoncreds.registry import AnonCredsRegistry + from ....connections.models.conn_record import ConnRecord from ....core.error import BaseError from ....core.profile import Profile @@ -15,17 +17,16 @@ from ....messaging.credential_definitions.util import notify_cred_def_event from ....messaging.schemas.util import notify_schema_event from ....revocation.util import ( - notify_revocation_reg_endorsed_event, notify_revocation_entry_endorsed_event, + notify_revocation_reg_endorsed_event, ) from ....storage.error import StorageError, StorageNotFoundError from ....transport.inbound.receipt import MessageReceipt from ....wallet.base import BaseWallet from ....wallet.util import ( - notify_endorse_did_event, notify_endorse_did_attrib_event, + notify_endorse_did_event, ) - from .messages.cancel_transaction import CancelTransaction from .messages.endorsed_transaction_response import EndorsedTransactionResponse from .messages.refused_transaction_response import RefusedTransactionResponse @@ -415,22 +416,28 @@ async def complete_transaction( if (endorser and transaction.endorser_write_txn) or ( (not endorser) and (not transaction.endorser_write_txn) ): - ledger = self._profile.inject(BaseLedger) - if not ledger: - reason = "No ledger available" - if not self._profile.context.settings.get_value("wallet.type"): - reason += ": missing wallet-type?" - raise TransactionManagerError(reason) - - async with ledger: - try: - ledger_response_json = await shield( - ledger.txn_submit( - ledger_transaction, sign=False, taa_accept=False + if ( + self._profile.context.settings.get_value("wallet.type") + == "askar-anoncreds" + ): + anoncreds_registry = self._profile.inject(AnonCredsRegistry) + ledger_response_json = await anoncreds_registry.txn_submit( + self._profile, ledger_transaction + ) + else: + ledger = self._profile.inject(BaseLedger) + if not ledger: + raise TransactionManagerError("No ledger available") + + async with ledger: + try: + ledger_response_json = await shield( + ledger.txn_submit( + ledger_transaction, sign=False, taa_accept=False + ) ) - ) - except (IndyIssuerError, LedgerError) as err: - raise TransactionManagerError(err.roll_up) from err + except (IndyIssuerError, LedgerError) as err: + raise TransactionManagerError(err.roll_up) from err ledger_response = json.loads(ledger_response_json) @@ -795,7 +802,10 @@ async def endorsed_txn_post_processing( meta_data["context"]["public_did"] = public_did # Notify schema ledger write event - await notify_schema_event(self._profile, schema_id, meta_data) + if self._profile.settings.get("wallet.type") == "askar-anoncreds": + await notify_anoncreds_schema_event(self._profile, schema_id, meta_data) + else: + await notify_schema_event(self._profile, schema_id, meta_data) elif ledger_response["result"]["txn"]["type"] == "102": # cred def transaction diff --git a/aries_cloudagent/protocols/endorse_transaction/v1_0/tests/test_manager.py b/aries_cloudagent/protocols/endorse_transaction/v1_0/tests/test_manager.py index 82902dfe93..db1ac44d9c 100644 --- a/aries_cloudagent/protocols/endorse_transaction/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/endorse_transaction/v1_0/tests/test_manager.py @@ -1,8 +1,9 @@ import asyncio import json import uuid - from unittest import IsolatedAsyncioTestCase + +from aries_cloudagent.anoncreds.registry import AnonCredsRegistry from aries_cloudagent.tests import mock from .....admin.request_context import AdminRequestContext @@ -497,6 +498,61 @@ async def test_complete_transaction(self): assert transaction_record.state == TransactionRecord.STATE_TRANSACTION_ACKED + async def test_complete_transaction_anoncreds(self): + self.profile.settings.set_value("wallet.type", "askar-anoncreds") + self.profile.context.injector.bind_instance( + AnonCredsRegistry, + mock.MagicMock( + txn_submit=mock.CoroutineMock( + return_value=json.dumps( + { + "result": { + "txn": {"type": "101", "metadata": {"from": TEST_DID}}, + "txnMetadata": {"txnId": SCHEMA_ID}, + } + } + ) + ) + ), + ) + transaction_record = await self.manager.create_record( + messages_attach=self.test_messages_attach, + connection_id=self.test_connection_id, + ) + future = asyncio.Future() + future.set_result( + mock.MagicMock(return_value=mock.MagicMock(add_record=mock.CoroutineMock())) + ) + self.ledger.get_indy_storage = future + + with mock.patch.object( + TransactionRecord, "save", autospec=True + ) as save_record, mock.patch.object( + ConnRecord, "retrieve_by_id" + ) as mock_conn_rec_retrieve: + mock_conn_rec_retrieve.return_value = mock.MagicMock( + metadata_get=mock.CoroutineMock( + return_value={ + "transaction_their_job": ( + TransactionJob.TRANSACTION_ENDORSER.name + ), + "transaction_my_job": (TransactionJob.TRANSACTION_AUTHOR.name), + } + ) + ) + + ( + transaction_record, + transaction_acknowledgement_message, + ) = await self.manager.complete_transaction(transaction_record, False) + save_record.assert_called_once() + + assert transaction_record.state == TransactionRecord.STATE_TRANSACTION_ACKED + # txn submit called from anoncreds registry + assert self.profile.context._injector.get_provider( + AnonCredsRegistry + )._instance.txn_submit.called + async def test_create_refuse_response_bad_state(self): transaction_record = await self.manager.create_record( messages_attach=self.test_messages_attach, diff --git a/demo/features/0586-sign-transaction.feature b/demo/features/0586-sign-transaction.feature index b7427d9c20..b58eb27ca4 100644 --- a/demo/features/0586-sign-transaction.feature +++ b/demo/features/0586-sign-transaction.feature @@ -28,6 +28,13 @@ Feature: RFC 0586 Aries sign (endorse) transactions functions | --multitenant --multi-ledger | --multitenant --multi-ledger | driverslicense | | --multitenant --multi-ledger --revocation | --multitenant --multi-ledger --revocation | driverslicense | + @WalletType_Askar_AnonCreds + Examples: + | Acme_capabilities | Bob_capabilities | Schema_name | + | --wallet-type askar-anoncreds | --wallet-type askar-anoncreds | anoncreds-testing | + | --wallet-type askar-anoncreds | | driverslicense | + | | --wallet-type askar-anoncreds | anoncreds-testing | + @T001.1-RFC0586 @GHA Scenario Outline: endorse a transaction and write to the ledger diff --git a/demo/features/steps/0586-sign-transaction.py b/demo/features/steps/0586-sign-transaction.py index 204eca0cfa..9020d71e11 100644 --- a/demo/features/steps/0586-sign-transaction.py +++ b/demo/features/steps/0586-sign-transaction.py @@ -1,5 +1,4 @@ import time -from time import sleep from bdd_support.agent_backchannel_client import ( agent_container_GET, @@ -7,15 +6,14 @@ agent_container_POST, agent_container_PUT, agent_container_register_did, + aries_container_fetch_cred_def, + aries_container_fetch_cred_defs, + aries_container_fetch_schemas, async_sleep, read_json_data, read_schema_data, - aries_container_fetch_schemas, - aries_container_fetch_cred_defs, - aries_container_fetch_cred_def, ) from behave import given, then, when -from runners.agent_container import AgentContainer # This step is defined in another feature file # Given "Acme" and "Bob" have an existing connection @@ -50,7 +48,7 @@ def step_impl(context, agent_name, did_role): # assume everything works! async_sleep(3.0) - if not "public_dids" in context: + if "public_dids" not in context: context.public_dids = {} context.public_dids[did_role] = created_did["result"]["did"] @@ -101,12 +99,25 @@ def step_impl(context, agent_name, schema_name): schema_info = read_schema_data(schema_name) connection_id = agent["agent"].agent.connection_id - created_txn = agent_container_POST( - agent["agent"], - "/schemas", - data=schema_info["schema"], - params={"conn_id": connection_id, "create_transaction_for_endorser": "true"}, - ) + if agent["agent"].wallet_type != "askar-anoncreds": + created_txn = agent_container_POST( + agent["agent"], + "/schemas", + data=schema_info["schema"], + params={ + "conn_id": connection_id, + "create_transaction_for_endorser": "true", + }, + ) + else: + schema_info["schema"]["issuerId"] = context.public_dids["AUTHOR"] + schema_info["options"]["create_transaction_for_endorser"] = True + schema_info["options"]["endorser_connection_id"] = connection_id + created_txn = agent_container_POST( + agent["agent"], + "/anoncreds/schema", + data=schema_info, + ) # assert goodness if agent["agent"].endorser_role and agent["agent"].endorser_role == "author": @@ -114,7 +125,7 @@ def step_impl(context, agent_name, schema_name): else: assert created_txn["txn"]["state"] == "transaction_created" - if not "txn_ids" in context: + if "txn_ids" not in context: context.txn_ids = {} context.txn_ids["AUTHOR"] = created_txn["txn"]["transaction_id"] @@ -188,8 +199,6 @@ def step_impl(context, agent_name): def step_impl(context, agent_name, schema_name): agent = context.active_agents[agent_name] - schema_info = read_schema_data(schema_name) - schemas = {"schema_ids": []} i = 5 while 0 == len(schemas["schema_ids"]) and i > 0: @@ -199,7 +208,10 @@ def step_impl(context, agent_name, schema_name): assert len(schemas["schema_ids"]) == 1 schema_id = schemas["schema_ids"][0] - schema = agent_container_GET(agent["agent"], "/schemas/" + schema_id) + if agent["agent"].wallet_type != "askar-anoncreds": + agent_container_GET(agent["agent"], "/schemas/" + schema_id) + else: + agent_container_GET(agent["agent"], "/anoncreds/schema/" + schema_id) context.schema_name = schema_name @@ -235,7 +247,7 @@ def step_impl(context, agent_name, schema_name): assert created_txn["txn"]["state"] == "request_sent" else: assert created_txn["txn"]["state"] == "transaction_created" - if not "txn_ids" in context: + if "txn_ids" not in context: context.txn_ids = {} context.txn_ids["AUTHOR"] = created_txn["txn"]["transaction_id"] @@ -312,7 +324,7 @@ def step_impl(context, agent_name, schema_name): }, ) assert created_txn["txn"]["state"] == "transaction_created" - if not "txn_ids" in context: + if "txn_ids" not in context: context.txn_ids = {} context.txn_ids["AUTHOR"] = created_txn["txn"]["transaction_id"] @@ -419,7 +431,7 @@ def step_impl(context, agent_name, schema_name): }, ) assert created_txn["txn"]["state"] == "transaction_created" - if not "txn_ids" in context: + if "txn_ids" not in context: context.txn_ids = {} context.txn_ids["AUTHOR"] = created_txn["txn"]["transaction_id"] @@ -519,7 +531,7 @@ def step_impl(context, agent_name): # create rev_reg entry transaction created_rev_reg = agent_container_POST( agent["agent"], - f"/revocation/publish-revocations", + "/revocation/publish-revocations", data={ "rrid2crid": { context.cred_exchange["indy"]["rev_reg_id"]: [ @@ -547,7 +559,7 @@ def step_impl(context, agent_name): # create rev_reg entry transaction created_rev_reg = agent_container_POST( agent["agent"], - f"/revocation/publish-revocations", + "/revocation/publish-revocations", data={ "rrid2crid": { context.cred_exchange["indy"]["rev_reg_id"]: [