From a2a6dd2ae66020306837e7ee6df0478b1df4821b Mon Sep 17 00:00:00 2001 From: jamshale <31809382+jamshale@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:56:01 -0800 Subject: [PATCH] BREAKING: VCHolder multitenant binding (#3391) * VCHolder multitenant binding Signed-off-by: jamshale * fix: soft binding for MT askar of VC Holder Signed-off-by: Daniel Bluhm * fix: openapi requests and responses for vc routes Signed-off-by: Daniel Bluhm * fix: example script result assertion Signed-off-by: Daniel Bluhm --------- Signed-off-by: jamshale Signed-off-by: Daniel Bluhm Co-authored-by: Daniel Bluhm --- acapy_agent/askar/profile.py | 2 +- acapy_agent/vc/routes.py | 11 ++- acapy_agent/vc/vc_ld/manager.py | 5 +- acapy_agent/vc/vc_ld/models/web_schemas.py | 14 ++- acapy_agent/vc/vc_ld/tests/test_manager.py | 2 +- .../examples/vc_holder/docker-compose.yml | 45 +++++++++ scenarios/examples/vc_holder/example.py | 95 +++++++++++++++++++ 7 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 scenarios/examples/vc_holder/docker-compose.yml create mode 100644 scenarios/examples/vc_holder/example.py diff --git a/acapy_agent/askar/profile.py b/acapy_agent/askar/profile.py index 313c026bf3..2b5c8bfe62 100644 --- a/acapy_agent/askar/profile.py +++ b/acapy_agent/askar/profile.py @@ -123,7 +123,7 @@ def bind_providers(self): VCHolder, ClassProvider( "acapy_agent.storage.vc_holder.askar.AskarVCHolder", - ref(self), + ClassProvider.Inject(Profile), ), ) if ( diff --git a/acapy_agent/vc/routes.py b/acapy_agent/vc/routes.py index 47e7d1b8f6..e2d14e80fe 100644 --- a/acapy_agent/vc/routes.py +++ b/acapy_agent/vc/routes.py @@ -34,7 +34,11 @@ async def list_credentials_route(request: web.BaseRequest): holder = context.inject(VCHolder) try: search = holder.search_credentials() - records = [record.serialize()["cred_value"] for record in await search.fetch()] + records = { + "results": [ + record.serialize()["cred_value"] for record in await search.fetch() + ] + } return web.json_response(records, status=200) except (StorageError, StorageNotFoundError) as err: return web.json_response({"message": err.roll_up}, status=400) @@ -133,6 +137,9 @@ async def verify_credential_route(request: web.BaseRequest): @docs(tags=["vc-api"], summary="Store a credential") +@request_schema(web_schemas.StoreCredentialRequest()) +@response_schema(web_schemas.StoreCredentialResponse(), 200, description="") +@tenant_authentication async def store_credential_route(request: web.BaseRequest): """Request handler for storing a credential. @@ -153,7 +160,7 @@ async def store_credential_route(request: web.BaseRequest): options = LDProofVCOptions.deserialize(options) await manager.verify_credential(vc) - await manager.store_credential(vc, options, cred_id) + await manager.store_credential(vc, cred_id) return web.json_response({"credentialId": cred_id}, status=200) diff --git a/acapy_agent/vc/vc_ld/manager.py b/acapy_agent/vc/vc_ld/manager.py index d6d4b6809c..bf3fa83bc4 100644 --- a/acapy_agent/vc/vc_ld/manager.py +++ b/acapy_agent/vc/vc_ld/manager.py @@ -405,9 +405,8 @@ async def issue( async def store_credential( self, vc: VerifiableCredential, - options: LDProofVCOptions, cred_id: Optional[str] = None, - ) -> VerifiableCredential: + ) -> VCRecord: """Store a verifiable credential.""" # Saving expanded type as a cred_tag @@ -437,6 +436,8 @@ async def store_credential( await vc_holder.store_credential(vc_record) + return vc_record + async def verify_credential( self, vc: VerifiableCredential ) -> DocumentVerificationResult: diff --git a/acapy_agent/vc/vc_ld/models/web_schemas.py b/acapy_agent/vc/vc_ld/models/web_schemas.py index 6bd6a93035..277c04e872 100644 --- a/acapy_agent/vc/vc_ld/models/web_schemas.py +++ b/acapy_agent/vc/vc_ld/models/web_schemas.py @@ -12,7 +12,7 @@ class ListCredentialsResponse(OpenAPISchema): """Response schema for listing credentials.""" - results = [fields.Nested(VerifiableCredentialSchema)] + results = fields.List(fields.Nested(VerifiableCredentialSchema)) class FetchCredentialResponse(OpenAPISchema): @@ -47,6 +47,18 @@ class VerifyCredentialResponse(OpenAPISchema): results = fields.Nested(PresentationVerificationResultSchema) +class StoreCredentialRequest(OpenAPISchema): + """Request schema for verifying an LDP VP.""" + + verifiableCredential = fields.Nested(VerifiableCredentialSchema) + + +class StoreCredentialResponse(OpenAPISchema): + """Request schema for verifying an LDP VP.""" + + credentialId = fields.Str() + + class ProvePresentationRequest(OpenAPISchema): """Request schema for proving a presentation.""" diff --git a/acapy_agent/vc/vc_ld/tests/test_manager.py b/acapy_agent/vc/vc_ld/tests/test_manager.py index bc6a5b3d4b..24182f5eda 100644 --- a/acapy_agent/vc/vc_ld/tests/test_manager.py +++ b/acapy_agent/vc/vc_ld/tests/test_manager.py @@ -331,7 +331,7 @@ async def test_store( self.vc.issuer = did.did self.options.proof_type = Ed25519Signature2018.signature_type cred = await self.manager.issue(self.vc, self.options) - await self.manager.store_credential(cred, self.options, TEST_UUID) + await self.manager.store_credential(cred, TEST_UUID) async with self.profile.session() as session: holder = session.inject(VCHolder) record = await holder.retrieve_credential_by_id(record_id=TEST_UUID) diff --git a/scenarios/examples/vc_holder/docker-compose.yml b/scenarios/examples/vc_holder/docker-compose.yml new file mode 100644 index 0000000000..ac9d0ef7a4 --- /dev/null +++ b/scenarios/examples/vc_holder/docker-compose.yml @@ -0,0 +1,45 @@ + services: + agency: + image: acapy-test + ports: + - "3001:3001" + environment: + RUST_LOG: 'aries-askar::log::target=error' + command: > + start + --label Agency + --inbound-transport http 0.0.0.0 3000 + --outbound-transport http + --endpoint http://agency:3000 + --admin 0.0.0.0 3001 + --admin-insecure-mode + --no-ledger + --wallet-type askar + --wallet-name alice + --wallet-key insecure + --auto-provision + --log-level debug + --debug-webhooks + --multitenant + --multitenant-admin + --jwt-secret insecure + --multitenancy-config wallet_type=single-wallet-askar key_derivation_method=RAW + healthcheck: + test: curl -s -o /dev/null -w '%{http_code}' "http://localhost:3001/status/live" | grep "200" > /dev/null + start_period: 30s + interval: 7s + timeout: 5s + retries: 5 + + example: + container_name: controller + build: + context: ../.. + environment: + - AGENCY=http://agency:3001 + volumes: + - ./example.py:/usr/src/app/example.py:ro,z + command: python -m example + depends_on: + agency: + condition: service_healthy diff --git a/scenarios/examples/vc_holder/example.py b/scenarios/examples/vc_holder/example.py new file mode 100644 index 0000000000..1e214419f0 --- /dev/null +++ b/scenarios/examples/vc_holder/example.py @@ -0,0 +1,95 @@ +"""Test VC Holder multi-tenancy isolation.""" + +import asyncio +from os import getenv + +from acapy_controller import Controller +from acapy_controller.logging import logging_to_stdout +from acapy_controller.models import CreateWalletResponse +from acapy_controller.protocols import DIDResult + +AGENCY = getenv("AGENCY", "http://agency:3001") + + +async def main(): + """Test Controller protocols.""" + async with Controller(base_url=AGENCY) as agency: + issuer = await agency.post( + "/multitenancy/wallet", + json={ + "label": "Issuer", + "wallet_type": "askar", + }, + response=CreateWalletResponse, + ) + alice = await agency.post( + "/multitenancy/wallet", + json={ + "label": "Alice", + "wallet_type": "askar", + }, + response=CreateWalletResponse, + ) + bob = await agency.post( + "/multitenancy/wallet", + json={ + "label": "Bob", + "wallet_type": "askar", + }, + response=CreateWalletResponse, + ) + + async with ( + Controller( + base_url=AGENCY, wallet_id=alice.wallet_id, subwallet_token=alice.token + ) as alice, + Controller( + base_url=AGENCY, wallet_id=bob.wallet_id, subwallet_token=bob.token + ) as bob, + Controller( + base_url=AGENCY, wallet_id=issuer.wallet_id, subwallet_token=issuer.token + ) as issuer, + ): + public_did = ( + await issuer.post( + "/wallet/did/create", + json={"method": "key", "options": {"key_type": "ed25519"}}, + response=DIDResult, + ) + ).result + assert public_did + cred = await issuer.post( + "/vc/credentials/issue", + json={ + "credential": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], + "id": "http://example.edu/credentials/1872", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21" + }, + "issuer": public_did.did, + "issuanceDate": "2024-12-10T10:00:00Z", + "type": ["VerifiableCredential", "AlumniCredential"], + }, + "options": { + "challenge": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "domain": "example.com", + "proofPurpose": "assertionMethod", + "proofType": "Ed25519Signature2018", + }, + }, + ) + await alice.post( + "/vc/credentials/store", + json={"verifiableCredential": cred["verifiableCredential"]}, + ) + result = await bob.get("/vc/credentials") + assert len(result["results"]) == 0 + + +if __name__ == "__main__": + logging_to_stdout() + asyncio.run(main())