From 58e63e2bd32c3e45446c0b07ee64e813f4899eea Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 7 Sep 2023 16:40:57 +0000 Subject: [PATCH 01/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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 6beb1b1d4db8f9e4f5938f5da852a7d16a6ba2c6 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Fri, 27 Oct 2023 09:20:52 -0700 Subject: [PATCH 21/56] 18284: digital credentials (#2260) * feat: devcontainer configuraton for vscode Signed-off-by: Akiff Manji * feat: hard code digital business card schema Signed-off-by: Akiff Manji * feat: hard code digital business card schema Signed-off-by: Akiff Manji * feat: issue credentials through Traction tenant Signed-off-by: Akiff Manji * refactor: app initialization workflow Signed-off-by: Akiff Manji * feat: use out-of-band invitation for connecting Signed-off-by: Akiff Manji * feat: use v2.0 for issuing credential Signed-off-by: Akiff Manji * feat: web socket implmentation with flask-socketio Signed-off-by: Akiff Manji * feat: db migration script to enable revocation Signed-off-by: Akiff Manji * feat: revocation endpoint Signed-off-by: Akiff Manji * feat: replace endpoints Signed-off-by: Akiff Manji * chore: fix linting errors Signed-off-by: Akiff Manji * chore: update requirements Signed-off-by: Akiff Manji * chore: update tests Signed-off-by: Akiff Manji * feat: traction token exchanger Signed-off-by: Akiff Manji * chore: update workflow variables Signed-off-by: Akiff Manji * chore: update workflow variables Signed-off-by: Akiff Manji * refactor: ws cors setting is a config option Signed-off-by: Akiff Manji * chore: fix linting errors Signed-off-by: Akiff Manji * refactor: clean up init in digital credential service Signed-off-by: Akiff Manji --------- Signed-off-by: Akiff Manji --- .devcontainer/Dockerfile | 15 ++ .devcontainer/devcontainer.json | 31 +++ .devcontainer/docker-compose.yml | 35 +++ .github/workflows/legal-api-ci.yml | 4 + legal-api/Makefile | 5 +- ...0a5164_add_revocation_to_dc_credentials.py | 26 +++ .../8148a25d695e_change_field_type.py | 28 +++ legal-api/requirements.txt | 15 +- legal-api/requirements.txt.1 | 52 ----- legal-api/src/legal_api/__init__.py | 6 + legal-api/src/legal_api/config.py | 18 +- legal-api/src/legal_api/decorators.py | 56 +++++ legal-api/src/legal_api/extensions.py | 25 +++ .../src/legal_api/models/dc_connection.py | 5 + .../src/legal_api/models/dc_definition.py | 16 +- .../legal_api/models/dc_issued_credential.py | 25 ++- .../business/business_digital_credentials.py | 193 ++++++++++++---- .../legal_api/services/digital_credentials.py | 208 +++++++++++------- .../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 | 5 +- 22 files changed, 605 insertions(+), 234 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 legal-api/migrations/versions/6b65b40a5164_add_revocation_to_dc_credentials.py create mode 100644 legal-api/migrations/versions/8148a25d695e_change_field_type.py delete mode 100644 legal-api/requirements.txt.1 create mode 100644 legal-api/src/legal_api/decorators.py create mode 100644 legal-api/src/legal_api/extensions.py 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..6d9f05ae90 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +// 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", + + // 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/.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: diff --git a/.github/workflows/legal-api-ci.yml b/.github/workflows/legal-api-ci.yml index 52b422ce67..11b959fbd8 100644 --- a/.github/workflows/legal-api-ci.yml +++ b/.github/workflows/legal-api-ci.yml @@ -64,6 +64,10 @@ 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 + BUSINESS_SCHEMA_NAME: digital_business_card + BUSINESS_SCHEMA_VERSION: "1.0.0" runs-on: ubuntu-20.04 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/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/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/requirements.txt b/legal-api/requirements.txt index 2290e9f2c3..5691dc745b 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,23 +32,29 @@ 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.13#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 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 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 +62,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..386a7ea04f 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 @@ -67,6 +68,11 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): register_shellcontext(app) + ws_allowed_origins = app.config.get('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) + return app diff --git a/legal-api/src/legal_api/config.py b/legal-api/src/legal_api/config.py index 283dc81b81..2d4a7fb5ce 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -144,9 +144,21 @@ 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_ENTITY_DID = os.getenv('ACA_PY_ENTITY_DID') + # 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') + BUSINESS_CRED_DEF_ID = os.getenv('BUSINESS_CRED_DEF_ID') TESTING = False DEBUG = False diff --git a/legal-api/src/legal_api/decorators.py b/legal-api/src/legal_api/decorators.py new file mode 100644 index 0000000000..0153b26316 --- /dev/null +++ b/legal-api/src/legal_api/decorators.py @@ -0,0 +1,56 @@ +# 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 +from jwt import ExpiredSignatureError + + +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, 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'}, + 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 new file mode 100644 index 0000000000..e87a05d7ac --- /dev/null +++ b/legal-api/src/legal_api/extensions.py @@ -0,0 +1,25 @@ +# 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. +"""Extensions module.""" +from flask import current_app +from flask_socketio import SocketIO + + +socketio = SocketIO() + + +@socketio.on('connect') +def on_connect(): + """Handle socket connection.""" + current_app.logger.debug('Socket connected to client') 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_definition.py b/legal-api/src/legal_api/models/dc_definition.py index 0094c8ff43..9f5eaf434d 100644 --- a/legal-api/src/legal_api/models/dc_definition.py +++ b/legal-api/src/legal_api/models/dc_definition.py @@ -83,14 +83,16 @@ 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.credential_type == credential_type). \ - filter(DCDefinition.schema_name == schema_name). \ - filter(DCDefinition.schema_version == schema_version) - + 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/src/legal_api/models/dc_issued_credential.py b/legal-api/src/legal_api/models/dc_issued_credential.py index ac56d1296e..70d71dedc7 100644 --- a/legal-api/src/legal_api/models/dc_issued_credential.py +++ b/legal-api/src/legal_api/models/dc_issued_credential.py @@ -30,11 +30,13 @@ 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)) 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): @@ -44,9 +46,12 @@ 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 + 'isRevoked': self.is_revoked, + 'credentialRevocationId': self.credential_revocation_id, + 'revocationRegistryId': self.revocation_registry_id } return dc_issued_credential @@ -55,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.""" @@ -65,13 +75,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 3d9f040b60..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 @@ -19,6 +19,7 @@ from flask import Blueprint, current_app, jsonify, request from flask_cors import cross_origin +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 @@ -42,7 +43,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] @@ -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', @@ -60,23 +60,43 @@ 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 + connections = DCConnection.find_by(business_id=business.id) + if len(connections) == 0: + return jsonify({'connections': []}), HTTPStatus.OK + + response = [] + for connection in connections: + 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': 'No active connection found.'}), HTTPStatus.NOT_FOUND + return jsonify({'message': f'{identifier} active connection not found.'}), HTTPStatus.NOT_FOUND - return jsonify(connection.json), HTTPStatus.OK + connection.delete() + return jsonify({'message': 'Connection has been deleted.'}), HTTPStatus.OK @bp.route('//digitalCredentials', methods=['GET', 'OPTIONS'], strict_slashes=False) @@ -102,6 +122,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 @@ -137,68 +158,146 @@ 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'], + # 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 -def _get_data_for_credential(credential_type: DCDefinition.CredentialType, business: Business): - if credential_type == DCDefinition.CredentialType.business: - return [ - { - 'name': 'legalName', - 'value': business.legal_name - }, - { - 'name': 'foundingDate', - 'value': business.founding_date.isoformat() - }, - { - 'name': 'taxId', - 'value': business.tax_id or '' - }, - { - 'name': 'homeJurisdiction', - 'value': 'BC' # for corp types that are not -xpro, the jurisdiction is BC - }, - { - 'name': 'legalType', - 'value': business.legal_type - }, - { - 'name': 'identifier', - 'value': business.identifier - } - ] +@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 - return None + 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 -@bp_dc.route('/topic/', methods=['POST'], strict_slashes=False) + revoked = digital_credentials.revoke_credential(connection.connection_id, + issued_credential.credential_revocation_id, + issued_credential.revocation_registry_id) + 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 + + +@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): """To receive notification from aca-py admin api.""" 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'] 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']) + # 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': - issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['credential_exchange_id']) - if issued_credential and json_input['state'] == 'credential_issued': + 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): + if credential_type == DCDefinition.CredentialType.business: + return [ + { + 'name': 'credential_id', + 'value': '' + }, + { + 'name': 'identifier', + 'value': business.identifier + }, + { + 'name': 'business_name', + 'value': business.legal_name + }, + { + 'name': 'business_type', + 'value': business.legal_type + }, + { + 'name': 'cra_business_number', + 'value': business.tax_id or '' + }, + { + 'name': 'registered_on_dateint', + 'value': business.founding_date.isoformat() + }, + { + 'name': 'company_status', + 'value': business.state + }, + { + 'name': 'family_name', + 'value': '' + }, + { + 'name': 'given_names', + 'value': '' + }, + { + 'name': 'role', + 'value': '' + } + ] + + 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 f606fc8814..08ba53fefc 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -21,116 +21,146 @@ import requests +from legal_api.decorators import requires_traction_auth from legal_api.models import DCDefinition 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 - } - def __init__(self): """Initialize this object.""" self.app = None self.api_url = None - self.api_key = None - self.entity_did = None + self.api_token = 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.""" 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.entity_did = app.config.get('ACA_PY_ENTITY_DID') + self.api_url = app.config.get('TRACTION_API_URL') + 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() - - def _register_business(self): - """Register business schema and credential definition.""" - # 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'] - ) - - 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 - else: - # deactivate any existing schema definition before registering new one - DCDefinition.deactivate(DCDefinition.CredentialType.business) - - schema_id = self._register_schema(self.business_schema) - definition = DCDefinition( + 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('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('Environment variable: BUSINESS_CRED_DEF_ID must be configured') + raise ValueError('Environment variable: BUSINESS_CRED_DEF_ID must be configured') + + ### + # 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 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 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_name=self.business_schema['schema_name'], - schema_version=self.business_schema['schema_version'], - schema_id=schema_id + schema_id=self.business_schema_id, + credential_definition_id=self.business_cred_def_id ) - definition.save() - if not definition.credential_definition_id: - definition.credential_definition_id = self._register_credential_definitions(definition.schema_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, + 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() + return None + except Exception as err: + self.app.logger.error(err) + return None - def _register_schema(self, schema: dict) -> Optional[str]: - """Send a schema to the ledger.""" + @requires_traction_auth + 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 = requests.get(self.api_url + '/schema-storage', + params={'schema_id': schema_id}, + headers=self._get_headers()) response.raise_for_status() - return response.json()['schema_id'] + 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 register digital credential schema {schema['schema_name']}:{schema['schema_version']}") + 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 _register_credential_definitions(self, schema_id: str) -> Optional[str]: - """Send a credential definition to the ledger.""" + @requires_traction_auth + 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': True, - 'tag': 'business_schema' - })) + 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 + @requires_traction_auth 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: self.app.logger.error(err) return None + @requires_traction_auth def issue_credential(self, connection_id: str, definition: DCDefinition, @@ -138,22 +168,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.entity_did, - 'schema_id': definition.schema_id, - 'schema_issuer_did': self.entity_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() @@ -162,8 +196,28 @@ 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: + 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', - 'X-API-KEY': self.api_key + 'Authorization': f'Bearer {self.app.api_token}' } 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 5d42ecd85f..f5a0bd6201 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 +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, debug=False, port=server_port, host='0.0.0.0') From 87d0854e48a52d2cfe3a03c3693dfc3af8260f40 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Sat, 28 Oct 2023 21:28:49 +0000 Subject: [PATCH 22/56] 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 23/56] 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 24/56] 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 205dd681fdce00c272178b5dcd1b522453af3ef4 Mon Sep 17 00:00:00 2001 From: Argus Chiu Date: Mon, 30 Oct 2023 16:05:53 -0700 Subject: [PATCH 25/56] 18284 Add pre-fork server hook to gunicorn config (#2285) --- legal-api/gunicorn_config.py | 4 ++++ legal-api/gunicorn_server.py | 10 ++++++++++ 2 files changed, 14 insertions(+) create mode 100644 legal-api/gunicorn_server.py diff --git a/legal-api/gunicorn_config.py b/legal-api/gunicorn_config.py index c4eb50acf2..dbcc1e0b51 100755 --- a/legal-api/gunicorn_config.py +++ b/legal-api/gunicorn_config.py @@ -16,9 +16,13 @@ """ import os +import gunicorn_server workers = int(os.environ.get('GUNICORN_PROCESSES', '1')) # pylint: disable=invalid-name threads = int(os.environ.get('GUNICORN_THREADS', '1')) # pylint: disable=invalid-name forwarded_allow_ips = '*' # pylint: disable=invalid-name secure_scheme_headers = {'X-Forwarded-Proto': 'https'} # pylint: disable=invalid-name + +# Server Hooks +pre_fork = gunicorn_server.pre_fork diff --git a/legal-api/gunicorn_server.py b/legal-api/gunicorn_server.py new file mode 100644 index 0000000000..31e124b99e --- /dev/null +++ b/legal-api/gunicorn_server.py @@ -0,0 +1,10 @@ + +import time + + +def pre_fork(server, worker): + # Delay loading of each worker by 5 seconds + # This is done to work around an issue where the Traction API is returning an invalid token. The issue happens + # when successive token retrieval calls are made with less than 2-3 seconds between the calls. + time.sleep(5) + From c55cebc88d709dcbfa11bc3001382a7a7d5c45d7 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 30 Oct 2023 23:21:04 +0000 Subject: [PATCH 26/56] 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 27/56] 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 From 65a9a4de4581f8fa0478aa27dddded5037ad3a7d Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 31 Oct 2023 08:58:54 -0700 Subject: [PATCH 28/56] 18284 feat: digital credentials (#2281) * feat: devcontainer configuraton for vscode Signed-off-by: Akiff Manji * feat: hard code digital business card schema Signed-off-by: Akiff Manji * feat: hard code digital business card schema Signed-off-by: Akiff Manji * feat: issue credentials through Traction tenant Signed-off-by: Akiff Manji * refactor: app initialization workflow Signed-off-by: Akiff Manji * feat: use out-of-band invitation for connecting Signed-off-by: Akiff Manji * feat: use v2.0 for issuing credential Signed-off-by: Akiff Manji * feat: web socket implmentation with flask-socketio Signed-off-by: Akiff Manji * feat: db migration script to enable revocation Signed-off-by: Akiff Manji * feat: revocation endpoint Signed-off-by: Akiff Manji * feat: replace endpoints Signed-off-by: Akiff Manji * chore: fix linting errors Signed-off-by: Akiff Manji * chore: update requirements Signed-off-by: Akiff Manji * chore: update tests Signed-off-by: Akiff Manji * feat: traction token exchanger Signed-off-by: Akiff Manji * chore: update workflow variables Signed-off-by: Akiff Manji * chore: update workflow variables Signed-off-by: Akiff Manji * refactor: ws cors setting is a config option Signed-off-by: Akiff Manji * chore: fix linting errors Signed-off-by: Akiff Manji * refactor: clean up init in digital credential service Signed-off-by: Akiff Manji * feat: endpoints to reset credential offers Signed-off-by: Akiff Manji * feat: credential id lookup table Signed-off-by: Akiff Manji * feat: add business roles Signed-off-by: Akiff Manji * chore: fix tests and linting Signed-off-by: Akiff Manji * chore: fix tests Signed-off-by: Akiff Manji --------- Signed-off-by: Akiff Manji --- ...reate_issued_business_user_credentials_.py | 30 +++++ legal-api/src/legal_api/models/__init__.py | 8 +- .../dc_issued_business_user_credential.py | 49 ++++++++ .../legal_api/models/dc_issued_credential.py | 2 +- .../business/business_digital_credentials.py | 107 ++++++++++++++---- .../v2/test_business_digital_credentials.py | 23 ++-- .../unit/services/test_digital_credentials.py | 4 +- 7 files changed, 183 insertions(+), 40 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..fc7fa5b6ec 100644 --- a/legal-api/src/legal_api/models/__init__.py +++ b/legal-api/src/legal_api/models/__init__.py @@ -23,6 +23,7 @@ from .corp_type import CorpType from .dc_connection import DCConnection from .dc_definition import DCDefinition +from .dc_issued_business_user_credential import DCIssuedBusinessUserCredential from .dc_issued_credential import DCIssuedCredential from .document import Document, DocumentType from .filing import Filing @@ -40,6 +41,7 @@ __all__ = ('db', 'Address', 'Alias', 'Business', 'ColinLastUpdate', 'Comment', 'ConsentContinuationOut', 'CorpType', - 'DCConnection', 'DCDefinition', 'DCIssuedCredential', '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/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/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..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 @@ -16,11 +16,19 @@ from datetime import datetime from http import HTTPStatus -from flask import Blueprint, current_app, jsonify, request +from flask import Blueprint, _request_ctx_stack, current_app, jsonify, request 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, + DCIssuedBusinessUserCredential, + DCIssuedCredential, + User, +) from legal_api.services import digital_credentials from legal_api.utils.auth import jwt @@ -82,10 +90,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: @@ -139,18 +165,27 @@ def send_credential(identifier, credential_type): 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=credential_data ) if not response: return jsonify({'message': 'Failed to issue credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR @@ -159,12 +194,11 @@ def send_credential(identifier, credential_type): 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() - return jsonify({'message': 'Credential offer has been sent.'}), HTTPStatus.OK + return jsonify(issued_credential.json), HTTPStatus.OK @bp.route('//digitalCredentials//revoke', @@ -181,8 +215,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 @@ -206,8 +239,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 @@ -241,7 +273,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() @@ -255,24 +286,52 @@ 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('%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() + + roles = ', '.join([party_role.role.title() for party_role in business.party_roles.all() if party_role.role]) + 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', @@ -280,23 +339,23 @@ 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', - 'value': '' + 'value': roles or '' } ] 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 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 d3686732070f9ab6dde78932e7fadd85d8be536b Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 1 Nov 2023 00:08:57 +0000 Subject: [PATCH 29/56] refactor: remove records from Traction on deletion Signed-off-by: Akiff Manji --- .../business/business_digital_credentials.py | 15 ++++++++++++ .../legal_api/services/digital_credentials.py | 24 +++++++++++++++++++ 2 files changed, 39 insertions(+) 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 96d0982c55..775da5acb5 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 @@ -104,6 +104,11 @@ def delete_connection(identifier, connection_id): if not connection: return jsonify({'message': f'{identifier} connection not found.'}), HTTPStatus.NOT_FOUND + try: + digital_credentials.remove_connection_record(connection_id=connection.connection_id) + except Exception: + return jsonify({'message': 'Failed to remove connection record.'}), HTTPStatus.INTERNAL_SERVER_ERROR + connection.delete() return jsonify({'message': 'Connection has been deleted.'}), HTTPStatus.OK @@ -121,6 +126,11 @@ def delete_active_connection(identifier): if not connection: return jsonify({'message': f'{identifier} active connection not found.'}), HTTPStatus.NOT_FOUND + try: + digital_credentials.remove_connection_record(connection_id=connection.connection_id) + except Exception: + return jsonify({'message': 'Failed to remove connection record.'}), HTTPStatus.INTERNAL_SERVER_ERROR + connection.delete() return jsonify({'message': 'Connection has been deleted.'}), HTTPStatus.OK @@ -243,6 +253,11 @@ def delete_credential(identifier, credential_id): if not issued_credential: return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND + try: + digital_credentials.remove_credential_exchange_record(issued_credential.credential_exchange_id) + except Exception: + return jsonify({'message': 'Failed to remove credential exchange record.'}), HTTPStatus.INTERNAL_SERVER_ERROR + issued_credential.delete() return jsonify({'message': 'Credential has been deleted.'}), HTTPStatus.OK diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 08ba53fefc..f0efba0c76 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -216,6 +216,30 @@ def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str) -> self.app.logger.error(err) return None + @requires_traction_auth + def remove_connection_record(self, connection_id: str) -> Optional[dict]: + """Delete a connection.""" + try: + response = requests.delete(self.api_url + '/connections/' + connection_id, + headers=self._get_headers()) + response.raise_for_status() + return response.json() + except Exception as err: + self.app.logger.error(err) + return None + + @requires_traction_auth + def remove_credential_exchange_record(self, cred_ex_id: str) -> Optional[dict]: + """Delete a credential exchange.""" + try: + response = requests.delete(self.api_url + '/issue-credential-2.0/records/' + cred_ex_id, + headers=self._get_headers()) + 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 0b3b60b4f0a1e7da24b26d9b73e20a0ed021ce0c Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 1 Nov 2023 20:18:06 +0000 Subject: [PATCH 30/56] Revert "feat: web socket implmentation with flask-socketio" This reverts commit 79a2631fb101222a55a2d8a226ab9ca2b166906a. Signed-off-by: Akiff Manji --- legal-api/Makefile | 5 +--- legal-api/requirements.txt | 14 +++++------ legal-api/src/legal_api/__init__.py | 6 ----- legal-api/src/legal_api/extensions.py | 25 ------------------- .../business/business_digital_credentials.py | 3 --- legal-api/wsgi.py | 3 +-- 6 files changed, 9 insertions(+), 47 deletions(-) delete mode 100644 legal-api/src/legal_api/extensions.py diff --git a/legal-api/Makefile b/legal-api/Makefile index ea26d5527e..37e2570c84 100644 --- a/legal-api/Makefile +++ b/legal-api/Makefile @@ -131,10 +131,7 @@ tag: push ## tag image # COMMANDS - Local # ################################################################################# run: ## Run the project in local - . 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 + . venv/bin/activate && python -m flask run -p 5000 ################################################################################# # Self Documenting Commands # diff --git a/legal-api/requirements.txt b/legal-api/requirements.txt index 5691dc745b..d5e70804af 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -6,16 +6,15 @@ 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 @@ -32,15 +31,11 @@ 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.13#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 @@ -54,7 +49,6 @@ 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 @@ -62,3 +56,9 @@ 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/src/legal_api/__init__.py b/legal-api/src/legal_api/__init__.py index 386a7ea04f..e0d2fa6474 100644 --- a/legal-api/src/legal_api/__init__.py +++ b/legal-api/src/legal_api/__init__.py @@ -24,7 +24,6 @@ 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 @@ -68,11 +67,6 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): register_shellcontext(app) - ws_allowed_origins = app.config.get('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) - return app diff --git a/legal-api/src/legal_api/extensions.py b/legal-api/src/legal_api/extensions.py deleted file mode 100644 index e87a05d7ac..0000000000 --- a/legal-api/src/legal_api/extensions.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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. -"""Extensions module.""" -from flask import current_app -from flask_socketio import SocketIO - - -socketio = SocketIO() - - -@socketio.on('connect') -def on_connect(): - """Handle socket connection.""" - current_app.logger.debug('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 775da5acb5..27e09fed3f 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,7 +19,6 @@ from flask import Blueprint, _request_ctx_stack, current_app, jsonify, request from flask_cors import cross_origin -from legal_api.extensions import socketio from legal_api.models import ( Business, CorpType, @@ -280,7 +279,6 @@ 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 == 'issuer_cred_rev': issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['cred_ex_id']) if issued_credential and json_input['state'] == 'issued': @@ -293,7 +291,6 @@ def webhook_notification(topic_name: str): 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 f5a0bd6201..18500b113f 100755 --- a/legal-api/wsgi.py +++ b/legal-api/wsgi.py @@ -16,11 +16,10 @@ 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') - socketio.run(application, debug=False, port=server_port, host='0.0.0.0') + application.run(debug=False, port=server_port, host='0.0.0.0') From 9763a176f47f53eaf84982cd4aec5178c2a409cc Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 1 Nov 2023 20:35:44 +0000 Subject: [PATCH 31/56] fix: port so it doesnt overlap with airplay server on OSX Signed-off-by: Akiff Manji --- legal-api/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/legal-api/Makefile b/legal-api/Makefile index 37e2570c84..8417b823cc 100644 --- a/legal-api/Makefile +++ b/legal-api/Makefile @@ -131,7 +131,7 @@ 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 ################################################################################# # Self Documenting Commands # From 633e3d9e8e9e7ba529e5b703aaaa04a2e939b911 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 1 Nov 2023 20:50:15 +0000 Subject: [PATCH 32/56] Revert "fix: port so it doesnt overlap with airplay server on OSX" This reverts commit 9763a176f47f53eaf84982cd4aec5178c2a409cc. Signed-off-by: Akiff Manji --- legal-api/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/legal-api/Makefile b/legal-api/Makefile index 8417b823cc..37e2570c84 100644 --- a/legal-api/Makefile +++ b/legal-api/Makefile @@ -131,7 +131,7 @@ tag: push ## tag image # COMMANDS - Local # ################################################################################# run: ## Run the project in local - . venv/bin/activate && python -m flask run -p 5050 + . venv/bin/activate && python -m flask run -p 5000 ################################################################################# # Self Documenting Commands # From 2976d11c4c5abc95ac5d0b1bf26c64ce495a13c0 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 1 Nov 2023 14:01:49 -0700 Subject: [PATCH 33/56] feat: digital credentials (#2287) * feat: devcontainer configuraton for vscode Signed-off-by: Akiff Manji * feat: hard code digital business card schema Signed-off-by: Akiff Manji * feat: hard code digital business card schema Signed-off-by: Akiff Manji * feat: issue credentials through Traction tenant Signed-off-by: Akiff Manji * refactor: app initialization workflow Signed-off-by: Akiff Manji * feat: use out-of-band invitation for connecting Signed-off-by: Akiff Manji * feat: use v2.0 for issuing credential Signed-off-by: Akiff Manji * feat: web socket implmentation with flask-socketio Signed-off-by: Akiff Manji * feat: db migration script to enable revocation Signed-off-by: Akiff Manji * feat: revocation endpoint Signed-off-by: Akiff Manji * feat: replace endpoints Signed-off-by: Akiff Manji * chore: fix linting errors Signed-off-by: Akiff Manji * chore: update requirements Signed-off-by: Akiff Manji * chore: update tests Signed-off-by: Akiff Manji * feat: traction token exchanger Signed-off-by: Akiff Manji * chore: update workflow variables Signed-off-by: Akiff Manji * chore: update workflow variables Signed-off-by: Akiff Manji * refactor: ws cors setting is a config option Signed-off-by: Akiff Manji * chore: fix linting errors Signed-off-by: Akiff Manji * refactor: clean up init in digital credential service Signed-off-by: Akiff Manji * feat: endpoints to reset credential offers Signed-off-by: Akiff Manji * feat: credential id lookup table Signed-off-by: Akiff Manji * feat: add business roles Signed-off-by: Akiff Manji * chore: fix tests and linting Signed-off-by: Akiff Manji * chore: fix tests Signed-off-by: Akiff Manji * refactor: remove records from Traction on deletion Signed-off-by: Akiff Manji * Revert "feat: web socket implmentation with flask-socketio" This reverts commit 79a2631fb101222a55a2d8a226ab9ca2b166906a. Signed-off-by: Akiff Manji * fix: port so it doesnt overlap with airplay server on OSX Signed-off-by: Akiff Manji * Revert "fix: port so it doesnt overlap with airplay server on OSX" This reverts commit 9763a176f47f53eaf84982cd4aec5178c2a409cc. Signed-off-by: Akiff Manji --------- Signed-off-by: Akiff Manji --- legal-api/Makefile | 5 +--- legal-api/requirements.txt | 14 +++++------ legal-api/src/legal_api/__init__.py | 6 ----- legal-api/src/legal_api/extensions.py | 25 ------------------- .../business/business_digital_credentials.py | 18 ++++++++++--- .../legal_api/services/digital_credentials.py | 24 ++++++++++++++++++ legal-api/wsgi.py | 3 +-- 7 files changed, 48 insertions(+), 47 deletions(-) delete mode 100644 legal-api/src/legal_api/extensions.py diff --git a/legal-api/Makefile b/legal-api/Makefile index ea26d5527e..37e2570c84 100644 --- a/legal-api/Makefile +++ b/legal-api/Makefile @@ -131,10 +131,7 @@ tag: push ## tag image # COMMANDS - Local # ################################################################################# run: ## Run the project in local - . 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 + . venv/bin/activate && python -m flask run -p 5000 ################################################################################# # Self Documenting Commands # diff --git a/legal-api/requirements.txt b/legal-api/requirements.txt index 5691dc745b..d5e70804af 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -6,16 +6,15 @@ 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 @@ -32,15 +31,11 @@ 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.13#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 @@ -54,7 +49,6 @@ 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 @@ -62,3 +56,9 @@ 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/src/legal_api/__init__.py b/legal-api/src/legal_api/__init__.py index 386a7ea04f..e0d2fa6474 100644 --- a/legal-api/src/legal_api/__init__.py +++ b/legal-api/src/legal_api/__init__.py @@ -24,7 +24,6 @@ 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 @@ -68,11 +67,6 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): register_shellcontext(app) - ws_allowed_origins = app.config.get('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) - return app diff --git a/legal-api/src/legal_api/extensions.py b/legal-api/src/legal_api/extensions.py deleted file mode 100644 index e87a05d7ac..0000000000 --- a/legal-api/src/legal_api/extensions.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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. -"""Extensions module.""" -from flask import current_app -from flask_socketio import SocketIO - - -socketio = SocketIO() - - -@socketio.on('connect') -def on_connect(): - """Handle socket connection.""" - current_app.logger.debug('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 96d0982c55..27e09fed3f 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,7 +19,6 @@ from flask import Blueprint, _request_ctx_stack, current_app, jsonify, request from flask_cors import cross_origin -from legal_api.extensions import socketio from legal_api.models import ( Business, CorpType, @@ -104,6 +103,11 @@ def delete_connection(identifier, connection_id): if not connection: return jsonify({'message': f'{identifier} connection not found.'}), HTTPStatus.NOT_FOUND + try: + digital_credentials.remove_connection_record(connection_id=connection.connection_id) + except Exception: + return jsonify({'message': 'Failed to remove connection record.'}), HTTPStatus.INTERNAL_SERVER_ERROR + connection.delete() return jsonify({'message': 'Connection has been deleted.'}), HTTPStatus.OK @@ -121,6 +125,11 @@ def delete_active_connection(identifier): if not connection: return jsonify({'message': f'{identifier} active connection not found.'}), HTTPStatus.NOT_FOUND + try: + digital_credentials.remove_connection_record(connection_id=connection.connection_id) + except Exception: + return jsonify({'message': 'Failed to remove connection record.'}), HTTPStatus.INTERNAL_SERVER_ERROR + connection.delete() return jsonify({'message': 'Connection has been deleted.'}), HTTPStatus.OK @@ -243,6 +252,11 @@ def delete_credential(identifier, credential_id): if not issued_credential: return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND + try: + digital_credentials.remove_credential_exchange_record(issued_credential.credential_exchange_id) + except Exception: + return jsonify({'message': 'Failed to remove credential exchange record.'}), HTTPStatus.INTERNAL_SERVER_ERROR + issued_credential.delete() return jsonify({'message': 'Credential has been deleted.'}), HTTPStatus.OK @@ -265,7 +279,6 @@ 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 == 'issuer_cred_rev': issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input['cred_ex_id']) if issued_credential and json_input['state'] == 'issued': @@ -278,7 +291,6 @@ def webhook_notification(topic_name: str): 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/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 08ba53fefc..f0efba0c76 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -216,6 +216,30 @@ def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str) -> self.app.logger.error(err) return None + @requires_traction_auth + def remove_connection_record(self, connection_id: str) -> Optional[dict]: + """Delete a connection.""" + try: + response = requests.delete(self.api_url + '/connections/' + connection_id, + headers=self._get_headers()) + response.raise_for_status() + return response.json() + except Exception as err: + self.app.logger.error(err) + return None + + @requires_traction_auth + def remove_credential_exchange_record(self, cred_ex_id: str) -> Optional[dict]: + """Delete a credential exchange.""" + try: + response = requests.delete(self.api_url + '/issue-credential-2.0/records/' + cred_ex_id, + headers=self._get_headers()) + 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', diff --git a/legal-api/wsgi.py b/legal-api/wsgi.py index f5a0bd6201..18500b113f 100755 --- a/legal-api/wsgi.py +++ b/legal-api/wsgi.py @@ -16,11 +16,10 @@ 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') - socketio.run(application, debug=False, port=server_port, host='0.0.0.0') + application.run(debug=False, port=server_port, host='0.0.0.0') From 0224805558ee7148e423d320fa20cdd99c04839c Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 24 Oct 2023 03:50:05 +0000 Subject: [PATCH 34/56] feat: base and scaffolding queue Signed-off-by: Akiff Manji --- .devcontainer/devcontainer.json | 14 +- .devcontainer/docker-compose.yml | 13 +- .../entity-digital-credentials/.env.sample | 82 ++++++++ .../entity-digital-credentials/.envrc | 6 + .../entity-digital-credentials/LICENSE | 13 ++ .../entity-digital-credentials/Makefile | 144 ++++++++++++++ .../entity-digital-credentials/README.md | 46 +++++ .../digital_credentials_service.py | 32 +++ .../entity-digital-credentials/q_cli.py | 130 ++++++++++++ .../requirements.txt | 24 +++ .../requirements/bcregistry-libraries.txt | 3 + .../requirements/dev.txt | 31 +++ .../requirements/prod.txt | 16 ++ .../entity-digital-credentials/setup.cfg | 120 +++++++++++ .../entity-digital-credentials/setup.py | 70 +++++++ .../entity_digital_credentials/__init__.py | 17 ++ .../src/entity_digital_credentials/config.py | 187 ++++++++++++++++++ .../src/entity_digital_credentials/version.py | 25 +++ .../src/entity_digital_credentials/worker.py | 75 +++++++ 19 files changed, 1038 insertions(+), 10 deletions(-) create mode 100644 queue_services/entity-digital-credentials/.env.sample create mode 100644 queue_services/entity-digital-credentials/.envrc create mode 100644 queue_services/entity-digital-credentials/LICENSE create mode 100644 queue_services/entity-digital-credentials/Makefile create mode 100755 queue_services/entity-digital-credentials/README.md create mode 100644 queue_services/entity-digital-credentials/digital_credentials_service.py create mode 100644 queue_services/entity-digital-credentials/q_cli.py create mode 100644 queue_services/entity-digital-credentials/requirements.txt create mode 100644 queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt create mode 100755 queue_services/entity-digital-credentials/requirements/dev.txt create mode 100755 queue_services/entity-digital-credentials/requirements/prod.txt create mode 100644 queue_services/entity-digital-credentials/setup.cfg create mode 100644 queue_services/entity-digital-credentials/setup.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/__init__.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/version.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6d9f05ae90..e18b047ad8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,23 +9,21 @@ "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], - + "forwardPorts": [ + 5432, + 4222, + 8222 + ], // 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", - // Enable this on OSX to add ssh key to agent inside container "initializeCommand": "find ~/.ssh/ -type f -exec grep -l 'PRIVATE' {} \\; | xargs ssh-add" -} +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f2e9705b07..ffae8d6cf0 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -13,8 +13,8 @@ services: command: sleep infinity # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. - network_mode: service:db - + network_mode: host + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. # (Adding the "ports" property to this file will not forward from a Codespace.) @@ -30,6 +30,15 @@ services: # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. # (Adding the "ports" property to this file will not forward from a Codespace.) + network_mode: host + + nats: + image: nats-streaming + restart: unless-stopped + ports: + - 4222:4222 + - 8222:8222 + network_mode: host volumes: postgres-data: diff --git a/queue_services/entity-digital-credentials/.env.sample b/queue_services/entity-digital-credentials/.env.sample new file mode 100644 index 0000000000..4a9e54b284 --- /dev/null +++ b/queue_services/entity-digital-credentials/.env.sample @@ -0,0 +1,82 @@ + +# Flask +FLASK_ENV= + +# TODO: Can remove these +ACCOUNT_SVC_AUTH_URL= +ACCOUNT_SVC_CLIENT_ID= +ACCOUNT_SVC_CLIENT_SECRET= +APP_FILE= +AUTH_URL= +DASHBOARD_URL= + + +LEGAL_API_URL= +#LEGAL_API_URL= +NOTIFY_API_URL= +#NOTIFY_API_URL= +PAY_API_URL= +#PAY_API_URL= +#SENTRY_DSN= +TEMPLATE_PATH= + + +NAMEX_AUTH_SVC_URL= +NAMEX_SERVICE_CLIENT_SECRET= +NAMEX_SERVICE_CLIENT_USERNAME= +NAMEX_SVC_URL= + +# namex config TEST +#NAMEX_AUTH_SVC_URL= +#NAMEX_SERVICE_CLIENT_USERNAME= +#NAMEX_SERVICE_CLIENT_SECRET= +#NAMEX_SVC_URL= + + +### SQL Alchemy +DATABASE_USERNAME= +DATABASE_PASSWORD= +DATABASE_NAME= +DATABASE_HOST= +DATABASE_PORT= + +DATABASE_TEST_USERNAME= +DATABASE_TEST_PASSWORD= +DATABASE_TEST_NAME= +DATABASE_TEST_HOST= +DATABASE_TEST_PORT= + + +TRACKER_DATABASE_USERNAME= +TRACKER_DATABASE_PASSWORD= +TRACKER_DATABASE_NAME= +TRACKER_DATABASE_HOST= +TRACKER_DATABASE_PORT= + +TRACKER_DATABASE_TEST_USERNAME= +TRACKER_DATABASE_TEST_PASSWORD= +TRACKER_DATABASE_TEST_NAME= +TRACKER_DATABASE_TEST_HOST= +TRACKER_DATABASE_TEST_PORT= + + +## ## NATS - STAN +NATS_SERVERS= +NATS_CLIENT_NAME= +NATS_CLUSTER_ID= +NATS_EMAILER_SUBJECT= +NATS_ENTITY_EVENT_SUBJECT= +#NATS_QUEUE= +NATS_QUEUE= +STAN_CLUSTER_NAME= + +# ## NATS - STAN - DEV +#NATS_SERVERS= +#NATS_CLIENT_NAME= +#NATS_CLUSTER_ID= +#NATS_EMAILER_SUBJECT= +#NATS_ENTITY_EVENT_SUBJECT= +#NATS_QUEUE= + + +#DEPLOYMENT_ENV= diff --git a/queue_services/entity-digital-credentials/.envrc b/queue_services/entity-digital-credentials/.envrc new file mode 100644 index 0000000000..64fff9a69f --- /dev/null +++ b/queue_services/entity-digital-credentials/.envrc @@ -0,0 +1,6 @@ +while read -r line; do + echo $line + [[ "$line" =~ ^#.*$ ]] && continue + export $line +done < .env +source venv/bin/activate diff --git a/queue_services/entity-digital-credentials/LICENSE b/queue_services/entity-digital-credentials/LICENSE new file mode 100644 index 0000000000..efe5d4e2d1 --- /dev/null +++ b/queue_services/entity-digital-credentials/LICENSE @@ -0,0 +1,13 @@ +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. diff --git a/queue_services/entity-digital-credentials/Makefile b/queue_services/entity-digital-credentials/Makefile new file mode 100644 index 0000000000..0c88cd374a --- /dev/null +++ b/queue_services/entity-digital-credentials/Makefile @@ -0,0 +1,144 @@ +.PHONY: license +.PHONY: setup +.PHONY: ci cd +.PHONY: run + +MKFILE_PATH:=$(abspath $(lastword $(MAKEFILE_LIST))) +CURRENT_ABS_DIR:=$(patsubst %/,%,$(dir $(MKFILE_PATH))) + +PROJECT_NAME:=entity_digital_credentials +DOCKER_NAME:=entity-digital-credentials + +################################################################################# +# COMMANDS -- Setup # +################################################################################# +setup: install install-dev ## Setup the project + +clean: clean-build clean-pyc clean-test ## Clean the project + rm -rf venv/ + +clean-build: ## Clean build files + rm -fr build/ + rm -fr dist/ + rm -fr .eggs/ + find . -name '*.egg-info' -exec rm -fr {} + + find . -name '*.egg' -exec rm -fr {} + + +clean-pyc: ## Clean cache files + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + find . -name '__pycache__' -exec rm -fr {} + + +clean-test: ## clean test files + find . -name '.pytest_cache' -exec rm -fr {} + + rm -fr .tox/ + rm -f .coverage + rm -fr htmlcov/ + +build-req: clean ## Upgrade requirements + test -f venv/bin/activate || python3.8 -m venv $(CURRENT_ABS_DIR)/venv ;\ + . venv/bin/activate ;\ + pip install pip==20.1.1 ;\ + pip install -Ur requirements/prod.txt ;\ + pip freeze | sort > requirements.txt ;\ + cat requirements/bcregistry-libraries.txt >> requirements.txt ;\ + pip install -Ur requirements/bcregistry-libraries.txt + +install: clean ## Install python virtrual environment + test -f venv/bin/activate || python3.8 -m venv $(CURRENT_ABS_DIR)/venv ;\ + . venv/bin/activate ;\ + pip install pip==20.1.1 ;\ + pip install -Ur requirements.txt + +install-dev: ## Install application for local development + . venv/bin/activate ; \ + pip install -Ur requirements/dev.txt; \ + pip install -e . + +################################################################################# +# COMMANDS - CI # +################################################################################# +ci: pylint flake8 test ## CI flow + +pylint: ## Linting with pylint + . venv/bin/activate && pylint --rcfile=setup.cfg src/$(PROJECT_NAME) + +flake8: ## Linting with flake8 + . venv/bin/activate && flake8 src/$(PROJECT_NAME) tests + +lint: pylint flake8 ## run all lint type scripts + +test: ## Unit testing + . venv/bin/activate && pytest + +mac-cov: test ## Run the coverage report and display in a browser window (mac) + @open -a "Google Chrome" htmlcov/index.html + +################################################################################# +# COMMANDS - CD +# expects the terminal to be openshift login +# expects export OPENSHIFT_DOCKER_REGISTRY="" +# expects export OPENSHIFT_SA_NAME="$(oc whoami)" +# expects export OPENSHIFT_SA_TOKEN="$(oc whoami -t)" +# expects export OPENSHIFT_REPOSITORY="" +# expects export TAG_NAME="dev/test/prod" +# expects export OPS_REPOSITORY="" # +################################################################################# +cd: ## CD flow +ifeq ($(TAG_NAME), test) +cd: update-env + oc -n "$(OPENSHIFT_REPOSITORY)-tools" tag $(DOCKER_NAME):dev $(DOCKER_NAME):$(TAG_NAME) +else ifeq ($(TAG_NAME), prod) +cd: update-env + oc -n "$(OPENSHIFT_REPOSITORY)-tools" tag $(DOCKER_NAME):$(TAG_NAME) $(DOCKER_NAME):$(TAG_NAME)-$(shell date +%F) + oc -n "$(OPENSHIFT_REPOSITORY)-tools" tag $(DOCKER_NAME):test $(DOCKER_NAME):$(TAG_NAME) +else +TAG_NAME=dev +cd: build update-env tag +endif + +build: ## Build the docker container + docker build . -t $(DOCKER_NAME) \ + --build-arg VCS_REF=$(shell git rev-parse --short HEAD) \ + --build-arg BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") \ + +build-nc: ## Build the docker container without caching + docker build --no-cache -t $(DOCKER_NAME) . + +REGISTRY_IMAGE=$(OPENSHIFT_DOCKER_REGISTRY)/$(OPENSHIFT_REPOSITORY)-tools/$(DOCKER_NAME) +push: #build ## Push the docker container to the registry & tag latest + @echo "$(OPENSHIFT_SA_TOKEN)" | docker login $(OPENSHIFT_DOCKER_REGISTRY) -u $(OPENSHIFT_SA_NAME) --password-stdin ;\ + docker tag $(DOCKER_NAME) $(REGISTRY_IMAGE):latest ;\ + docker push $(REGISTRY_IMAGE):latest + +VAULTS=`cat devops/vaults.json` +update-env: ## Update env from 1pass + oc -n "$(OPS_REPOSITORY)-$(TAG_NAME)" exec "dc/vault-service-$(TAG_NAME)" -- ./scripts/1pass.sh \ + -m "secret" \ + -e "$(TAG_NAME)" \ + -a "$(DOCKER_NAME)-$(TAG_NAME)" \ + -n "$(OPENSHIFT_REPOSITORY)-$(TAG_NAME)" \ + -v "$(VAULTS)" \ + -r "true" \ + -f "false" + +tag: push ## tag image + oc -n "$(OPENSHIFT_REPOSITORY)-tools" tag $(DOCKER_NAME):latest $(DOCKER_NAME):$(TAG_NAME) + +################################################################################# +# COMMANDS - Local # +################################################################################# + +run: ## Run the project in local + . venv/bin/activate && python entity_digital_credentials_service.py + +################################################################################# +# Self Documenting Commands # +################################################################################# +.PHONY: help + +.DEFAULT_GOAL := help + +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/queue_services/entity-digital-credentials/README.md b/queue_services/entity-digital-credentials/README.md new file mode 100755 index 0000000000..cefdecd74e --- /dev/null +++ b/queue_services/entity-digital-credentials/README.md @@ -0,0 +1,46 @@ + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![codecov](https://codecov.io/gh/bcgov/lear/branch/master/graph/badge.svg?flag=entityemailer)](https://codecov.io/gh/bcgov/lear/tree/master/queue_services/entity-emailer) + +# Application Name + +BC Registries Entity Digital Credentials Service + +## Technology Stack Used +* NATS-streaming +* Python +* Postgres - SQLAlchemy, psycopg2-binary & alembic + +## Project Status + +## Documentation + +## Security + +## Getting Help or Reporting an Issue + +To report bugs/issues/feature requests, please file an [issue](../../issues). + +## How to Contribute + +If you would like to contribute, please see our [CONTRIBUTING](./CONTRIBUTING.md) guidelines. + +Please note that this project is released with a [Contributor Code of Conduct](./CODE_OF_CONDUCT.md). +By participating in this project you agree to abide by its terms. + +## License + + 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. + diff --git a/queue_services/entity-digital-credentials/digital_credentials_service.py b/queue_services/entity-digital-credentials/digital_credentials_service.py new file mode 100644 index 0000000000..cf6530af16 --- /dev/null +++ b/queue_services/entity-digital-credentials/digital_credentials_service.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# 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. +"""s2i based launch script to run the service.""" +import asyncio + +from entity_digital_credentials.worker import APP_CONFIG, cb_subscription_handler, qsm + + +if __name__ == '__main__': + + event_loop = asyncio.get_event_loop() + event_loop.run_until_complete(qsm.run(loop=event_loop, + config=APP_CONFIG, + callback=cb_subscription_handler)) + try: + event_loop.run_forever() + finally: + event_loop.close() diff --git a/queue_services/entity-digital-credentials/q_cli.py b/queue_services/entity-digital-credentials/q_cli.py new file mode 100644 index 0000000000..8da8d69f1e --- /dev/null +++ b/queue_services/entity-digital-credentials/q_cli.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# 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. +"""Service for listening and handling Queue Messages. + +This service registers interest in listening to a Queue and processing received messages. +""" +import asyncio +import functools +import getopt +import json +import os +import random +import signal +import sys +import time +import uuid + +from datetime import datetime, timezone + +from nats.aio.client import Client as NATS # noqa N814; by convention the name is NATS +from stan.aio.client import Client as STAN # noqa N814; by convention the name is STAN + +from entity_queue_common.service_utils import error_cb, logger, signal_handler + + +async def run(loop, identifier, filing_id, filing_type): # pylint: disable=too-many-locals + """Run the main application loop for the service. + + This runs the main top level service functions for working with the Queue. + """ + # NATS client connections + nc = NATS() + sc = STAN() + + async def close(): + """Close the stream and nats connections.""" + await sc.close() + await nc.close() + + # Connection and Queue configuration. + def nats_connection_options(): + return { + 'servers': os.getenv('NATS_SERVERS', 'nats://127.0.0.1:4222').split(','), + 'io_loop': loop, + 'error_cb': error_cb, + 'name': os.getenv('NATS_CLIENT_NAME', 'entity.filing.tester') + } + + def stan_connection_options(): + return { + 'cluster_id': os.getenv('NATS_CLUSTER_ID', 'test-cluster'), + 'client_id': str(random.SystemRandom().getrandbits(0x58)), + 'nats': nc + } + + def subscription_options(): + return { + 'subject': os.getenv('NATS_ENTITY_EVENT_SUBJECT', 'error'), + 'queue': os.getenv('NATS_QUEUE', 'error'), + 'durable_name': os.getenv('NATS_QUEUE', 'error') + '_durable' + } + + try: + # Connect to the NATS server, and then use that for the streaming connection. + await nc.connect(**nats_connection_options()) + await sc.connect(**stan_connection_options()) + + # register the signal handler + for sig in ('SIGINT', 'SIGTERM'): + loop.add_signal_handler(getattr(signal, sig), + functools.partial(signal_handler, sig_loop=loop, sig_nc=nc, task=close) + ) + + payload = { + 'specversion': '1.x-wip', + 'type': f'bc.registry.business.{filing_type}', + 'source': f'/businesses/{identifier}', + 'id': str(uuid.uuid4()), + 'time': datetime.utcfromtimestamp(time.time()).replace(tzinfo=timezone.utc).isoformat(), + 'datacontenttype': 'application/json', + 'identifier': identifier, + 'data': { + 'filing': { + 'header': {'filingId': filing_id}, + 'business': {'identifier': identifier} + } + } + } + + await sc.publish(subject=subscription_options().get('subject'), + payload=json.dumps(payload).encode('utf-8')) + + except Exception as e: # pylint: disable=broad-except + logger.error(e) + + +if __name__ == '__main__': + try: + opts, args = getopt.getopt(sys.argv[1:], 'i:f:t:', ['identifier=', 'filing_id=', 'filing_type=']) + except getopt.GetoptError: + print('q_cli.py -i -f -t ') + sys.exit(2) + for opt, arg in opts: + if opt == '-h': + print('q_cli.py -i -f -t ') + sys.exit() + elif opt in ('-i', '--identifier'): + identifier = arg + elif opt in ('-f', '--filing-id'): + filing_id = arg + elif opt in ('-t', '--filing-type'): + filing_type = arg + + print('publish:', identifier, filing_id, filing_type) + event_loop = asyncio.get_event_loop() + event_loop.run_until_complete(run(event_loop, identifier, filing_id, filing_type)) diff --git a/queue_services/entity-digital-credentials/requirements.txt b/queue_services/entity-digital-credentials/requirements.txt new file mode 100644 index 0000000000..3dcfc6a0f2 --- /dev/null +++ b/queue_services/entity-digital-credentials/requirements.txt @@ -0,0 +1,24 @@ +aiohttp +asyncio-nats-streaming +asyncio-nats-client==0.11.4 +attrs==20.3.0 +blinker==1.4 +certifi==2020.12.5 +click==8.1.3 +dpath==2.0.1 +Flask==1.1.2 +Jinja2==2.11.3 +itsdangerous==1.1.0 +jsonschema==4.16.0 +MarkupSafe==1.1.1 +protobuf==3.15.8 +pyrsistent==0.17.3 +python-dotenv==0.17.1 +requests==2.25.1 +sentry-sdk==1.20.0 +six==1.15.0 +urllib3==1.26.11 +Werkzeug==1.0.1 +git+https://github.com/bcgov/business-schemas.git@2.18.10#egg=registry_schemas +git+https://github.com/seeker25/lear.git@16466___#egg=legal_api&subdirectory=legal-api +git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common \ No newline at end of file diff --git a/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt b/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt new file mode 100644 index 0000000000..241ec02cb8 --- /dev/null +++ b/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt @@ -0,0 +1,3 @@ +git+https://github.com/bcgov/business-schemas.git@2.18.10#egg=registry_schemas +git+https://github.com/seeker25/lear.git@16466___#egg=legal_api&subdirectory=legal-api +git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common diff --git a/queue_services/entity-digital-credentials/requirements/dev.txt b/queue_services/entity-digital-credentials/requirements/dev.txt new file mode 100755 index 0000000000..9ffdc961cc --- /dev/null +++ b/queue_services/entity-digital-credentials/requirements/dev.txt @@ -0,0 +1,31 @@ +# Everything the developer needs outside of the production requirements + +# Testing +dpath +freezegun +pyhamcrest +pytest +pytest-aiohttp +pytest-asyncio +pytest-mock +requests +requests-mock + +# Lint and code style +pydocstyle<4 +flake8==5.0.4 +flake8-blind-except +flake8-debugger +flake8-docstrings +flake8-isort==5.0.3 +flake8-quotes +pep8-naming +autopep8 +coverage +pylint<=2.3.1 +pylint-flask +isort<5,>=4.2.5 +pytest-cov + +# docker +lovely-pytest-docker diff --git a/queue_services/entity-digital-credentials/requirements/prod.txt b/queue_services/entity-digital-credentials/requirements/prod.txt new file mode 100755 index 0000000000..9ae2a03618 --- /dev/null +++ b/queue_services/entity-digital-credentials/requirements/prod.txt @@ -0,0 +1,16 @@ +sqlalchemy < 1.4.0 +Flask +Flask-Babel +Flask-Migrate +Flask-Moment +Flask-Script +Flask-SQLAlchemy +asyncio-nats-client +asyncio-nats-streaming +attrs==23.1.0 +jinja2 +python-dotenv<0.16.0 +psycopg2-binary +requests +sentry-sdk[flask] +Werkzeug<1.0.2 diff --git a/queue_services/entity-digital-credentials/setup.cfg b/queue_services/entity-digital-credentials/setup.cfg new file mode 100644 index 0000000000..cf8571f9aa --- /dev/null +++ b/queue_services/entity-digital-credentials/setup.cfg @@ -0,0 +1,120 @@ +[metadata] +name = entity_digital_credentials +url = https://github.com/bcgov/lear/queue-services/entity_digital_credentials +classifiers = + Development Status :: Beta + Intended Audience :: Developers / QA + Topic :: Legal Entities + License :: OSI Approved :: Apache Software License + Natural Language :: English + Programming Language :: Python :: 3.7 +license = Apache Software License Version 2.0 +description = A short description of the project +long_description = file: README.md +keywords = + +[options] +zip_safe = True +python_requires = >=3.6 +include_package_data = True +packages = find: + +[options.package_data] +entity_digital_credentials = + +[wheel] +universal = 1 + +[bdist_wheel] +universal = 1 + +[aliases] +test = pytest + +[flake8] +exclude = .git,*migrations* +max-line-length = 120 +docstring-min-length=10 +per-file-ignores = + */__init__.py:F401 + +[pycodestyle] +max_line_length = 120 +ignore = E501 +docstring-min-length=10 +notes=FIXME,XXX # TODO is ignored +match_dir = src/entity_digital_credentials +ignored-modules=flask_sqlalchemy + sqlalchemy +per-file-ignores = + */__init__.py:F401 +good-names= + b, + d, + i, + e, + f, + k, + u, + v, + ar, + cb, #common shorthand for callback + nc, + rv, + sc, + event_loop, + logger, + loop, + +[pylint] +ignore=migrations,test +max_line_length=120 +notes=FIXME,XXX,TODO +ignored-modules=flask_sqlalchemy,sqlalchemy,SQLAlchemy,alembic,scoped_session +ignored-classes=scoped_session +disable=C0301,W0511,R0801,R0902 + +[isort] +line_length = 120 +indent = 4 +multi_line_output = 3 +lines_after_imports = 2 +include_trailing_comma = True + +[tool:pytest] +minversion = 2.0 +testpaths = tests +addopts = --verbose + --strict + -p no:warnings + --cov=src --cov-report html:htmlcov --cov-report xml:coverage.xml +python_files = tests/*/test*.py +norecursedirs = .git .tox venv* requirements* build +log_cli = true +log_cli_level = 1 +filterwarnings = + ignore::UserWarning +markers = + slow + serial + +[coverage:run] +branch = True +source = + src/entity_digital_credentials +omit = + src/entity_digital_credentials/wsgi.py + src/entity_digital_credentials/gunicorn_config.py + +[report:run] +exclude_lines = + pragma: no cover + from + import + def __repr__ + if self.debug: + if settings.DEBUG + raise AssertionError + raise NotImplementedError + if 0: + if __name__ == .__main__.: diff --git a/queue_services/entity-digital-credentials/setup.py b/queue_services/entity-digital-credentials/setup.py new file mode 100644 index 0000000000..846e8744a5 --- /dev/null +++ b/queue_services/entity-digital-credentials/setup.py @@ -0,0 +1,70 @@ +# Copyright © 2019 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. +"""Installer and setup for this module.""" +import ast +import re +from glob import glob +from os.path import basename, splitext + +from setuptools import find_packages, setup + + +_version_re = re.compile(r'__version__\s+=\s+(.*)') # pylint: disable=invalid-name + +with open('src/entity_digital_credentials/version.py', 'rb') as f: + version = str(ast.literal_eval(_version_re.search( # pylint: disable=invalid-name + f.read().decode('utf-8')).group(1))) + + +def read_requirements(filename): + """ + Get application requirements from the requirements.txt file. + + :return: Python requirements + """ + with open(filename, 'r') as req: + requirements = req.readlines() + install_requires = [r.strip() for r in requirements if r.find('git+') != 0] + return install_requires + + +def read(filepath): + """ + Read the contents from a file. + + :param str filepath: path to the file to be read + :return: file contents + """ + with open(filepath, 'r') as file_handle: + content = file_handle.read() + return content + + +REQUIREMENTS = read_requirements('requirements.txt') + +setup( + name="entity_digital_credentials", + version=version, + author_email='akiff.manji@quartech.com', + packages=find_packages('src'), + package_dir={'': 'src'}, + py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], + include_package_data=True, + license=read('LICENSE'), + long_description=read('README.md'), + zip_safe=False, + install_requires=REQUIREMENTS, + setup_requires=['pytest-runner', ], + tests_require=['pytest', ], +) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/__init__.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/__init__.py new file mode 100644 index 0000000000..32a262926b --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/__init__.py @@ -0,0 +1,17 @@ +# 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. +"""The Entity Digital Credentials service. + +This module is the service worker for issuing and revoking digital credentials for entity related events. +""" diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py new file mode 100644 index 0000000000..4f11bf6d17 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py @@ -0,0 +1,187 @@ +# 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. +"""All of the configuration for the service is captured here. + +All items are loaded, or have Constants defined here that +are loaded into the Flask configuration. +All modules and lookups get their configuration from the +Flask config, rather than reading environment variables directly +or by accessing this configuration directly. +""" +import os +import random + +from dotenv import find_dotenv, load_dotenv + + +# this will load all the envars from a .env file located in the project root (api) +load_dotenv(find_dotenv()) + +CONFIGURATION = { + 'development': 'legal_api.config.DevConfig', + 'testing': 'legal_api.config.TestConfig', + 'production': 'legal_api.config.ProdConfig', + 'default': 'legal_api.config.ProdConfig' +} + + +def get_named_config(config_name: str = 'production'): + """Return the configuration object based on the name. + + :raise: KeyError: if an unknown configuration is requested + """ + if config_name in ['production', 'staging', 'default']: + config = ProdConfig() + elif config_name == 'testing': + config = TestConfig() + elif config_name == 'development': + config = DevConfig() + else: + raise KeyError(f'Unknown configuration: {config_name}') + return config + + +class _Config(): # pylint: disable=too-few-public-methods + """Base class configuration that should set reasonable defaults. + + Used as the base for all the other configurations. + """ + + PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) + + MSG_RETRY_NUM = int(os.getenv('MSG_RETRY_NUM', '5')) + + LD_SDK_KEY = os.getenv('LD_SDK_KEY', None) + + # urls + LEGAL_API_URL = os.getenv('LEGAL_API_URL', None) + + # variables + LEGISLATIVE_TIMEZONE = os.getenv( + 'LEGISLATIVE_TIMEZONE', 'America/Vancouver') + + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # POSTGRESQL + DB_USER = os.getenv('DATABASE_USERNAME', '') + DB_PASSWORD = os.getenv('DATABASE_PASSWORD', '') + DB_NAME = os.getenv('DATABASE_NAME', '') + DB_HOST = os.getenv('DATABASE_HOST', '') + DB_PORT = os.getenv('DATABASE_PORT', '5432') + SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( + user=DB_USER, + password=DB_PASSWORD, + host=DB_HOST, + port=int(DB_PORT), + name=DB_NAME, + ) + + TRACKER_DB_USER = os.getenv('TRACKER_DATABASE_USERNAME', '') + TRACKER_DB_PASSWORD = os.getenv('TRACKER_DATABASE_PASSWORD', '') + TRACKER_DB_NAME = os.getenv('TRACKER_DATABASE_NAME', '') + TRACKER_DB_HOST = os.getenv('TRACKER_DATABASE_HOST', '') + TRACKER_DB_PORT = os.getenv('TRACKER_DATABASE_PORT', '5432') + TRACKER_SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( + user=TRACKER_DB_USER, + password=TRACKER_DB_PASSWORD, + host=TRACKER_DB_HOST, + port=int(TRACKER_DB_PORT), + name=TRACKER_DB_NAME, + ) + + SQLALCHEMY_BINDS = { + 'tracker': TRACKER_SQLALCHEMY_DATABASE_URI + } + + NATS_CONNECTION_OPTIONS = { + 'servers': os.getenv('NATS_SERVERS', 'nats://127.0.0.1:4222').split(','), + 'name': os.getenv('NATS_CLIENT_NAME', 'entity.filing.worker') + + } + STAN_CONNECTION_OPTIONS = { + 'cluster_id': os.getenv('NATS_CLUSTER_ID', 'test-cluster'), + 'client_id': str(random.SystemRandom().getrandbits(0x58)), + 'ping_interval': 1, + 'ping_max_out': 5, + } + + SUBSCRIPTION_OPTIONS = { + 'subject': os.getenv('NATS_DIGITAL_CREDENTIALS_SUBJECT', 'error'), + 'queue': os.getenv('NATS_QUEUE', 'error'), + 'durable_name': os.getenv('NATS_QUEUE', 'error') + '_durable', + } + + ENTITY_EVENT_PUBLISH_OPTIONS = { + 'subject': os.getenv('NATS_ENTITY_EVENT_SUBJECT', 'entity.events'), + } + + NAME_REQUEST_URL = os.getenv('NAME_REQUEST_URL', '') + DECIDE_BUSINESS_URL = os.getenv('DECIDE_BUSINESS_URL', '') + + +class DevConfig(_Config): # pylint: disable=too-few-public-methods + """Creates the Development Config object.""" + + TESTING = False + DEBUG = True + + +class TestConfig(_Config): # pylint: disable=too-few-public-methods + """In support of testing only. + + Used by the py.test suite + """ + + DEBUG = True + TESTING = True + # POSTGRESQL + DB_USER = os.getenv('DATABASE_TEST_USERNAME', '') + DB_PASSWORD = os.getenv('DATABASE_TEST_PASSWORD', '') + DB_NAME = os.getenv('DATABASE_TEST_NAME', '') + DB_HOST = os.getenv('DATABASE_TEST_HOST', '') + DB_PORT = os.getenv('DATABASE_TEST_PORT', '5432') + DEPLOYMENT_ENV = 'testing' + LEGAL_API_URL = 'https://legal-api-url/' + PAY_API_URL = 'https://pay-api-url/' + SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( + user=DB_USER, + password=DB_PASSWORD, + host=DB_HOST, + port=int(DB_PORT), + name=DB_NAME, + ) + + TRACKER_DB_USER = os.getenv('TRACKER_DATABASE_TEST_USERNAME', '') + TRACKER_DB_PASSWORD = os.getenv('TRACKER_DATABASE_TEST_PASSWORD', '') + TRACKER_DB_NAME = os.getenv('TRACKER_DATABASE_TEST_NAME', '') + TRACKER_DB_HOST = os.getenv('TRACKER_DATABASE_TEST_HOST', '') + TRACKER_DB_PORT = os.getenv('TRACKER_DATABASE_TEST_PORT', '5432') + TRACKER_SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( + user=TRACKER_DB_USER, + password=TRACKER_DB_PASSWORD, + host=TRACKER_DB_HOST, + port=int(TRACKER_DB_PORT), + name=TRACKER_DB_NAME, + ) + + SQLALCHEMY_BINDS = { + 'tracker': TRACKER_SQLALCHEMY_DATABASE_URI + } + + +class ProdConfig(_Config): # pylint: disable=too-few-public-methods + """Production environment configuration.""" + + TESTING = False + DEBUG = False diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/version.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/version.py new file mode 100644 index 0000000000..71aa3a7e04 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/version.py @@ -0,0 +1,25 @@ +# 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. + +"""Version of this service in PEP440. + +[N!]N(.N)*[{a|b|rc}N][.postN][.devN] +Epoch segment: N! +Release segment: N(.N)* +Pre-release segment: {a|b|rc}N +Post-release segment: .postN +Development release segment: .devN +""" + +__version__ = '1.0.0' # pylint: disable=invalid-name diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py new file mode 100644 index 0000000000..3635b41278 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -0,0 +1,75 @@ +# Copyright © 2019 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. +"""The unique worker functionality for this service is contained here. + +The entry-point is the **cb_subscription_handler** + +The design and flow leverage a few constraints that are placed upon it +by NATS Streaming and using AWAIT on the default loop. +- NATS streaming queues require one message to be processed at a time. +- AWAIT on the default loop effectively runs synchronously + +If these constraints change, the use of Flask-SQLAlchemy would need to change. +Flask-SQLAlchemy currently allows the base model to be changed, or reworking +the model to a standalone SQLAlchemy usage with an async engine would need +to be pursued. +""" +import json +import os +from http import HTTPStatus + +import nats +from entity_queue_common.service import QueueServiceManager +from entity_queue_common.service_utils import EmailException, QueueException, logger +from flask import Flask +from legal_api import db +from legal_api.models import Filing +from legal_api.services.bootstrap import AccountService +from legal_api.services.flags import Flags +from sqlalchemy.exc import OperationalError + +from entity_digital_credentials import config + + +qsm = QueueServiceManager() # pylint: disable=invalid-name +flags = Flags() # pylint: disable=invalid-name +APP_CONFIG = config.get_named_config(os.getenv('DEPLOYMENT_ENV', 'production')) +FLASK_APP = Flask(__name__) +FLASK_APP.config.from_object(APP_CONFIG) +db.init_app(FLASK_APP) + +if FLASK_APP.config.get('LD_SDK_KEY', None): + flags.init_app(FLASK_APP) + + +def process_digital_credential(dc_msg: dict, flask_app: Flask): # pylint: disable=too-many-branches, too-many-statements + pass + + +async def cb_subscription_handler(msg: nats.aio.client.Msg): + """Use Callback to process Queue Msg objects.""" + with FLASK_APP.app_context(): + try: + logger.info('Received raw message seq: %s, data= %s', + msg.sequence, msg.data.decode()) + dc_msg = json.loads(msg.data.decode('utf-8')) + logger.debug('Extracted digital credential msg: %s', dc_msg) + process_digital_credential(dc_msg, FLASK_APP) + except OperationalError as err: + logger.error('Queue Blocked - Database Issue: %s', + json.dumps(dc_msg), exc_info=True) + raise err # We don't want to handle the error, as a DB down would drain the queue + except (QueueException, Exception) as err: # noqa B902; pylint: disable=W0703; + # Catch Exception so that any error is still caught and the message is removed from the queue + logger.error('Queue Error: %s', json.dumps(dc_msg), exc_info=True) From f9e67955d55970a82f83c43512ecd523ffa20211 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 24 Oct 2023 18:54:04 +0000 Subject: [PATCH 35/56] feat: add placeholders for events to capture Signed-off-by: Akiff Manji --- .../src/entity_digital_credentials/worker.py | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py index 3635b41278..2471cd1b77 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -31,11 +31,11 @@ import nats from entity_queue_common.service import QueueServiceManager -from entity_queue_common.service_utils import EmailException, QueueException, logger +from entity_queue_common.service_utils import QueueException, logger from flask import Flask from legal_api import db -from legal_api.models import Filing -from legal_api.services.bootstrap import AccountService +from legal_api.core import Filing as FilingCore +from legal_api.models import Business from legal_api.services.flags import Flags from sqlalchemy.exc import OperationalError @@ -54,7 +54,58 @@ def process_digital_credential(dc_msg: dict, flask_app: Flask): # pylint: disable=too-many-branches, too-many-statements - pass + """Process any digital credential messages in queue""" + if not flask_app: + raise QueueException('Flask App not available.') + + with flask_app.app_context(): + logger.debug('Attempting to process digital credential message: %s', dc_msg) + + if dc_msg is None: + raise QueueException + + if dc_msg['type'] is None: + raise QueueException('Digital credential message is missing type.') + + if dc_msg['type'] == 'bc.registry.business.bn': + # When a BN is added or changed the queue message does not have a data object. + # We queue the Busines information using the identifier and revoke/reissue the credential immediately. + # TODO: + pass + else: + if (dc_msg['data'] is None + or dc_msg['data']['filing'] is None + or dc_msg['data']['filing']['header'] is None + or dc_msg['data']['filing']['header']['filingId'] is None): + raise QueueException('Digital credential message is missing data.') + + filing_id = dc_msg['data']['filing']['header']['filingId'] + filing_core = FilingCore.find_by_id(filing_id) + if not filing_core: + raise QueueException(f'Filing not found for id: {filing_id}.') + + filing = filing_core.storage + if not filing: + raise QueueException(f'Filing not found for id: {filing_id}.') + + if filing.status != FilingCore.Status.COMPLETED.value: + raise QueueException(f'Filing with id: {filing_id} processing not complete.') + + identifier = filing.business_id + business = Business.find_by_internal_id(identifier) + if not business: + raise QueueException(f'Business with identifiter: {identifier} not found for filing.') + + # Process individual filing events + if filing.filing_type == FilingCore.FilingTypes.CHANGEOFREGISTRATION.value: + # TODO: + pass + if filing.filing_type == FilingCore.FilingTypes.DISSOLUTION.value: + # TODO: + pass + if filing.filing_type == FilingCore.FilingTypes.PUTBACKON.value: + # TODO: + pass async def cb_subscription_handler(msg: nats.aio.client.Msg): From 808b58e6fa6dd12a4fde0da2bca773245b015f23 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 25 Oct 2023 00:49:29 +0000 Subject: [PATCH 36/56] feat: add processor scaffolding and revocation reasons Signed-off-by: Akiff Manji --- legal-api/requirements.txt | 3 +- .../legal_api/helpers/digital_credentials.py | 156 ++++++++++++++++++ .../business/business_digital_credentials.py | 96 ++--------- .../legal_api/services/digital_credentials.py | 7 +- .../business_number.py | 19 +++ .../change_of_registration.py | 19 +++ .../dissolution.py | 19 +++ .../put_back_on.py | 19 +++ .../src/entity_digital_credentials/worker.py | 43 ++--- 9 files changed, 270 insertions(+), 111 deletions(-) create mode 100644 legal-api/src/legal_api/helpers/digital_credentials.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py diff --git a/legal-api/requirements.txt b/legal-api/requirements.txt index 481921ccc5..b344ca2021 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -60,5 +60,4 @@ 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.14#egg=registry_schemas - +git+https://github.com/bcgov/business-schemas.git@2.18.14#egg=registry_schemas \ No newline at end of file diff --git a/legal-api/src/legal_api/helpers/digital_credentials.py b/legal-api/src/legal_api/helpers/digital_credentials.py new file mode 100644 index 0000000000..be5dd5c897 --- /dev/null +++ b/legal-api/src/legal_api/helpers/digital_credentials.py @@ -0,0 +1,156 @@ +# 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. +"""Helper functions for digital credentials.""" + +from enum import Enum + +from legal_api.models.business import Business +from legal_api.models.dc_connection import DCConnection +from legal_api.models.dc_definition import DCDefinition +from legal_api.models.dc_issued_credential import DCIssuedCredential +from legal_api.services import digital_credentials + + +class DCRevocationReason(Enum): + UPDATED_INFORMATION = 'You were offered a new credential with updated information and that revoked all previous copies.' + VOLUNTARY_DISSOLUTION = 'You chose to dissolve your business. A new credential was offered that reflects the new company status and that revoked all previous copies.' + ADMINISTRATIVE_DISSOLUTION = 'Your business was dissolved by the Registrar.' + PUT_BACK_ON = 'Your business was put back on the Registry.' + RESTORATION = 'Your business was put back on the Registry. A new credential was offered that reflects the new company status and that revoked all previous copies.' + ACCESS_REMOVED = 'Your role in the business was changed and you no longer have system access to the business.' + SELF_REISSUANCE = 'You chose to issue yourself a new credential and that revoked all previous copies.' + SELF_REVOCATION = 'You chose to revoke your own credential.' + + +def get_issued_digital_credentials(business: Business): + try: + connection = DCConnection.find_active_by(business_id=business.id) + if not connection: + raise Exception(f'{Business.identifier} active connection not found.') + + issued_credentials = DCIssuedCredential.find_by(dc_connection_id=connection.id) + if not issued_credentials: + return [] + + return issued_credentials + except Exception as err: + raise err + + +def issue_digital_credential(business: Business, credential_type: DCDefinition.credential_type): + try: + definition = DCDefinition.find_by_credential_type(DCDefinition.CredentialType[credential_type]) + if (not definition): + raise Exception(f'Definition not found for credential type: {credential_type}') + + connection = DCConnection.find_active_by(business_id=business.id) + if (not connection): + raise Exception(f'{Business.identifier} active connection not found.') + + issued = digital_credentials.issue_credential( + connection_id=connection.connection_id, + definition=definition, + data=get_data_for_credential(business, definition.credential_type) + ) + if not issued: + raise Exception('Failed to issue credential.') + + issued_credential = DCIssuedCredential( + dc_definition_id=definition.id, + dc_connection_id=connection.id, + credential_exchange_id=issued['cred_ex_id'], + # TODO: Add a real ID + credential_id='123456' + ) + issued_credential.save() + + return issued_credential + except Exception as err: + raise err + + +def revoke_issued_digital_credential(business: Business, issued_credential: DCIssuedCredential, reason: DCRevocationReason): + try: + connection = DCConnection.find_active_by(business_id=business.id) + if (not connection): + raise Exception(f'{Business.identifier} active connection not found.') + + revoked = digital_credentials.revoke_credential(connection.connection_id, + issued_credential.credential_revocation_id, + issued_credential.revocation_registry_id, + reason) + if not revoked: + raise Exception('Failed to revoke credential.') + + return revoked + except Exception as err: + raise err + + +def replace_issued_digital_credential(business: Business, issued_credential: DCIssuedCredential, credential_type: DCDefinition.CredentialType, reason: DCRevocationReason): + try: + revoke_issued_digital_credential(business, issued_credential, reason) + issued_credential.delete() + + return issue_digital_credential(business, credential_type) + except Exception as err: + raise err + + +def get_digital_credential_data(business: Business, credential_type: DCDefinition.CredentialType,): + if credential_type == DCDefinition.CredentialType.business: + return [ + { + 'name': 'credential_id', + 'value': '' + }, + { + 'name': 'identifier', + 'value': business.identifier + }, + { + 'name': 'business_name', + 'value': business.legal_name + }, + { + 'name': 'business_type', + 'value': business.legal_type + }, + { + 'name': 'cra_business_number', + 'value': business.tax_id or '' + }, + { + 'name': 'registered_on_dateint', + 'value': business.founding_date.isoformat() + }, + { + 'name': 'company_status', + 'value': business.state + }, + { + 'name': 'family_name', + 'value': '' + }, + { + 'name': 'given_names', + 'value': '' + }, + { + 'name': 'role', + 'value': '' + } + ] + + return 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 995cf9c072..63d3246417 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,23 +13,20 @@ # 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, _request_ctx_stack, current_app, jsonify, request from flask_cors import cross_origin +from flask_socketio import emit -from legal_api.models import ( - Business, - CorpType, - DCConnection, - DCDefinition, - DCIssuedBusinessUserCredential, - DCIssuedCredential, - User, -) +from legal_api.extensions import socketio +from legal_api.helpers.digital_credentials import DCRevocationReason, get_digital_credential_data +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 @@ -88,6 +85,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 @@ -247,7 +245,7 @@ def revoke_credential(identifier, credential_id): revoked = digital_credentials.revoke_credential(connection.connection_id, issued_credential.credential_revocation_id, issued_credential.revocation_registry_id) - if revoked is None: + if not revoked: return jsonify({'message': 'Failed to revoke credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR issued_credential.is_revoked = True @@ -301,90 +299,16 @@ def webhook_notification(topic_name: str): issued_credential.credential_revocation_id = json_input['cred_rev_id'] issued_credential.revocation_registry_id = json_input['rev_reg_id'] issued_credential.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'] == '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, 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('%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() - - roles = ', '.join([party_role.role.title() for party_role in business.party_roles.all() if party_role.role]) - - return [ - { - 'name': 'credential_id', - 'value': credential_id or '' - }, - { - 'name': 'identifier', - 'value': business.identifier or '' - }, - { - 'name': 'business_name', - 'value': business.legal_name or '' - }, - { - 'name': 'business_type', - 'value': business_type or '' - }, - { - 'name': 'cra_business_number', - 'value': business.tax_id or '' - }, - { - 'name': 'registered_on_dateint', - 'value': registered_on_dateint or '' - }, - { - 'name': 'company_status', - 'value': company_status or '' - }, - { - 'name': 'family_name', - 'value': family_name or '' - }, - { - 'name': 'given_names', - 'value': given_names or '' - }, - { - 'name': 'role', - 'value': roles or '' - } - ] - - 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 21a7ff285f..1b92a4f0e4 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -21,7 +21,7 @@ import requests -from legal_api.decorators import requires_traction_auth +from legal_api.helpers.digital_credentials import DCRevocationReason from legal_api.models import DCDefinition @@ -210,7 +210,7 @@ def issue_credential(self, return None @requires_traction_auth - def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str) -> Optional[dict]: + def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str, reason: DCRevocationReason) -> Optional[dict]: """Revoke a credential.""" try: response = requests.post(self.api_url + '/revocation/revoke', @@ -221,7 +221,8 @@ def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str) -> 'rev_reg_id': rev_reg_id, 'publish': True, 'notify': True, - 'notify_version': 'v1_0' + 'notify_version': 'v1_0', + 'comment': reason.value if reason else '' })) response.raise_for_status() return response.json() diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py new file mode 100644 index 0000000000..1ca4a2c211 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py @@ -0,0 +1,19 @@ +# 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. +"""Processing business number actions.""" + + +async def process(msg: dict): + """Process business number actions.""" + pass diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py new file mode 100644 index 0000000000..5464b6b703 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py @@ -0,0 +1,19 @@ +# 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. +"""Processing change of registration actions.""" + + +async def process(msg: dict): + """Process change of registration actions.""" + pass diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py new file mode 100644 index 0000000000..4c84b4186c --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py @@ -0,0 +1,19 @@ +# 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. +"""Processing dissolution actions.""" + + +async def process(msg: dict): + """Process dissolution actions.""" + pass diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py new file mode 100644 index 0000000000..1f92ae1fd5 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py @@ -0,0 +1,19 @@ +# 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. +"""Processing put back on actions.""" + + +async def process(msg: dict): + """Process put back on actions.""" + pass diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py index 2471cd1b77..12bc30c29f 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -27,7 +27,6 @@ """ import json import os -from http import HTTPStatus import nats from entity_queue_common.service import QueueServiceManager @@ -35,11 +34,16 @@ from flask import Flask from legal_api import db from legal_api.core import Filing as FilingCore -from legal_api.models import Business from legal_api.services.flags import Flags from sqlalchemy.exc import OperationalError from entity_digital_credentials import config +from entity_digital_credentials.digital_credentials_processors import ( # noqa: I001 + business_number, + change_of_registration, + dissolution, + put_back_on, +) qsm = QueueServiceManager() # pylint: disable=invalid-name @@ -53,8 +57,9 @@ flags.init_app(FLASK_APP) -def process_digital_credential(dc_msg: dict, flask_app: Flask): # pylint: disable=too-many-branches, too-many-statements - """Process any digital credential messages in queue""" +def process_digital_credential(dc_msg: dict, flask_app: Flask): + # pylint: disable=too-many-branches, too-many-statements + """Process any digital credential messages in queue.""" if not flask_app: raise QueueException('Flask App not available.') @@ -69,14 +74,17 @@ def process_digital_credential(dc_msg: dict, flask_app: Flask): # pylint: disab if dc_msg['type'] == 'bc.registry.business.bn': # When a BN is added or changed the queue message does not have a data object. - # We queue the Busines information using the identifier and revoke/reissue the credential immediately. - # TODO: - pass + # We queue the business information using the identifier and revoke/reissue the credential immediately. + if dc_msg['identifiler'] is None: + raise QueueException('Digital credential message is missing identifier') + + identifier = dc_msg['identifier'] + business_number.process(identifier) else: - if (dc_msg['data'] is None - or dc_msg['data']['filing'] is None - or dc_msg['data']['filing']['header'] is None - or dc_msg['data']['filing']['header']['filingId'] is None): + if dc_msg['data'] is None \ + or dc_msg['data']['filing'] is None \ + or dc_msg['data']['filing']['header'] is None \ + or dc_msg['data']['filing']['header']['filingId'] is None: raise QueueException('Digital credential message is missing data.') filing_id = dc_msg['data']['filing']['header']['filingId'] @@ -92,20 +100,15 @@ def process_digital_credential(dc_msg: dict, flask_app: Flask): # pylint: disab raise QueueException(f'Filing with id: {filing_id} processing not complete.') identifier = filing.business_id - business = Business.find_by_internal_id(identifier) - if not business: - raise QueueException(f'Business with identifiter: {identifier} not found for filing.') # Process individual filing events if filing.filing_type == FilingCore.FilingTypes.CHANGEOFREGISTRATION.value: - # TODO: - pass + change_of_registration.process(identifier) if filing.filing_type == FilingCore.FilingTypes.DISSOLUTION.value: - # TODO: - pass + filing_sub_type = filing.filing_sub_type + dissolution.process(identifier, filing_sub_type) # pylint: disable=too-many-function-args if filing.filing_type == FilingCore.FilingTypes.PUTBACKON.value: - # TODO: - pass + put_back_on.process(identifier) async def cb_subscription_handler(msg: nats.aio.client.Msg): From a4ceff61fd2dfd278743bea48131dec9343d2345 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 1 Nov 2023 16:10:14 +0000 Subject: [PATCH 37/56] chore: clean up code based on review comments Signed-off-by: Akiff Manji --- .devcontainer/docker-compose.yml | 2 +- legal-api/src/legal_api/decorators.py | 10 +- .../legal_api/helpers/digital_credentials.py | 113 ++++++++++++------ .../business/business_digital_credentials.py | 91 ++++---------- .../legal_api/services/digital_credentials.py | 30 ++--- .../src/entity_digital_credentials/worker.py | 7 +- 6 files changed, 116 insertions(+), 137 deletions(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index ffae8d6cf0..232713c8d6 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -14,7 +14,7 @@ services: # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. network_mode: host - + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. # (Adding the "ports" property to this file will not forward from a Codespace.) diff --git a/legal-api/src/legal_api/decorators.py b/legal-api/src/legal_api/decorators.py index 0153b26316..b49f8070f1 100644 --- a/legal-api/src/legal_api/decorators.py +++ b/legal-api/src/legal_api/decorators.py @@ -26,17 +26,13 @@ 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: + if not (traction_api_url := current_app.config['TRACTION_API_URL']): raise EnvironmentError('TRACTION_API_URL environment vairable is not set') - if traction_tenant_id is None: + if not (traction_tenant_id := current_app.config['TRACTION_TENANT_ID']): raise EnvironmentError('TRACTION_TENANT_ID environment vairable is not set') - if traction_api_key is None: + if not (traction_api_key := current_app.config['TRACTION_API_KEY']): raise EnvironmentError('TRACTION_API_KEY environment vairable is not set') try: diff --git a/legal-api/src/legal_api/helpers/digital_credentials.py b/legal-api/src/legal_api/helpers/digital_credentials.py index be5dd5c897..d8de2cd5f6 100644 --- a/legal-api/src/legal_api/helpers/digital_credentials.py +++ b/legal-api/src/legal_api/helpers/digital_credentials.py @@ -15,32 +15,41 @@ from enum import Enum -from legal_api.models.business import Business -from legal_api.models.dc_connection import DCConnection -from legal_api.models.dc_definition import DCDefinition -from legal_api.models.dc_issued_credential import DCIssuedCredential +from legal_api.models import ( + Business, + CorpType, + DCConnection, + DCDefinition, + DCIssuedBusinessUserCredential, + DCIssuedCredential, + User, +) from legal_api.services import digital_credentials class DCRevocationReason(Enum): - UPDATED_INFORMATION = 'You were offered a new credential with updated information and that revoked all previous copies.' - VOLUNTARY_DISSOLUTION = 'You chose to dissolve your business. A new credential was offered that reflects the new company status and that revoked all previous copies.' + """Digital Credential Revocation Reasons.""" + + UPDATED_INFORMATION = 'You were offered a new credential with updated information \ + and that revoked all previous copies.' + VOLUNTARY_DISSOLUTION = 'You chose to dissolve your business. \ + A new credential was offered that reflects the new company status and that revoked all previous copies.' ADMINISTRATIVE_DISSOLUTION = 'Your business was dissolved by the Registrar.' PUT_BACK_ON = 'Your business was put back on the Registry.' - RESTORATION = 'Your business was put back on the Registry. A new credential was offered that reflects the new company status and that revoked all previous copies.' + RESTORATION = 'Your business was put back on the Registry. \ + A new credential was offered that reflects the new company status and that revoked all previous copies.' ACCESS_REMOVED = 'Your role in the business was changed and you no longer have system access to the business.' SELF_REISSUANCE = 'You chose to issue yourself a new credential and that revoked all previous copies.' SELF_REVOCATION = 'You chose to revoke your own credential.' def get_issued_digital_credentials(business: Business): + """Get issued digital credentials for a business.""" try: - connection = DCConnection.find_active_by(business_id=business.id) - if not connection: + if not (connection := DCConnection.find_active_by(business_id=business.id)): raise Exception(f'{Business.identifier} active connection not found.') - issued_credentials = DCIssuedCredential.find_by(dc_connection_id=connection.id) - if not issued_credentials: + if not (issued_credentials := DCIssuedCredential.find_by(dc_connection_id=connection.id)): return [] return issued_credentials @@ -48,22 +57,20 @@ def get_issued_digital_credentials(business: Business): raise err -def issue_digital_credential(business: Business, credential_type: DCDefinition.credential_type): +def issue_digital_credential(business: Business, user: User, credential_type: DCDefinition.credential_type): + """Issue a digital credential for a business.""" try: - definition = DCDefinition.find_by_credential_type(DCDefinition.CredentialType[credential_type]) - if (not definition): + if not (definition := DCDefinition.find_by_credential_type(DCDefinition.CredentialType[credential_type])): raise Exception(f'Definition not found for credential type: {credential_type}') - connection = DCConnection.find_active_by(business_id=business.id) - if (not connection): + if not (connection := DCConnection.find_active_by(business_id=business.id)): raise Exception(f'{Business.identifier} active connection not found.') - issued = digital_credentials.issue_credential( + if not (issued := digital_credentials.issue_credential( connection_id=connection.connection_id, definition=definition, - data=get_data_for_credential(business, definition.credential_type) - ) - if not issued: + credential_data=get_digital_credential_data(business, user, definition.credential_type) + )): raise Exception('Failed to issue credential.') issued_credential = DCIssuedCredential( @@ -80,17 +87,18 @@ def issue_digital_credential(business: Business, credential_type: DCDefinition.c raise err -def revoke_issued_digital_credential(business: Business, issued_credential: DCIssuedCredential, reason: DCRevocationReason): +def revoke_issued_digital_credential(business: Business, + issued_credential: DCIssuedCredential, + reason: DCRevocationReason): + """Revoke an issued digital credential for a business.""" try: - connection = DCConnection.find_active_by(business_id=business.id) - if (not connection): + if not (connection := DCConnection.find_active_by(business_id=business.id)): raise Exception(f'{Business.identifier} active connection not found.') - revoked = digital_credentials.revoke_credential(connection.connection_id, - issued_credential.credential_revocation_id, - issued_credential.revocation_registry_id, - reason) - if not revoked: + if not (revoked := digital_credentials.revoke_credential(connection.connection_id, + issued_credential.credential_revocation_id, + issued_credential.revocation_registry_id, + reason)): raise Exception('Failed to revoke credential.') return revoked @@ -98,7 +106,11 @@ def revoke_issued_digital_credential(business: Business, issued_credential: DCIs raise err -def replace_issued_digital_credential(business: Business, issued_credential: DCIssuedCredential, credential_type: DCDefinition.CredentialType, reason: DCRevocationReason): +def replace_issued_digital_credential(business: Business, + issued_credential: DCIssuedCredential, + credential_type: DCDefinition.CredentialType, + reason: DCRevocationReason): + """Replace an issued digital credential for a business.""" try: revoke_issued_digital_credential(business, issued_credential, reason) issued_credential.delete() @@ -108,24 +120,49 @@ def replace_issued_digital_credential(business: Business, issued_credential: DCI raise err -def get_digital_credential_data(business: Business, credential_type: DCDefinition.CredentialType,): +def get_digital_credential_data(business: Business, user: User, credential_type: DCDefinition.CredentialType): + """Get the data for a digital credential.""" if credential_type == DCDefinition.CredentialType.business: + + # Find the credential id from dc_issued_business_user_credentials and if there isn't one create one + if not (issued_business_user_credential := DCIssuedBusinessUserCredential.find_by( + business_id=business.id, user_id=user.id)): + 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}' + + if (business_type := CorpType.find_by_id(business.legal_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('%Y%m%d') + + company_status = Business.State(business.state).name + + family_name = (user.lastname or '').strip().upper() + + given_names = ' '.join([x.strip() for x in [user.firstname, user.middlename] if x and x.strip()]).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', @@ -133,19 +170,19 @@ def get_digital_credential_data(business: Business, credential_type: DCDefinitio }, { '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', 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 63d3246417..fa2c83ee30 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,20 +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, _request_ctx_stack, current_app, jsonify, request from flask_cors import cross_origin -from flask_socketio import emit -from legal_api.extensions import socketio from legal_api.helpers.digital_credentials import DCRevocationReason, get_digital_credential_data -from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential +from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential, User from legal_api.services import digital_credentials from legal_api.utils.auth import jwt -from legal_api.extensions import socketio from .bp import bp @@ -39,20 +35,16 @@ @jwt.requires_auth def create_invitation(identifier): """Create a new connection invitation.""" - business = Business.find_by_identifier(identifier) - if not business: + if not (business := Business.find_by_identifier(identifier)): return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND - active_connection = DCConnection.find_active_by(business_id=business.id) - if active_connection: + if DCConnection.find_active_by(business_id=business.id): return jsonify({'message': f'{identifier} already have an active connection.'}), HTTPStatus.UNPROCESSABLE_ENTITY - connections = DCConnection.find_by(business_id=business.id, connection_state='invitation') - if connections: + if (connections := DCConnection.find_by(business_id=business.id, connection_state='invitation')): connection = connections[0] else: - invitation = digital_credentials.create_invitation() - if not invitation: + if not (invitation := digital_credentials.create_invitation()): return jsonify({'message': 'Unable to create an invitation.'}), HTTPStatus.INTERNAL_SERVER_ERROR connection = DCConnection( @@ -72,8 +64,7 @@ def create_invitation(identifier): @jwt.requires_auth def get_connections(identifier): """Get active connection for this business.""" - business = Business.find_by_identifier(identifier) - if not business: + if not (business := Business.find_by_identifier(identifier)): return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND connections = DCConnection.find_by(business_id=business.id) @@ -86,35 +77,16 @@ def get_connections(identifier): 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/connections/', methods=['DELETE'], strict_slashes=False) @cross_origin(origin='*') @jwt.requires_auth def delete_connection(identifier, connection_id): """Delete a connection.""" - business = Business.find_by_identifier(identifier) - if not business: + if not Business.find_by_identifier(identifier): return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND - connection = DCConnection.find_by_connection_id(connection_id=connection_id) - if not connection: + if not (connection := DCConnection.find_by_connection_id(connection_id=connection_id)): return jsonify({'message': f'{identifier} connection not found.'}), HTTPStatus.NOT_FOUND try: @@ -131,12 +103,10 @@ def delete_connection(identifier, connection_id): @jwt.requires_auth def delete_active_connection(identifier): """Delete an active connection for this business.""" - business = Business.find_by_identifier(identifier) - if not business: + if not (business := Business.find_by_identifier(identifier)): return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND - connection = DCConnection.find_active_by(business_id=business.id) - if not connection: + if not (connection := DCConnection.find_active_by(business_id=business.id)): return jsonify({'message': f'{identifier} active connection not found.'}), HTTPStatus.NOT_FOUND try: @@ -153,16 +123,13 @@ def delete_active_connection(identifier): @jwt.requires_auth def get_issued_credentials(identifier): """Get all issued credentials.""" - business = Business.find_by_identifier(identifier) - if not business: + if not (business := Business.find_by_identifier(identifier)): return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND - connection = DCConnection.find_active_by(business_id=business.id) - if not connection: + if not (connection := DCConnection.find_active_by(business_id=business.id)): return jsonify({'issuedCredentials': []}), HTTPStatus.OK - issued_credentials = DCIssuedCredential.find_by(dc_connection_id=connection.id) - if not issued_credentials: + if not (issued_credentials := DCIssuedCredential.find_by(dc_connection_id=connection.id)): return jsonify({'issuedCredentials': []}), HTTPStatus.OK response = [] @@ -184,12 +151,10 @@ def get_issued_credentials(identifier): @jwt.requires_auth def send_credential(identifier, credential_type): """Issue credentials to the connection.""" - business = Business.find_by_identifier(identifier) - if not business: + if not (business := Business.find_by_identifier(identifier)): 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: + if not (user := User.find_by_jwt_token(_request_ctx_stack.top.current_user)): return jsonify({'message': 'User not found'}, HTTPStatus.NOT_FOUND) connection = DCConnection.find_active_by(business_id=business.id) @@ -202,15 +167,14 @@ def send_credential(identifier, credential_type): 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_data = get_digital_credential_data(business, user, definition.credential_type) credential_id = next((item['value'] for item in credential_data if item['name'] == 'credential_id'), None) - response = digital_credentials.issue_credential( + if not (response := digital_credentials.issue_credential( connection_id=connection.connection_id, definition=definition, data=credential_data - ) - if not response: + )): return jsonify({'message': 'Failed to issue credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR issued_credential = DCIssuedCredential( @@ -230,12 +194,10 @@ def send_credential(identifier, credential_type): @jwt.requires_auth def revoke_credential(identifier, credential_id): """Revoke a credential.""" - business = Business.find_by_identifier(identifier) - if not business: + if not (business := Business.find_by_identifier(identifier)): return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND - connection = DCConnection.find_active_by(business_id=business.id) - if not connection: + if not (connection := DCConnection.find_active_by(business_id=business.id)): return jsonify({'message': f'{identifier} active connection not found.'}), HTTPStatus.NOT_FOUND issued_credential = DCIssuedCredential.find_by_credential_id(credential_id=credential_id) @@ -244,8 +206,9 @@ def revoke_credential(identifier, credential_id): revoked = digital_credentials.revoke_credential(connection.connection_id, issued_credential.credential_revocation_id, - issued_credential.revocation_registry_id) - if not revoked: + issued_credential.revocation_registry_id, + DCRevocationReason.SELF_REVOCATION) + if revoked is None: return jsonify({'message': 'Failed to revoke credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR issued_credential.is_revoked = True @@ -258,12 +221,10 @@ def revoke_credential(identifier, credential_id): @jwt.requires_auth def delete_credential(identifier, credential_id): """Delete a credential.""" - business = Business.find_by_identifier(identifier) - if not business: + if not Business.find_by_identifier(identifier): return jsonify({'message': f'{identifier} not found.'}), HTTPStatus.NOT_FOUND - issued_credential = DCIssuedCredential.find_by_credential_id(credential_id=credential_id) - if not issued_credential: + if not (issued_credential := DCIssuedCredential.find_by_credential_id(credential_id=credential_id)): return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND try: @@ -299,14 +260,12 @@ def webhook_notification(topic_name: str): issued_credential.credential_revocation_id = json_input['cred_rev_id'] issued_credential.revocation_registry_id = json_input['rev_reg_id'] issued_credential.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'] == '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/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 1b92a4f0e4..118993e5ef 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.helpers.digital_credentials import DCRevocationReason from legal_api.models import DCDefinition @@ -61,11 +62,11 @@ def init_app(self, app): def _register_business_definition(self): """Fetch schema and credential definition and save a Business definition.""" try: - if self.business_schema_id is None: + if not self.business_schema_id: 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: + if not self.business_cred_def_id: self.app.logger.error('Environment variable: BUSINESS_CRED_DEF_ID must be configured') raise ValueError('Environment variable: BUSINESS_CRED_DEF_ID must be configured') @@ -77,14 +78,12 @@ def _register_business_definition(self): ### # 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: + if not (schema_id := self._fetch_schema(self.business_schema_id)): 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: + if not (credential_definition_id := self._fetch_credential_definition(self.business_cred_def_id)): raise ValueError(f'Credential Definition with id:{self.business_cred_def_id}' + ' must be avaible in Traction tenant storage') @@ -94,7 +93,6 @@ def _register_business_definition(self): schema_id=self.business_schema_id, credential_definition_id=self.business_cred_def_id ) - if definition and not definition.is_deleted: return None @@ -127,19 +125,6 @@ def _fetch_schema(self, schema_id: str) -> Optional[str]: 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 _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 @requires_traction_auth def _fetch_credential_definition(self, cred_def_id: str) -> Optional[str]: @@ -210,7 +195,10 @@ def issue_credential(self, return None @requires_traction_auth - def revoke_credential(self, connection_id, cred_rev_id: str, rev_reg_id: str, reason: DCRevocationReason) -> Optional[dict]: + def revoke_credential(self, connection_id, + cred_rev_id: str, + rev_reg_id: str, + reason: DCRevocationReason) -> Optional[dict]: """Revoke a credential.""" try: response = requests.post(self.api_url + '/revocation/revoke', diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py index 12bc30c29f..a2a4375beb 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -88,12 +88,11 @@ def process_digital_credential(dc_msg: dict, flask_app: Flask): raise QueueException('Digital credential message is missing data.') filing_id = dc_msg['data']['filing']['header']['filingId'] - filing_core = FilingCore.find_by_id(filing_id) - if not filing_core: + + if not (filing_core := FilingCore.find_by_id(filing_id)): raise QueueException(f'Filing not found for id: {filing_id}.') - filing = filing_core.storage - if not filing: + if not (filing := filing_core.storage): raise QueueException(f'Filing not found for id: {filing_id}.') if filing.status != FilingCore.Status.COMPLETED.value: From 18b4831e4d053cc67c4c315ce097c67f155c8d88 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 6 Nov 2023 21:26:58 +0000 Subject: [PATCH 38/56] fix: 404 errors when attempting to remove invitations and credentials Signed-off-by: Akiff Manji --- .../legal_api/helpers/digital_credentials.py | 9 ++++ .../src/legal_api/models/dc_connection.py | 13 +++++ .../business/business_digital_credentials.py | 47 +++++++++---------- .../legal_api/services/digital_credentials.py | 12 +++++ 4 files changed, 57 insertions(+), 24 deletions(-) diff --git a/legal-api/src/legal_api/helpers/digital_credentials.py b/legal-api/src/legal_api/helpers/digital_credentials.py index d8de2cd5f6..2eb332dfd7 100644 --- a/legal-api/src/legal_api/helpers/digital_credentials.py +++ b/legal-api/src/legal_api/helpers/digital_credentials.py @@ -191,3 +191,12 @@ def get_digital_credential_data(business: Business, user: User, credential_type: ] return None + + +def extract_invitation_message_id(json_message: dict): + """Extract the invitation message id from the json message.""" + if 'invitation' in json_message and json_message['invitation'] is not None: + invitation_message_id = json_message['invitation']['@id'] + else: + invitation_message_id = json_message['invitation_msg_id'] + return invitation_message_id diff --git a/legal-api/src/legal_api/models/dc_connection.py b/legal-api/src/legal_api/models/dc_connection.py index f1e7320c5e..a1f153925b 100644 --- a/legal-api/src/legal_api/models/dc_connection.py +++ b/legal-api/src/legal_api/models/dc_connection.py @@ -14,6 +14,7 @@ """This module holds data for digital credentials connection.""" from __future__ import annotations +from enum import Enum from typing import List from .db import db @@ -22,6 +23,18 @@ class DCConnection(db.Model): # pylint: disable=too-many-instance-attributes """This class manages the digital credentials connection.""" + class State(Enum): + """Enum of the connection states.""" + + INIT = 'init' + INVITATION = 'invitation' + REQUEST = 'request' + RESPONSE = 'response' + ACTIVE = 'active' + COMPLETED = 'completed' + INACTIVE = 'inactive' + ERROR = 'error' + __tablename__ = 'dc_connections' id = db.Column(db.Integer, primary_key=True) 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 fa2c83ee30..5a7a4ff690 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,7 +19,11 @@ from flask import Blueprint, _request_ctx_stack, current_app, jsonify, request from flask_cors import cross_origin -from legal_api.helpers.digital_credentials import DCRevocationReason, get_digital_credential_data +from legal_api.helpers.digital_credentials import ( + DCRevocationReason, + extract_invitation_message_id, + get_digital_credential_data, +) from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential, User from legal_api.services import digital_credentials from legal_api.utils.auth import jwt @@ -44,14 +48,16 @@ def create_invitation(identifier): if (connections := DCConnection.find_by(business_id=business.id, connection_state='invitation')): connection = connections[0] else: - if not (invitation := digital_credentials.create_invitation()): + if not (response := digital_credentials.create_invitation()): return jsonify({'message': 'Unable to create an invitation.'}), HTTPStatus.INTERNAL_SERVER_ERROR + invitation_message_id = extract_invitation_message_id(response) + connection = DCConnection( - connection_id=invitation['invitation']['@id'], - invitation_url=invitation['invitation_url'], + connection_id=invitation_message_id, + invitation_url=response['invitation_url'], is_active=False, - connection_state='invitation', + connection_state=DCConnection.State.INVITATION.value, business_id=business.id ) connection.save() @@ -89,9 +95,8 @@ def delete_connection(identifier, connection_id): if not (connection := DCConnection.find_by_connection_id(connection_id=connection_id)): return jsonify({'message': f'{identifier} connection not found.'}), HTTPStatus.NOT_FOUND - try: - digital_credentials.remove_connection_record(connection_id=connection.connection_id) - except Exception: + if (connection.connection_state != DCConnection.State.INVITATION.value and + digital_credentials.remove_connection_record(connection_id=connection.connection_id) is None): return jsonify({'message': 'Failed to remove connection record.'}), HTTPStatus.INTERNAL_SERVER_ERROR connection.delete() @@ -109,9 +114,7 @@ def delete_active_connection(identifier): if not (connection := DCConnection.find_active_by(business_id=business.id)): return jsonify({'message': f'{identifier} active connection not found.'}), HTTPStatus.NOT_FOUND - try: - digital_credentials.remove_connection_record(connection_id=connection.connection_id) - except Exception: + if digital_credentials.remove_connection_record(connection_id=connection.connection_id) is None: return jsonify({'message': 'Failed to remove connection record.'}), HTTPStatus.INTERNAL_SERVER_ERROR connection.delete() @@ -204,11 +207,10 @@ def revoke_credential(identifier, credential_id): 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, - DCRevocationReason.SELF_REVOCATION) - if revoked is None: + if digital_credentials.revoke_credential(connection.connection_id, + issued_credential.credential_revocation_id, + issued_credential.revocation_registry_id, + DCRevocationReason.SELF_REVOCATION) is None: return jsonify({'message': 'Failed to revoke credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR issued_credential.is_revoked = True @@ -227,9 +229,8 @@ def delete_credential(identifier, credential_id): if not (issued_credential := DCIssuedCredential.find_by_credential_id(credential_id=credential_id)): return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND - try: - digital_credentials.remove_credential_exchange_record(issued_credential.credential_exchange_id) - except Exception: + if (digital_credentials.fetch_credential_exchange_record(issued_credential.credential_exchange_id) is not None and + digital_credentials.remove_credential_exchange_record(issued_credential.credential_exchange_id) is None): return jsonify({'message': 'Failed to remove credential exchange record.'}), HTTPStatus.INTERNAL_SERVER_ERROR issued_credential.delete() @@ -243,13 +244,11 @@ 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'] 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']) + connection = DCConnection.find_by_connection_id(extract_invitation_message_id(json_input)) # 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'): + if connection and not connection.is_active and json_input['state'] in ( + DCConnection.State.ACTIVE.value, DCConnection.State.COMPLETED.value): connection.connection_id = json_input['connection_id'] connection.connection_state = json_input['state'] connection.is_active = True diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 118993e5ef..23ef9059c5 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -194,6 +194,18 @@ def issue_credential(self, self.app.logger.error(err) return None + @requires_traction_auth + def fetch_credential_exchange_record(self, cred_ex_id: str) -> Optional[dict]: + """Fetch a credential exchange record.""" + try: + response = requests.get(self.api_url + '/issue-credential-2.0/records/' + cred_ex_id, + headers=self._get_headers()) + response.raise_for_status() + return response.json() + except Exception as err: + self.app.logger.error(err) + return None + @requires_traction_auth def revoke_credential(self, connection_id, cred_rev_id: str, From b0f66b5dfd34ea05962b9553a5f79dbc4086faec Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 7 Nov 2023 02:53:34 +0000 Subject: [PATCH 39/56] refactor: update digital credential helpers Signed-off-by: Akiff Manji --- .../legal_api/helpers/digital_credentials.py | 29 ++++++++++++------- .../entity-digital-credentials/Makefile | 2 +- .../requirements.txt | 6 ++-- .../src/entity_digital_credentials/config.py | 16 ++++++++++ 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/legal-api/src/legal_api/helpers/digital_credentials.py b/legal-api/src/legal_api/helpers/digital_credentials.py index 2eb332dfd7..9f9a681a30 100644 --- a/legal-api/src/legal_api/helpers/digital_credentials.py +++ b/legal-api/src/legal_api/helpers/digital_credentials.py @@ -66,19 +66,19 @@ def issue_digital_credential(business: Business, user: User, credential_type: DC if not (connection := DCConnection.find_active_by(business_id=business.id)): raise Exception(f'{Business.identifier} active connection not found.') - if not (issued := digital_credentials.issue_credential( - connection_id=connection.connection_id, - definition=definition, - credential_data=get_digital_credential_data(business, user, definition.credential_type) - )): + credential_data = get_digital_credential_data(business, user, definition.credential_type) + credential_id = next((item['value'] for item in credential_data if item['name'] == 'credential_id'), None) + + if (issued := digital_credentials.issue_credential(connection_id=connection.connection_id, + definition=definition, + credential_data=credential_data) is None): raise Exception('Failed to issue credential.') issued_credential = DCIssuedCredential( dc_definition_id=definition.id, dc_connection_id=connection.id, credential_exchange_id=issued['cred_ex_id'], - # TODO: Add a real ID - credential_id='123456' + credential_id=credential_id ) issued_credential.save() @@ -95,10 +95,10 @@ def revoke_issued_digital_credential(business: Business, if not (connection := DCConnection.find_active_by(business_id=business.id)): raise Exception(f'{Business.identifier} active connection not found.') - if not (revoked := digital_credentials.revoke_credential(connection.connection_id, - issued_credential.credential_revocation_id, - issued_credential.revocation_registry_id, - reason)): + if (revoked := digital_credentials.revoke_credential(connection.connection_id, + issued_credential.credential_revocation_id, + issued_credential.revocation_registry_id, + reason) is None): raise Exception('Failed to revoke credential.') return revoked @@ -113,6 +113,13 @@ def replace_issued_digital_credential(business: Business, """Replace an issued digital credential for a business.""" try: revoke_issued_digital_credential(business, issued_credential, reason) + + if (digital_credentials.fetch_credential_exchange_record( + issued_credential.credential_exchange_id) is not None and + digital_credentials.remove_credential_exchange_record( + issued_credential.credential_exchange_id) is None): + raise Exception('Failed to remove credential exchange record.') + issued_credential.delete() return issue_digital_credential(business, credential_type) diff --git a/queue_services/entity-digital-credentials/Makefile b/queue_services/entity-digital-credentials/Makefile index 0c88cd374a..c46db1fcd5 100644 --- a/queue_services/entity-digital-credentials/Makefile +++ b/queue_services/entity-digital-credentials/Makefile @@ -131,7 +131,7 @@ tag: push ## tag image ################################################################################# run: ## Run the project in local - . venv/bin/activate && python entity_digital_credentials_service.py + . venv/bin/activate && python digital_credentials_service.py ################################################################################# # Self Documenting Commands # diff --git a/queue_services/entity-digital-credentials/requirements.txt b/queue_services/entity-digital-credentials/requirements.txt index 3dcfc6a0f2..afe328dfbe 100644 --- a/queue_services/entity-digital-credentials/requirements.txt +++ b/queue_services/entity-digital-credentials/requirements.txt @@ -19,6 +19,6 @@ sentry-sdk==1.20.0 six==1.15.0 urllib3==1.26.11 Werkzeug==1.0.1 -git+https://github.com/bcgov/business-schemas.git@2.18.10#egg=registry_schemas -git+https://github.com/seeker25/lear.git@16466___#egg=legal_api&subdirectory=legal-api -git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common \ No newline at end of file +git+https://github.com/bcgov/business-schemas.git@2.18.14#egg=registry_schemas +git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common +git+https://github.com/petridishdev/lear.git@feature-digital-credentials#egg=legal_api&subdirectory=legal-api diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py index 4f11bf6d17..394876def7 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py @@ -129,6 +129,22 @@ class _Config(): # pylint: disable=too-few-public-methods NAME_REQUEST_URL = os.getenv('NAME_REQUEST_URL', '') DECIDE_BUSINESS_URL = os.getenv('DECIDE_BUSINESS_URL', '') + # 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') + BUSINESS_CRED_DEF_ID = os.getenv('BUSINESS_CRED_DEF_ID') + class DevConfig(_Config): # pylint: disable=too-few-public-methods """Creates the Development Config object.""" From 27f4d19606d2c055155045dc7534f8e58da60e0a Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 7 Nov 2023 03:12:35 +0000 Subject: [PATCH 40/56] feat: add init file for helpers module Signed-off-by: Akiff Manji --- legal-api/src/legal_api/helpers/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 legal-api/src/legal_api/helpers/__init__.py diff --git a/legal-api/src/legal_api/helpers/__init__.py b/legal-api/src/legal_api/helpers/__init__.py new file mode 100644 index 0000000000..a3aa209b73 --- /dev/null +++ b/legal-api/src/legal_api/helpers/__init__.py @@ -0,0 +1,14 @@ +# 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. +"""Helper functions for digital credentials API and queue services.""" \ No newline at end of file From 4fce1695c3e1aa47cf4447a645d590c8052ba865 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 7 Nov 2023 03:51:29 +0000 Subject: [PATCH 41/56] refactor: move methods into digital credential service Signed-off-by: Akiff Manji --- legal-api/src/legal_api/helpers/__init__.py | 14 -- .../legal_api/helpers/digital_credentials.py | 209 ------------------ .../business/business_digital_credentials.py | 14 +- .../legal_api/services/digital_credentials.py | 100 ++++++++- .../requirements.txt | 4 +- .../src/entity_digital_credentials/config.py | 3 - 6 files changed, 105 insertions(+), 239 deletions(-) delete mode 100644 legal-api/src/legal_api/helpers/__init__.py delete mode 100644 legal-api/src/legal_api/helpers/digital_credentials.py diff --git a/legal-api/src/legal_api/helpers/__init__.py b/legal-api/src/legal_api/helpers/__init__.py deleted file mode 100644 index a3aa209b73..0000000000 --- a/legal-api/src/legal_api/helpers/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# 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. -"""Helper functions for digital credentials API and queue services.""" \ No newline at end of file diff --git a/legal-api/src/legal_api/helpers/digital_credentials.py b/legal-api/src/legal_api/helpers/digital_credentials.py deleted file mode 100644 index 9f9a681a30..0000000000 --- a/legal-api/src/legal_api/helpers/digital_credentials.py +++ /dev/null @@ -1,209 +0,0 @@ -# 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. -"""Helper functions for digital credentials.""" - -from enum import Enum - -from legal_api.models import ( - Business, - CorpType, - DCConnection, - DCDefinition, - DCIssuedBusinessUserCredential, - DCIssuedCredential, - User, -) -from legal_api.services import digital_credentials - - -class DCRevocationReason(Enum): - """Digital Credential Revocation Reasons.""" - - UPDATED_INFORMATION = 'You were offered a new credential with updated information \ - and that revoked all previous copies.' - VOLUNTARY_DISSOLUTION = 'You chose to dissolve your business. \ - A new credential was offered that reflects the new company status and that revoked all previous copies.' - ADMINISTRATIVE_DISSOLUTION = 'Your business was dissolved by the Registrar.' - PUT_BACK_ON = 'Your business was put back on the Registry.' - RESTORATION = 'Your business was put back on the Registry. \ - A new credential was offered that reflects the new company status and that revoked all previous copies.' - ACCESS_REMOVED = 'Your role in the business was changed and you no longer have system access to the business.' - SELF_REISSUANCE = 'You chose to issue yourself a new credential and that revoked all previous copies.' - SELF_REVOCATION = 'You chose to revoke your own credential.' - - -def get_issued_digital_credentials(business: Business): - """Get issued digital credentials for a business.""" - try: - if not (connection := DCConnection.find_active_by(business_id=business.id)): - raise Exception(f'{Business.identifier} active connection not found.') - - if not (issued_credentials := DCIssuedCredential.find_by(dc_connection_id=connection.id)): - return [] - - return issued_credentials - except Exception as err: - raise err - - -def issue_digital_credential(business: Business, user: User, credential_type: DCDefinition.credential_type): - """Issue a digital credential for a business.""" - try: - if not (definition := DCDefinition.find_by_credential_type(DCDefinition.CredentialType[credential_type])): - raise Exception(f'Definition not found for credential type: {credential_type}') - - if not (connection := DCConnection.find_active_by(business_id=business.id)): - raise Exception(f'{Business.identifier} active connection not found.') - - credential_data = get_digital_credential_data(business, user, definition.credential_type) - credential_id = next((item['value'] for item in credential_data if item['name'] == 'credential_id'), None) - - if (issued := digital_credentials.issue_credential(connection_id=connection.connection_id, - definition=definition, - credential_data=credential_data) is None): - raise Exception('Failed to issue credential.') - - issued_credential = DCIssuedCredential( - dc_definition_id=definition.id, - dc_connection_id=connection.id, - credential_exchange_id=issued['cred_ex_id'], - credential_id=credential_id - ) - issued_credential.save() - - return issued_credential - except Exception as err: - raise err - - -def revoke_issued_digital_credential(business: Business, - issued_credential: DCIssuedCredential, - reason: DCRevocationReason): - """Revoke an issued digital credential for a business.""" - try: - if not (connection := DCConnection.find_active_by(business_id=business.id)): - raise Exception(f'{Business.identifier} active connection not found.') - - if (revoked := digital_credentials.revoke_credential(connection.connection_id, - issued_credential.credential_revocation_id, - issued_credential.revocation_registry_id, - reason) is None): - raise Exception('Failed to revoke credential.') - - return revoked - except Exception as err: - raise err - - -def replace_issued_digital_credential(business: Business, - issued_credential: DCIssuedCredential, - credential_type: DCDefinition.CredentialType, - reason: DCRevocationReason): - """Replace an issued digital credential for a business.""" - try: - revoke_issued_digital_credential(business, issued_credential, reason) - - if (digital_credentials.fetch_credential_exchange_record( - issued_credential.credential_exchange_id) is not None and - digital_credentials.remove_credential_exchange_record( - issued_credential.credential_exchange_id) is None): - raise Exception('Failed to remove credential exchange record.') - - issued_credential.delete() - - return issue_digital_credential(business, credential_type) - except Exception as err: - raise err - - -def get_digital_credential_data(business: Business, user: User, credential_type: DCDefinition.CredentialType): - """Get the data for a digital credential.""" - if credential_type == DCDefinition.CredentialType.business: - - # Find the credential id from dc_issued_business_user_credentials and if there isn't one create one - if not (issued_business_user_credential := DCIssuedBusinessUserCredential.find_by( - business_id=business.id, user_id=user.id)): - 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}' - - if (business_type := CorpType.find_by_id(business.legal_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('%Y%m%d') - - company_status = Business.State(business.state).name - - family_name = (user.lastname or '').strip().upper() - - given_names = ' '.join([x.strip() for x in [user.firstname, user.middlename] if x and x.strip()]).upper() - - return [ - { - 'name': 'credential_id', - 'value': credential_id or '' - }, - { - 'name': 'identifier', - 'value': business.identifier or '' - }, - { - 'name': 'business_name', - 'value': business.legal_name or '' - }, - { - 'name': 'business_type', - 'value': business_type or '' - }, - { - 'name': 'cra_business_number', - 'value': business.tax_id or '' - }, - { - 'name': 'registered_on_dateint', - 'value': registered_on_dateint or '' - }, - { - 'name': 'company_status', - 'value': company_status or '' - }, - { - 'name': 'family_name', - 'value': family_name or '' - }, - { - 'name': 'given_names', - 'value': given_names or '' - }, - { - 'name': 'role', - 'value': '' - } - ] - - return None - - -def extract_invitation_message_id(json_message: dict): - """Extract the invitation message id from the json message.""" - if 'invitation' in json_message and json_message['invitation'] is not None: - invitation_message_id = json_message['invitation']['@id'] - else: - invitation_message_id = json_message['invitation_msg_id'] - return invitation_message_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 5a7a4ff690..1b8aa054e2 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,11 +19,6 @@ from flask import Blueprint, _request_ctx_stack, current_app, jsonify, request from flask_cors import cross_origin -from legal_api.helpers.digital_credentials import ( - DCRevocationReason, - extract_invitation_message_id, - get_digital_credential_data, -) from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential, User from legal_api.services import digital_credentials from legal_api.utils.auth import jwt @@ -51,7 +46,7 @@ def create_invitation(identifier): if not (response := digital_credentials.create_invitation()): return jsonify({'message': 'Unable to create an invitation.'}), HTTPStatus.INTERNAL_SERVER_ERROR - invitation_message_id = extract_invitation_message_id(response) + invitation_message_id = digital_credentials.extract_invitation_message_id(response) connection = DCConnection( connection_id=invitation_message_id, @@ -170,7 +165,7 @@ def send_credential(identifier, credential_type): 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_digital_credential_data(business, user, definition.credential_type) + credential_data = digital_credentials.get_digital_credential_data(business, user, definition.credential_type) credential_id = next((item['value'] for item in credential_data if item['name'] == 'credential_id'), None) if not (response := digital_credentials.issue_credential( @@ -210,7 +205,7 @@ def revoke_credential(identifier, credential_id): if digital_credentials.revoke_credential(connection.connection_id, issued_credential.credential_revocation_id, issued_credential.revocation_registry_id, - DCRevocationReason.SELF_REVOCATION) is None: + digital_credentials.DCRevocationReason.SELF_REVOCATION) is None: return jsonify({'message': 'Failed to revoke credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR issued_credential.is_revoked = True @@ -244,7 +239,8 @@ def webhook_notification(topic_name: str): json_input = request.get_json() try: if topic_name == 'connections': - connection = DCConnection.find_by_connection_id(extract_invitation_message_id(json_input)) + connection = DCConnection.find_by_connection_id( + digital_credentials.extract_invitation_message_id(json_input)) # 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 ( diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 23ef9059c5..eadb2066de 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -17,18 +17,33 @@ import json from contextlib import suppress +from enum import Enum from typing import Optional import requests from legal_api.decorators import requires_traction_auth -from legal_api.helpers.digital_credentials import DCRevocationReason -from legal_api.models import DCDefinition +from legal_api.models import Business, CorpType, DCDefinition, DCIssuedBusinessUserCredential, User class DigitalCredentialsService: """Provides services to do digital credentials using aca-py agent.""" + class DCRevocationReason(Enum): + """Digital Credential Revocation Reasons.""" + + UPDATED_INFORMATION = 'You were offered a new credential with updated information \ + and that revoked all previous copies.' + VOLUNTARY_DISSOLUTION = 'You chose to dissolve your business. \ + A new credential was offered that reflects the new company status and that revoked all previous copies.' + ADMINISTRATIVE_DISSOLUTION = 'Your business was dissolved by the Registrar.' + PUT_BACK_ON = 'Your business was put back on the Registry.' + RESTORATION = 'Your business was put back on the Registry. \ + A new credential was offered that reflects the new company status and that revoked all previous copies.' + ACCESS_REMOVED = 'Your role in the business was changed and you no longer have system access to the business.' + SELF_REISSUANCE = 'You chose to issue yourself a new credential and that revoked all previous copies.' + SELF_REVOCATION = 'You chose to revoke your own credential.' + def __init__(self): """Initialize this object.""" self.app = None @@ -259,3 +274,84 @@ def _get_headers(self) -> dict: 'Content-Type': 'application/json', 'Authorization': f'Bearer {self.app.api_token}' } + + def get_digital_credential_data(self, business: Business, user: User, credential_type: DCDefinition.CredentialType): + """Get the data for a digital credential.""" + if credential_type == DCDefinition.CredentialType.business: + + # Find the credential id from dc_issued_business_user_credentials and if there isn't one create one + if not (issued_business_user_credential := DCIssuedBusinessUserCredential.find_by( + business_id=business.id, user_id=user.id)): + 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}' + + if (business_type := CorpType.find_by_id(business.legal_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('%Y%m%d') + + company_status = Business.State(business.state).name + + family_name = (user.lastname or '').strip().upper() + + given_names = ' '.join([x.strip() for x in [user.firstname, user.middlename] if x and x.strip()]).upper() + + return [ + { + 'name': 'credential_id', + 'value': credential_id or '' + }, + { + 'name': 'identifier', + 'value': business.identifier or '' + }, + { + 'name': 'business_name', + 'value': business.legal_name or '' + }, + { + 'name': 'business_type', + 'value': business_type or '' + }, + { + 'name': 'cra_business_number', + 'value': business.tax_id or '' + }, + { + 'name': 'registered_on_dateint', + 'value': registered_on_dateint or '' + }, + { + 'name': 'company_status', + 'value': company_status or '' + }, + { + 'name': 'family_name', + 'value': family_name or '' + }, + { + 'name': 'given_names', + 'value': given_names or '' + }, + { + 'name': 'role', + 'value': '' + } + ] + + return None + + def extract_invitation_message_id(self, json_message: dict): + """Extract the invitation message id from the json message.""" + if 'invitation' in json_message and json_message['invitation'] is not None: + invitation_message_id = json_message['invitation']['@id'] + else: + invitation_message_id = json_message['invitation_msg_id'] + return invitation_message_id diff --git a/queue_services/entity-digital-credentials/requirements.txt b/queue_services/entity-digital-credentials/requirements.txt index afe328dfbe..dea54f8145 100644 --- a/queue_services/entity-digital-credentials/requirements.txt +++ b/queue_services/entity-digital-credentials/requirements.txt @@ -1,7 +1,7 @@ aiohttp asyncio-nats-streaming asyncio-nats-client==0.11.4 -attrs==20.3.0 +attrs==23.1.0 blinker==1.4 certifi==2020.12.5 click==8.1.3 @@ -9,7 +9,7 @@ dpath==2.0.1 Flask==1.1.2 Jinja2==2.11.3 itsdangerous==1.1.0 -jsonschema==4.16.0 +jsonschema==4.19.0 MarkupSafe==1.1.1 protobuf==3.15.8 pyrsistent==0.17.3 diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py index 394876def7..f1dcdbd09a 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py @@ -136,9 +136,6 @@ class _Config(): # pylint: disable=too-few-public-methods 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') From 4eeebd9372ae2142f0518962d7a71d3e70e11647 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 7 Nov 2023 04:24:14 +0000 Subject: [PATCH 42/56] feat: add query methods Signed-off-by: Akiff Manji --- .../models/dc_issued_business_user_credential.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index fe43d7e076..fee3ef999e 100644 --- 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 @@ -34,6 +34,14 @@ def save(self): db.session.add(self) db.session.commit() + @classmethod + def find_by_id(cls, dc_issued_business_user_id: str) -> DCIssuedBusinessUserCredential: + """Return the issued business user credential matching the id.""" + dc_issued_business_user = None + if dc_issued_business_user_id: + dc_issued_business_user = cls.query.filter_by(id=dc_issued_business_user_id).one_or_none() + return dc_issued_business_user + @classmethod def find_by(cls, business_id: int = None, From 695117914f29f51902954575a8e702720fd13322 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Tue, 7 Nov 2023 15:03:53 +0000 Subject: [PATCH 43/56] refactor: more code re-organization Signed-off-by: Akiff Manji --- legal-api/src/legal_api/models/__init__.py | 11 ++++--- .../legal_api/models/dc_revocation_reason.py | 31 +++++++++++++++++++ .../business/business_digital_credentials.py | 11 ++++--- .../legal_api/services/digital_credentials.py | 28 ++++++----------- 4 files changed, 52 insertions(+), 29 deletions(-) create mode 100644 legal-api/src/legal_api/models/dc_revocation_reason.py diff --git a/legal-api/src/legal_api/models/__init__.py b/legal-api/src/legal_api/models/__init__.py index fc7fa5b6ec..9f5b20b4f6 100644 --- a/legal-api/src/legal_api/models/__init__.py +++ b/legal-api/src/legal_api/models/__init__.py @@ -25,6 +25,7 @@ from .dc_definition import DCDefinition from .dc_issued_business_user_credential import DCIssuedBusinessUserCredential from .dc_issued_credential import DCIssuedCredential +from .dc_revocation_reason import DCRevocationReason from .document import Document, DocumentType from .filing import Filing from .naics_element import NaicsElement @@ -40,8 +41,8 @@ __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') + 'Address', 'Alias', 'Business', 'ColinLastUpdate', 'Comment', 'ConsentContinuationOut', + 'CorpType', 'DCConnection', 'DCDefinition', 'DCIssuedCredential', 'DCIssuedBusinessUserCredential', + 'DCRevocationReason', '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_revocation_reason.py b/legal-api/src/legal_api/models/dc_revocation_reason.py new file mode 100644 index 0000000000..96a5f1cee4 --- /dev/null +++ b/legal-api/src/legal_api/models/dc_revocation_reason.py @@ -0,0 +1,31 @@ +# 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 revocation reasons for issued credential.""" +from enum import Enum + + +class DCRevocationReason(Enum): + """Digital Credential Revocation Reasons.""" + + UPDATED_INFORMATION = 'You were offered a new credential with updated information ' \ + 'and that revoked all previous copies.' + VOLUNTARY_DISSOLUTION = 'You chose to dissolve your business. ' \ + 'A new credential was offered that reflects the new company status and that revoked all previous copies.' + ADMINISTRATIVE_DISSOLUTION = 'Your business was dissolved by the Registrar.' + PUT_BACK_ON = 'Your business was put back on the Registry. ' + RESTORATION = 'Your business was put back on the Registry. ' \ + 'A new credential was offered that reflects the new company status and that revoked all previous copies.' + ACCESS_REMOVED = 'Your role in the business was changed and you no longer have system access to the business.' + SELF_REISSUANCE = 'You chose to issue yourself a new credential and that revoked all previous copies.' + SELF_REVOCATION = 'You chose to revoke your own 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 1b8aa054e2..d4a19658cb 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,8 +19,9 @@ from flask import Blueprint, _request_ctx_stack, current_app, jsonify, request from flask_cors import cross_origin -from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential, User +from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential, DCRevocationReason, User from legal_api.services import digital_credentials +from legal_api.services.digital_credentials import DigitalCredentialsHelpers from legal_api.utils.auth import jwt from .bp import bp @@ -46,7 +47,7 @@ def create_invitation(identifier): if not (response := digital_credentials.create_invitation()): return jsonify({'message': 'Unable to create an invitation.'}), HTTPStatus.INTERNAL_SERVER_ERROR - invitation_message_id = digital_credentials.extract_invitation_message_id(response) + invitation_message_id = DigitalCredentialsHelpers.extract_invitation_message_id(response) connection = DCConnection( connection_id=invitation_message_id, @@ -165,7 +166,7 @@ def send_credential(identifier, credential_type): if issued_credentials and issued_credentials[0].credential_exchange_id: return jsonify({'message': 'Already requested to issue credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR - credential_data = digital_credentials.get_digital_credential_data(business, user, definition.credential_type) + credential_data = DigitalCredentialsHelpers.get_digital_credential_data(business, user, definition.credential_type) credential_id = next((item['value'] for item in credential_data if item['name'] == 'credential_id'), None) if not (response := digital_credentials.issue_credential( @@ -205,7 +206,7 @@ def revoke_credential(identifier, credential_id): if digital_credentials.revoke_credential(connection.connection_id, issued_credential.credential_revocation_id, issued_credential.revocation_registry_id, - digital_credentials.DCRevocationReason.SELF_REVOCATION) is None: + DCRevocationReason.SELF_REVOCATION) is None: return jsonify({'message': 'Failed to revoke credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR issued_credential.is_revoked = True @@ -240,7 +241,7 @@ def webhook_notification(topic_name: str): try: if topic_name == 'connections': connection = DCConnection.find_by_connection_id( - digital_credentials.extract_invitation_message_id(json_input)) + DigitalCredentialsHelpers.extract_invitation_message_id(json_input)) # 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 ( diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index eadb2066de..3b7555461f 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -17,33 +17,17 @@ import json from contextlib import suppress -from enum import Enum from typing import Optional import requests from legal_api.decorators import requires_traction_auth -from legal_api.models import Business, CorpType, DCDefinition, DCIssuedBusinessUserCredential, User +from legal_api.models import Business, CorpType, DCDefinition, DCIssuedBusinessUserCredential, DCRevocationReason, User class DigitalCredentialsService: """Provides services to do digital credentials using aca-py agent.""" - class DCRevocationReason(Enum): - """Digital Credential Revocation Reasons.""" - - UPDATED_INFORMATION = 'You were offered a new credential with updated information \ - and that revoked all previous copies.' - VOLUNTARY_DISSOLUTION = 'You chose to dissolve your business. \ - A new credential was offered that reflects the new company status and that revoked all previous copies.' - ADMINISTRATIVE_DISSOLUTION = 'Your business was dissolved by the Registrar.' - PUT_BACK_ON = 'Your business was put back on the Registry.' - RESTORATION = 'Your business was put back on the Registry. \ - A new credential was offered that reflects the new company status and that revoked all previous copies.' - ACCESS_REMOVED = 'Your role in the business was changed and you no longer have system access to the business.' - SELF_REISSUANCE = 'You chose to issue yourself a new credential and that revoked all previous copies.' - SELF_REVOCATION = 'You chose to revoke your own credential.' - def __init__(self): """Initialize this object.""" self.app = None @@ -275,7 +259,12 @@ def _get_headers(self) -> dict: 'Authorization': f'Bearer {self.app.api_token}' } - def get_digital_credential_data(self, business: Business, user: User, credential_type: DCDefinition.CredentialType): + +class DigitalCredentialsHelpers: + """Provides helper functions for digital credentials.""" + + @staticmethod + def get_digital_credential_data(business: Business, user: User, credential_type: DCDefinition.CredentialType): """Get the data for a digital credential.""" if credential_type == DCDefinition.CredentialType.business: @@ -348,7 +337,8 @@ def get_digital_credential_data(self, business: Business, user: User, credential return None - def extract_invitation_message_id(self, json_message: dict): + @staticmethod + def extract_invitation_message_id(json_message: dict): """Extract the invitation message id from the json message.""" if 'invitation' in json_message and json_message['invitation'] is not None: invitation_message_id = json_message['invitation']['@id'] From 48e638c97b36af0c19b280bb4b78395ecb15f8c6 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 8 Nov 2023 01:51:07 +0000 Subject: [PATCH 44/56] feat: complete queue event processors Signed-off-by: Akiff Manji --- .../business/business_digital_credentials.py | 5 +- .../business_number.py | 16 ++- .../change_of_registration.py | 17 ++- .../dissolution.py | 29 +++- .../put_back_on.py | 16 ++- .../src/entity_digital_credentials/helpers.py | 132 ++++++++++++++++++ .../src/entity_digital_credentials/worker.py | 35 +++-- 7 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py 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 d4a19658cb..bd8b709613 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 @@ -203,10 +203,13 @@ def revoke_credential(identifier, credential_id): if not issued_credential or issued_credential.is_revoked: return jsonify({'message': f'{identifier} issued credential not found.'}), HTTPStatus.NOT_FOUND + reissue = request.get_json().get('reissue', False) + reason = DCRevocationReason.SELF_REISSUANCE if reissue else DCRevocationReason.SELF_REVOCATION + if digital_credentials.revoke_credential(connection.connection_id, issued_credential.credential_revocation_id, issued_credential.revocation_registry_id, - DCRevocationReason.SELF_REVOCATION) is None: + reason) is None: return jsonify({'message': 'Failed to revoke credential.'}), HTTPStatus.INTERNAL_SERVER_ERROR issued_credential.is_revoked = True diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py index 1ca4a2c211..e570128d38 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py @@ -13,7 +13,19 @@ # limitations under the License. """Processing business number actions.""" +from legal_api.models import Business, DCDefinition, DCRevocationReason -async def process(msg: dict): +from entity_digital_credentials.helpers import get_issued_digital_credentials, replace_issued_digital_credential + + +async def process(business: Business): """Process business number actions.""" - pass + issued_credentials = get_issued_digital_credentials(business=business) + + if not (issued_credentials and len(issued_credentials)): + raise Exception('No issued credentials found.') + + return replace_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py index 5464b6b703..320cdb46a7 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py @@ -13,7 +13,20 @@ # limitations under the License. """Processing change of registration actions.""" +from legal_api.models import Business, DCDefinition, DCRevocationReason, Filing -async def process(msg: dict): +from entity_digital_credentials.helpers import get_issued_digital_credentials, replace_issued_digital_credential + + +async def process(business: Business, filing: Filing): """Process change of registration actions.""" - pass + if filing.filing_json.get('filing').get(filing.filing_type).get('nameRequest') is not None: + + issued_credentials = get_issued_digital_credentials(business=business) + if not (issued_credentials and len(issued_credentials)): + raise Exception('No issued credentials found.') + + return replace_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py index 4c84b4186c..7920e65e4b 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py @@ -13,7 +13,32 @@ # limitations under the License. """Processing dissolution actions.""" +from legal_api.models import Business, DCDefinition, DCRevocationReason -async def process(msg: dict): +from entity_digital_credentials.helpers import ( + get_issued_digital_credentials, + replace_issued_digital_credential, + revoke_issued_digital_credential, +) + + +async def process(business: Business, filing_sub_type: str): """Process dissolution actions.""" - pass + issued_credentials = get_issued_digital_credentials(business=business) + + if not (issued_credentials and len(issued_credentials)): + raise Exception('No issued credentials found.') + + if filing_sub_type == 'voluntary': + reason = DCRevocationReason.VOLUNTARY_DISSOLUTION + return replace_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + credential_type=DCDefinition.CredentialType.business.name, + reason=reason) + elif filing_sub_type == 'administrative': + reason = DCRevocationReason.ADMINISTRATIVE_DISSOLUTION + return revoke_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + reason=reason) + else: + raise Exception('Invalid filing sub type.') diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py index 1f92ae1fd5..c5704aacae 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py @@ -14,6 +14,18 @@ """Processing put back on actions.""" -async def process(msg: dict): +from legal_api.models import Business, DCRevocationReason + +from entity_digital_credentials.helpers import get_issued_digital_credentials, revoke_issued_digital_credential + + +async def process(business: Business): """Process put back on actions.""" - pass + issued_credentials = get_issued_digital_credentials(business=business) + + if not (issued_credentials and len(issued_credentials)): + raise Exception('No issued credentials found.') + + return revoke_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + reason=DCRevocationReason.PUT_BACK_ON) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py new file mode 100644 index 0000000000..f2a353d46f --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py @@ -0,0 +1,132 @@ +# 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. +"""Helper functions for digital credentials.""" + +from legal_api.models import ( + Business, + DCConnection, + DCDefinition, + DCIssuedBusinessUserCredential, + DCIssuedCredential, + DCRevocationReason, + User, +) +from legal_api.services import digital_credentials +from legal_api.services.digital_credentials import DigitalCredentialsHelpers + + +def get_issued_digital_credentials(business: Business): + """Get issued digital credentials for a business.""" + try: + # pylint: disable=superfluous-parens + if not (connection := DCConnection.find_active_by(business_id=business.id)): + raise Exception(f'{Business.identifier} active connection not found.') + + # pylint: disable=superfluous-parens + if not (issued_credentials := DCIssuedCredential.find_by(dc_connection_id=connection.id)): + return [] + + return issued_credentials + except Exception as err: # noqa: B902 + raise err + + +def issue_digital_credential(business: Business, user: User, credential_type: DCDefinition.credential_type): + """Issue a digital credential for a business to a user.""" + try: + if not (definition := DCDefinition.find_by(DCDefinition.CredentialType[credential_type], + digital_credentials.business_schema_id, + digital_credentials.business_cred_def_id)): + raise Exception(f'Definition not found for credential type: {credential_type}') + + # pylint: disable=superfluous-parens + if not (connection := DCConnection.find_active_by(business_id=business.id)): + raise Exception(f'{Business.identifier} active connection not found.') + + credential_data = DigitalCredentialsHelpers.get_digital_credential_data(business, + user, + definition.credential_type) + credential_id = next((item['value'] for item in credential_data if item['name'] == 'credential_id'), None) + + if not (response := digital_credentials.issue_credential(connection_id=connection.connection_id, + definition=definition, + data=credential_data)): + raise Exception('Failed to issue credential.') + + issued_credential = DCIssuedCredential( + dc_definition_id=definition.id, + dc_connection_id=connection.id, + credential_exchange_id=response['cred_ex_id'], + credential_id=credential_id + ) + issued_credential.save() + + return issued_credential + except Exception as err: # noqa: B902 + raise err + + +def revoke_issued_digital_credential(business: Business, + issued_credential: DCIssuedCredential, + reason: DCRevocationReason): + """Revoke an issued digital credential for a business.""" + try: + if not issued_credential.is_issued or issued_credential.is_revoked: + raise Exception('Credential is not issued yet or is revoked already.') + + # pylint: disable=superfluous-parens + if not (connection := DCConnection.find_active_by(business_id=business.id)): + raise Exception(f'{Business.identifier} active connection not found.') + + if (revoked := digital_credentials.revoke_credential(connection.connection_id, + issued_credential.credential_revocation_id, + issued_credential.revocation_registry_id, + reason) is None): + raise Exception('Failed to revoke credential.') + + issued_credential.is_revoked = True + issued_credential.save() + + return revoked + except Exception as err: # noqa: B902 + raise err + + +def replace_issued_digital_credential(business: Business, + issued_credential: DCIssuedCredential, + credential_type: DCDefinition.CredentialType, + reason: DCRevocationReason): + """Replace an issued digital credential for a business.""" + try: + if issued_credential.is_issued and not issued_credential.is_revoked: + revoke_issued_digital_credential(business, issued_credential, reason) + + if (digital_credentials.fetch_credential_exchange_record( + issued_credential.credential_exchange_id) is not None and + digital_credentials.remove_credential_exchange_record( + issued_credential.credential_exchange_id) is None): + raise Exception('Failed to remove credential exchange record.') + + if not (issued_business_user_credential := DCIssuedBusinessUserCredential.find_by_id( + dc_issued_business_user_id=issued_credential.credential_id)): + raise Exception('Unable to find buisness user for issued credential.') + + if not (user := User.find_by_id(issued_business_user_credential.user_id)): # pylint: disable=superfluous-parens + raise Exception('Unable to find user for issued business user credential.') + + issued_credential.delete() + + return issue_digital_credential(business, user, credential_type) + except Exception as err: # noqa: B902 + raise err diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py index a2a4375beb..70dc74a0dc 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -34,11 +34,12 @@ from flask import Flask from legal_api import db from legal_api.core import Filing as FilingCore -from legal_api.services.flags import Flags +from legal_api.models import Business +from legal_api.services import digital_credentials, flags from sqlalchemy.exc import OperationalError from entity_digital_credentials import config -from entity_digital_credentials.digital_credentials_processors import ( # noqa: I001 +from entity_digital_credentials.digital_credentials_processors import ( business_number, change_of_registration, dissolution, @@ -47,17 +48,20 @@ qsm = QueueServiceManager() # pylint: disable=invalid-name -flags = Flags() # pylint: disable=invalid-name + APP_CONFIG = config.get_named_config(os.getenv('DEPLOYMENT_ENV', 'production')) FLASK_APP = Flask(__name__) FLASK_APP.config.from_object(APP_CONFIG) db.init_app(FLASK_APP) +with FLASK_APP.app_context(): # db require app context + digital_credentials.init_app(FLASK_APP) + if FLASK_APP.config.get('LD_SDK_KEY', None): flags.init_app(FLASK_APP) -def process_digital_credential(dc_msg: dict, flask_app: Flask): +async def process_digital_credential(dc_msg: dict, flask_app: Flask): # pylint: disable=too-many-branches, too-many-statements """Process any digital credential messages in queue.""" if not flask_app: @@ -75,11 +79,14 @@ def process_digital_credential(dc_msg: dict, flask_app: Flask): if dc_msg['type'] == 'bc.registry.business.bn': # When a BN is added or changed the queue message does not have a data object. # We queue the business information using the identifier and revoke/reissue the credential immediately. - if dc_msg['identifiler'] is None: + if dc_msg['identifier'] is None: raise QueueException('Digital credential message is missing identifier') identifier = dc_msg['identifier'] - business_number.process(identifier) + if not (business := Business.find_by_identifier(identifier)): # pylint: disable=superfluous-parens + raise Exception(f'Business with identifier: {identifier} not found.') + + await business_number.process(business) else: if dc_msg['data'] is None \ or dc_msg['data']['filing'] is None \ @@ -89,25 +96,27 @@ def process_digital_credential(dc_msg: dict, flask_app: Flask): filing_id = dc_msg['data']['filing']['header']['filingId'] - if not (filing_core := FilingCore.find_by_id(filing_id)): + if not (filing_core := FilingCore.find_by_id(filing_id)): # pylint: disable=superfluous-parens raise QueueException(f'Filing not found for id: {filing_id}.') - if not (filing := filing_core.storage): + if not (filing := filing_core.storage): # pylint: disable=superfluous-parens raise QueueException(f'Filing not found for id: {filing_id}.') if filing.status != FilingCore.Status.COMPLETED.value: raise QueueException(f'Filing with id: {filing_id} processing not complete.') - identifier = filing.business_id + business_id = filing.business_id + if not (business := Business.find_by_internal_id(business_id)): # pylint: disable=superfluous-parens + raise Exception(f'Business with internal id: {business_id} not found.') # Process individual filing events if filing.filing_type == FilingCore.FilingTypes.CHANGEOFREGISTRATION.value: - change_of_registration.process(identifier) + await change_of_registration.process(business, filing) if filing.filing_type == FilingCore.FilingTypes.DISSOLUTION.value: filing_sub_type = filing.filing_sub_type - dissolution.process(identifier, filing_sub_type) # pylint: disable=too-many-function-args + await dissolution.process(business, filing_sub_type) # pylint: disable=too-many-function-args if filing.filing_type == FilingCore.FilingTypes.PUTBACKON.value: - put_back_on.process(identifier) + await put_back_on.process(business) async def cb_subscription_handler(msg: nats.aio.client.Msg): @@ -118,7 +127,7 @@ async def cb_subscription_handler(msg: nats.aio.client.Msg): msg.sequence, msg.data.decode()) dc_msg = json.loads(msg.data.decode('utf-8')) logger.debug('Extracted digital credential msg: %s', dc_msg) - process_digital_credential(dc_msg, FLASK_APP) + await process_digital_credential(dc_msg, FLASK_APP) except OperationalError as err: logger.error('Queue Blocked - Database Issue: %s', json.dumps(dc_msg), exc_info=True) From 95e54b6b14a8ed1a9250cfd76fbae41a4166b167 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 8 Nov 2023 15:27:34 +0000 Subject: [PATCH 45/56] feat: add manual revocation processor Signed-off-by: Akiff Manji --- .../digital_credentials_processors/manual.py | 30 +++++++++++++++++++ .../src/entity_digital_credentials/worker.py | 13 +++++--- 2 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py new file mode 100644 index 0000000000..0b52c8edc6 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py @@ -0,0 +1,30 @@ +# 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. +"""Processing manual actions.""" + +from legal_api.models import Business, DCRevocationReason + +from entity_digital_credentials.helpers import get_issued_digital_credentials, revoke_issued_digital_credential + + +async def process(business: Business): + """Process business number actions.""" + issued_credentials = get_issued_digital_credentials(business=business) + + if not (issued_credentials and len(issued_credentials)): + raise Exception('No issued credentials found.') + + return revoke_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py index 70dc74a0dc..b4032e33ac 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -43,6 +43,7 @@ business_number, change_of_registration, dissolution, + manual, put_back_on, ) @@ -76,9 +77,10 @@ async def process_digital_credential(dc_msg: dict, flask_app: Flask): if dc_msg['type'] is None: raise QueueException('Digital credential message is missing type.') - if dc_msg['type'] == 'bc.registry.business.bn': - # When a BN is added or changed the queue message does not have a data object. - # We queue the business information using the identifier and revoke/reissue the credential immediately. + if dc_msg['type'] in ('bc.registry.business.bn', 'bc.registry.business.manual'): + # When a BN is added or changed or there is a manuak administrative update the queue message does not have + # a data object. We queue the business information using the identifier and revoke/reissue the credential + # immediately. if dc_msg['identifier'] is None: raise QueueException('Digital credential message is missing identifier') @@ -86,7 +88,10 @@ async def process_digital_credential(dc_msg: dict, flask_app: Flask): if not (business := Business.find_by_identifier(identifier)): # pylint: disable=superfluous-parens raise Exception(f'Business with identifier: {identifier} not found.') - await business_number.process(business) + if dc_msg['type'] == 'bc.registry.business.bn': + await business_number.process(business) + elif dc_msg['type'] == 'bc.registry.business.manual': + await manual.process(business) else: if dc_msg['data'] is None \ or dc_msg['data']['filing'] is None \ From b84052759a5aa6e943e2198ce70977f30bc25cea Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Wed, 15 Nov 2023 22:46:10 +0000 Subject: [PATCH 46/56] fix: improved token validation in traction auth decorator Signed-off-by: Akiff Manji --- legal-api/src/legal_api/decorators.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/legal-api/src/legal_api/decorators.py b/legal-api/src/legal_api/decorators.py index b49f8070f1..de91911309 100644 --- a/legal-api/src/legal_api/decorators.py +++ b/legal-api/src/legal_api/decorators.py @@ -14,6 +14,7 @@ """This module holds function decorators.""" import json +from datetime import datetime from functools import wraps import jwt @@ -39,7 +40,11 @@ def decorated_function(*args, **kwargs): if not hasattr(current_app, 'api_token'): raise jwt.ExpiredSignatureError - jwt.decode(current_app.api_token, options={'verify_signature': False}) + if not (decoded := jwt.decode(current_app.api_token, options={'verify_signature': False})): + raise jwt.ExpiredSignatureError + + if datetime.utcfromtimestamp(decoded['exp']) <= datetime.utcnow(): + raise jwt.ExpiredSignatureError 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', From d1a246341c513ad23e6e89513faa950f503e325c Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 16 Nov 2023 20:28:35 +0000 Subject: [PATCH 47/56] chore: address first set of code review comments Signed-off-by: Akiff Manji --- legal-api/src/legal_api/decorators.py | 6 +-- .../legal_api/models/dc_revocation_reason.py | 1 - .../legal_api/services/digital_credentials.py | 2 +- .../entity-digital-credentials/.env.sample | 43 ------------------- .../entity-digital-credentials/README.md | 4 +- .../requirements.txt | 2 +- .../requirements/bcregistry-libraries.txt | 4 +- .../entity-digital-credentials/setup.py | 2 +- .../src/entity_digital_credentials/config.py | 34 --------------- .../src/entity_digital_credentials/worker.py | 2 +- 10 files changed, 11 insertions(+), 89 deletions(-) diff --git a/legal-api/src/legal_api/decorators.py b/legal-api/src/legal_api/decorators.py index de91911309..4f06a2e61b 100644 --- a/legal-api/src/legal_api/decorators.py +++ b/legal-api/src/legal_api/decorators.py @@ -28,13 +28,13 @@ def requires_traction_auth(f): @wraps(f) def decorated_function(*args, **kwargs): if not (traction_api_url := current_app.config['TRACTION_API_URL']): - raise EnvironmentError('TRACTION_API_URL environment vairable is not set') + raise EnvironmentError('TRACTION_API_URL environment variable is not set') if not (traction_tenant_id := current_app.config['TRACTION_TENANT_ID']): - raise EnvironmentError('TRACTION_TENANT_ID environment vairable is not set') + raise EnvironmentError('TRACTION_TENANT_ID environment variable is not set') if not (traction_api_key := current_app.config['TRACTION_API_KEY']): - raise EnvironmentError('TRACTION_API_KEY environment vairable is not set') + raise EnvironmentError('TRACTION_API_KEY environment variable is not set') try: if not hasattr(current_app, 'api_token'): diff --git a/legal-api/src/legal_api/models/dc_revocation_reason.py b/legal-api/src/legal_api/models/dc_revocation_reason.py index 96a5f1cee4..dda2384231 100644 --- a/legal-api/src/legal_api/models/dc_revocation_reason.py +++ b/legal-api/src/legal_api/models/dc_revocation_reason.py @@ -26,6 +26,5 @@ class DCRevocationReason(Enum): PUT_BACK_ON = 'Your business was put back on the Registry. ' RESTORATION = 'Your business was put back on the Registry. ' \ 'A new credential was offered that reflects the new company status and that revoked all previous copies.' - ACCESS_REMOVED = 'Your role in the business was changed and you no longer have system access to the business.' SELF_REISSUANCE = 'You chose to issue yourself a new credential and that revoked all previous copies.' SELF_REVOCATION = 'You chose to revoke your own credential.' diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index 3b7555461f..5ab516782f 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -84,7 +84,7 @@ def _register_business_definition(self): # Look for a published credential definition first, and copy it into the Traction tenant if it's not there if not (credential_definition_id := self._fetch_credential_definition(self.business_cred_def_id)): raise ValueError(f'Credential Definition with id:{self.business_cred_def_id}' + - ' must be avaible in Traction tenant storage') + ' must be available in Traction tenant storage') # Check for the current Business definition. definition = DCDefinition.find_by( diff --git a/queue_services/entity-digital-credentials/.env.sample b/queue_services/entity-digital-credentials/.env.sample index 4a9e54b284..87570670f8 100644 --- a/queue_services/entity-digital-credentials/.env.sample +++ b/queue_services/entity-digital-credentials/.env.sample @@ -2,36 +2,6 @@ # Flask FLASK_ENV= -# TODO: Can remove these -ACCOUNT_SVC_AUTH_URL= -ACCOUNT_SVC_CLIENT_ID= -ACCOUNT_SVC_CLIENT_SECRET= -APP_FILE= -AUTH_URL= -DASHBOARD_URL= - - -LEGAL_API_URL= -#LEGAL_API_URL= -NOTIFY_API_URL= -#NOTIFY_API_URL= -PAY_API_URL= -#PAY_API_URL= -#SENTRY_DSN= -TEMPLATE_PATH= - - -NAMEX_AUTH_SVC_URL= -NAMEX_SERVICE_CLIENT_SECRET= -NAMEX_SERVICE_CLIENT_USERNAME= -NAMEX_SVC_URL= - -# namex config TEST -#NAMEX_AUTH_SVC_URL= -#NAMEX_SERVICE_CLIENT_USERNAME= -#NAMEX_SERVICE_CLIENT_SECRET= -#NAMEX_SVC_URL= - ### SQL Alchemy DATABASE_USERNAME= @@ -47,19 +17,6 @@ DATABASE_TEST_HOST= DATABASE_TEST_PORT= -TRACKER_DATABASE_USERNAME= -TRACKER_DATABASE_PASSWORD= -TRACKER_DATABASE_NAME= -TRACKER_DATABASE_HOST= -TRACKER_DATABASE_PORT= - -TRACKER_DATABASE_TEST_USERNAME= -TRACKER_DATABASE_TEST_PASSWORD= -TRACKER_DATABASE_TEST_NAME= -TRACKER_DATABASE_TEST_HOST= -TRACKER_DATABASE_TEST_PORT= - - ## ## NATS - STAN NATS_SERVERS= NATS_CLIENT_NAME= diff --git a/queue_services/entity-digital-credentials/README.md b/queue_services/entity-digital-credentials/README.md index cefdecd74e..e2d0b799de 100755 --- a/queue_services/entity-digital-credentials/README.md +++ b/queue_services/entity-digital-credentials/README.md @@ -1,6 +1,6 @@ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) -[![codecov](https://codecov.io/gh/bcgov/lear/branch/master/graph/badge.svg?flag=entityemailer)](https://codecov.io/gh/bcgov/lear/tree/master/queue_services/entity-emailer) +[![codecov](https://codecov.io/gh/bcgov/lear/branch/master/graph/badge.svg?flag=entitydigitalcredentials)](https://codecov.io/gh/bcgov/lear/tree/master/queue_services/entity-digital-credentials) # Application Name @@ -19,7 +19,7 @@ BC Registries Entity Digital Credentials Service ## Getting Help or Reporting an Issue -To report bugs/issues/feature requests, please file an [issue](../../issues). +To report bugs/issues/feature requests, please file an [issue](https://github.com/bcgov/entity/issues). ## How to Contribute diff --git a/queue_services/entity-digital-credentials/requirements.txt b/queue_services/entity-digital-credentials/requirements.txt index e62d0e4599..00c53fc261 100644 --- a/queue_services/entity-digital-credentials/requirements.txt +++ b/queue_services/entity-digital-credentials/requirements.txt @@ -21,4 +21,4 @@ urllib3==1.26.11 Werkzeug==1.0.1 git+https://github.com/bcgov/business-schemas.git@2.18.15#egg=registry_schemas git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common -git+https://github.com/petridishdev/lear.git@feature-digital-credentials#egg=legal_api&subdirectory=legal-api +git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api \ No newline at end of file diff --git a/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt b/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt index 241ec02cb8..f2a149d754 100644 --- a/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt +++ b/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt @@ -1,3 +1,3 @@ -git+https://github.com/bcgov/business-schemas.git@2.18.10#egg=registry_schemas -git+https://github.com/seeker25/lear.git@16466___#egg=legal_api&subdirectory=legal-api +git+https://github.com/bcgov/business-schemas.git@2.18.15#egg=registry_schemas +git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common diff --git a/queue_services/entity-digital-credentials/setup.py b/queue_services/entity-digital-credentials/setup.py index 846e8744a5..d8c8b2cec5 100644 --- a/queue_services/entity-digital-credentials/setup.py +++ b/queue_services/entity-digital-credentials/setup.py @@ -1,4 +1,4 @@ -# Copyright © 2019 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/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py index f1dcdbd09a..d29ea81f34 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py @@ -87,23 +87,6 @@ class _Config(): # pylint: disable=too-few-public-methods name=DB_NAME, ) - TRACKER_DB_USER = os.getenv('TRACKER_DATABASE_USERNAME', '') - TRACKER_DB_PASSWORD = os.getenv('TRACKER_DATABASE_PASSWORD', '') - TRACKER_DB_NAME = os.getenv('TRACKER_DATABASE_NAME', '') - TRACKER_DB_HOST = os.getenv('TRACKER_DATABASE_HOST', '') - TRACKER_DB_PORT = os.getenv('TRACKER_DATABASE_PORT', '5432') - TRACKER_SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( - user=TRACKER_DB_USER, - password=TRACKER_DB_PASSWORD, - host=TRACKER_DB_HOST, - port=int(TRACKER_DB_PORT), - name=TRACKER_DB_NAME, - ) - - SQLALCHEMY_BINDS = { - 'tracker': TRACKER_SQLALCHEMY_DATABASE_URI - } - NATS_CONNECTION_OPTIONS = { 'servers': os.getenv('NATS_SERVERS', 'nats://127.0.0.1:4222').split(','), 'name': os.getenv('NATS_CLIENT_NAME', 'entity.filing.worker') @@ -175,23 +158,6 @@ class TestConfig(_Config): # pylint: disable=too-few-public-methods name=DB_NAME, ) - TRACKER_DB_USER = os.getenv('TRACKER_DATABASE_TEST_USERNAME', '') - TRACKER_DB_PASSWORD = os.getenv('TRACKER_DATABASE_TEST_PASSWORD', '') - TRACKER_DB_NAME = os.getenv('TRACKER_DATABASE_TEST_NAME', '') - TRACKER_DB_HOST = os.getenv('TRACKER_DATABASE_TEST_HOST', '') - TRACKER_DB_PORT = os.getenv('TRACKER_DATABASE_TEST_PORT', '5432') - TRACKER_SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( - user=TRACKER_DB_USER, - password=TRACKER_DB_PASSWORD, - host=TRACKER_DB_HOST, - port=int(TRACKER_DB_PORT), - name=TRACKER_DB_NAME, - ) - - SQLALCHEMY_BINDS = { - 'tracker': TRACKER_SQLALCHEMY_DATABASE_URI - } - class ProdConfig(_Config): # pylint: disable=too-few-public-methods """Production environment configuration.""" diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py index b4032e33ac..27bbee1d5e 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -1,4 +1,4 @@ -# Copyright © 2019 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. From b3bbad109fa6a245357670ad6b96d76729f3f7c8 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 16 Nov 2023 20:39:47 +0000 Subject: [PATCH 48/56] chore: fix linting Signed-off-by: Akiff Manji --- queue_services/entity-digital-credentials/requirements.txt | 4 +++- .../src/entity_digital_credentials/helpers.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/queue_services/entity-digital-credentials/requirements.txt b/queue_services/entity-digital-credentials/requirements.txt index 00c53fc261..03db965a05 100644 --- a/queue_services/entity-digital-credentials/requirements.txt +++ b/queue_services/entity-digital-credentials/requirements.txt @@ -21,4 +21,6 @@ urllib3==1.26.11 Werkzeug==1.0.1 git+https://github.com/bcgov/business-schemas.git@2.18.15#egg=registry_schemas git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common -git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api \ No newline at end of file +# git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api +# Temporary until PR is merged +git+https://github.com/petridishdev/lear.git@feature-digital-credentials#egg=legal_api&subdirectory=legal-api \ No newline at end of file diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py index f2a353d46f..a239e0bdf4 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py @@ -106,7 +106,7 @@ def revoke_issued_digital_credential(business: Business, def replace_issued_digital_credential(business: Business, issued_credential: DCIssuedCredential, credential_type: DCDefinition.CredentialType, - reason: DCRevocationReason): + reason: DCRevocationReason): # pylint: disable=too-many-arguments """Replace an issued digital credential for a business.""" try: if issued_credential.is_issued and not issued_credential.is_revoked: From 9524ecc1b724575a2662f8420a4c62ec2aa54d5a Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 16 Nov 2023 20:58:10 +0000 Subject: [PATCH 49/56] chore: address second set of code reivew comments Signed-off-by: Akiff Manji --- .../business_number.py | 4 +++- .../change_of_registration.py | 4 +++- .../digital_credentials_processors/dissolution.py | 4 +++- .../digital_credentials_processors/manual.py | 4 +++- .../digital_credentials_processors/put_back_on.py | 4 +++- .../src/entity_digital_credentials/worker.py | 15 +++++++++------ 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py index e570128d38..d631003bfa 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py @@ -13,6 +13,7 @@ # limitations under the License. """Processing business number actions.""" +from entity_queue_common.service_utils import logger from legal_api.models import Business, DCDefinition, DCRevocationReason from entity_digital_credentials.helpers import get_issued_digital_credentials, replace_issued_digital_credential @@ -23,7 +24,8 @@ async def process(business: Business): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - raise Exception('No issued credentials found.') + logger.error('No issued credentials found for business: %s', business.identifier) + return None return replace_issued_digital_credential(business=business, issued_credential=issued_credentials[0], diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py index 320cdb46a7..08981e6529 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py @@ -13,6 +13,7 @@ # limitations under the License. """Processing change of registration actions.""" +from entity_queue_common.service_utils import logger from legal_api.models import Business, DCDefinition, DCRevocationReason, Filing from entity_digital_credentials.helpers import get_issued_digital_credentials, replace_issued_digital_credential @@ -24,7 +25,8 @@ async def process(business: Business, filing: Filing): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - raise Exception('No issued credentials found.') + logger.error('No issued credentials found for business: %s', business.identifier) + return None return replace_issued_digital_credential(business=business, issued_credential=issued_credentials[0], diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py index 7920e65e4b..d66a5fca6f 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py @@ -13,6 +13,7 @@ # limitations under the License. """Processing dissolution actions.""" +from entity_queue_common.service_utils import logger from legal_api.models import Business, DCDefinition, DCRevocationReason from entity_digital_credentials.helpers import ( @@ -27,7 +28,8 @@ async def process(business: Business, filing_sub_type: str): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - raise Exception('No issued credentials found.') + logger.error('No issued credentials found for business: %s', business.identifier) + return None if filing_sub_type == 'voluntary': reason = DCRevocationReason.VOLUNTARY_DISSOLUTION diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py index 0b52c8edc6..572278647e 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py @@ -13,6 +13,7 @@ # limitations under the License. """Processing manual actions.""" +from entity_queue_common.service_utils import logger from legal_api.models import Business, DCRevocationReason from entity_digital_credentials.helpers import get_issued_digital_credentials, revoke_issued_digital_credential @@ -23,7 +24,8 @@ async def process(business: Business): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - raise Exception('No issued credentials found.') + logger.error('No issued credentials found for business: %s', business.identifier) + return None return revoke_issued_digital_credential(business=business, issued_credential=issued_credentials[0], diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py index c5704aacae..fb63b4021f 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py @@ -14,6 +14,7 @@ """Processing put back on actions.""" +from entity_queue_common.service_utils import logger from legal_api.models import Business, DCRevocationReason from entity_digital_credentials.helpers import get_issued_digital_credentials, revoke_issued_digital_credential @@ -24,7 +25,8 @@ async def process(business: Business): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - raise Exception('No issued credentials found.') + logger.error('No issued credentials found for business: %s', business.identifier) + return None return revoke_issued_digital_credential(business=business, issued_credential=issued_credentials[0], diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py index 27bbee1d5e..f75618fc9c 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -65,18 +65,21 @@ async def process_digital_credential(dc_msg: dict, flask_app: Flask): # pylint: disable=too-many-branches, too-many-statements """Process any digital credential messages in queue.""" + if not dc_msg or dc_msg.get('type') not in [ + f'bc.registry.business.{FilingCore.FilingTypes.CHANGEOFREGISTRATION.value}', + f'bc.registry.business.{FilingCore.FilingTypes.DISSOLUTION.value}', + f'bc.registry.business.{FilingCore.FilingTypes.PUTBACKON.value}', + 'bc.registry.admin.bn', + 'bc.registry.admin.manual' + ]: + return None + if not flask_app: raise QueueException('Flask App not available.') with flask_app.app_context(): logger.debug('Attempting to process digital credential message: %s', dc_msg) - if dc_msg is None: - raise QueueException - - if dc_msg['type'] is None: - raise QueueException('Digital credential message is missing type.') - if dc_msg['type'] in ('bc.registry.business.bn', 'bc.registry.business.manual'): # When a BN is added or changed or there is a manuak administrative update the queue message does not have # a data object. We queue the business information using the identifier and revoke/reissue the credential From 4eae36730071080cb1268205f60791e5512ae7fe Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Thu, 16 Nov 2023 21:00:18 +0000 Subject: [PATCH 50/56] chore: address third set of code reivew comments Signed-off-by: Akiff Manji --- legal-api/src/legal_api/models/dc_revocation_reason.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/legal-api/src/legal_api/models/dc_revocation_reason.py b/legal-api/src/legal_api/models/dc_revocation_reason.py index dda2384231..150e552c53 100644 --- a/legal-api/src/legal_api/models/dc_revocation_reason.py +++ b/legal-api/src/legal_api/models/dc_revocation_reason.py @@ -24,7 +24,5 @@ class DCRevocationReason(Enum): 'A new credential was offered that reflects the new company status and that revoked all previous copies.' ADMINISTRATIVE_DISSOLUTION = 'Your business was dissolved by the Registrar.' PUT_BACK_ON = 'Your business was put back on the Registry. ' - RESTORATION = 'Your business was put back on the Registry. ' \ - 'A new credential was offered that reflects the new company status and that revoked all previous copies.' SELF_REISSUANCE = 'You chose to issue yourself a new credential and that revoked all previous copies.' SELF_REVOCATION = 'You chose to revoke your own credential.' From f359f9cc1169b1a893f18e5ac3e20b595b15392a Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 20 Nov 2023 17:09:31 +0000 Subject: [PATCH 51/56] chore: clean up config file for edc queue Signed-off-by: Akiff Manji --- .../entity-digital-credentials/.env.sample | 10 ++++---- .../src/entity_digital_credentials/config.py | 24 +++++-------------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/queue_services/entity-digital-credentials/.env.sample b/queue_services/entity-digital-credentials/.env.sample index 87570670f8..030dbe3a45 100644 --- a/queue_services/entity-digital-credentials/.env.sample +++ b/queue_services/entity-digital-credentials/.env.sample @@ -4,11 +4,11 @@ FLASK_ENV= ### SQL Alchemy -DATABASE_USERNAME= -DATABASE_PASSWORD= -DATABASE_NAME= -DATABASE_HOST= -DATABASE_PORT= +ENTITY_DATABASE_USERNAME= +ENTITY_DATABASE_PASSWORD= +ENTITY_DATABASE_NAME= +ENTITY_DATABASE_HOST= +ENTITY_DATABASE_PORT= DATABASE_TEST_USERNAME= DATABASE_TEST_PASSWORD= diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py index d29ea81f34..9270f60867 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py @@ -58,15 +58,8 @@ class _Config(): # pylint: disable=too-few-public-methods Used as the base for all the other configurations. """ - PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) - - MSG_RETRY_NUM = int(os.getenv('MSG_RETRY_NUM', '5')) - LD_SDK_KEY = os.getenv('LD_SDK_KEY', None) - # urls - LEGAL_API_URL = os.getenv('LEGAL_API_URL', None) - # variables LEGISLATIVE_TIMEZONE = os.getenv( 'LEGISLATIVE_TIMEZONE', 'America/Vancouver') @@ -74,11 +67,11 @@ class _Config(): # pylint: disable=too-few-public-methods SQLALCHEMY_TRACK_MODIFICATIONS = False # POSTGRESQL - DB_USER = os.getenv('DATABASE_USERNAME', '') - DB_PASSWORD = os.getenv('DATABASE_PASSWORD', '') - DB_NAME = os.getenv('DATABASE_NAME', '') - DB_HOST = os.getenv('DATABASE_HOST', '') - DB_PORT = os.getenv('DATABASE_PORT', '5432') + DB_USER = os.getenv('ENTITY_DATABASE_USERNAME', '') + DB_PASSWORD = os.getenv('ENTITY_DATABASE_PASSWORD', '') + DB_NAME = os.getenv('ENTITY_DATABASE_NAME', '') + DB_HOST = os.getenv('ENTITY_DATABASE_HOST', '') + DB_PORT = os.getenv('ENTITY_DATABASE_PORT', '5432') SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( user=DB_USER, password=DB_PASSWORD, @@ -100,7 +93,7 @@ class _Config(): # pylint: disable=too-few-public-methods } SUBSCRIPTION_OPTIONS = { - 'subject': os.getenv('NATS_DIGITAL_CREDENTIALS_SUBJECT', 'error'), + 'subject': os.getenv('NATS_DIGITAL_CREDENTIALS_SUBJECT', 'entity.events'), 'queue': os.getenv('NATS_QUEUE', 'error'), 'durable_name': os.getenv('NATS_QUEUE', 'error') + '_durable', } @@ -109,9 +102,6 @@ class _Config(): # pylint: disable=too-few-public-methods 'subject': os.getenv('NATS_ENTITY_EVENT_SUBJECT', 'entity.events'), } - NAME_REQUEST_URL = os.getenv('NAME_REQUEST_URL', '') - DECIDE_BUSINESS_URL = os.getenv('DECIDE_BUSINESS_URL', '') - # 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') @@ -148,8 +138,6 @@ class TestConfig(_Config): # pylint: disable=too-few-public-methods DB_HOST = os.getenv('DATABASE_TEST_HOST', '') DB_PORT = os.getenv('DATABASE_TEST_PORT', '5432') DEPLOYMENT_ENV = 'testing' - LEGAL_API_URL = 'https://legal-api-url/' - PAY_API_URL = 'https://pay-api-url/' SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( user=DB_USER, password=DB_PASSWORD, From dae32ad22248f7983c7f1f7e536d4ccc22e96c00 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 20 Nov 2023 17:26:22 +0000 Subject: [PATCH 52/56] feat: add DevOps config files Signed-off-by: Akiff Manji --- .../entity-digital-credentials-cd.yml | 114 ++++++++++++++++++ .../entity-digital-credentials-ci.yml | 110 +++++++++++++++++ .../devops/vaults.json | 1 + .../entity-digital-credentials/k8s/Readme.md | 7 ++ 4 files changed, 232 insertions(+) create mode 100644 .github/workflows/entity-digital-credentials-cd.yml create mode 100644 .github/workflows/entity-digital-credentials-ci.yml create mode 100644 queue_services/entity-digital-credentials/devops/vaults.json create mode 100644 queue_services/entity-digital-credentials/k8s/Readme.md diff --git a/.github/workflows/entity-digital-credentials-cd.yml b/.github/workflows/entity-digital-credentials-cd.yml new file mode 100644 index 0000000000..56ceda2230 --- /dev/null +++ b/.github/workflows/entity-digital-credentials-cd.yml @@ -0,0 +1,114 @@ +name: Entity Digital Credentials CD + +on: + push: + branches: + - main + paths: + - "queue_services/entity-digital-credentials/**" + - "queue_services/common/**" + workflow_dispatch: + inputs: + environment: + description: "Environment (dev/test/prod)" + required: true + default: "dev" + +defaults: + run: + shell: bash + working-directory: ./queue_services/entity-digital-credentials + +env: + APP_NAME: "entity-digital-credentials" + TAG_NAME: "dev" + +jobs: + entity-digital-credentials-cd-by-push: + runs-on: ubuntu-20.04 + + if: github.event_name == 'push' && github.repository == 'bcgov/lear' + environment: + name: "dev" + + steps: + - uses: actions/checkout@v3 + + - name: Login Openshift + shell: bash + run: | + oc login --server=${{secrets.OPENSHIFT4_LOGIN_REGISTRY}} --token=${{secrets.OPENSHIFT4_SA_TOKEN}} + + - name: CD Flow + shell: bash + env: + OPS_REPOSITORY: ${{ secrets.OPS_REPOSITORY }} + OPENSHIFT_DOCKER_REGISTRY: ${{ secrets.OPENSHIFT4_DOCKER_REGISTRY }} + OPENSHIFT_SA_NAME: ${{ secrets.OPENSHIFT4_SA_NAME }} + OPENSHIFT_SA_TOKEN: ${{ secrets.OPENSHIFT4_SA_TOKEN }} + OPENSHIFT_REPOSITORY: ${{ secrets.OPENSHIFT4_REPOSITORY }} + TAG_NAME: ${{ env.TAG_NAME }} + run: | + make cd + + - name: Watch new rollout (trigger by image change in Openshift) + shell: bash + run: | + oc rollout status dc/${{ env.APP_NAME }}-${{ env.TAG_NAME }} -n ${{ secrets.OPENSHIFT4_REPOSITORY }}-${{ env.TAG_NAME }} -w + + - name: Rocket.Chat Notification + uses: RocketChat/Rocket.Chat.GitHub.Action.Notification@master + if: failure() + with: + type: ${{ job.status }} + job_name: "*Entity Digital Credentials Built and Deployed to ${{env.TAG_NAME}}*" + channel: "#registries-bot" + url: ${{ secrets.ROCKETCHAT_WEBHOOK }} + commit: true + token: ${{ secrets.GITHUB_TOKEN }} + + entity-digital-credentials-cd-by-dispatch: + runs-on: ubuntu-20.04 + + if: github.event_name == 'workflow_dispatch' && github.repository == 'bcgov/lear' + environment: + name: "${{ github.event.inputs.environment }}" + + steps: + - uses: actions/checkout@v3 + - name: Set env by input + run: | + echo "TAG_NAME=${{ github.event.inputs.environment }}" >> $GITHUB_ENV + + - name: Login Openshift + shell: bash + run: | + oc login --server=${{secrets.OPENSHIFT4_LOGIN_REGISTRY}} --token=${{secrets.OPENSHIFT4_SA_TOKEN}} + + - name: CD Flow + shell: bash + env: + OPS_REPOSITORY: ${{ secrets.OPS_REPOSITORY }} + OPENSHIFT_DOCKER_REGISTRY: ${{ secrets.OPENSHIFT4_DOCKER_REGISTRY }} + OPENSHIFT_SA_NAME: ${{ secrets.OPENSHIFT4_SA_NAME }} + OPENSHIFT_SA_TOKEN: ${{ secrets.OPENSHIFT4_SA_TOKEN }} + OPENSHIFT_REPOSITORY: ${{ secrets.OPENSHIFT4_REPOSITORY }} + TAG_NAME: ${{ env.TAG_NAME }} + run: | + make cd + + - name: Watch new rollout (trigger by image change in Openshift) + shell: bash + run: | + oc rollout status dc/${{ env.APP_NAME }}-${{ env.TAG_NAME }} -n ${{ secrets.OPENSHIFT4_REPOSITORY }}-${{ env.TAG_NAME }} -w + + - name: Rocket.Chat Notification + uses: RocketChat/Rocket.Chat.GitHub.Action.Notification@master + if: failure() + with: + type: ${{ job.status }} + job_name: "*Entity Digital Credentials Built and Deployed to ${{env.TAG_NAME}}*" + channel: "#registries-bot" + url: ${{ secrets.ROCKETCHAT_WEBHOOK }} + commit: true + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/entity-digital-credentials-ci.yml b/.github/workflows/entity-digital-credentials-ci.yml new file mode 100644 index 0000000000..e79ae304de --- /dev/null +++ b/.github/workflows/entity-digital-credentials-ci.yml @@ -0,0 +1,110 @@ +name: Entity Digital Credentials CI + +on: + pull_request: + types: [assigned, synchronize] + paths: + - "queue_services/entity-digital-credentials/**" + - "queue_services/common/**" + +defaults: + run: + shell: bash + working-directory: ./queue_services/entity-digital-credentials + +jobs: + setup-job: + runs-on: ubuntu-20.04 + + if: github.repository == 'bcgov/lear' + + steps: + - uses: actions/checkout@v3 + - run: "true" + + linting: + needs: setup-job + runs-on: ubuntu-20.04 + + strategy: + matrix: + python-version: [3.8] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + make setup + - name: Lint with pylint + id: pylint + run: | + make pylint + - name: Lint with flake8 + id: flake8 + run: | + make flake8 + + testing: + needs: setup-job + env: + DATABASE_TEST_USERNAME: postgres + DATABASE_TEST_PASSWORD: postgres + DATABASE_TEST_NAME: postgres + DATABASE_TEST_HOST: localhost + NATS_SERVERS: "nats://nats:4222" + NATS_CLIENT_NAME: entity.digital-credentials.tester + NATS_CLUSTER_ID: test-cluster + NATS_ENTITY_EVENT_SUBJECT: entity.events + NATS_QUEUE: entity-digital-credentials-worker + TEST_NATS_DOCKER: True + STAN_CLUSTER_NAME: test-cluster + + runs-on: ubuntu-20.04 + + services: + postgres: + image: postgres:12 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + make setup + - name: Test with pytest + id: test + run: | + make test + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./queue_services/entity-digital-credentials/coverage.xml + flags: entity-digital-credentials + name: codecov-entity-digital-credentials + fail_ci_if_error: true + + build-check: + needs: setup-job + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v3 + - name: build to check strictness + id: build + run: | + make build-nc diff --git a/queue_services/entity-digital-credentials/devops/vaults.json b/queue_services/entity-digital-credentials/devops/vaults.json new file mode 100644 index 0000000000..0637a088a0 --- /dev/null +++ b/queue_services/entity-digital-credentials/devops/vaults.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/queue_services/entity-digital-credentials/k8s/Readme.md b/queue_services/entity-digital-credentials/k8s/Readme.md new file mode 100644 index 0000000000..40227bf8d2 --- /dev/null +++ b/queue_services/entity-digital-credentials/k8s/Readme.md @@ -0,0 +1,7 @@ +# buildconfig +oc process -f openshift/templates/bc.yaml -o yaml | oc apply -f - -n cc892f-tools +# deploymentconfig, service +oc process -f openshift/templates/dc.yaml -o yaml | oc apply -f - -n cc892f-dev +oc process -f openshift/templates/dc.yaml -p TAG=test -o yaml | oc apply -f - -n cc892f-test +oc process -f openshift/templates/dc.yaml -p TAG=prod -o yaml | oc apply -f - -n cc892f-prod + From ff89db32d865c078057e0e23106242c48aebad51 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 20 Nov 2023 20:54:17 +0000 Subject: [PATCH 53/56] chore: address code review comments Signed-off-by: Akiff Manji --- queue_services/entity-digital-credentials/requirements.txt | 4 +--- .../src/entity_digital_credentials/config.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/queue_services/entity-digital-credentials/requirements.txt b/queue_services/entity-digital-credentials/requirements.txt index 03db965a05..00c53fc261 100644 --- a/queue_services/entity-digital-credentials/requirements.txt +++ b/queue_services/entity-digital-credentials/requirements.txt @@ -21,6 +21,4 @@ urllib3==1.26.11 Werkzeug==1.0.1 git+https://github.com/bcgov/business-schemas.git@2.18.15#egg=registry_schemas git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common -# git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api -# Temporary until PR is merged -git+https://github.com/petridishdev/lear.git@feature-digital-credentials#egg=legal_api&subdirectory=legal-api \ No newline at end of file +git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api \ No newline at end of file diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py index 9270f60867..810aefef26 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py @@ -93,7 +93,7 @@ class _Config(): # pylint: disable=too-few-public-methods } SUBSCRIPTION_OPTIONS = { - 'subject': os.getenv('NATS_DIGITAL_CREDENTIALS_SUBJECT', 'entity.events'), + 'subject': os.getenv('NATS_ENTITY_EVENT_SUBJECT', 'entity.events'), 'queue': os.getenv('NATS_QUEUE', 'error'), 'durable_name': os.getenv('NATS_QUEUE', 'error') + '_durable', } From 48c121358f2251442c2e808e732165bf3a1ea7e5 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 20 Nov 2023 20:55:35 +0000 Subject: [PATCH 54/56] chore: fix linting errors Signed-off-by: Akiff Manji --- .../src/entity_digital_credentials/config.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py index 810aefef26..cb0fec3079 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py @@ -67,11 +67,11 @@ class _Config(): # pylint: disable=too-few-public-methods SQLALCHEMY_TRACK_MODIFICATIONS = False # POSTGRESQL - DB_USER = os.getenv('ENTITY_DATABASE_USERNAME', '') - DB_PASSWORD = os.getenv('ENTITY_DATABASE_PASSWORD', '') - DB_NAME = os.getenv('ENTITY_DATABASE_NAME', '') - DB_HOST = os.getenv('ENTITY_DATABASE_HOST', '') - DB_PORT = os.getenv('ENTITY_DATABASE_PORT', '5432') + DB_USER = os.getenv('ENTITY_DATABASE_USERNAME', '') + DB_PASSWORD = os.getenv('ENTITY_DATABASE_PASSWORD', '') + DB_NAME = os.getenv('ENTITY_DATABASE_NAME', '') + DB_HOST = os.getenv('ENTITY_DATABASE_HOST', '') + DB_PORT = os.getenv('ENTITY_DATABASE_PORT', '5432') SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( user=DB_USER, password=DB_PASSWORD, From 1a732a900391e5aa9b4a89c28881ca31a70e2b0b Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 20 Nov 2023 23:12:13 +0000 Subject: [PATCH 55/56] refactor: relax error logging level Signed-off-by: Akiff Manji --- .../digital_credentials_processors/business_number.py | 2 +- .../digital_credentials_processors/change_of_registration.py | 2 +- .../digital_credentials_processors/dissolution.py | 2 +- .../digital_credentials_processors/manual.py | 2 +- .../digital_credentials_processors/put_back_on.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py index d631003bfa..87dc6253c1 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py @@ -24,7 +24,7 @@ async def process(business: Business): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - logger.error('No issued credentials found for business: %s', business.identifier) + logger.warning('No issued credentials found for business: %s', business.identifier) return None return replace_issued_digital_credential(business=business, diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py index 08981e6529..5f0d4227fa 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py @@ -25,7 +25,7 @@ async def process(business: Business, filing: Filing): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - logger.error('No issued credentials found for business: %s', business.identifier) + logger.warning('No issued credentials found for business: %s', business.identifier) return None return replace_issued_digital_credential(business=business, diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py index d66a5fca6f..dfb8cea9e6 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py @@ -28,7 +28,7 @@ async def process(business: Business, filing_sub_type: str): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - logger.error('No issued credentials found for business: %s', business.identifier) + logger.warning('No issued credentials found for business: %s', business.identifier) return None if filing_sub_type == 'voluntary': diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py index 572278647e..19688b4174 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py @@ -24,7 +24,7 @@ async def process(business: Business): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - logger.error('No issued credentials found for business: %s', business.identifier) + logger.warning('No issued credentials found for business: %s', business.identifier) return None return revoke_issued_digital_credential(business=business, diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py index fb63b4021f..66768be9c1 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py @@ -25,7 +25,7 @@ async def process(business: Business): issued_credentials = get_issued_digital_credentials(business=business) if not (issued_credentials and len(issued_credentials)): - logger.error('No issued credentials found for business: %s', business.identifier) + logger.warning('No issued credentials found for business: %s', business.identifier) return None return revoke_issued_digital_credential(business=business, From 81f8abc7193c3350707fc492843786afb24e2ba3 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Mon, 20 Nov 2023 23:14:25 +0000 Subject: [PATCH 56/56] chore: add k8s files Signed-off-by: Akiff Manji --- .../k8s/templates/dc.yaml | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 queue_services/entity-digital-credentials/k8s/templates/dc.yaml diff --git a/queue_services/entity-digital-credentials/k8s/templates/dc.yaml b/queue_services/entity-digital-credentials/k8s/templates/dc.yaml new file mode 100644 index 0000000000..adf95338f4 --- /dev/null +++ b/queue_services/entity-digital-credentials/k8s/templates/dc.yaml @@ -0,0 +1,175 @@ +--- +kind: Template +apiVersion: v1 +metadata: + name: ${NAME}-${TAG}-deployment-template + annotations: + description: + Deployment template for an API application and connect to database. + tags: Flask + iconClass: icon-python +objects: + - kind: Service + apiVersion: v1 + metadata: + name: ${NAME}-${TAG} + labels: + name: ${NAME} + environment: ${TAG} + role: ${ROLE} + spec: + ports: + - name: ${NAME}-${TAG}-tcp + port: 8080 + targetPort: 8080 + selector: + name: ${NAME} + environment: ${TAG} + + - kind: DeploymentConfig + apiVersion: v1 + metadata: + name: ${NAME}-${TAG} + labels: + name: ${NAME} + environment: ${TAG} + role: ${ROLE} + annotations: + description: Defines how to deploy the application server + spec: + strategy: + rollingParams: + intervalSeconds: 1 + maxSurge: 25% + maxUnavailable: 25% + timeoutSeconds: 600 + updatePeriodSeconds: 1 + type: Rolling + triggers: + - type: ImageChange + imageChangeParams: + automatic: true + containerNames: + - ${NAME}-${TAG} + from: + kind: ImageStreamTag + namespace: ${NAMESPACE}-${IMAGE_NAMESPACE} + name: ${NAME}:${TAG} + replicas: 1 + selector: + name: ${NAME} + environment: ${TAG} + template: + metadata: + name: ${NAME}-${TAG} + labels: + name: ${NAME} + environment: ${TAG} + role: ${ROLE} + spec: + containers: + - name: ${NAME}-${TAG} + image: ${IMAGE_REGISTRY}/${NAMESPACE}-${IMAGE_NAMESPACE}/${NAME}:${TAG} + ports: + - containerPort: 8080 + protocol: TCP + readinessProbe: + initialDelaySeconds: 3 + timeoutSeconds: 30 + httpGet: + path: /readyz + port: 7070 + livenessProbe: + initialDelaySeconds: 120 + timeoutSeconds: 30 + httpGet: + path: /healthz + port: 7070 + + - kind: HorizontalPodAutoscaler + apiVersion: autoscaling/v1 + metadata: + name: ${NAME}-${TAG} + labels: + name: ${NAME} + environment: ${TAG} + role: ${ROLE} + spec: + scaleTargetRef: + kind: DeploymentConfig + name: ${NAME}-${TAG} + minReplicas: ${{MIN_REPLICAS}} + maxReplicas: ${{MAX_REPLICAS}} + +parameters: + - name: NAME + displayName: Name + description: The name assigned to all of the OpenShift resources associated to the server instance. + required: true + value: entity-digital-credentials + + - name: TAG + displayName: Environment TAG name + description: The TAG name for this environment, e.g., dev, test, prod + value: dev + required: true + + - name: ROLE + displayName: Role + description: Role + required: true + value: queue + + - name: NAMESPACE + displayName: Namespace Name + description: The base namespace name for the project. + required: true + value: cc892f + + - name: IMAGE_NAMESPACE + displayName: Image Namespace + required: true + description: The namespace of the OpenShift project containing the imagestream for the application. + value: tools + + - name: IMAGE_REGISTRY + displayName: Image Registry + required: true + description: The image registry of the OpenShift project. + value: image-registry.openshift-image-registry.svc:5000 + + - name: MIN_REPLICAS + displayName: Minimum Replicas + description: The minimum number of pods to have running. + required: true + value: "1" + + - name: MAX_REPLICAS + displayName: Maximum Replicas + description: The maximum number of pods to have running. + required: true + value: "1" + + - name: CPU_REQUEST + displayName: Resources CPU Request + description: The resources CPU request (in cores) for this build. + required: true + value: 10m + + - name: CPU_LIMIT + displayName: Resources CPU Limit + description: The resources CPU limit (in cores) for this build. + required: true + value: 500m + + - name: MEMORY_REQUEST + displayName: Resources Memory Request + description: The resources Memory request (in Mi, Gi, etc) for this build. + required: true + value: 10Mi + + - name: MEMORY_LIMIT + displayName: Resources Memory Limit + description: The resources Memory limit (in Mi, Gi, etc) for this build. + required: true + value: 1Gi