From 6beb1b1d4db8f9e4f5938f5da852a7d16a6ba2c6 Mon Sep 17 00:00:00 2001 From: Akiff Manji Date: Fri, 27 Oct 2023 09:20:52 -0700 Subject: [PATCH] 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')