diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..5c8f9e58f --- /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 000000000..6d9f05ae9 --- /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 000000000..f2e9705b0 --- /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/legal-api/Makefile b/legal-api/Makefile index 37e2570c8..ea26d5527 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 000000000..5392f271d --- /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 000000000..cd01a0a05 --- /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 2290e9f2c..23ce7d78d 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.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 @@ -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 167cdacaa..000000000 --- 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 e0d2fa647..7956f43a9 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,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/config.py b/legal-api/src/legal_api/config.py index 283dc81b8..ed13c9c9b 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -144,9 +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') - 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') + # Digital Credential configuration values + TRACTION_API_URL = os.getenv('TRACTION_API_URL') + TRACTION_API_TOKEN = os.getenv('TRACTION_API_TOKEN') + 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/extensions.py b/legal-api/src/legal_api/extensions.py new file mode 100644 index 000000000..d7cc8f257 --- /dev/null +++ b/legal-api/src/legal_api/extensions.py @@ -0,0 +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(): + """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 06bf725f7..f1e7320c5 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 0094c8ff4..4e05061a7 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 is 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/models/dc_issued_credential.py b/legal-api/src/legal_api/models/dc_issued_credential.py index ac56d1296..70d71dedc 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 3d9f040b6..91fd7913c 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 f606fc881..f22985d91 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -29,15 +29,21 @@ class DigitalCredentialsService: business_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 + # 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): @@ -45,86 +51,126 @@ def __init__(self): 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.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') + + 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') + + # 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: + # 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 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') + + # 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.""" + 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.""" + 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 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: @@ -138,22 +184,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 +212,27 @@ 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', - 'X-API-KEY': self.api_key + 'Authorization': f'Bearer {self.api_token}' } diff --git a/legal-api/wsgi.py b/legal-api/wsgi.py index 5d42ecd85..93c38f48d 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')