From 58e63e2bd32c3e45446c0b07ee64e813f4899eea Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 7 Sep 2023 16:40:57 +0000 Subject: [PATCH 01/25] feat: devcontainer configuraton for vscode Signed-off-by: Akiff Manji --- .devcontainer/Dockerfile | 15 ++++++++++++++ .devcontainer/devcontainer.json | 28 +++++++++++++++++++++++++ .devcontainer/docker-compose.yml | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000000..5c8f9e58f6 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/devcontainers/python:1-3.8-bookworm + +ENV PYTHONUNBUFFERED 1 + +# [Optional] If your requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + + + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..dd57cbfbfc --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/postgres +{ + "name": "Python 3 & PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, + "ghcr.io/itsmechlark/features/postgresql:1": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or the host. + // "forwardPorts": [5000, 5432], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "pip install --user -r requirements.txt", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000000..f2e9705b07 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + image: postgres:latest + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + postgres-data: From 107604ebbe960f2266cdbc4fa6f3b80170811193 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 7 Sep 2023 16:41:41 +0000 Subject: [PATCH 02/25] feat: hard code digital business card schema Signed-off-by: Akiff Manji --- .../legal_api/services/digital_credentials.py | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index f606fc8814..6f2339db7c 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -27,17 +27,34 @@ class DigitalCredentialsService: """Provides services to do digital credentials using aca-py agent.""" - business_schema = { + # business_schema = { + # 'attributes': [ + # 'legalName', + # 'foundingDate', + # 'taxId', + # 'homeJurisdiction', + # 'legalType', + # 'identifier' + # ], + # 'schema_name': 'business_schema', # do not change schema name. this is the name registered in aca-py agent + # 'schema_version': '1.0.0' # if attributes changes update schema_version to re-register + # } + + digital_business_card_schema = { 'attributes': [ - 'legalName', - 'foundingDate', - 'taxId', - 'homeJurisdiction', - 'legalType', - 'identifier' + 'business_name', + 'company_status', + 'credential_id', + 'identifier', + 'registered_on_dateint', + 'role', + 'cra_business_number', + 'family_name', + 'business_type', + 'given_names', ], - 'schema_name': 'business_schema', # do not change schema name. this is the name registered in aca-py agent - 'schema_version': '1.0.0' # if attributes changes update schema_version to re-register + 'schema_name': 'digital_business_card', + 'schema_version': '1.0.0' } def __init__(self): @@ -46,6 +63,7 @@ def __init__(self): self.api_url = None self.api_key = None + self.api_token = None self.entity_did = None def init_app(self, app): @@ -54,6 +72,7 @@ def init_app(self, app): self.api_url = app.config.get('ACA_PY_ADMIN_API_URL') self.api_key = app.config.get('ACA_PY_ADMIN_API_KEY') + self.api_token = app.config.get('ACA_PY_ADMIN_API_TOKEN') self.entity_did = app.config.get('ACA_PY_ENTITY_DID') with suppress(Exception): self._register_business() @@ -63,22 +82,22 @@ def _register_business(self): # check for the current schema definition. definition = DCDefinition.find_by( credential_type=DCDefinition.CredentialType.business, - schema_name=self.business_schema['schema_name'], - schema_version=self.business_schema['schema_version'] + schema_name=self.digital_business_card_schema['schema_name'], + schema_version=self.digital_business_card_schema['schema_version'] ) if definition: if definition.is_deleted: - raise Exception('Digital Credentials: business_schema is marked as delete, fix it.') # noqa: E501; pylint: disable=broad-exception-raised, line-too-long + raise Exception('Digital Credentials: digital_business_card_schema is marked as delete, fix it.') # noqa: E501; pylint: disable=broad-exception-raised, line-too-long else: # deactivate any existing schema definition before registering new one DCDefinition.deactivate(DCDefinition.CredentialType.business) - schema_id = self._register_schema(self.business_schema) + schema_id = self._register_schema(self.digital_business_card_schema) definition = DCDefinition( credential_type=DCDefinition.CredentialType.business, - schema_name=self.business_schema['schema_name'], - schema_version=self.business_schema['schema_version'], + schema_name=self.digital_business_card_schema['schema_name'], + schema_version=self.digital_business_card_schema['schema_version'], schema_id=schema_id ) definition.save() @@ -110,7 +129,7 @@ def _register_credential_definitions(self, schema_id: str) -> Optional[str]: 'revocation_registry_size': 1000, 'schema_id': schema_id, 'support_revocation': True, - 'tag': 'business_schema' + 'tag': 'Digital Business Card' })) response.raise_for_status() return response.json()['credential_definition_id'] @@ -165,5 +184,5 @@ def issue_credential(self, def _get_headers(self) -> dict: return { 'Content-Type': 'application/json', - 'X-API-KEY': self.api_key + 'Authorization': f'Bearer {self.api_token}' } From 3793db1906e952fd7ca585b9df745b173ef367d0 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 7 Sep 2023 16:41:41 +0000 Subject: [PATCH 03/25] feat: hard code digital business card schema Signed-off-by: Akiff Manji --- legal-api/src/legal_api/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/legal-api/src/legal_api/config.py b/legal-api/src/legal_api/config.py index 283dc81b81..caab458b69 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -146,6 +146,7 @@ class _Config(): # pylint: disable=too-few-public-methods ACA_PY_ADMIN_API_URL = os.getenv('ACA_PY_ADMIN_API_URL') ACA_PY_ADMIN_API_KEY = os.getenv('ACA_PY_ADMIN_API_KEY') + ACA_PY_ADMIN_API_TOKEN = os.getenv('ACA_PY_ADMIN_API_TOKEN') ACA_PY_ENTITY_DID = os.getenv('ACA_PY_ENTITY_DID') TESTING = False From d0c51ceb54550f0a7c910dffa1bcfb3c4e7916ce Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 11 Sep 2023 23:49:02 +0000 Subject: [PATCH 04/25] feat: issue credentials through Traction tenant Signed-off-by: Akiff Manji --- legal-api/src/legal_api/config.py | 8 +- .../business/business_digital_credentials.py | 37 +++-- .../legal_api/services/digital_credentials.py | 129 +++++++++++------- 3 files changed, 106 insertions(+), 68 deletions(-) diff --git a/legal-api/src/legal_api/config.py b/legal-api/src/legal_api/config.py index caab458b69..d4a00c0f14 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -144,10 +144,10 @@ class _Config(): # pylint: disable=too-few-public-methods NAICS_API_URL = os.getenv('NAICS_API_URL', 'https://NAICS_API_URL/api/v2/naics') - ACA_PY_ADMIN_API_URL = os.getenv('ACA_PY_ADMIN_API_URL') - ACA_PY_ADMIN_API_KEY = os.getenv('ACA_PY_ADMIN_API_KEY') - ACA_PY_ADMIN_API_TOKEN = os.getenv('ACA_PY_ADMIN_API_TOKEN') - ACA_PY_ENTITY_DID = os.getenv('ACA_PY_ENTITY_DID') + # digital credential configuration values + TRACTION_API_URL = os.getenv('TRACTION_API_URL') + TRACTION_API_TOKEN = os.getenv('TRACTION_API_TOKEN') + TRACTION_PUBLIC_DID = os.getenv('TRACTION_PUBLIC_DID') TESTING = False DEBUG = False diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index 3d9f040b60..a77ecfc762 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -148,28 +148,44 @@ def _get_data_for_credential(credential_type: DCDefinition.CredentialType, busin if credential_type == DCDefinition.CredentialType.business: return [ { - 'name': 'legalName', + 'name': 'credential_id', + 'value': '' + }, + { + 'name': 'identifier', + 'value': business.identifier + }, + { + 'name': 'business_name', 'value': business.legal_name }, { - 'name': 'foundingDate', - 'value': business.founding_date.isoformat() + 'name': 'business_type', + 'value': business.legal_type }, { - 'name': 'taxId', + 'name': 'cra_business_number', 'value': business.tax_id or '' }, { - 'name': 'homeJurisdiction', - 'value': 'BC' # for corp types that are not -xpro, the jurisdiction is BC + 'name': 'registered_on_dateint', + 'value': business.founding_date.isoformat() }, { - 'name': 'legalType', - 'value': business.legal_type + 'name': 'company_status', + 'value': business.state }, { - 'name': 'identifier', - 'value': business.identifier + 'name': 'family_name', + 'value': '' + }, + { + 'name': 'given_names', + 'value': '' + }, + { + 'name': 'role', + 'value': '' } ] @@ -178,7 +194,6 @@ def _get_data_for_credential(credential_type: DCDefinition.CredentialType, busin @bp_dc.route('/topic/', methods=['POST'], strict_slashes=False) @cross_origin(origin='*') -@jwt.requires_auth def webhook_notification(topic_name: str): """To receive notification from aca-py admin api.""" json_input = request.get_json() diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 6f2339db7c..db6c446e67 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -27,20 +27,7 @@ class DigitalCredentialsService: """Provides services to do digital credentials using aca-py agent.""" - # business_schema = { - # 'attributes': [ - # 'legalName', - # 'foundingDate', - # 'taxId', - # 'homeJurisdiction', - # 'legalType', - # 'identifier' - # ], - # 'schema_name': 'business_schema', # do not change schema name. this is the name registered in aca-py agent - # 'schema_version': '1.0.0' # if attributes changes update schema_version to re-register - # } - - digital_business_card_schema = { + business_schema = { 'attributes': [ 'business_name', 'company_status', @@ -53,8 +40,8 @@ class DigitalCredentialsService: 'business_type', 'given_names', ], - 'schema_name': 'digital_business_card', - 'schema_version': '1.0.0' + 'schema_name': 'digital_business_card', # do not change schema name. this is the name registered in aca-py agent + 'schema_version': '1.0.0' # if attributes changes update schema_version to re-register } def __init__(self): @@ -62,74 +49,110 @@ def __init__(self): self.app = None self.api_url = None - self.api_key = None self.api_token = None - self.entity_did = None + self.public_did = None def init_app(self, app): """Initialize digital credentials using aca-py agent.""" self.app = app - self.api_url = app.config.get('ACA_PY_ADMIN_API_URL') - self.api_key = app.config.get('ACA_PY_ADMIN_API_KEY') - self.api_token = app.config.get('ACA_PY_ADMIN_API_TOKEN') - self.entity_did = app.config.get('ACA_PY_ENTITY_DID') + self.api_url = app.config.get('TRACTION_API_URL') + self.api_token = app.config.get('TRACTION_API_TOKEN') + self.public_did = app.config.get('TRACTION_PUBLIC_DID') with suppress(Exception): - self._register_business() + self._register_business_definition() - def _register_business(self): - """Register business schema and credential definition.""" - # check for the current schema definition. + def _register_business_definition(self): + """Publish Business schema and credential definition and save a Business definition.""" + # check for the current Business definition. definition = DCDefinition.find_by( credential_type=DCDefinition.CredentialType.business, - schema_name=self.digital_business_card_schema['schema_name'], - schema_version=self.digital_business_card_schema['schema_version'] + schema_name=self.business_schema['schema_name'], + schema_version=self.business_schema['schema_version'] ) - if definition: - if definition.is_deleted: - raise Exception('Digital Credentials: digital_business_card_schema is marked as delete, fix it.') # noqa: E501; pylint: disable=broad-exception-raised, line-too-long - else: - # deactivate any existing schema definition before registering new one + if definition and not definition.is_deleted: + # deactivate any existing Business definition before creating new one DCDefinition.deactivate(DCDefinition.CredentialType.business) - schema_id = self._register_schema(self.digital_business_card_schema) - definition = DCDefinition( - credential_type=DCDefinition.CredentialType.business, - schema_name=self.digital_business_card_schema['schema_name'], - schema_version=self.digital_business_card_schema['schema_version'], - schema_id=schema_id - ) - definition.save() + # look for a published schema first, if it's not there then register one. + schema_id = self._get_schema(self.business_schema) # TODO: This should look up the last updated definition in Traction storage + if not schema_id: + schema_id = self._publish_schema(self.business_schema) + # create a new definition and add the new schema_id + definition = DCDefinition( + credential_type=DCDefinition.CredentialType.business, + schema_name=self.business_schema['schema_name'], + schema_version=self.business_schema['schema_version'], + schema_id=schema_id + ) + + # look for a published credential definition first, if it's not there then register one. if not definition.credential_definition_id: - definition.credential_definition_id = self._register_credential_definitions(definition.schema_id) - definition.save() + schema_id = definition.schema_id + credential_definition_id = self._get_credential_definition(schema_id) # TODO: this should look up the last updated credential definition in Traction storage + if not credential_definition_id: + credential_definition_id = self._publish_credential_definition(schema_id) + + # add the new credential_definition_id + definition.credential_definition_id = credential_definition_id + + # lastly, save the definition + definition.save() - def _register_schema(self, schema: dict) -> Optional[str]: - """Send a schema to the ledger.""" + def _get_schema(self, schema: dict) -> Optional[str]: + """Find a published schema""" + try: + response = requests.get(self.api_url + '/schemas/created', + params={'schema_name': schema['schema_name'], + 'schema_version': schema['schema_version']}, + headers=self._get_headers()) + response.raise_for_status() + return response.json()['schema_ids'][0] + except Exception as err: + self.app.logger.error( + f"Failed to find digital credential schema {schema['schema_name']}:{schema['schema_version']}") + self.app.logger.error(err) + raise err + + def _publish_schema(self, schema: dict) -> Optional[str]: + """Publish a schema onto the ledger.""" try: response = requests.post(self.api_url + '/schemas', headers=self._get_headers(), data=json.dumps(schema)) response.raise_for_status() - return response.json()['schema_id'] + return response.json()[0]['schema_id'] except Exception as err: self.app.logger.error( f"Failed to register digital credential schema {schema['schema_name']}:{schema['schema_version']}") self.app.logger.error(err) raise err + + def _get_credential_definition(self, schema_id: str) -> Optional[str]: + """Find a published credential definition""" + try: + response = requests.get(self.api_url + '/credential-definitions/created', + params={'schema_id': schema_id}, + headers=self._get_headers()) + response.raise_for_status() + return response.json()['credential_definition_ids'][0] + except Exception as err: + self.app.logger.error(f'Failed to find credential definition with schema_id:{schema_id}') + self.app.logger.error(err) + raise err - def _register_credential_definitions(self, schema_id: str) -> Optional[str]: - """Send a credential definition to the ledger.""" + def _publish_credential_definition(self, schema_id: str) -> Optional[str]: + """Publish a credential definition onto the ledger.""" try: response = requests.post(self.api_url + '/credential-definitions', headers=self._get_headers(), data=json.dumps({ - 'revocation_registry_size': 1000, + # 'revocation_registry_size': 1000, 'schema_id': schema_id, - 'support_revocation': True, - 'tag': 'Digital Business Card' + 'support_revocation': False, + 'tag': 'DigitalBusinessCard' })) response.raise_for_status() return response.json()['credential_definition_id'] @@ -168,9 +191,9 @@ def issue_credential(self, '@type': 'issue-credential/1.0/credential-preview', 'attributes': data }, - 'issuer_did': self.entity_did, + 'issuer_did': self.public_did, 'schema_id': definition.schema_id, - 'schema_issuer_did': self.entity_did, + 'schema_issuer_did': self.public_did, 'schema_name': definition.schema_name, 'schema_version': definition.schema_version, 'trace': True From 7b889826c271fe7d4c677e608aaa6517c49f8118 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 12 Sep 2023 17:27:49 +0000 Subject: [PATCH 05/25] refactor: app initialization workflow Signed-off-by: Akiff Manji --- .devcontainer/devcontainer.json | 7 +- legal-api/src/legal_api/config.py | 10 +- .../src/legal_api/models/dc_definition.py | 11 +- .../legal_api/services/digital_credentials.py | 161 +++++++++--------- 4 files changed, 96 insertions(+), 93 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dd57cbfbfc..6d9f05ae90 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,7 @@ "features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, "ghcr.io/itsmechlark/features/postgresql:1": {} - } + }, // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, @@ -24,5 +24,8 @@ // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" + // "remoteUser": "root", + + // Enable this on OSX to add ssh key to agent inside container + "initializeCommand": "find ~/.ssh/ -type f -exec grep -l 'PRIVATE' {} \\; | xargs ssh-add" } diff --git a/legal-api/src/legal_api/config.py b/legal-api/src/legal_api/config.py index d4a00c0f14..ed13c9c9b7 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -144,10 +144,16 @@ class _Config(): # pylint: disable=too-few-public-methods NAICS_API_URL = os.getenv('NAICS_API_URL', 'https://NAICS_API_URL/api/v2/naics') - # digital credential configuration values + # Digital Credential configuration values TRACTION_API_URL = os.getenv('TRACTION_API_URL') TRACTION_API_TOKEN = os.getenv('TRACTION_API_TOKEN') - TRACTION_PUBLIC_DID = os.getenv('TRACTION_PUBLIC_DID') + TRACTION_PUBLIC_SCHEMA_DID = os.getenv('TRACTION_PUBLIC_SCHEMA_DID') + TRACTION_PUBLIC_ISSUER_DID = os.getenv('TRACTION_PUBLIC_ISSUER_DID') + + BUSINESS_SCHEMA_NAME = os.getenv('BUSINESS_SCHEMA_NAME') + BUSINESS_SCHEMA_VERSION = os.getenv('BUSINESS_SCHEMA_VERSION') + BUSINESS_SCHEMA_ID = os.getenv('BUSINESS_SCHEMA_ID') + BUSINESS_CRED_DEF_ID = os.getenv('BUSINESS_CRED_DEF_ID') TESTING = False DEBUG = False diff --git a/legal-api/src/legal_api/models/dc_definition.py b/legal-api/src/legal_api/models/dc_definition.py index 0094c8ff43..11158b90d7 100644 --- a/legal-api/src/legal_api/models/dc_definition.py +++ b/legal-api/src/legal_api/models/dc_definition.py @@ -83,14 +83,15 @@ def find_by_credential_type(cls, credential_type: CredentialType) -> DCDefinitio @classmethod def find_by(cls, credential_type: CredentialType, - schema_name: str, - schema_version: str) -> DCDefinition: + schema_id: str, + credential_definition_id: str, + ) -> DCDefinition: """Return the digital credential definition matching the filter.""" query = db.session.query(DCDefinition). \ + filter(DCDefinition.is_deleted == False). \ filter(DCDefinition.credential_type == credential_type). \ - filter(DCDefinition.schema_name == schema_name). \ - filter(DCDefinition.schema_version == schema_version) - + filter(DCDefinition.schema_id == schema_id). \ + filter(DCDefinition.credential_definition_id == credential_definition_id) return query.one_or_none() @classmethod diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index db6c446e67..1addcb3940 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -50,7 +50,13 @@ def __init__(self): self.api_url = None self.api_token = None - self.public_did = None + self.public_schema_did = None + self.public_issuer_did = None + + self.business_schema_name = None + self.business_schema_version = None + self.business_schema_id = None + self.business_cred_def_id = None def init_app(self, app): """Initialize digital credentials using aca-py agent.""" @@ -58,106 +64,93 @@ def init_app(self, app): self.api_url = app.config.get('TRACTION_API_URL') self.api_token = app.config.get('TRACTION_API_TOKEN') - self.public_did = app.config.get('TRACTION_PUBLIC_DID') + self.public_schema_did = app.config.get('TRACTION_PUBLIC_SCHEMA_DID') + self.public_issuer_did = app.config.get('TRACTION_PUBLIC_ISSUER_DID') + + self.business_schema_name = app.config.get('BUSINESS_SCHEMA_NAME') + self.business_schema_version = app.config.get('BUSINESS_SCHEMA_VERSION') + self.business_schema_id = app.config.get('BUSINESS_SCHEMA_ID') + self.business_cred_def_id = app.config.get('BUSINESS_CRED_DEF_ID') + with suppress(Exception): self._register_business_definition() def _register_business_definition(self): - """Publish Business schema and credential definition and save a Business definition.""" - # check for the current Business definition. - definition = DCDefinition.find_by( - credential_type=DCDefinition.CredentialType.business, - schema_name=self.business_schema['schema_name'], - schema_version=self.business_schema['schema_version'] - ) - - if definition and not definition.is_deleted: - # deactivate any existing Business definition before creating new one - DCDefinition.deactivate(DCDefinition.CredentialType.business) - - # look for a published schema first, if it's not there then register one. - schema_id = self._get_schema(self.business_schema) # TODO: This should look up the last updated definition in Traction storage - if not schema_id: - schema_id = self._publish_schema(self.business_schema) - - # create a new definition and add the new schema_id - definition = DCDefinition( - credential_type=DCDefinition.CredentialType.business, - schema_name=self.business_schema['schema_name'], - schema_version=self.business_schema['schema_version'], - schema_id=schema_id - ) - - # look for a published credential definition first, if it's not there then register one. - if not definition.credential_definition_id: - schema_id = definition.schema_id - credential_definition_id = self._get_credential_definition(schema_id) # TODO: this should look up the last updated credential definition in Traction storage - if not credential_definition_id: - credential_definition_id = self._publish_credential_definition(schema_id) - - # add the new credential_definition_id - definition.credential_definition_id = credential_definition_id - - # lastly, save the definition - definition.save() - - def _get_schema(self, schema: dict) -> Optional[str]: - """Find a published schema""" try: - response = requests.get(self.api_url + '/schemas/created', - params={'schema_name': schema['schema_name'], - 'schema_version': schema['schema_version']}, - headers=self._get_headers()) - response.raise_for_status() - return response.json()['schema_ids'][0] + if (self.business_schema_id is None): + self.app.logger.error(f'Environment variable: BUSINESS_SCHEMA_ID must be configured') + raise Exception(f'Environment variable: BUSINESS_SCHEMA_ID must be configured') + + if (self.business_cred_def_id is None): + self.app.logger.error(f'Environment variable: BUSINESS_CRED_DEF_ID must be configured') + raise Exception(f'Environment variable: BUSINESS_CRED_DEF_ID must be configured') + + """Fetch schema and credential definition and save a Business definition.""" + # Check for the current Business definition. + definition = DCDefinition.find_by( + credential_type=DCDefinition.CredentialType.business, + schema_id=self.business_schema_id, + credential_definition_id=self.business_cred_def_id + ) + + if definition and not definition.is_deleted: + # Deactivate any existing Business definition before creating new one + DCDefinition.deactivate(DCDefinition.CredentialType.business) + + ### + # The following just a sanity check to make sure the schema and credential definition are stored in Traction tenant + # These calls also include a ledger lookup to see if the schema and credential definition are published + ### + + # Look for a schema first, and copy it into the Traction tenant if it's not there + schema_id = self._fetch_schema(self.business_schema_id) + if not schema_id: + raise Exception(f'Schema with id:{self.business_schema_id} must be available in Traction tenant storage') + + # Look for a published credential definition first, and copy it into the Traction tenant if it's not there + credential_definition_id = self._fetch_credential_definition(self.business_cred_def_id) + if not credential_definition_id: + raise Exception(f'Credential Definition with id:{self.business_cred_def_id} must be avaible in Traction tenant storage') + + # Create a new definition and add the new schema_id + definition = DCDefinition( + credential_type=DCDefinition.CredentialType.business, + schema_name=self.business_schema_name, + schema_version=self.business_schema_version, + schema_id=schema_id, + credential_definition_id=credential_definition_id + ) + # Lastly, save the definition + definition.save() except Exception as err: - self.app.logger.error( - f"Failed to find digital credential schema {schema['schema_name']}:{schema['schema_version']}") self.app.logger.error(err) - raise err + return None - def _publish_schema(self, schema: dict) -> Optional[str]: - """Publish a schema onto the ledger.""" + def _fetch_schema(self, schema_id: str) -> Optional[str]: + """Find a schema in Traction storage""" try: - response = requests.post(self.api_url + '/schemas', - headers=self._get_headers(), - data=json.dumps(schema)) - response.raise_for_status() - return response.json()[0]['schema_id'] - except Exception as err: - self.app.logger.error( - f"Failed to register digital credential schema {schema['schema_name']}:{schema['schema_version']}") - self.app.logger.error(err) - raise err - - def _get_credential_definition(self, schema_id: str) -> Optional[str]: - """Find a published credential definition""" - try: - response = requests.get(self.api_url + '/credential-definitions/created', + response = requests.get(self.api_url + '/schema-storage', params={'schema_id': schema_id}, headers=self._get_headers()) response.raise_for_status() - return response.json()['credential_definition_ids'][0] + first_or_default = next((x for x in response.json()['results'] if x['schema_id'] == schema_id), None) + return first_or_default['schema_id'] if first_or_default else None except Exception as err: - self.app.logger.error(f'Failed to find credential definition with schema_id:{schema_id}') + self.app.logger.error(f"Failed to fetch schema with id:{schema_id} from Traction tenant storage") self.app.logger.error(err) raise err - def _publish_credential_definition(self, schema_id: str) -> Optional[str]: - """Publish a credential definition onto the ledger.""" + def _fetch_credential_definition(self, cred_def_id: str) -> Optional[str]: + """Find a published credential definition""" try: - response = requests.post(self.api_url + '/credential-definitions', - headers=self._get_headers(), - data=json.dumps({ - # 'revocation_registry_size': 1000, - 'schema_id': schema_id, - 'support_revocation': False, - 'tag': 'DigitalBusinessCard' - })) + response = requests.get(self.api_url + '/credential-definition-storage', + params={'cred_def_id': cred_def_id}, + headers=self._get_headers()) response.raise_for_status() - return response.json()['credential_definition_id'] + first_or_default = next((x for x in response.json()['results'] if x['cred_def_id'] == cred_def_id), None) + return first_or_default['cred_def_id'] if first_or_default else None except Exception as err: - self.app.logger.error(f'Failed to register credential definition schema_id:{schema_id}') + self.app.logger.error(f'Failed to find credential definition with id:{cred_def_id} from Traction tenant storage') self.app.logger.error(err) raise err @@ -191,9 +184,9 @@ def issue_credential(self, '@type': 'issue-credential/1.0/credential-preview', 'attributes': data }, - 'issuer_did': self.public_did, + 'issuer_did': self.public_issuer_did, 'schema_id': definition.schema_id, - 'schema_issuer_did': self.public_did, + 'schema_issuer_did': self.public_schema_did, 'schema_name': definition.schema_name, 'schema_version': definition.schema_version, 'trace': True From ad0fa010b21140a5d41bf90977ccfecdf0fc7992 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 20 Sep 2023 23:38:19 +0000 Subject: [PATCH 06/25] feat: use out-of-band invitation for connecting Signed-off-by: Akiff Manji --- .../v2/business/business_digital_credentials.py | 16 ++++++++++------ .../legal_api/services/digital_credentials.py | 13 ++++++++----- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index a77ecfc762..f9e1f4b875 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -52,7 +52,7 @@ def create_invitation(identifier): return jsonify({'message': 'Unable to create an invitation.'}), HTTPStatus.INTERNAL_SERVER_ERROR connection = DCConnection( - connection_id=invitation['connection_id'], + connection_id=invitation['invitation']['@id'], invitation_url=invitation['invitation_url'], is_active=False, connection_state='invitation', @@ -199,11 +199,15 @@ def webhook_notification(topic_name: str): json_input = request.get_json() try: if topic_name == 'connections': - connection = DCConnection.find_by_connection_id(json_input['connection_id']) - # Trinsic Wallet will send `active` only when it’s used the first time. - # Looking for `response` state to handle it. - if connection and not connection.is_active and json_input['state'] in ('response', 'active'): - connection.connection_state = 'active' + if 'invitation' in json_input and json_input['invitation'] != None: + connection = DCConnection.find_by_connection_id(json_input['invitation']['@id']) + else: + connection = DCConnection.find_by_connection_id(json_input['invitation_msg_id']) + # Using https://didcomm.org/connections/1.0 protocol the final state is 'active' + # Using https://didcomm.org/didexchange/1.0 protocol the final state is 'completed' + if connection and not connection.is_active and json_input['state'] in ('active', 'completed'): + connection.connection_id = json_input['connection_id'] + connection.connection_state = json_input['state'] connection.is_active = True connection.save() elif topic_name == 'issue_credential': diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 1addcb3940..3b9968ddfa 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -80,10 +80,10 @@ def _register_business_definition(self): if (self.business_schema_id is None): self.app.logger.error(f'Environment variable: BUSINESS_SCHEMA_ID must be configured') raise Exception(f'Environment variable: BUSINESS_SCHEMA_ID must be configured') - + if (self.business_cred_def_id is None): self.app.logger.error(f'Environment variable: BUSINESS_CRED_DEF_ID must be configured') - raise Exception(f'Environment variable: BUSINESS_CRED_DEF_ID must be configured') + raise Exception(f'Environment variable: BUSINESS_CRED_DEF_ID must be configured') """Fetch schema and credential definition and save a Business definition.""" # Check for the current Business definition. @@ -120,7 +120,7 @@ def _register_business_definition(self): schema_id=schema_id, credential_definition_id=credential_definition_id ) - # Lastly, save the definition + # Lastly, save the definition definition.save() except Exception as err: self.app.logger.error(err) @@ -157,9 +157,12 @@ def _fetch_credential_definition(self, cred_def_id: str) -> Optional[str]: def create_invitation(self) -> Optional[dict]: """Create a new connection invitation.""" try: - response = requests.post(self.api_url + '/connections/create-invitation', + response = requests.post(self.api_url + '/out-of-band/create-invitation', headers=self._get_headers(), - data={}) + params={'auto_accept': 'true'}, + data=json.dumps({ + 'handshake_protocols': ['https://didcomm.org/connections/1.0'] + })) response.raise_for_status() return response.json() except Exception as err: From 2e75d5f48f26c0d337068f68bef91ff74ad75cd6 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 21 Sep 2023 00:40:15 +0000 Subject: [PATCH 07/25] feat: use v2.0 for issuing credential Signed-off-by: Akiff Manji --- .../business/business_digital_credentials.py | 8 +++--- .../legal_api/services/digital_credentials.py | 28 +++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index f9e1f4b875..690c687780 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -137,7 +137,7 @@ def send_credential(identifier, credential_type): issued_credential = DCIssuedCredential( dc_definition_id=definition.id, dc_connection_id=connection.id, - credential_exchange_id=response['credential_exchange_id'] + credential_exchange_id=response['cred_ex_id'] ) issued_credential.save() @@ -210,9 +210,9 @@ def webhook_notification(topic_name: str): connection.connection_state = json_input['state'] connection.is_active = True connection.save() - elif topic_name == 'issue_credential': - issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['credential_exchange_id']) - if issued_credential and json_input['state'] == 'credential_issued': + elif topic_name == 'issue_credential_v2_0': + issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['cred_ex_id']) + if issued_credential and json_input['state'] in ('credential-issued', 'done'): issued_credential.date_of_issue = datetime.utcnow() issued_credential.is_issued = True issued_credential.save() diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 3b9968ddfa..06b50da732 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -40,8 +40,8 @@ class DigitalCredentialsService: 'business_type', 'given_names', ], - 'schema_name': 'digital_business_card', # do not change schema name. this is the name registered in aca-py agent - 'schema_version': '1.0.0' # if attributes changes update schema_version to re-register + 'schema_name': 'digital_business_card', # do not change schema name. this is the name registered in aca-py agent + 'schema_version': '1.0.0' # if attributes changes update schema_version to re-register } def __init__(self): @@ -176,22 +176,26 @@ def issue_credential(self, comment: str = '') -> Optional[dict]: """Send holder a credential, automating entire flow.""" try: - response = requests.post(self.api_url + '/issue-credential/send', + response = requests.post(self.api_url + '/issue-credential-2.0/send', headers=self._get_headers(), data=json.dumps({ - 'auto_remove': True, + 'auto_remove': 'true', 'comment': comment, 'connection_id': connection_id, - 'cred_def_id': definition.credential_definition_id, - 'credential_proposal': { - '@type': 'issue-credential/1.0/credential-preview', + 'credential_preview': { + '@type': 'issue-credential/2.0/credential-preview', 'attributes': data }, - 'issuer_did': self.public_issuer_did, - 'schema_id': definition.schema_id, - 'schema_issuer_did': self.public_schema_did, - 'schema_name': definition.schema_name, - 'schema_version': definition.schema_version, + 'filter': { + 'indy': { + 'cred_def_id': definition.credential_definition_id, + 'issuer_did': self.public_issuer_did, + 'schema_id': definition.schema_id, + 'schema_issuer_did': self.public_schema_did, + 'schema_name': definition.schema_name, + 'schema_version': definition.schema_version, + } + }, 'trace': True })) response.raise_for_status() From 79a2631fb101222a55a2d8a226ab9ca2b166906a Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 11 Oct 2023 19:08:04 +0000 Subject: [PATCH 08/25] feat: web socket implmentation with flask-socketio Signed-off-by: Akiff Manji --- legal-api/Makefile | 5 +- legal-api/requirements.txt | 14 ++--- legal-api/requirements.txt.1 | 52 ------------------- legal-api/src/legal_api/__init__.py | 3 ++ legal-api/src/legal_api/extensions.py | 9 ++++ .../business/business_digital_credentials.py | 22 +++++--- legal-api/wsgi.py | 3 +- 7 files changed, 40 insertions(+), 68 deletions(-) delete mode 100644 legal-api/requirements.txt.1 create mode 100644 legal-api/src/legal_api/extensions.py diff --git a/legal-api/Makefile b/legal-api/Makefile index 37e2570c84..ea26d5527e 100644 --- a/legal-api/Makefile +++ b/legal-api/Makefile @@ -131,7 +131,10 @@ tag: push ## tag image # COMMANDS - Local # ################################################################################# run: ## Run the project in local - . venv/bin/activate && python -m flask run -p 5000 + . venv/bin/activate && python -m flask run -p 5050 + +run-websockets: ## Run the project in local with websockets + . venv/bin/activate && python -m gunicorn --threads 100 --bind :5050 wsgi ################################################################################# # Self Documenting Commands # diff --git a/legal-api/requirements.txt b/legal-api/requirements.txt index 2290e9f2c3..f582b911e6 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -6,15 +6,16 @@ Flask-Moment==0.11.0 Flask-Pydantic==0.8.0 Flask-SQLAlchemy==2.5.1 Flask-Script==2.0.6 +Flask-SocketIO==5.3.6 Flask==1.1.2 Jinja2==2.11.3 Mako==1.1.4 MarkupSafe==1.1.1 +PyPDF2==1.26.0 SQLAlchemy-Continuum==1.3.13 SQLAlchemy-Utils==0.37.8 SQLAlchemy==1.4.44 Werkzeug==1.0.1 -nest_asyncio alembic==1.7.5 aniso8601==9.0.1 asyncio-nats-client==0.11.4 @@ -31,11 +32,15 @@ ecdsa==0.14.1 expiringdict==1.1.4 flask-jwt-oidc==0.3.0 flask-restx==0.3.0 +git+https://github.com/bcgov/business-schemas.git@2.18.10#egg=registry_schemas gunicorn==20.1.0 +html-sanitizer==1.9.3 idna==2.10 itsdangerous==1.1.0 jsonschema==4.19.0 launchdarkly-server-sdk==7.1.0 +minio==7.0.2 +nest_asyncio protobuf==3.15.8 psycopg2-binary==2.8.6 pyRFC3339==1.1 @@ -48,6 +53,7 @@ python-dotenv==0.17.1 python-editor==1.0.4 python-jose==3.2.0 pytz==2021.1 +reportlab==3.6.12 requests==2.25.1 rsa==4.7.2 semver==2.13.0 @@ -55,9 +61,3 @@ sentry-sdk==1.20.0 six==1.15.0 strict-rfc3339==0.7 urllib3==1.26.11 -minio==7.0.2 -PyPDF2==1.26.0 -reportlab==3.6.12 -html-sanitizer==1.9.3 -git+https://github.com/bcgov/business-schemas.git@2.18.13#egg=registry_schemas - diff --git a/legal-api/requirements.txt.1 b/legal-api/requirements.txt.1 deleted file mode 100644 index 167cdacaab..0000000000 --- a/legal-api/requirements.txt.1 +++ /dev/null @@ -1,52 +0,0 @@ -Babel==2.9.1 -Flask-Babel==2.0.0 -Flask-Migrate==3.1.0 -Flask-Moment==1.0.2 -Flask-SQLAlchemy==2.5.1 -Flask-Script==2.0.6 -Flask==2.0.1 -Jinja2==3.0.1 -Mako==1.1.5 -MarkupSafe==2.0.1 -SQLAlchemy-Continuum==1.3.11 -SQLAlchemy-Utils==0.37.8 -SQLAlchemy==1.4.23 -Werkzeug==2.0.1 -alembic==1.7.3 -aniso8601==9.0.1 -asyncio-nats-client==0.11.4 -asyncio-nats-streaming==0.4.0 -attrs==21.2.0 -blinker==1.4 -cachelib==0.3.0 -certifi==2021.5.30 -charset-normalizer==2.0.6 -click==8.0.1 -datedelta==1.3 -dpath==2.0.5 -ecdsa==0.17.0 -expiringdict==1.1.4 -flask-jwt-oidc==0.3.0 -flask-restx==0.5.1 -gunicorn==20.1.0 -idna==3.2 -itsdangerous==2.0.1 -jsonschema==3.2.0 -launchdarkly-server-sdk==7.2.0 -minio==7.1.0 -protobuf==3.18.0 -psycopg2-binary==2.9.1 -pyRFC3339==1.1 -pyasn1==0.4.8 -pycountry==20.7.3 -pyrsistent==0.18.0 -python-dotenv==0.19.0 -python-jose==3.3.0 -pytz==2021.1 -requests==2.26.0 -rsa==4.7.2 -semver==2.13.0 -sentry-sdk==1.4.0 -six==1.16.0 -strict-rfc3339==0.7 -urllib3==1.26.6 diff --git a/legal-api/src/legal_api/__init__.py b/legal-api/src/legal_api/__init__.py index e0d2fa6474..858da23ddf 100644 --- a/legal-api/src/legal_api/__init__.py +++ b/legal-api/src/legal_api/__init__.py @@ -32,6 +32,7 @@ from legal_api.utils.auth import jwt from legal_api.utils.logging import setup_logging from legal_api.utils.run_version import get_run_version +from legal_api.extensions import socketio # noqa: I003; the sentry import creates a bad line count in isort setup_logging(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'logging.conf')) # important to do this first @@ -67,6 +68,8 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): register_shellcontext(app) + socketio.init_app(app, cors_allowed_origins='*') + return app diff --git a/legal-api/src/legal_api/extensions.py b/legal-api/src/legal_api/extensions.py new file mode 100644 index 0000000000..0c904db60d --- /dev/null +++ b/legal-api/src/legal_api/extensions.py @@ -0,0 +1,9 @@ +from flask import current_app +from flask_socketio import SocketIO + +socketio = SocketIO() + + +@socketio.on('connect') +def on_connect(): + current_app.logger.debug(f"Socket connected to client") diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index 690c687780..1463b83334 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -13,15 +13,18 @@ # limitations under the License. """API endpoints for managing an Digital Credentials resource.""" +import json from datetime import datetime from http import HTTPStatus from flask import Blueprint, current_app, jsonify, request from flask_cors import cross_origin +from flask_socketio import emit from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential from legal_api.services import digital_credentials from legal_api.utils.auth import jwt +from legal_api.extensions import socketio from .bp import bp @@ -60,23 +63,26 @@ def create_invitation(identifier): ) connection.save() - return jsonify({'invitationUrl': connection.invitation_url}), HTTPStatus.OK + return jsonify(connection.json), HTTPStatus.OK -@bp.route('//digitalCredentials/connection', methods=['GET', 'OPTIONS'], strict_slashes=False) +@bp.route('//digitalCredentials/connections', methods=['GET', 'OPTIONS'], strict_slashes=False) @cross_origin(origin='*') @jwt.requires_auth -def get_active_connection(identifier): +def get_connections(identifier): """Get active connection for this business.""" business = Business.find_by_identifier(identifier) if not business: return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND - connection = DCConnection.find_active_by(business_id=business.id) - if not connection: - return jsonify({'message': 'No active connection found.'}), HTTPStatus.NOT_FOUND + connections = DCConnection.find_by(business_id=business.id) + if len(connections) == 0: + return jsonify({'connections': []}), HTTPStatus.OK - return jsonify(connection.json), HTTPStatus.OK + response = [] + for connection in connections: + response.append(connection.json) + return jsonify({'connections': response}), HTTPStatus.OK @bp.route('//digitalCredentials', methods=['GET', 'OPTIONS'], strict_slashes=False) @@ -210,12 +216,14 @@ def webhook_notification(topic_name: str): connection.connection_state = json_input['state'] connection.is_active = True connection.save() + socketio.emit('connections', connection.json) elif topic_name == 'issue_credential_v2_0': issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['cred_ex_id']) if issued_credential and json_input['state'] in ('credential-issued', 'done'): issued_credential.date_of_issue = datetime.utcnow() issued_credential.is_issued = True issued_credential.save() + socketio.emit('issue_credential_v2_0', issued_credential.json) except Exception as err: current_app.logger.error(err) raise err diff --git a/legal-api/wsgi.py b/legal-api/wsgi.py index 5d42ecd85f..93c38f48df 100755 --- a/legal-api/wsgi.py +++ b/legal-api/wsgi.py @@ -16,10 +16,11 @@ import os from legal_api import create_app +from legal_api.extensions import socketio # Openshift s2i expects a lower case name of application application = create_app() # pylint: disable=invalid-name if __name__ == "__main__": server_port = os.environ.get('PORT', '8080') - application.run(debug=False, port=server_port, host='0.0.0.0') + socketio.run(application, port=server_port, host='0.0.0.0') From fc8edc4f1616e406cad341c15b3f460d91b720cb Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 16 Oct 2023 18:42:34 +0000 Subject: [PATCH 09/25] feat: db migration script to enable revocation Signed-off-by: Akiff Manji --- ...0a5164_add_revocation_to_dc_credentials.py | 26 +++++++++++++++++++ .../legal_api/models/dc_issued_credential.py | 6 ++++- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 legal-api/migrations/versions/6b65b40a5164_add_revocation_to_dc_credentials.py diff --git a/legal-api/migrations/versions/6b65b40a5164_add_revocation_to_dc_credentials.py b/legal-api/migrations/versions/6b65b40a5164_add_revocation_to_dc_credentials.py new file mode 100644 index 0000000000..5392f271d3 --- /dev/null +++ b/legal-api/migrations/versions/6b65b40a5164_add_revocation_to_dc_credentials.py @@ -0,0 +1,26 @@ +"""add revocation to dc_credentials + +Revision ID: 6b65b40a5164 +Revises: 9a9ac165365e +Create Date: 2023-10-11 22:20:14.023687 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6b65b40a5164' +down_revision = '9a9ac165365e' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('dc_issued_credentials', sa.Column('credential_revocation_id', sa.String(length=10), nullable=True)) + op.add_column('dc_issued_credentials', sa.Column('revocation_registry_id', sa.String(length=200), nullable=True)) + + +def downgrade(): + op.drop_column('dc_issued_credentials', 'credential_revocation_id') + op.drop_column('dc_issued_credentials', 'revocation_registry_id') diff --git a/legal-api/src/legal_api/models/dc_issued_credential.py b/legal-api/src/legal_api/models/dc_issued_credential.py index ac56d1296e..3189d617d6 100644 --- a/legal-api/src/legal_api/models/dc_issued_credential.py +++ b/legal-api/src/legal_api/models/dc_issued_credential.py @@ -35,6 +35,8 @@ class DCIssuedCredential(db.Model): # pylint: disable=too-many-instance-attribu date_of_issue = db.Column('date_of_issue', db.DateTime(timezone=True)) is_revoked = db.Column('is_revoked', db.Boolean, default=False) + credential_revocation_id = db.Column('credential_revocation_id', db.String(10)) + revocation_registry_id = db.Column('revocation_registry_id', db.String(200)) @property def json(self): @@ -46,7 +48,9 @@ def json(self): 'credentialExchangeId': self.credential_exchange_id, 'isIssued': self.is_issued, 'dateOfIssue': self.date_of_issue.isoformat(), - 'isRevoked': self.is_revoked + 'isRevoked': self.is_revoked, + 'credentialRevocationId': self.credential_revocation_id, + 'revocationRegistryId': self.revocation_registry_id } return dc_issued_credential From 4fe84063a4eca01a641c838a58a90f4fd312f5bc Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 18 Oct 2023 01:20:54 +0000 Subject: [PATCH 10/25] feat: revocation endpoint Signed-off-by: Akiff Manji --- .../8148a25d695e_change_field_type.py | 28 +++++ .../legal_api/models/dc_issued_credential.py | 14 ++- .../business/business_digital_credentials.py | 112 ++++++++++++------ .../legal_api/services/digital_credentials.py | 23 +++- 4 files changed, 136 insertions(+), 41 deletions(-) create mode 100644 legal-api/migrations/versions/8148a25d695e_change_field_type.py diff --git a/legal-api/migrations/versions/8148a25d695e_change_field_type.py b/legal-api/migrations/versions/8148a25d695e_change_field_type.py new file mode 100644 index 0000000000..cd01a0a053 --- /dev/null +++ b/legal-api/migrations/versions/8148a25d695e_change_field_type.py @@ -0,0 +1,28 @@ +"""change field type + +Revision ID: 8148a25d695e +Revises: 6b65b40a5164 +Create Date: 2023-10-17 01:05:30.977475 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8148a25d695e' +down_revision = '6b65b40a5164' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column('dc_issued_credentials', 'credential_id', + existing_type=sa.String(length=100), + type_=sa.String(length=10)) + + +def downgrade(): + op.alter_column('dc_issued_credentials', 'credential_id', + existing_type=sa.String(length=10), + type_=sa.String(length=100)) diff --git a/legal-api/src/legal_api/models/dc_issued_credential.py b/legal-api/src/legal_api/models/dc_issued_credential.py index 3189d617d6..beac6a3eae 100644 --- a/legal-api/src/legal_api/models/dc_issued_credential.py +++ b/legal-api/src/legal_api/models/dc_issued_credential.py @@ -30,7 +30,7 @@ class DCIssuedCredential(db.Model): # pylint: disable=too-many-instance-attribu dc_connection_id = db.Column('dc_connection_id', db.Integer, db.ForeignKey('dc_connections.id')) credential_exchange_id = db.Column('credential_exchange_id', db.String(100)) - credential_id = db.Column('credential_id', db.String(100)) # not in use + credential_id = db.Column('credential_id', db.String(10)) is_issued = db.Column('is_issued', db.Boolean, default=False) date_of_issue = db.Column('date_of_issue', db.DateTime(timezone=True)) @@ -46,6 +46,7 @@ def json(self): 'dcDefinitionId': self.dc_definition_id, 'dcConnectionId': self.dc_connection_id, 'credentialExchangeId': self.credential_exchange_id, + "credentialId": self.credential_id, 'isIssued': self.is_issued, 'dateOfIssue': self.date_of_issue.isoformat(), 'isRevoked': self.is_revoked, @@ -69,13 +70,22 @@ def find_by_id(cls, dc_issued_credential_id: str) -> DCIssuedCredential: @classmethod def find_by_credential_exchange_id(cls, credential_exchange_id: str) -> DCIssuedCredential: - """Return the issued credential matching the id.""" + """Return the issued credential matching the credential exchange id.""" dc_issued_credential = None if credential_exchange_id: dc_issued_credential = cls.query. \ filter(DCIssuedCredential.credential_exchange_id == credential_exchange_id).one_or_none() return dc_issued_credential + @classmethod + def find_by_credential_id(cls, credential_id: str) -> DCIssuedCredential: + """Return the issued credential matching the credential id.""" + dc_issued_credential = None + if credential_id: + dc_issued_credential = cls.query. \ + filter(DCIssuedCredential.credential_id == credential_id).one_or_none() + return dc_issued_credential + @classmethod def find_by(cls, dc_definition_id: int = None, diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index 1463b83334..2692c25d9f 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -45,7 +45,6 @@ def create_invitation(identifier): if active_connection: return jsonify({'message': f'{identifier} already have an active connection.'}), HTTPStatus.UNPROCESSABLE_ENTITY - # check whether this business has an existing connection which is not active connections = DCConnection.find_by(business_id=business.id, connection_state='invitation') if connections: connection = connections[0] @@ -108,6 +107,7 @@ def get_issued_credentials(identifier): response.append({ 'legalName': business.legal_name, 'credentialType': definition.credential_type.name, + 'credentialId': issued_credential.credential_id, 'isIssued': issued_credential.is_issued, 'dateOfIssue': issued_credential.date_of_issue.isoformat() if issued_credential.date_of_issue else '', 'isRevoked': issued_credential.is_revoked @@ -143,11 +143,82 @@ def send_credential(identifier, credential_type): issued_credential = DCIssuedCredential( dc_definition_id=definition.id, dc_connection_id=connection.id, - credential_exchange_id=response['cred_ex_id'] + credential_exchange_id=response['cred_ex_id'], + # TODO: Add a real ID + credential_id='123456' ) issued_credential.save() - return jsonify({'message': 'Issue Credential is initiated.'}), HTTPStatus.OK + return jsonify({'message': 'Credential offer has been sent.'}), HTTPStatus.OK + + +@bp.route('//digitalCredentials//revoke', methods=['POST'], strict_slashes=False) +@cross_origin(origin='*') +@jwt.requires_auth +def revoke_credential(identifier, credential_id): + """Revoke a credential.""" + business = Business.find_by_identifier(identifier) + if not business: + return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND + + connection = DCConnection.find_active_by(business_id=business.id) + if not connection: + return jsonify({'message': f'{identifier} active connection not found.'}), HTTPStatus.NOT_FOUND + + # TODO: Use a real ID + issued_credential = DCIssuedCredential.find_by_credential_id(credential_id='123456') + if not issued_credential or issued_credential.is_revoked: + return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND + + revoked = digital_credentials.revoke_credential(connection.connection_id, + issued_credential.credential_revocation_id, + issued_credential.revocation_registry_id) + if revoked == None: + return jsonify({'message': 'Failed to revoke credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR + + issued_credential.is_revoked = True + issued_credential.save() + return jsonify({'message': 'Credential has been revoked.'}), HTTPStatus.OK + + +@bp_dc.route('/topic/', methods=['POST'], strict_slashes=False) +@cross_origin(origin='*') +def webhook_notification(topic_name: str): + """To receive notification from aca-py admin api.""" + json_input = request.get_json() + try: + if topic_name == 'connections': + if 'invitation' in json_input and json_input['invitation'] != None: + connection = DCConnection.find_by_connection_id(json_input['invitation']['@id']) + else: + connection = DCConnection.find_by_connection_id(json_input['invitation_msg_id']) + # Using https://didcomm.org/connections/1.0 protocol the final state is 'active' + # Using https://didcomm.org/didexchange/1.0 protocol the final state is 'completed' + if connection and not connection.is_active and json_input['state'] in ('active', 'completed'): + connection.connection_id = json_input['connection_id'] + connection.connection_state = json_input['state'] + connection.is_active = True + connection.save() + socketio.emit('connections', connection.json) + elif topic_name == 'issuer_cred_rev': + issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['cred_ex_id']) + if issued_credential and json_input['state'] == 'issued': + issued_credential.credential_revocation_id=json_input['cred_rev_id'], + issued_credential.revocation_registry_id=json_input['rev_reg_id'] + issued_credential.save() + elif topic_name == 'issue_credential_v2_0': + # TODO: We want to deactivate the connection once the credential is issued + issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['cred_ex_id']) + if issued_credential and json_input['state'] == 'done': + issued_credential.date_of_issue = datetime.utcnow() + issued_credential.is_issued = True + issued_credential.save() + socketio.emit('issue_credential_v2_0', issued_credential.json) + except Exception as err: + current_app.logger.error(err) + raise err + + return jsonify({'message': 'Webhook received.'}), HTTPStatus.OK def _get_data_for_credential(credential_type: DCDefinition.CredentialType, business: Business): @@ -195,37 +266,4 @@ def _get_data_for_credential(credential_type: DCDefinition.CredentialType, busin } ] - return None - - -@bp_dc.route('/topic/', methods=['POST'], strict_slashes=False) -@cross_origin(origin='*') -def webhook_notification(topic_name: str): - """To receive notification from aca-py admin api.""" - json_input = request.get_json() - try: - if topic_name == 'connections': - if 'invitation' in json_input and json_input['invitation'] != None: - connection = DCConnection.find_by_connection_id(json_input['invitation']['@id']) - else: - connection = DCConnection.find_by_connection_id(json_input['invitation_msg_id']) - # Using https://didcomm.org/connections/1.0 protocol the final state is 'active' - # Using https://didcomm.org/didexchange/1.0 protocol the final state is 'completed' - if connection and not connection.is_active and json_input['state'] in ('active', 'completed'): - connection.connection_id = json_input['connection_id'] - connection.connection_state = json_input['state'] - connection.is_active = True - connection.save() - socketio.emit('connections', connection.json) - elif topic_name == 'issue_credential_v2_0': - issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['cred_ex_id']) - if issued_credential and json_input['state'] in ('credential-issued', 'done'): - issued_credential.date_of_issue = datetime.utcnow() - issued_credential.is_issued = True - issued_credential.save() - socketio.emit('issue_credential_v2_0', issued_credential.json) - except Exception as err: - current_app.logger.error(err) - raise err - - return jsonify({'message': 'Webhook received.'}), HTTPStatus.OK + return None \ No newline at end of file diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 06b50da732..41d2df5c44 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -41,7 +41,7 @@ class DigitalCredentialsService: 'given_names', ], 'schema_name': 'digital_business_card', # do not change schema name. this is the name registered in aca-py agent - 'schema_version': '1.0.0' # if attributes changes update schema_version to re-register + 'schema_version': '1.0.0' # if attributes change update schema_version to re-register } def __init__(self): @@ -193,7 +193,7 @@ def issue_credential(self, 'schema_id': definition.schema_id, 'schema_issuer_did': self.public_schema_did, 'schema_name': definition.schema_name, - 'schema_version': definition.schema_version, + 'schema_version': definition.schema_version } }, 'trace': True @@ -204,6 +204,25 @@ def issue_credential(self, self.app.logger.error(err) return None + def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str) -> Optional[dict]: + """Revoke a credential.""" + try: + response = requests.post(self.api_url + '/revocation/revoke', + headers=self._get_headers(), + data=json.dumps({ + 'connection_id': connection_id, + 'cred_rev_id': cred_rev_id, + 'rev_reg_id': rev_reg_id, + 'publish': True, + "notify": True, + "notify_version": "v1_0" + })) + response.raise_for_status() + return response.json() + except Exception as err: + self.app.logger.error(err) + return None + def _get_headers(self) -> dict: return { 'Content-Type': 'application/json', From aec27f21ec7413e723ec357262c093f54a54c8bd Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 19 Oct 2023 02:35:20 +0000 Subject: [PATCH 11/25] feat: replace endpoints Signed-off-by: Akiff Manji --- .../src/legal_api/models/dc_connection.py | 5 +++ .../legal_api/models/dc_issued_credential.py | 5 +++ .../business/business_digital_credentials.py | 36 ++++++++++++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/legal-api/src/legal_api/models/dc_connection.py b/legal-api/src/legal_api/models/dc_connection.py index 06bf725f70..f1e7320c5e 100644 --- a/legal-api/src/legal_api/models/dc_connection.py +++ b/legal-api/src/legal_api/models/dc_connection.py @@ -53,6 +53,11 @@ def save(self): db.session.add(self) db.session.commit() + def delete(self): + """Delete the object from the database immediately.""" + db.session.delete(self) + db.session.commit() + @classmethod def find_by_id(cls, dc_connection_id: str) -> DCConnection: """Return the digital credential connection matching the id.""" diff --git a/legal-api/src/legal_api/models/dc_issued_credential.py b/legal-api/src/legal_api/models/dc_issued_credential.py index beac6a3eae..0dbe593e4b 100644 --- a/legal-api/src/legal_api/models/dc_issued_credential.py +++ b/legal-api/src/legal_api/models/dc_issued_credential.py @@ -60,6 +60,11 @@ def save(self): db.session.add(self) db.session.commit() + def delete(self): + """Delete the object from the database immediately.""" + db.session.delete(self) + db.session.commit() + @classmethod def find_by_id(cls, dc_issued_credential_id: str) -> DCIssuedCredential: """Return the issued credential matching the id.""" diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index 2692c25d9f..2fd416a68b 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -83,6 +83,22 @@ def get_connections(identifier): response.append(connection.json) return jsonify({'connections': response}), HTTPStatus.OK +@bp.route('//digitalCredentials/connection', methods=['DELETE'], strict_slashes=False) +@cross_origin(origin='*') +@jwt.requires_auth +def delete_connection(identifier): + """Delete an active connection for this business.""" + business = Business.find_by_identifier(identifier) + if not business: + return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND + + connection = DCConnection.find_active_by(business_id=business.id) + if not connection: + return jsonify({'message': f'{identifier} active connection not found.'}), HTTPStatus.NOT_FOUND + + connection.delete() + return jsonify({'message': 'Connection has been deleted.'}), HTTPStatus.OK + @bp.route('//digitalCredentials', methods=['GET', 'OPTIONS'], strict_slashes=False) @cross_origin(origin='*') @@ -152,7 +168,7 @@ def send_credential(identifier, credential_type): return jsonify({'message': 'Credential offer has been sent.'}), HTTPStatus.OK -@bp.route('//digitalCredentials//revoke', methods=['POST'], strict_slashes=False) +@bp.route('//digitalCredentials//revoke', methods=['POST'], strict_slashes=False) @cross_origin(origin='*') @jwt.requires_auth def revoke_credential(identifier, credential_id): @@ -181,6 +197,24 @@ def revoke_credential(identifier, credential_id): return jsonify({'message': 'Credential has been revoked.'}), HTTPStatus.OK +@bp.route('//digitalCredentials/', methods=['DELETE'], strict_slashes=False) +@cross_origin(origin='*') +@jwt.requires_auth +def delete_credential(identifier, credential_id): + """Delete a credential.""" + business = Business.find_by_identifier(identifier) + if not business: + return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND + + # TODO: Use a real ID + issued_credential = DCIssuedCredential.find_by_credential_id(credential_id='123456') + if not issued_credential: + return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND + + issued_credential.delete() + return jsonify({'message': 'Credential has been deleted.'}), HTTPStatus.OK + + @bp_dc.route('/topic/', methods=['POST'], strict_slashes=False) @cross_origin(origin='*') def webhook_notification(topic_name: str): From 99e54febdf2bf9b2d94f3832b0843eede0a73f0b Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 19 Oct 2023 15:06:17 +0000 Subject: [PATCH 12/25] chore: fix linting errors Signed-off-by: Akiff Manji --- legal-api/src/legal_api/__init__.py | 2 +- legal-api/src/legal_api/extensions.py | 18 +++++++- .../src/legal_api/models/dc_definition.py | 2 +- .../legal_api/models/dc_issued_credential.py | 2 +- .../business/business_digital_credentials.py | 22 ++++----- .../legal_api/services/digital_credentials.py | 46 +++++++++++-------- 6 files changed, 58 insertions(+), 34 deletions(-) diff --git a/legal-api/src/legal_api/__init__.py b/legal-api/src/legal_api/__init__.py index 858da23ddf..7956f43a9f 100644 --- a/legal-api/src/legal_api/__init__.py +++ b/legal-api/src/legal_api/__init__.py @@ -24,6 +24,7 @@ from registry_schemas.flask import SchemaServices # noqa: I001 from legal_api import config, models +from legal_api.extensions import socketio from legal_api.models import db from legal_api.resources import endpoints from legal_api.schemas import rsbc_schemas @@ -32,7 +33,6 @@ from legal_api.utils.auth import jwt from legal_api.utils.logging import setup_logging from legal_api.utils.run_version import get_run_version -from legal_api.extensions import socketio # noqa: I003; the sentry import creates a bad line count in isort setup_logging(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'logging.conf')) # important to do this first diff --git a/legal-api/src/legal_api/extensions.py b/legal-api/src/legal_api/extensions.py index 0c904db60d..d7cc8f257a 100644 --- a/legal-api/src/legal_api/extensions.py +++ b/legal-api/src/legal_api/extensions.py @@ -1,9 +1,25 @@ +# Copyright © 2022 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Extensions module.""" from flask import current_app from flask_socketio import SocketIO + socketio = SocketIO() @socketio.on('connect') def on_connect(): - current_app.logger.debug(f"Socket connected to client") + """Handle socket connection.""" + current_app.logger.debug('Socket connected to client') diff --git a/legal-api/src/legal_api/models/dc_definition.py b/legal-api/src/legal_api/models/dc_definition.py index 11158b90d7..4e05061a71 100644 --- a/legal-api/src/legal_api/models/dc_definition.py +++ b/legal-api/src/legal_api/models/dc_definition.py @@ -88,7 +88,7 @@ def find_by(cls, ) -> DCDefinition: """Return the digital credential definition matching the filter.""" query = db.session.query(DCDefinition). \ - filter(DCDefinition.is_deleted == False). \ + filter(DCDefinition.is_deleted is False). \ filter(DCDefinition.credential_type == credential_type). \ filter(DCDefinition.schema_id == schema_id). \ filter(DCDefinition.credential_definition_id == credential_definition_id) diff --git a/legal-api/src/legal_api/models/dc_issued_credential.py b/legal-api/src/legal_api/models/dc_issued_credential.py index 0dbe593e4b..70d71dedc7 100644 --- a/legal-api/src/legal_api/models/dc_issued_credential.py +++ b/legal-api/src/legal_api/models/dc_issued_credential.py @@ -46,7 +46,7 @@ def json(self): 'dcDefinitionId': self.dc_definition_id, 'dcConnectionId': self.dc_connection_id, 'credentialExchangeId': self.credential_exchange_id, - "credentialId": self.credential_id, + 'credentialId': self.credential_id, 'isIssued': self.is_issued, 'dateOfIssue': self.date_of_issue.isoformat(), 'isRevoked': self.is_revoked, diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index 2fd416a68b..91fd7913c7 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -13,18 +13,16 @@ # limitations under the License. """API endpoints for managing an Digital Credentials resource.""" -import json from datetime import datetime from http import HTTPStatus from flask import Blueprint, current_app, jsonify, request from flask_cors import cross_origin -from flask_socketio import emit +from legal_api.extensions import socketio from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential from legal_api.services import digital_credentials from legal_api.utils.auth import jwt -from legal_api.extensions import socketio from .bp import bp @@ -83,6 +81,7 @@ def get_connections(identifier): response.append(connection.json) return jsonify({'connections': response}), HTTPStatus.OK + @bp.route('//digitalCredentials/connection', methods=['DELETE'], strict_slashes=False) @cross_origin(origin='*') @jwt.requires_auth @@ -168,7 +167,8 @@ def send_credential(identifier, credential_type): return jsonify({'message': 'Credential offer has been sent.'}), HTTPStatus.OK -@bp.route('//digitalCredentials//revoke', methods=['POST'], strict_slashes=False) +@bp.route('//digitalCredentials//revoke', + methods=['POST'], strict_slashes=False) @cross_origin(origin='*') @jwt.requires_auth def revoke_credential(identifier, credential_id): @@ -185,13 +185,13 @@ def revoke_credential(identifier, credential_id): issued_credential = DCIssuedCredential.find_by_credential_id(credential_id='123456') if not issued_credential or issued_credential.is_revoked: return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND - + revoked = digital_credentials.revoke_credential(connection.connection_id, issued_credential.credential_revocation_id, issued_credential.revocation_registry_id) - if revoked == None: + if revoked is None: return jsonify({'message': 'Failed to revoke credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR - + issued_credential.is_revoked = True issued_credential.save() return jsonify({'message': 'Credential has been revoked.'}), HTTPStatus.OK @@ -222,7 +222,7 @@ def webhook_notification(topic_name: str): json_input = request.get_json() try: if topic_name == 'connections': - if 'invitation' in json_input and json_input['invitation'] != None: + if 'invitation' in json_input and json_input['invitation'] is not None: connection = DCConnection.find_by_connection_id(json_input['invitation']['@id']) else: connection = DCConnection.find_by_connection_id(json_input['invitation_msg_id']) @@ -237,8 +237,8 @@ def webhook_notification(topic_name: str): elif topic_name == 'issuer_cred_rev': issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['cred_ex_id']) if issued_credential and json_input['state'] == 'issued': - issued_credential.credential_revocation_id=json_input['cred_rev_id'], - issued_credential.revocation_registry_id=json_input['rev_reg_id'] + issued_credential.credential_revocation_id = json_input['cred_rev_id'] + issued_credential.revocation_registry_id = json_input['rev_reg_id'] issued_credential.save() elif topic_name == 'issue_credential_v2_0': # TODO: We want to deactivate the connection once the credential is issued @@ -300,4 +300,4 @@ def _get_data_for_credential(credential_type: DCDefinition.CredentialType, busin } ] - return None \ No newline at end of file + return None diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 41d2df5c44..f22985d911 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -40,8 +40,10 @@ class DigitalCredentialsService: 'business_type', 'given_names', ], - 'schema_name': 'digital_business_card', # do not change schema name. this is the name registered in aca-py agent - 'schema_version': '1.0.0' # if attributes change update schema_version to re-register + # do not change schema name. this is the name registered in aca-py agent + 'schema_name': 'digital_business_card', + # if attributes change update schema_version to re-register + 'schema_version': '1.0.0' } def __init__(self): @@ -76,16 +78,16 @@ def init_app(self, app): self._register_business_definition() def _register_business_definition(self): + """Fetch schema and credential definition and save a Business definition.""" try: - if (self.business_schema_id is None): - self.app.logger.error(f'Environment variable: BUSINESS_SCHEMA_ID must be configured') - raise Exception(f'Environment variable: BUSINESS_SCHEMA_ID must be configured') + if self.business_schema_id is None: + self.app.logger.error('Environment variable: BUSINESS_SCHEMA_ID must be configured') + raise ValueError('Environment variable: BUSINESS_SCHEMA_ID must be configured') - if (self.business_cred_def_id is None): - self.app.logger.error(f'Environment variable: BUSINESS_CRED_DEF_ID must be configured') - raise Exception(f'Environment variable: BUSINESS_CRED_DEF_ID must be configured') + if self.business_cred_def_id is None: + self.app.logger.error('Environment variable: BUSINESS_CRED_DEF_ID must be configured') + raise ValueError('Environment variable: BUSINESS_CRED_DEF_ID must be configured') - """Fetch schema and credential definition and save a Business definition.""" # Check for the current Business definition. definition = DCDefinition.find_by( credential_type=DCDefinition.CredentialType.business, @@ -98,19 +100,23 @@ def _register_business_definition(self): DCDefinition.deactivate(DCDefinition.CredentialType.business) ### - # The following just a sanity check to make sure the schema and credential definition are stored in Traction tenant - # These calls also include a ledger lookup to see if the schema and credential definition are published + # The following just a sanity check to make sure the schema and + # credential definition are stored in Traction tenant. + # These calls also include a ledger lookup to see if the schema + # and credential definition are published. ### # Look for a schema first, and copy it into the Traction tenant if it's not there schema_id = self._fetch_schema(self.business_schema_id) if not schema_id: - raise Exception(f'Schema with id:{self.business_schema_id} must be available in Traction tenant storage') + raise ValueError(f'Schema with id:{self.business_schema_id}' + + ' must be available in Traction tenant storage') # Look for a published credential definition first, and copy it into the Traction tenant if it's not there credential_definition_id = self._fetch_credential_definition(self.business_cred_def_id) if not credential_definition_id: - raise Exception(f'Credential Definition with id:{self.business_cred_def_id} must be avaible in Traction tenant storage') + raise ValueError(f'Credential Definition with id:{self.business_cred_def_id}' + + ' must be avaible in Traction tenant storage') # Create a new definition and add the new schema_id definition = DCDefinition( @@ -122,12 +128,13 @@ def _register_business_definition(self): ) # Lastly, save the definition definition.save() + return None except Exception as err: self.app.logger.error(err) return None def _fetch_schema(self, schema_id: str) -> Optional[str]: - """Find a schema in Traction storage""" + """Find a schema in Traction storage.""" try: response = requests.get(self.api_url + '/schema-storage', params={'schema_id': schema_id}, @@ -136,12 +143,12 @@ def _fetch_schema(self, schema_id: str) -> Optional[str]: first_or_default = next((x for x in response.json()['results'] if x['schema_id'] == schema_id), None) return first_or_default['schema_id'] if first_or_default else None except Exception as err: - self.app.logger.error(f"Failed to fetch schema with id:{schema_id} from Traction tenant storage") + self.app.logger.error(f'Failed to fetch schema with id:{schema_id} from Traction tenant storage') self.app.logger.error(err) raise err def _fetch_credential_definition(self, cred_def_id: str) -> Optional[str]: - """Find a published credential definition""" + """Find a published credential definition.""" try: response = requests.get(self.api_url + '/credential-definition-storage', params={'cred_def_id': cred_def_id}, @@ -150,7 +157,8 @@ def _fetch_credential_definition(self, cred_def_id: str) -> Optional[str]: first_or_default = next((x for x in response.json()['results'] if x['cred_def_id'] == cred_def_id), None) return first_or_default['cred_def_id'] if first_or_default else None except Exception as err: - self.app.logger.error(f'Failed to find credential definition with id:{cred_def_id} from Traction tenant storage') + self.app.logger.error(f'Failed to find credential definition with id:{cred_def_id}' + + ' from Traction tenant storage') self.app.logger.error(err) raise err @@ -214,8 +222,8 @@ def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str) -> 'cred_rev_id': cred_rev_id, 'rev_reg_id': rev_reg_id, 'publish': True, - "notify": True, - "notify_version": "v1_0" + 'notify': True, + 'notify_version': 'v1_0' })) response.raise_for_status() return response.json() From 88e3ccc0c2e92f8be10421ccc8fea30aac2266df Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 19 Oct 2023 15:46:07 +0000 Subject: [PATCH 13/25] chore: update requirements Signed-off-by: Akiff Manji --- legal-api/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/legal-api/requirements.txt b/legal-api/requirements.txt index f582b911e6..23ce7d78d6 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -32,7 +32,7 @@ ecdsa==0.14.1 expiringdict==1.1.4 flask-jwt-oidc==0.3.0 flask-restx==0.3.0 -git+https://github.com/bcgov/business-schemas.git@2.18.10#egg=registry_schemas +git+https://github.com/bcgov/business-schemas.git@2.18.13#egg=registry_schemas gunicorn==20.1.0 html-sanitizer==1.9.3 idna==2.10 From fd96a6e7b2c1792dafbf06200bd615301061e698 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 25 Oct 2023 22:58:41 +0000 Subject: [PATCH 14/25] chore: update tests Signed-off-by: Akiff Manji --- .../src/legal_api/models/dc_definition.py | 11 ++-- .../tests/unit/models/test_dc_definition.py | 12 ++--- .../v2/test_business_digital_credentials.py | 51 ++++++++++--------- .../unit/services/test_digital_credentials.py | 8 +-- legal-api/wsgi.py | 4 +- 5 files changed, 44 insertions(+), 42 deletions(-) diff --git a/legal-api/src/legal_api/models/dc_definition.py b/legal-api/src/legal_api/models/dc_definition.py index 4e05061a71..9f5eaf434d 100644 --- a/legal-api/src/legal_api/models/dc_definition.py +++ b/legal-api/src/legal_api/models/dc_definition.py @@ -87,11 +87,12 @@ def find_by(cls, credential_definition_id: str, ) -> DCDefinition: """Return the digital credential definition matching the filter.""" - query = db.session.query(DCDefinition). \ - filter(DCDefinition.is_deleted is False). \ - filter(DCDefinition.credential_type == credential_type). \ - filter(DCDefinition.schema_id == schema_id). \ - filter(DCDefinition.credential_definition_id == credential_definition_id) + query = ( + db.session.query(DCDefinition) + .filter(DCDefinition.is_deleted == False) # noqa: E712 # pylint: disable=singleton-comparison + .filter(DCDefinition.credential_type == credential_type) + .filter(DCDefinition.schema_id == schema_id) + .filter(DCDefinition.credential_definition_id == credential_definition_id)) return query.one_or_none() @classmethod diff --git a/legal-api/tests/unit/models/test_dc_definition.py b/legal-api/tests/unit/models/test_dc_definition.py index da2e3a8161..9e308287e4 100644 --- a/legal-api/tests/unit/models/test_dc_definition.py +++ b/legal-api/tests/unit/models/test_dc_definition.py @@ -64,9 +64,9 @@ def test_find_by(session): """Assert that the method returns correct value.""" definition = create_dc_definition() - res = DCDefinition.find_by(DCDefinition.CredentialType.business, - 'business_schema', - schema_version='1.0.0' + res = DCDefinition.find_by(credential_type=DCDefinition.CredentialType.business, + schema_id='test_schema_id', + credential_definition_id='test_credential_definition_id', ) assert res assert res.id == definition.id @@ -76,10 +76,10 @@ def create_dc_definition(): """Create new dc_definition object.""" definition = DCDefinition( credential_type=DCDefinition.CredentialType.business, - schema_name='business_schema', + schema_name='test_business_schema', schema_version='1.0.0', - schema_id='3ENKbWGgUBXXzDHnG11phS:2:business_schema:1.0.0', - credential_definition_id='3ENKbWGgUBXXzDHnG11phS:3:CL:146949:business_schema' + schema_id='test_schema_id', + credential_definition_id='test_credential_definition_id' ) definition.save() return definition diff --git a/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py b/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py index c8ddd3338d..dec01a941e 100644 --- a/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py +++ b/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py @@ -34,40 +34,40 @@ content_type = 'application/json' -def test_create_invitation(session, client, jwt): # pylint:disable=unused-argument +def test_create_invitation(app, session, client, jwt): # pylint:disable=unused-argument """Assert create invitation endpoint returns invitation_url.""" headers = create_header(jwt, [BASIC_USER]) identifier = 'FM1234567' factory_business(identifier) - connection_id = '0d94e18b-3a52-4122-8adf-33e2ccff681f' + invitation_id = '0d94e18b-3a52-4122-8adf-33e2ccff681f' invitation_url = """http://192.168.65.3:8020?c_i=eyJAdHlwZSI6ICJodHRwczovL2RpZGNvbW0ub3JnL2Nvbm5lY3Rpb 25zLzEuMC9pbnZpdGF0aW9uIiwgIkBpZCI6ICIyZjU1M2JkZS01YWJlLTRkZDctODIwZi1mNWQ2Mjc1OWQxODgi LCAicmVjaXBpZW50S2V5cyI6IFsiMkFHSjVrRDlVYU45OVpSeUFHZVZKNDkxclZhNzZwZGZYdkxXZkFyc2lKWjY iXSwgImxhYmVsIjogImZhYmVyLmFnZW50IiwgInNlcnZpY2VFbmRwb2ludCI6ICJodHRwOi8vMTkyLjE2OC42NS4zOjgwMjAifQ==""" with patch.object(DigitalCredentialsService, 'create_invitation', return_value={ - 'connection_id': connection_id, 'invitation_url': invitation_url}): + 'invitation': {'@id': invitation_id}, 'invitation_url': invitation_url}): + rv = client.post(f'/api/v2/businesses/{identifier}/digitalCredentials/invitation', headers=headers, content_type=content_type) assert rv.status_code == HTTPStatus.OK assert rv.json.get('invitationUrl') == invitation_url -def test_get_connection_not_found(session, client, jwt): # pylint:disable=unused-argument - """Assert get connection endpoint returns not found when there is no active connection.""" +def test_get_connections_not_found(session, client, jwt): # pylint:disable=unused-argument + """Assert get connections endpoint returns not found when there is no active connection.""" headers = create_header(jwt, [BASIC_USER]) identifier = 'FM1234567' - business = factory_business(identifier) - create_dc_connection(business) + factory_business(identifier) - rv = client.get(f'/api/v2/businesses/{identifier}/digitalCredentials/connection', + rv = client.get(f'/api/v2/businesses/{identifier}/digitalCredentials/connections', headers=headers, content_type=content_type) - assert rv.status_code == HTTPStatus.NOT_FOUND - assert rv.json.get('message') == 'No active connection found.' + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('connections') == [] -def test_get_connection(session, client, jwt): # pylint:disable=unused-argument +def test_get_connections(session, client, jwt): # pylint:disable=unused-argument """Assert get connection endpoint returns connection json.""" headers = create_header(jwt, [BASIC_USER]) identifier = 'FM1234567' @@ -75,13 +75,13 @@ def test_get_connection(session, client, jwt): # pylint:disable=unused-argument connection = create_dc_connection(business, is_active=True) - rv = client.get(f'/api/v2/businesses/{identifier}/digitalCredentials/connection', + rv = client.get(f'/api/v2/businesses/{identifier}/digitalCredentials/connections', headers=headers, content_type=content_type) assert rv.status_code == HTTPStatus.OK - assert rv.json.get('invitationUrl') == connection.invitation_url - assert rv.json.get('connectionId') == connection.connection_id - assert rv.json.get('isActive') == connection.is_active - assert rv.json.get('connectionState') == connection.connection_state + assert rv.json.get('connections')[0].get('invitationUrl') == connection.invitation_url + assert rv.json.get('connections')[0].get('connectionId') == connection.connection_id + assert rv.json.get('connections')[0].get('isActive') == connection.is_active + assert rv.json.get('connections')[0].get('connectionState') == connection.connection_state def test_send_credential(session, client, jwt): # pylint:disable=unused-argument @@ -94,12 +94,12 @@ def test_send_credential(session, client, jwt): # pylint:disable=unused-argumen create_dc_connection(business, is_active=True) with patch.object(DigitalCredentialsService, 'issue_credential', return_value={ - 'credential_exchange_id': '3fa85f64-5717-4562-b3fc-2c963f66afa6'}): + 'cred_ex_id': '3fa85f64-5717-4562-b3fc-2c963f66afa6'}): rv = client.post( f'/api/v2/businesses/{identifier}/digitalCredentials/{DCDefinition.CredentialType.business.name}', headers=headers, content_type=content_type) assert rv.status_code == HTTPStatus.OK - assert rv.json.get('message') == 'Issue Credential is initiated.' + assert rv.json.get('message') == 'Credential offer has been sent.' def test_get_issued_credentials(session, client, jwt): # pylint:disable=unused-argument @@ -130,7 +130,8 @@ def test_webhook_connections_notification(session, client, jwt): # pylint:disab connection = create_dc_connection(business) json_data = { - 'connection_id': connection.connection_id, + 'invitation': {'@id': connection.connection_id}, + "connection_id": connection.connection_id, 'state': 'active' } rv = client.post('/api/v2/digitalCredentials/topic/connections', @@ -138,11 +139,11 @@ def test_webhook_connections_notification(session, client, jwt): # pylint:disab headers=headers, content_type=content_type) assert rv.status_code == HTTPStatus.OK - rv = client.get(f'/api/v2/businesses/{identifier}/digitalCredentials/connection', + rv = client.get(f'/api/v2/businesses/{identifier}/digitalCredentials/connections', headers=headers, content_type=content_type) assert rv.status_code == HTTPStatus.OK - assert rv.json.get('isActive') == connection.is_active - assert rv.json.get('connectionState') == connection.connection_state + assert rv.json.get('connections')[0].get('isActive') == connection.is_active + assert rv.json.get('connections')[0].get('connectionState') == connection.connection_state def test_webhook_issue_credential_notification(session, client, jwt): # pylint:disable=unused-argument @@ -154,10 +155,10 @@ def test_webhook_issue_credential_notification(session, client, jwt): # pylint: issued_credential = create_dc_issued_credential(business=business) json_data = { - 'credential_exchange_id': issued_credential.credential_exchange_id, - 'state': 'credential_issued' + 'cred_ex_id': issued_credential.credential_exchange_id, + 'state': 'done' } - rv = client.post('/api/v2/digitalCredentials/topic/issue_credential', + rv = client.post('/api/v2/digitalCredentials/topic/issue_credential_v2_0', json=json_data, headers=headers, content_type=content_type) assert rv.status_code == HTTPStatus.OK diff --git a/legal-api/tests/unit/services/test_digital_credentials.py b/legal-api/tests/unit/services/test_digital_credentials.py index c7b411930a..d4d77dfab4 100644 --- a/legal-api/tests/unit/services/test_digital_credentials.py +++ b/legal-api/tests/unit/services/test_digital_credentials.py @@ -24,10 +24,10 @@ def test_init_app(session, app): # pylint:disable=unused-argument """Assert that the init app register schema and credential definition.""" - schema_id = '3ENKbWGgUBXXzDHnG11phS:2:business_schema:1.0.0' - cred_def_id = '3ENKbWGgUBXXzDHnG11phS:3:CL:146949:business_schema' - with patch.object(DigitalCredentialsService, '_register_schema', return_value=schema_id): - with patch.object(DigitalCredentialsService, '_register_credential_definitions', return_value=cred_def_id): + schema_id = 'test_schema_id' + cred_def_id = 'test_credential_definition_id' + with patch.object(DigitalCredentialsService, '_fetch_schema', return_value=schema_id): + with patch.object(DigitalCredentialsService, '_fetch_credential_definition', return_value=cred_def_id): digital_credentials.init_app(app) definition = DCDefinition.find_by_credential_type(DCDefinition.CredentialType.business) assert definition.schema_id == schema_id diff --git a/legal-api/wsgi.py b/legal-api/wsgi.py index 93c38f48df..f5a0bd6201 100755 --- a/legal-api/wsgi.py +++ b/legal-api/wsgi.py @@ -19,8 +19,8 @@ from legal_api.extensions import socketio # Openshift s2i expects a lower case name of application -application = create_app() # pylint: disable=invalid-name +application = create_app() # pylint: disable=invalid-name if __name__ == "__main__": server_port = os.environ.get('PORT', '8080') - socketio.run(application, port=server_port, host='0.0.0.0') + socketio.run(application, debug=False, port=server_port, host='0.0.0.0') From e397e041c62d3e9c72c91c9319107ff900e58479 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 26 Oct 2023 02:21:10 +0000 Subject: [PATCH 15/25] feat: traction token exchanger Signed-off-by: Akiff Manji --- legal-api/requirements.txt | 1 + legal-api/src/legal_api/config.py | 3 +- legal-api/src/legal_api/decorators.py | 55 +++++++++++++++++++ legal-api/src/legal_api/extensions.py | 2 +- .../legal_api/services/digital_credentials.py | 9 ++- 5 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 legal-api/src/legal_api/decorators.py diff --git a/legal-api/requirements.txt b/legal-api/requirements.txt index 23ce7d78d6..5691dc745b 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -47,6 +47,7 @@ pyRFC3339==1.1 pyasn1==0.4.8 pycountry==20.7.3 pydantic==1.10.2 +pyjwt==2.8.0 pyrsistent==0.17.3 python-dateutil==2.8.1 python-dotenv==0.17.1 diff --git a/legal-api/src/legal_api/config.py b/legal-api/src/legal_api/config.py index ed13c9c9b7..5e7c5a68d3 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -146,7 +146,8 @@ class _Config(): # pylint: disable=too-few-public-methods # Digital Credential configuration values TRACTION_API_URL = os.getenv('TRACTION_API_URL') - TRACTION_API_TOKEN = os.getenv('TRACTION_API_TOKEN') + TRACTION_TENANT_ID = os.getenv('TRACTION_TENANT_ID') + TRACTION_API_KEY = os.getenv('TRACTION_API_KEY') TRACTION_PUBLIC_SCHEMA_DID = os.getenv('TRACTION_PUBLIC_SCHEMA_DID') TRACTION_PUBLIC_ISSUER_DID = os.getenv('TRACTION_PUBLIC_ISSUER_DID') diff --git a/legal-api/src/legal_api/decorators.py b/legal-api/src/legal_api/decorators.py new file mode 100644 index 0000000000..5169379b6f --- /dev/null +++ b/legal-api/src/legal_api/decorators.py @@ -0,0 +1,55 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module holds function decorators.""" + +import json +from functools import wraps + +import jwt +import requests +from flask import current_app + + +def requires_traction_auth(f): + """Check for a valid Traction token and refresh if needed.""" + @wraps(f) + def decorated_function(*args, **kwargs): + traction_api_url = current_app.config['TRACTION_API_URL'] + traction_tenant_id = current_app.config['TRACTION_TENANT_ID'] + traction_api_key = current_app.config['TRACTION_API_KEY'] + + if traction_api_url is None: + raise EnvironmentError('TRACTION_API_URL environment vairable is not set') + + if traction_tenant_id is None: + raise EnvironmentError('TRACTION_TENANT_ID environment vairable is not set') + + if traction_api_key is None: + raise EnvironmentError('TRACTION_API_KEY environment vairable is not set') + + try: + if not hasattr(current_app, 'api_token'): + raise jwt.ExpiredSignatureError + + jwt.decode(current_app.api_token, verify=False) + except jwt.ExpiredSignatureError: + current_app.logger.info('JWT token expired or is missing, requesting new token') + response = requests.post(f'{traction_api_url}/multitenancy/tenant/{traction_tenant_id}/token', + headers={'Content-Type': 'application/json'}, + data=json.dumps({'api_key': traction_api_key})) + response.raise_for_status() + current_app.api_token = response.json()['token'] + + return f(*args, **kwargs) + return decorated_function diff --git a/legal-api/src/legal_api/extensions.py b/legal-api/src/legal_api/extensions.py index d7cc8f257a..e87a05d7ac 100644 --- a/legal-api/src/legal_api/extensions.py +++ b/legal-api/src/legal_api/extensions.py @@ -1,4 +1,4 @@ -# Copyright © 2022 Province of British Columbia +# Copyright © 2023 Province of British Columbia # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index f22985d911..3667ee267f 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -21,6 +21,7 @@ import requests +from legal_api.decorators import requires_traction_auth from legal_api.models import DCDefinition @@ -65,7 +66,6 @@ def init_app(self, app): self.app = app self.api_url = app.config.get('TRACTION_API_URL') - self.api_token = app.config.get('TRACTION_API_TOKEN') self.public_schema_did = app.config.get('TRACTION_PUBLIC_SCHEMA_DID') self.public_issuer_did = app.config.get('TRACTION_PUBLIC_ISSUER_DID') @@ -133,6 +133,7 @@ def _register_business_definition(self): self.app.logger.error(err) return None + @requires_traction_auth def _fetch_schema(self, schema_id: str) -> Optional[str]: """Find a schema in Traction storage.""" try: @@ -147,6 +148,7 @@ def _fetch_schema(self, schema_id: str) -> Optional[str]: self.app.logger.error(err) raise err + @requires_traction_auth def _fetch_credential_definition(self, cred_def_id: str) -> Optional[str]: """Find a published credential definition.""" try: @@ -162,6 +164,7 @@ def _fetch_credential_definition(self, cred_def_id: str) -> Optional[str]: self.app.logger.error(err) raise err + @requires_traction_auth def create_invitation(self) -> Optional[dict]: """Create a new connection invitation.""" try: @@ -177,6 +180,7 @@ def create_invitation(self) -> Optional[dict]: self.app.logger.error(err) return None + @requires_traction_auth def issue_credential(self, connection_id: str, definition: DCDefinition, @@ -212,6 +216,7 @@ def issue_credential(self, self.app.logger.error(err) return None + @requires_traction_auth def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str) -> Optional[dict]: """Revoke a credential.""" try: @@ -234,5 +239,5 @@ def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str) -> def _get_headers(self) -> dict: return { 'Content-Type': 'application/json', - 'Authorization': f'Bearer {self.api_token}' + 'Authorization': f'Bearer {self.app.api_token}' } From 1f2fe2beee9f4a2a942c8d700399c133aeda02e6 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 26 Oct 2023 02:50:35 +0000 Subject: [PATCH 16/25] chore: update workflow variables Signed-off-by: Akiff Manji --- .github/workflows/legal-api-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/legal-api-ci.yml b/.github/workflows/legal-api-ci.yml index 52b422ce67..1e159147b4 100644 --- a/.github/workflows/legal-api-ci.yml +++ b/.github/workflows/legal-api-ci.yml @@ -64,6 +64,8 @@ jobs: NATS_QUEUE: entity-filer-worker JWT_OIDC_JWKS_CACHE_TIMEOUT: 300 GO_LIVE_DATE: 2019-08-12 + BUSINESS_SCHEMA_ID: test_business_schema_id + BUSINESS_CRED_DEF_ID: test_credential_definition_id runs-on: ubuntu-20.04 From 4090b35693240695d6b7b409ecf1d6d3b09dbec9 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 26 Oct 2023 03:03:56 +0000 Subject: [PATCH 17/25] chore: update workflow variables Signed-off-by: Akiff Manji --- .github/workflows/legal-api-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/legal-api-ci.yml b/.github/workflows/legal-api-ci.yml index 1e159147b4..11b959fbd8 100644 --- a/.github/workflows/legal-api-ci.yml +++ b/.github/workflows/legal-api-ci.yml @@ -66,6 +66,8 @@ jobs: GO_LIVE_DATE: 2019-08-12 BUSINESS_SCHEMA_ID: test_business_schema_id BUSINESS_CRED_DEF_ID: test_credential_definition_id + BUSINESS_SCHEMA_NAME: digital_business_card + BUSINESS_SCHEMA_VERSION: "1.0.0" runs-on: ubuntu-20.04 From 7f1f546eb36f9e8c4035d78f4f4538124c962ef4 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 26 Oct 2023 16:11:23 +0000 Subject: [PATCH 18/25] refactor: ws cors setting is a config option Signed-off-by: Akiff Manji --- legal-api/src/legal_api/__init__.py | 5 ++++- legal-api/src/legal_api/config.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/legal-api/src/legal_api/__init__.py b/legal-api/src/legal_api/__init__.py index 7956f43a9f..9cf6602c8d 100644 --- a/legal-api/src/legal_api/__init__.py +++ b/legal-api/src/legal_api/__init__.py @@ -68,7 +68,10 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): register_shellcontext(app) - socketio.init_app(app, cors_allowed_origins='*') + ws_allowed_origins = app.config.get('WS_ALLOWED_ORIGINS', []) + if type(ws_allowed_origins) == str and ws_allowed_origins != '*': + ws_allowed_origins = ws_allowed_origins.split(',') + socketio.init_app(app, cors_allowed_origins=ws_allowed_origins) return app diff --git a/legal-api/src/legal_api/config.py b/legal-api/src/legal_api/config.py index 5e7c5a68d3..2d4a7fb5ce 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -144,13 +144,17 @@ class _Config(): # pylint: disable=too-few-public-methods NAICS_API_URL = os.getenv('NAICS_API_URL', 'https://NAICS_API_URL/api/v2/naics') - # Digital Credential configuration values + # Traction ACA-Py tenant settings to issue credentials from TRACTION_API_URL = os.getenv('TRACTION_API_URL') TRACTION_TENANT_ID = os.getenv('TRACTION_TENANT_ID') TRACTION_API_KEY = os.getenv('TRACTION_API_KEY') TRACTION_PUBLIC_SCHEMA_DID = os.getenv('TRACTION_PUBLIC_SCHEMA_DID') TRACTION_PUBLIC_ISSUER_DID = os.getenv('TRACTION_PUBLIC_ISSUER_DID') + # Web socket settings + WS_ALLOWED_ORIGINS = os.getenv('WS_ALLOWED_ORIGINS') + + # Digital Business Card configuration values (required to issue credentials) BUSINESS_SCHEMA_NAME = os.getenv('BUSINESS_SCHEMA_NAME') BUSINESS_SCHEMA_VERSION = os.getenv('BUSINESS_SCHEMA_VERSION') BUSINESS_SCHEMA_ID = os.getenv('BUSINESS_SCHEMA_ID') From a259818e8cfc837721944ffa6d3212a9da44d25a Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 26 Oct 2023 16:14:14 +0000 Subject: [PATCH 19/25] chore: fix linting errors Signed-off-by: Akiff Manji --- legal-api/src/legal_api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/legal-api/src/legal_api/__init__.py b/legal-api/src/legal_api/__init__.py index 9cf6602c8d..386a7ea04f 100644 --- a/legal-api/src/legal_api/__init__.py +++ b/legal-api/src/legal_api/__init__.py @@ -69,7 +69,7 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): register_shellcontext(app) ws_allowed_origins = app.config.get('WS_ALLOWED_ORIGINS', []) - if type(ws_allowed_origins) == str and ws_allowed_origins != '*': + if isinstance(ws_allowed_origins, str) and ws_allowed_origins != '*': ws_allowed_origins = ws_allowed_origins.split(',') socketio.init_app(app, cors_allowed_origins=ws_allowed_origins) From 080825f56ecd8a25307d47be6bb5edf4d1fa6f1f Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Fri, 27 Oct 2023 15:10:31 +0000 Subject: [PATCH 20/25] refactor: clean up init in digital credential service Signed-off-by: Akiff Manji --- legal-api/src/legal_api/decorators.py | 5 ++- .../legal_api/services/digital_credentials.py | 40 +++++-------------- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/legal-api/src/legal_api/decorators.py b/legal-api/src/legal_api/decorators.py index 5169379b6f..0153b26316 100644 --- a/legal-api/src/legal_api/decorators.py +++ b/legal-api/src/legal_api/decorators.py @@ -19,6 +19,7 @@ import jwt import requests from flask import current_app +from jwt import ExpiredSignatureError def requires_traction_auth(f): @@ -42,8 +43,8 @@ def decorated_function(*args, **kwargs): if not hasattr(current_app, 'api_token'): raise jwt.ExpiredSignatureError - jwt.decode(current_app.api_token, verify=False) - except jwt.ExpiredSignatureError: + jwt.decode(current_app.api_token, options={'verify_signature': False}) + except ExpiredSignatureError: current_app.logger.info('JWT token expired or is missing, requesting new token') response = requests.post(f'{traction_api_url}/multitenancy/tenant/{traction_tenant_id}/token', headers={'Content-Type': 'application/json'}, diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 3667ee267f..08ba53fefc 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -28,25 +28,6 @@ class DigitalCredentialsService: """Provides services to do digital credentials using aca-py agent.""" - business_schema = { - 'attributes': [ - 'business_name', - 'company_status', - 'credential_id', - 'identifier', - 'registered_on_dateint', - 'role', - 'cra_business_number', - 'family_name', - 'business_type', - 'given_names', - ], - # do not change schema name. this is the name registered in aca-py agent - 'schema_name': 'digital_business_card', - # if attributes change update schema_version to re-register - 'schema_version': '1.0.0' - } - def __init__(self): """Initialize this object.""" self.app = None @@ -88,17 +69,6 @@ def _register_business_definition(self): self.app.logger.error('Environment variable: BUSINESS_CRED_DEF_ID must be configured') raise ValueError('Environment variable: BUSINESS_CRED_DEF_ID must be configured') - # Check for the current Business definition. - definition = DCDefinition.find_by( - credential_type=DCDefinition.CredentialType.business, - schema_id=self.business_schema_id, - credential_definition_id=self.business_cred_def_id - ) - - if definition and not definition.is_deleted: - # Deactivate any existing Business definition before creating new one - DCDefinition.deactivate(DCDefinition.CredentialType.business) - ### # The following just a sanity check to make sure the schema and # credential definition are stored in Traction tenant. @@ -118,6 +88,16 @@ def _register_business_definition(self): raise ValueError(f'Credential Definition with id:{self.business_cred_def_id}' + ' must be avaible in Traction tenant storage') + # Check for the current Business definition. + definition = DCDefinition.find_by( + credential_type=DCDefinition.CredentialType.business, + schema_id=self.business_schema_id, + credential_definition_id=self.business_cred_def_id + ) + + if definition and not definition.is_deleted: + return None + # Create a new definition and add the new schema_id definition = DCDefinition( credential_type=DCDefinition.CredentialType.business, From 87d0854e48a52d2cfe3a03c3693dfc3af8260f40 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Sat, 28 Oct 2023 21:28:49 +0000 Subject: [PATCH 21/25] feat: endpoints to reset credential offers Signed-off-by: Akiff Manji --- .../legal_api/models/dc_issued_credential.py | 2 +- .../business/business_digital_credentials.py | 24 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/legal-api/src/legal_api/models/dc_issued_credential.py b/legal-api/src/legal_api/models/dc_issued_credential.py index 70d71dedc7..81dbe0dd32 100644 --- a/legal-api/src/legal_api/models/dc_issued_credential.py +++ b/legal-api/src/legal_api/models/dc_issued_credential.py @@ -48,7 +48,7 @@ def json(self): 'credentialExchangeId': self.credential_exchange_id, 'credentialId': self.credential_id, 'isIssued': self.is_issued, - 'dateOfIssue': self.date_of_issue.isoformat(), + 'dateOfIssue': self.date_of_issue.isoformat() if self.date_of_issue else None, 'isRevoked': self.is_revoked, 'credentialRevocationId': self.credential_revocation_id, 'revocationRegistryId': self.revocation_registry_id diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index 91fd7913c7..000ff4f930 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -82,10 +82,28 @@ def get_connections(identifier): return jsonify({'connections': response}), HTTPStatus.OK -@bp.route('//digitalCredentials/connection', methods=['DELETE'], strict_slashes=False) +@bp.route('//digitalCredentials/connections/', + methods=['DELETE'], strict_slashes=False) @cross_origin(origin='*') @jwt.requires_auth -def delete_connection(identifier): +def delete_connection(identifier, connection_id): + """Delete a connection.""" + business = Business.find_by_identifier(identifier) + if not business: + return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND + + connection = DCConnection.find_by_connection_id(connection_id=connection_id) + if not connection: + return jsonify({'message': f'{identifier} connection not found.'}), HTTPStatus.NOT_FOUND + + connection.delete() + return jsonify({'message': 'Connection has been deleted.'}), HTTPStatus.OK + + +@bp.route('//digitalCredentials/activeConnection', methods=['DELETE'], strict_slashes=False) +@cross_origin(origin='*') +@jwt.requires_auth +def delete_active_connection(identifier): """Delete an active connection for this business.""" business = Business.find_by_identifier(identifier) if not business: @@ -164,7 +182,7 @@ def send_credential(identifier, credential_type): ) issued_credential.save() - return jsonify({'message': 'Credential offer has been sent.'}), HTTPStatus.OK + return jsonify(issued_credential.json), HTTPStatus.OK @bp.route('//digitalCredentials//revoke', From 26a01e7baa3f9e080b5e8d0123794b8135bce907 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 30 Oct 2023 20:18:11 +0000 Subject: [PATCH 22/25] feat: credential id lookup table Signed-off-by: Akiff Manji --- ...reate_issued_business_user_credentials_.py | 30 +++++++ legal-api/src/legal_api/models/__init__.py | 3 +- .../dc_issued_business_user_credential.py | 49 ++++++++++++ .../business/business_digital_credentials.py | 80 ++++++++++++++----- 4 files changed, 141 insertions(+), 21 deletions(-) create mode 100644 legal-api/migrations/versions/6e28f267db2a_create_issued_business_user_credentials_.py create mode 100644 legal-api/src/legal_api/models/dc_issued_business_user_credential.py diff --git a/legal-api/migrations/versions/6e28f267db2a_create_issued_business_user_credentials_.py b/legal-api/migrations/versions/6e28f267db2a_create_issued_business_user_credentials_.py new file mode 100644 index 0000000000..8066646ab8 --- /dev/null +++ b/legal-api/migrations/versions/6e28f267db2a_create_issued_business_user_credentials_.py @@ -0,0 +1,30 @@ +"""create issued business user credentials table + +Revision ID: 6e28f267db2a +Revises: 8148a25d695e +Create Date: 2023-10-17 02:17:08.232290 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6e28f267db2a' +down_revision = '8148a25d695e' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('dc_issued_business_user_credentials', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id']), + sa.ForeignKeyConstraint(['user_id'], ['users.id'])) + + +def downgrade(): + op.drop_table('dc_issued_business_user_credentials') diff --git a/legal-api/src/legal_api/models/__init__.py b/legal-api/src/legal_api/models/__init__.py index 3c38b92cf5..7609c6e2bd 100644 --- a/legal-api/src/legal_api/models/__init__.py +++ b/legal-api/src/legal_api/models/__init__.py @@ -24,6 +24,7 @@ from .dc_connection import DCConnection from .dc_definition import DCDefinition from .dc_issued_credential import DCIssuedCredential +from .dc_issued_business_user_credential import DCIssuedBusinessUserCredential from .document import Document, DocumentType from .filing import Filing from .naics_element import NaicsElement @@ -40,6 +41,6 @@ __all__ = ('db', 'Address', 'Alias', 'Business', 'ColinLastUpdate', 'Comment', 'ConsentContinuationOut', 'CorpType', - 'DCConnection', 'DCDefinition', 'DCIssuedCredential', 'Document', 'DocumentType', + 'DCConnection', 'DCDefinition', 'DCIssuedCredential', 'DCIssuedBusinessUserCredential', 'Document', 'DocumentType', 'Filing', 'Office', 'OfficeType', 'Party', 'RegistrationBootstrap', 'RequestTracker', 'Resolution', 'PartyRole', 'ShareClass', 'ShareSeries', 'User', 'UserRoles', 'NaicsStructure', 'NaicsElement') diff --git a/legal-api/src/legal_api/models/dc_issued_business_user_credential.py b/legal-api/src/legal_api/models/dc_issued_business_user_credential.py new file mode 100644 index 0000000000..fe43d7e076 --- /dev/null +++ b/legal-api/src/legal_api/models/dc_issued_business_user_credential.py @@ -0,0 +1,49 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module holds data for issued credential.""" +from __future__ import annotations + +from typing import List + +from .db import db + + +class DCIssuedBusinessUserCredential(db.Model): # pylint: disable=too-many-instance-attributes + """This class manages the issued credential IDs for a user of a business.""" + + __tablename__ = 'dc_issued_business_user_credentials' + + id = db.Column(db.Integer, primary_key=True) + + user_id = db.Column('user_id', db.Integer, db.ForeignKey('users.id')) + business_id = db.Column('business_id', db.Integer, db.ForeignKey('businesses.id')) + + def save(self): + """Save the object to the database immediately.""" + db.session.add(self) + db.session.commit() + + @classmethod + def find_by(cls, + business_id: int = None, + user_id: int = None) -> List[DCIssuedBusinessUserCredential]: + """Return the issued business user credential matching the user_id and buisness_id.""" + dc_issued_business_user_credential = None + if business_id and user_id: + dc_issued_business_user_credential = ( + cls.query + .filter(DCIssuedBusinessUserCredential.business_id == business_id) + .filter(DCIssuedBusinessUserCredential.user_id == user_id) + .one_or_none()) + return dc_issued_business_user_credential diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index 000ff4f930..b7c07db002 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -16,11 +16,19 @@ from datetime import datetime from http import HTTPStatus -from flask import Blueprint, current_app, jsonify, request +from flask import Blueprint, current_app, jsonify, request, _request_ctx_stack from flask_cors import cross_origin from legal_api.extensions import socketio -from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential +from legal_api.models import ( + Business, + CorpType, + DCConnection, + DCDefinition, + DCIssuedCredential, + DCIssuedBusinessUserCredential, + User +) from legal_api.services import digital_credentials from legal_api.utils.auth import jwt @@ -156,29 +164,39 @@ def send_credential(identifier, credential_type): business = Business.find_by_identifier(identifier) if not business: return jsonify({'message': f'{identifier} not found'}), HTTPStatus.NOT_FOUND + + user = User.find_by_jwt_token(_request_ctx_stack.top.current_user) + if not user: + return jsonify({'message': 'User not found'}, HTTPStatus.NOT_FOUND) connection = DCConnection.find_active_by(business_id=business.id) - definition = DCDefinition.find_by_credential_type(DCDefinition.CredentialType[credential_type]) + definition = DCDefinition.find_by(DCDefinition.CredentialType[credential_type], + digital_credentials.business_schema_id, + digital_credentials.business_cred_def_id) issued_credentials = DCIssuedCredential.find_by(dc_connection_id=connection.id, dc_definition_id=definition.id) if issued_credentials and issued_credentials[0].credential_exchange_id: return jsonify({'message': 'Already requested to issue credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR + + credential_data = _get_data_for_credential(definition.credential_type, business, user) + credential_id = next((item['value'] for item in credential_data if item['name'] == 'credential_id'), None) response = digital_credentials.issue_credential( connection_id=connection.connection_id, definition=definition, - data=_get_data_for_credential(definition.credential_type, business) + data=_get_data_for_credential(definition.credential_type, business, user) ) if not response: return jsonify({'message': 'Failed to issue credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR + + print(_request_ctx_stack.top.current_user) issued_credential = DCIssuedCredential( dc_definition_id=definition.id, dc_connection_id=connection.id, credential_exchange_id=response['cred_ex_id'], - # TODO: Add a real ID - credential_id='123456' + credential_id=credential_id ) issued_credential.save() @@ -199,8 +217,7 @@ def revoke_credential(identifier, credential_id): if not connection: return jsonify({'message': f'{identifier} active connection not found.'}), HTTPStatus.NOT_FOUND - # TODO: Use a real ID - issued_credential = DCIssuedCredential.find_by_credential_id(credential_id='123456') + issued_credential = DCIssuedCredential.find_by_credential_id(credential_id=credential_id) if not issued_credential or issued_credential.is_revoked: return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND @@ -224,8 +241,7 @@ def delete_credential(identifier, credential_id): if not business: return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND - # TODO: Use a real ID - issued_credential = DCIssuedCredential.find_by_credential_id(credential_id='123456') + issued_credential = DCIssuedCredential.find_by_credential_id(credential_id=credential_id) if not issued_credential: return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND @@ -259,7 +275,6 @@ def webhook_notification(topic_name: str): issued_credential.revocation_registry_id = json_input['rev_reg_id'] issued_credential.save() elif topic_name == 'issue_credential_v2_0': - # TODO: We want to deactivate the connection once the credential is issued issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['cred_ex_id']) if issued_credential and json_input['state'] == 'done': issued_credential.date_of_issue = datetime.utcnow() @@ -273,24 +288,49 @@ def webhook_notification(topic_name: str): return jsonify({'message': 'Webhook received.'}), HTTPStatus.OK -def _get_data_for_credential(credential_type: DCDefinition.CredentialType, business: Business): +def _get_data_for_credential(credential_type: DCDefinition.CredentialType, business: Business, user: User): if credential_type == DCDefinition.CredentialType.business: + + # Find the credential id from dc_issued_business_user_credentials and if there isn't one create one + issued_business_user_credential = DCIssuedBusinessUserCredential.find_by(business_id=business.id, user_id=user.id) + if not issued_business_user_credential: + issued_business_user_credential = DCIssuedBusinessUserCredential(business_id=business.id, user_id=user.id) + issued_business_user_credential.save() + + credential_id = f'{issued_business_user_credential.id:08}' + + business_type = CorpType.find_by_id(business.legal_type) + if business_type: + business_type = business_type.full_desc + else: + business_type = business.legal_type + + registered_on_dateint = '' + if business.founding_date: + registered_on_dateint = business.founding_date.strftime(f'%Y%m%d') + + company_status = Business.State(business.state).name + + family_name = (user.lastname or '').upper() + + given_names = (user.firstname + (' ' + user.middlename if user.middlename else '') or '').upper() + return [ { 'name': 'credential_id', - 'value': '' + 'value': credential_id or '' }, { 'name': 'identifier', - 'value': business.identifier + 'value': business.identifier or '' }, { 'name': 'business_name', - 'value': business.legal_name + 'value': business.legal_name or '' }, { 'name': 'business_type', - 'value': business.legal_type + 'value': business_type or '' }, { 'name': 'cra_business_number', @@ -298,19 +338,19 @@ def _get_data_for_credential(credential_type: DCDefinition.CredentialType, busin }, { 'name': 'registered_on_dateint', - 'value': business.founding_date.isoformat() + 'value': registered_on_dateint or '' }, { 'name': 'company_status', - 'value': business.state + 'value': company_status or '' }, { 'name': 'family_name', - 'value': '' + 'value': family_name or '' }, { 'name': 'given_names', - 'value': '' + 'value': given_names or '' }, { 'name': 'role', From 32a7d7ab97d99fd9e660abead9b3329bab71612e Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 30 Oct 2023 22:52:12 +0000 Subject: [PATCH 23/25] feat: add business roles Signed-off-by: Akiff Manji --- .../v2/business/business_digital_credentials.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index b7c07db002..d69ac6c3db 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -19,14 +19,17 @@ from flask import Blueprint, current_app, jsonify, request, _request_ctx_stack from flask_cors import cross_origin +from legal_api.core import Filing as FilingCore from legal_api.extensions import socketio from legal_api.models import ( Business, CorpType, + Filing, DCConnection, DCDefinition, DCIssuedCredential, DCIssuedBusinessUserCredential, + PartyRole, User ) from legal_api.services import digital_credentials @@ -189,8 +192,6 @@ def send_credential(identifier, credential_type): ) if not response: return jsonify({'message': 'Failed to issue credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR - - print(_request_ctx_stack.top.current_user) issued_credential = DCIssuedCredential( dc_definition_id=definition.id, @@ -315,6 +316,8 @@ def _get_data_for_credential(credential_type: DCDefinition.CredentialType, busin given_names = (user.firstname + (' ' + user.middlename if user.middlename else '') or '').upper() + roles = ', '.join([party_role.role.title() for party_role in business.party_roles.all() if party_role.role]) + return [ { 'name': 'credential_id', @@ -354,7 +357,7 @@ def _get_data_for_credential(credential_type: DCDefinition.CredentialType, busin }, { 'name': 'role', - 'value': '' + 'value': roles or '' } ] From c55cebc88d709dcbfa11bc3001382a7a7d5c45d7 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 30 Oct 2023 23:21:04 +0000 Subject: [PATCH 24/25] chore: fix tests and linting Signed-off-by: Akiff Manji --- legal-api/src/legal_api/models/__init__.py | 9 +++++---- .../business/business_digital_credentials.py | 20 +++++++++---------- .../unit/services/test_digital_credentials.py | 4 ++-- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/legal-api/src/legal_api/models/__init__.py b/legal-api/src/legal_api/models/__init__.py index 7609c6e2bd..fc7fa5b6ec 100644 --- a/legal-api/src/legal_api/models/__init__.py +++ b/legal-api/src/legal_api/models/__init__.py @@ -23,8 +23,8 @@ from .corp_type import CorpType from .dc_connection import DCConnection from .dc_definition import DCDefinition -from .dc_issued_credential import DCIssuedCredential from .dc_issued_business_user_credential import DCIssuedBusinessUserCredential +from .dc_issued_credential import DCIssuedCredential from .document import Document, DocumentType from .filing import Filing from .naics_element import NaicsElement @@ -41,6 +41,7 @@ __all__ = ('db', 'Address', 'Alias', 'Business', 'ColinLastUpdate', 'Comment', 'ConsentContinuationOut', 'CorpType', - 'DCConnection', 'DCDefinition', 'DCIssuedCredential', 'DCIssuedBusinessUserCredential', 'Document', 'DocumentType', - 'Filing', 'Office', 'OfficeType', 'Party', 'RegistrationBootstrap', 'RequestTracker', 'Resolution', - 'PartyRole', 'ShareClass', 'ShareSeries', 'User', 'UserRoles', 'NaicsStructure', 'NaicsElement') + 'DCConnection', 'DCDefinition', 'DCIssuedCredential', 'DCIssuedBusinessUserCredential', 'Document', + 'DocumentType', 'Filing', 'Office', 'OfficeType', 'Party', 'RegistrationBootstrap', 'RequestTracker', + 'Resolution', 'PartyRole', 'ShareClass', 'ShareSeries', 'User', 'UserRoles', 'NaicsStructure', + 'NaicsElement') diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index d69ac6c3db..85fbbf4c2d 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -16,21 +16,18 @@ from datetime import datetime from http import HTTPStatus -from flask import Blueprint, current_app, jsonify, request, _request_ctx_stack +from flask import Blueprint, _request_ctx_stack, current_app, jsonify, request from flask_cors import cross_origin -from legal_api.core import Filing as FilingCore from legal_api.extensions import socketio from legal_api.models import ( Business, CorpType, - Filing, DCConnection, DCDefinition, - DCIssuedCredential, DCIssuedBusinessUserCredential, - PartyRole, - User + DCIssuedCredential, + User, ) from legal_api.services import digital_credentials from legal_api.utils.auth import jwt @@ -167,21 +164,21 @@ def send_credential(identifier, credential_type): business = Business.find_by_identifier(identifier) if not business: return jsonify({'message': f'{identifier} not found'}), HTTPStatus.NOT_FOUND - + user = User.find_by_jwt_token(_request_ctx_stack.top.current_user) if not user: return jsonify({'message': 'User not found'}, HTTPStatus.NOT_FOUND) connection = DCConnection.find_active_by(business_id=business.id) definition = DCDefinition.find_by(DCDefinition.CredentialType[credential_type], - digital_credentials.business_schema_id, + digital_credentials.business_schema_id, digital_credentials.business_cred_def_id) issued_credentials = DCIssuedCredential.find_by(dc_connection_id=connection.id, dc_definition_id=definition.id) if issued_credentials and issued_credentials[0].credential_exchange_id: return jsonify({'message': 'Already requested to issue credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR - + credential_data = _get_data_for_credential(definition.credential_type, business, user) credential_id = next((item['value'] for item in credential_data if item['name'] == 'credential_id'), None) @@ -293,7 +290,8 @@ def _get_data_for_credential(credential_type: DCDefinition.CredentialType, busin if credential_type == DCDefinition.CredentialType.business: # Find the credential id from dc_issued_business_user_credentials and if there isn't one create one - issued_business_user_credential = DCIssuedBusinessUserCredential.find_by(business_id=business.id, user_id=user.id) + issued_business_user_credential = DCIssuedBusinessUserCredential.find_by( + business_id=business.id, user_id=user.id) if not issued_business_user_credential: issued_business_user_credential = DCIssuedBusinessUserCredential(business_id=business.id, user_id=user.id) issued_business_user_credential.save() @@ -308,7 +306,7 @@ def _get_data_for_credential(credential_type: DCDefinition.CredentialType, busin registered_on_dateint = '' if business.founding_date: - registered_on_dateint = business.founding_date.strftime(f'%Y%m%d') + registered_on_dateint = business.founding_date.strftime('%Y%m%d') company_status = Business.State(business.state).name diff --git a/legal-api/tests/unit/services/test_digital_credentials.py b/legal-api/tests/unit/services/test_digital_credentials.py index d4d77dfab4..157de205c6 100644 --- a/legal-api/tests/unit/services/test_digital_credentials.py +++ b/legal-api/tests/unit/services/test_digital_credentials.py @@ -31,7 +31,7 @@ def test_init_app(session, app): # pylint:disable=unused-argument digital_credentials.init_app(app) definition = DCDefinition.find_by_credential_type(DCDefinition.CredentialType.business) assert definition.schema_id == schema_id - assert definition.schema_name == digital_credentials.business_schema['schema_name'] - assert definition.schema_version == digital_credentials.business_schema['schema_version'] + assert definition.schema_name == digital_credentials.business_schema_name + assert definition.schema_version == digital_credentials.business_schema_version assert definition.credential_definition_id == cred_def_id assert not definition.is_deleted From 8ad8cb9780b0e4c06b3a69177398d98248279dd2 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 31 Oct 2023 01:56:29 +0000 Subject: [PATCH 25/25] chore: fix tests Signed-off-by: Akiff Manji --- .../business/business_digital_credentials.py | 2 +- .../v2/test_business_digital_credentials.py | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index 85fbbf4c2d..96d0982c55 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -185,7 +185,7 @@ def send_credential(identifier, credential_type): response = digital_credentials.issue_credential( connection_id=connection.connection_id, definition=definition, - data=_get_data_for_credential(definition.credential_type, business, user) + data=credential_data ) if not response: return jsonify({'message': 'Failed to issue credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py b/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py index dec01a941e..6be5405970 100644 --- a/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py +++ b/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py @@ -21,7 +21,7 @@ from unittest.mock import patch from legal_api.services.authz import BASIC_USER -from legal_api.models import DCDefinition +from legal_api.models import DCDefinition, User from legal_api.services.digital_credentials import DigitalCredentialsService from tests.unit.models import factory_business @@ -89,17 +89,20 @@ def test_send_credential(session, client, jwt): # pylint:disable=unused-argumen headers = create_header(jwt, [BASIC_USER]) identifier = 'FM1234567' business = factory_business(identifier) - - create_dc_definition() + definition = create_dc_definition() + test_user = User(username='test-user', firstname='test', lastname='test') + test_user.save() create_dc_connection(business, is_active=True) + cred_ex_id = '3fa85f64-5717-4562-b3fc-2c963f66afa6' - with patch.object(DigitalCredentialsService, 'issue_credential', return_value={ - 'cred_ex_id': '3fa85f64-5717-4562-b3fc-2c963f66afa6'}): - rv = client.post( - f'/api/v2/businesses/{identifier}/digitalCredentials/{DCDefinition.CredentialType.business.name}', - headers=headers, content_type=content_type) - assert rv.status_code == HTTPStatus.OK - assert rv.json.get('message') == 'Credential offer has been sent.' + with patch.object(User, 'find_by_jwt_token', return_value=test_user): + with patch.object(DCDefinition, 'find_by', return_value=definition): + with patch.object(DigitalCredentialsService, 'issue_credential', return_value={'cred_ex_id': cred_ex_id}): + rv = client.post( + f'/api/v2/businesses/{identifier}/digitalCredentials/{DCDefinition.CredentialType.business.name}', + headers=headers, content_type=content_type) + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('credentialExchangeId') == cred_ex_id def test_get_issued_credentials(session, client, jwt): # pylint:disable=unused-argument