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/.github/workflows/legal-api-ci.yml b/.github/workflows/legal-api-ci.yml index 52b422ce6..11b959fbd 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/gunicorn_config.py b/legal-api/gunicorn_config.py index c4eb50acf..dbcc1e0b5 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 000000000..31e124b99 --- /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) + 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/6e28f267db2a_create_issued_business_user_credentials_.py b/legal-api/migrations/versions/6e28f267db2a_create_issued_business_user_credentials_.py new file mode 100644 index 000000000..8066646ab --- /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/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 1bbc1ef7f..481921ccc 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -42,6 +42,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/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/config.py b/legal-api/src/legal_api/config.py index 283dc81b8..2d4a7fb5c 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 000000000..0153b2631 --- /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/models/__init__.py b/legal-api/src/legal_api/models/__init__.py index 3c38b92cf..fc7fa5b6e 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_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..9f5eaf434 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_business_user_credential.py b/legal-api/src/legal_api/models/dc_issued_business_user_credential.py new file mode 100644 index 000000000..fe43d7e07 --- /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 ac56d1296..81dbe0dd3 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 + '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 } 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..27e09fed3 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,10 +16,18 @@ 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.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 @@ -42,7 +50,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 +59,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 +67,71 @@ 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/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: + 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 + + 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 + + +@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: + 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 + 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 @bp.route('//digitalCredentials', methods=['GET', 'OPTIONS'], strict_slashes=False) @@ -102,6 +157,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 @@ -118,18 +174,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 @@ -137,63 +202,92 @@ 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'], + credential_id=credential_id ) issued_credential.save() - return jsonify({'message': 'Issue Credential is initiated.'}), HTTPStatus.OK + return jsonify(issued_credential.json), 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 + 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 -@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 + + 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 + + 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 + + +@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': + 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': + 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() @@ -202,3 +296,79 @@ def webhook_notification(topic_name: str): 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 f606fc881..f0efba0c7 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,52 @@ 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 + + @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', - '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 da2e3a816..9e308287e 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 c8ddd3338..6be540597 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 @@ -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 @@ -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={ - 'credential_exchange_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.' + 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 @@ -130,7 +133,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 +142,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 +158,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 c7b411930..157de205c 100644 --- a/legal-api/tests/unit/services/test_digital_credentials.py +++ b/legal-api/tests/unit/services/test_digital_credentials.py @@ -24,14 +24,14 @@ 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 - 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 diff --git a/legal-api/wsgi.py b/legal-api/wsgi.py index 5d42ecd85..18500b113 100755 --- a/legal-api/wsgi.py +++ b/legal-api/wsgi.py @@ -18,7 +18,7 @@ from legal_api import create_app # 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')