From 3f70b0b0f6f14d09c6104532731193f442e9fbc8 Mon Sep 17 00:00:00 2001 From: hjhsalo Date: Sat, 19 Nov 2016 04:07:11 +0200 Subject: [PATCH] All components updated to v1.2.1 specification. Added Docker support and Docker-Compose file for quick start. --- .dockerignore | 5 + .gitignore | 5 +- .gitmodules | 3 + Account/Dockerfile-account | 98 + Account/account_config_template.py.j2 | 2 +- Account/app/__init__.py | 7 +- Account/app/mod_account/controllers.py | 2296 +++++++++++++++- Account/app/mod_account/models.py | 188 +- Account/app/mod_account/services.py | 350 +-- Account/app/mod_account/view_api.py | 2408 ++++++++++++++++- Account/app/mod_account/view_html.py | 63 - Account/app/mod_api_auth/controllers.py | 25 +- Account/app/mod_api_auth/view_api.py | 39 +- Account/app/mod_auth/helpers.py | 24 +- Account/app/mod_authorization/controllers.py | 664 ++++- Account/app/mod_authorization/models.py | 85 +- Account/app/mod_authorization/view_api.py | 300 +- Account/app/mod_blackbox/controllers.py | 18 +- Account/app/mod_blackbox/helpers.py | 3 + Account/app/mod_blackbox/services.py | 17 +- Account/app/mod_database/helpers.py | 387 ++- Account/app/mod_database/models.py | 929 +++++-- Account/app/mod_service/controllers.py | 82 +- Account/app/mod_service/models.py | 13 +- Account/app/mod_service/view_api.py | 33 +- Account/app/mod_system/controllers.py | 66 +- Account/config.py | 2 +- Account/doc/api/account_api_external.yaml | 44 +- Account/doc/api/account_api_internal.yaml | 194 +- Account/doc/database/MyDataAccount-DBinit.sql | 55 +- .../doc/database/MyDataAccount-UserInit.sql | 11 + Account/doc/database/MyDataAccount.mwb | Bin 23501 -> 27724 bytes Account/doc/database/MyDataAccount.png | Bin 110248 -> 111152 bytes Account/doc/deployment.md | 4 +- Account/doc/developer_oneliners.md | 5 +- Account/docker-entrypoint-account.sh | 19 + .../operator_emulator/operator_emulator.py | 432 ++- Account/operator_emulator/ui_emulator.py | 911 +++++++ Dockerfile-overholt | 105 + Operator_Components/Operator_CR/auth_token.py | 4 +- .../Operator_CR/consent_form.py | 144 +- .../Operator_CR/introspection.py | 78 + .../Operator_CR/status_change.py | 57 + .../Operator_SLR/registerSur.py | 76 +- Operator_Components/Operator_SLR/start.py | 17 +- Operator_Components/Operator_SLR/verify.py | 15 +- Operator_Components/Templates.py | 34 +- Operator_Components/db_handler.py | 27 +- .../doc/api/swagger_Operator_CR.yml | 142 +- .../doc/api/swagger_Operator_SLR.yml | 10 +- .../database/Operator_Components-DBinit.sql | 83 + .../Operator_Components_db_image-v001.png | Bin 0 -> 16871 bytes Operator_Components/doc/deployment.md | 2 +- Operator_Components/factory.py | 9 +- Operator_Components/helpers.py | 630 +++-- Operator_Components/instance/__init__.py | 0 Operator_Components/instance/settings.py | 49 + .../instance/settings_template.py.j2 | 187 ++ Operator_Components/op_tasks.py | 45 + Operator_Components/requirements.txt | 3 + Operator_Components/wsgi.py | 2 +- .../authorization_management.py | 37 +- .../Service_Mgmnt/service_mgmnt.py | 109 +- Service_Components/Sink/Sink_DataFlow.py | 148 + Service_Components/Sink/__init__.py | 15 + Service_Components/Source/Source_DataFlow.py | 125 + Service_Components/Source/__init__.py | 15 + Service_Components/Templates.py | 283 +- Service_Components/db_handler.py | 38 +- .../api/swagger_Authorization_Management.yml | 6 +- .../doc/api/swagger_Service_Mgmnt.yml | 49 +- .../doc/api/swagger_Sink_DC.yaml | 71 + .../doc/api/swagger_Source_DC.yaml | 57 + .../database/Service_Components-DBinit.sql | 103 + .../Service_Components_db_image-v001.png | Bin 0 -> 47241 bytes Service_Components/doc/deployment.md | 4 +- Service_Components/factory.py | 2 +- Service_Components/helpers.py | 826 ++++-- Service_Components/instance/__init__.py | 0 Service_Components/instance/settings.py | 52 + .../instance/settings_template.py.j2 | 152 ++ Service_Components/requirements.txt | 3 + .../signed_requests/__init__.py | 28 + Service_Components/signed_requests/doc_tests | 93 + .../signed_requests/json_builder.py | 136 + .../signed_requests/run_tests.py | 31 + .../signed_requests/signed_request_auth.py | 131 + Service_Components/srv_tasks.py | 56 + Service_Components/wsgi.py | 28 +- Service_Mockup/Service/service.py | 92 +- Service_Mockup/Service_Root/root.py | 9 +- Service_Mockup/db_handler.py | 43 +- Service_Mockup/doc/api/swagger_Service.yml | 6 +- .../doc/database/Service_Mockup-DBinit.sql | 68 + .../database/Service_Mockup_db_image-v001.png | Bin 0 -> 18994 bytes Service_Mockup/helpers.py | 101 + Service_Mockup/instance/__init__.py | 0 Service_Mockup/instance/settings.py | 6 + .../instance/settings_template.py.j2 | 79 + Service_Mockup/requirements.txt | 3 + Service_Mockup/wsgi.py | 2 +- docker-compose.yml | 239 ++ docker-entrypoint-overholt.sh | 22 + init-db/MyDataAccount-DBinit.sql | 405 +++ init-db/MyDataAccount-UserInit.sql | 11 + init-db/Operator_Components-DBinit.sql | 83 + init-db/Service_Components-DBinit.sql | 103 + init-db/Service_Mockup-DBinit.sql | 68 + start.sh | 13 + ui_flow.py | 189 +- 110 files changed, 13730 insertions(+), 1916 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitmodules create mode 100644 Account/Dockerfile-account mode change 100644 => 100755 Account/doc/database/MyDataAccount-DBinit.sql create mode 100644 Account/doc/database/MyDataAccount-UserInit.sql mode change 100644 => 100755 Account/doc/database/MyDataAccount.mwb mode change 100644 => 100755 Account/doc/database/MyDataAccount.png create mode 100755 Account/docker-entrypoint-account.sh create mode 100644 Account/operator_emulator/ui_emulator.py create mode 100644 Dockerfile-overholt create mode 100644 Operator_Components/Operator_CR/introspection.py create mode 100644 Operator_Components/Operator_CR/status_change.py create mode 100644 Operator_Components/doc/database/Operator_Components-DBinit.sql create mode 100644 Operator_Components/doc/database/Operator_Components_db_image-v001.png create mode 100644 Operator_Components/instance/__init__.py create mode 100644 Operator_Components/instance/settings.py create mode 100644 Operator_Components/instance/settings_template.py.j2 create mode 100644 Operator_Components/op_tasks.py create mode 100644 Service_Components/Sink/Sink_DataFlow.py create mode 100644 Service_Components/Sink/__init__.py create mode 100644 Service_Components/Source/Source_DataFlow.py create mode 100644 Service_Components/Source/__init__.py create mode 100644 Service_Components/doc/api/swagger_Sink_DC.yaml create mode 100644 Service_Components/doc/api/swagger_Source_DC.yaml create mode 100644 Service_Components/doc/database/Service_Components-DBinit.sql create mode 100644 Service_Components/doc/database/Service_Components_db_image-v001.png create mode 100644 Service_Components/instance/__init__.py create mode 100644 Service_Components/instance/settings.py create mode 100644 Service_Components/instance/settings_template.py.j2 create mode 100644 Service_Components/signed_requests/__init__.py create mode 100644 Service_Components/signed_requests/doc_tests create mode 100644 Service_Components/signed_requests/json_builder.py create mode 100644 Service_Components/signed_requests/run_tests.py create mode 100644 Service_Components/signed_requests/signed_request_auth.py create mode 100644 Service_Components/srv_tasks.py create mode 100644 Service_Mockup/doc/database/Service_Mockup-DBinit.sql create mode 100644 Service_Mockup/doc/database/Service_Mockup_db_image-v001.png create mode 100644 Service_Mockup/instance/__init__.py create mode 100644 Service_Mockup/instance/settings.py create mode 100644 Service_Mockup/instance/settings_template.py.j2 create mode 100644 docker-compose.yml create mode 100755 docker-entrypoint-overholt.sh create mode 100755 init-db/MyDataAccount-DBinit.sql create mode 100644 init-db/MyDataAccount-UserInit.sql create mode 100644 init-db/Operator_Components-DBinit.sql create mode 100644 init-db/Service_Components-DBinit.sql create mode 100644 init-db/Service_Mockup-DBinit.sql create mode 100755 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d5c25d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +doc +Dockerfile* +nginx.conf +uwsgi.ini +docker-compose.yml diff --git a/.gitignore b/.gitignore index d494434..01f9735 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ # Gradle: .idea/gradle.xml .idea/libraries - +.idea # Mongo Explorer plugin: .idea/mongoSettings.xml @@ -113,7 +113,8 @@ coverage.xml local_settings.py # Flask stuff: -instance/ +####instance/ + .webassets-cache # Scrapy stuff: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..04816c7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Service_Components/signed_requests"] + path = Service_Components/signed_requests + url = https://github.com/Allu2/signed_requests diff --git a/Account/Dockerfile-account b/Account/Dockerfile-account new file mode 100644 index 0000000..f4963b8 --- /dev/null +++ b/Account/Dockerfile-account @@ -0,0 +1,98 @@ +FROM python:2.7 +MAINTAINER hjhsalo + +# NOTE: Baseimage python:2.7 already contains latest pip + +# TODO: Compile cryptography (and everything else pip related) elsewhere and +# get rid of "build-essential libssl-dev libffi-dev python-dev" +# Maybe according to these instructions: +# https://glyph.twistedmatrix.com/2015/03/docker-deploy-double-dutch.html + +# TODO: Double check and think about the order of commands. Should application +# specific stuff be moved to the end of the file? +# What are actually application specific? etc. + +# TODO: Have brainstorming session on how to properly setup EXPOSE ports, hosts, etc. +# Now it is difficult to come up with sensible defaults. +# Remember to check out what Docker Compose offers. + +# TODO: Make a new user and usergroup. +# Now everything including the ENTRYPOINT is being run as root which is bad +# practise and for example uWSGI complains about this. + +### +# Install +# Specific structure where a single RUN is used to execute everything. +# Based on Docker Best practices -document. To force cache busting. +# https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#/apt-get +# NOTE: python-mysql.connector is MyData Account specific dependency. +RUN apt-get update && apt-get install -y \ + build-essential \ + libffi-dev \ + libssl-dev \ + python-dev \ + python-mysql.connector \ + && rm -rf /var/lib/apt/lists/* + + +### +# Install application specific Python-dependencies. + +# NOTE: If you have multiple Dockerfile steps that use different files from +# your context, COPY them individually, rather than all at once. This will +# ensure that each step’s build cache is only invalidated (forcing the step +# to be re-run) if the specifically required files change. +# https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#/add-or-copy +COPY requirements.txt /tmp/ +RUN pip install --requirement /tmp/requirements.txt + +# NOTE: As uwsgi is part the configuration in some sense, how should we make +# this optional or at least clear to the reader? +RUN pip install uwsgi + +# NOTE: j2cli is needed to preprocess config files based on values +# environment variables +# https://github.com/kolypto/j2cli +# https://tryolabs.com/blog/2015/03/26/configurable-docker-containers-for-multiple-environments/ +RUN pip install j2cli + +### +# Setup configuration using environment variables +ENV MYSQL_HOST ${MYSQL_HOST:-'mysql-db'} +ENV MYSQL_USER ${MYSQL_USER:-'mydataaccount'} +ENV MYSQL_PASSWORD ${MYSQL_PASSWORD:-'wr8gabrA'} +ENV MYSQL_DB ${MYSQL_DB:-'MyDataAccount'} +ENV MYSQL_PORT ${MYSQL_PORT:-3306} +ENV URL_PREFIX ${URL_PREFIX:-''} + +### +# Create a installation directory into the container and copy the application +# to that folder. +ARG APP_INSTALL_PATH=/mydata-sdk-account + +# TODO: This may not be needed. Test and refactor if necessary to keep it. +ENV APP_INSTALL_PATH ${APP_INSTALL_PATH:-/mydata-account} +RUN mkdir -p $APP_INSTALL_PATH + +# Change current directory inside the container / image to this path. +WORKDIR $APP_INSTALL_PATH + +# Copy everything (including previously copied filed and folders) from directory +# where Dockerfile is located to current WORKDIR inside container. +# Remember that must be inside the context of the build: +# http://serverfault.com/a/666154 +COPY . . + +### +# Configure and run the application using entrypoint.sh. +# NOTE: Content of CMD are the default parameters passed to entrypoint.sh. +# These can be overwritten on "docker run " +# https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#/entrypoint +COPY ./docker-entrypoint-account.sh / + +ENTRYPOINT ["/docker-entrypoint-account.sh"] + +# NOTE: Maybe this should be replaced with something that doesn't run anything +# and the command below should go to compose.yml ?? +CMD ["uwsgi --socket 0.0.0.0:8080 --protocol=http -w wsgi --callable app --processes 2"] + diff --git a/Account/account_config_template.py.j2 b/Account/account_config_template.py.j2 index f8b6209..820e3a0 100644 --- a/Account/account_config_template.py.j2 +++ b/Account/account_config_template.py.j2 @@ -82,7 +82,7 @@ MYSQL_PORT = 3306 #MYSQL_READ_DEFAULT_FILE = '' # MySQL configuration file to read, see the MySQL documentation for mysql_options(). #MYSQL_USE_UNICODE = '' # If True, CHAR and VARCHAR and TEXT columns are returned as Unicode strings, using the configured character set. MYSQL_CHARSET = 'utf8' # If present, the connection character set will be changed to this character set, if they are not equal. Default: utf-8 -#MYSQL_SQL_MODE = '' # If present, the session SQL mode will be set to the given string. +MYSQL_SQL_MODE = 'TRADITIONAL' # If present, the session SQL mode will be set to the given string. #MYSQL_CURSORCLASS = '' # If present, the cursor class will be set to the given string. diff --git a/Account/app/__init__.py b/Account/app/__init__.py index d4dd9bc..679bc42 100644 --- a/Account/app/__init__.py +++ b/Account/app/__init__.py @@ -51,15 +51,18 @@ # ========================================= api = Api(app, prefix=app.config["URL_PREFIX"]) + @app.before_request def new_request(): print("New Request") print("############") + @app.errorhandler(404) def not_found(error): - # When file not found, respon with 403 - return make_response(('FORBIDDEN', 403)) + not_found_error = ApiError(code=404, title="Not Found", detail="Endpoint not found", status="NotFound") + error_dict = not_found_error.to_dict() + return make_json_response(errors=error_dict, status_code=str(error_dict['code'])) @app.errorhandler(ApiError) diff --git a/Account/app/mod_account/controllers.py b/Account/app/mod_account/controllers.py index ac03f5d..4da0c59 100644 --- a/Account/app/mod_account/controllers.py +++ b/Account/app/mod_account/controllers.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Import dependencies +import json import uuid import logging import bcrypt # https://github.com/pyca/bcrypt/, https://pypi.python.org/pypi/bcrypt/2.0.0 @@ -17,107 +18,2306 @@ from app import db, api, login_manager, app # Import services -from app.helpers import get_custom_logger -from app.mod_database.helpers import get_db_cursor -from app.mod_account.services import get_contacts_by_account, get_emails_by_account, get_telephones_by_account, \ - get_service_link_record_count_by_account, get_consent_record_count_by_account - +from app.helpers import get_custom_logger, ApiError +from app.mod_api_auth.controllers import get_account_id_by_api_key +from app.mod_database.helpers import get_db_cursor, get_primary_keys_by_account_id, get_slr_ids, \ + get_slsr_ids, get_cr_ids, get_csr_ids # create logger with 'spam_application' -logger = get_custom_logger('mod_account_controllers') +from app.mod_database.models import Particulars, Contacts, Email, Telephone, Settings, EventLog, ServiceLinkRecord, \ + ServiceLinkStatusRecord, ConsentRecord, ConsentStatusRecord + +logger = get_custom_logger(__name__) + +def verify_account_id_match(account_id=None, api_key=None, account_id_to_compare=None, endpoint=None): + """ + Verifies that provided account id matches with account id fetched with api key. -def check_account_id(account_id=None): - # TODO: check that session[account_id] and account_id from path are matching + :param account_id: + :param api_key: + :param endpoint: + :return: + """ if account_id is None: - logger.debug('Account ID must be provided as call parameter.') - raise AttributeError('Account ID must be provided as call parameter.') + raise AttributeError("Provide account_id as parameter") + if endpoint is None: + raise AttributeError("Provide endpoint as parameter") + + # Get Account ID by Api-Key or compare to provided + if api_key is not None: + try: + logger.info("Fetching Account ID by Api-Key") + account_id_by_api_key = get_account_id_by_api_key(api_key=api_key) + except Exception as exp: + error_title = "Account ID not found with provided ApiKey" + logger.error(error_title) + raise ApiError( + code=403, + title=error_title, + detail=repr(exp), + source=endpoint + ) + else: + logger.info("account_id_by_api_key: " + str(account_id_by_api_key)) + account_id_to_compare = account_id_by_api_key + error_title = "Authenticated Account ID not matching with Account ID that was provided with request" + elif account_id_to_compare is not None: + logger.info("account_id_to_compare provided as parameter") + error_title = "Account ID in payload not matching with Account ID that was provided with request" + + # Check if Account IDs are matching + logger.info("Check if Account IDs are matching") + logger.info("account_id: " + str(account_id)) + logger.info("account_id_to_compare: " + str(account_id_to_compare)) + if str(account_id) != str(account_id_to_compare): + logger.error(error_title) + raise ApiError( + code=403, + title=error_title, + source=endpoint + ) else: - return True + logger.info("Account IDs are matching") + logger.info("account_id: " + str(account_id)) + logger.info("account_id_to_compare: " + str(account_id_to_compare)) + + return True -def get_potential_services_count(cursor=None, account_id=None): - data = randint(10, 100) - return cursor, data +################################## +################################## +# Particulars +################################## +################################## +def get_particular(account_id=None, id=None, cursor=None): + """ + Get one particular entry from database by Account ID and Particulars ID + :param account_id: + :param id: + :return: Particular dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + logger.info("Creating Particulars object") + db_entry_object = Particulars(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create Particulars object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("Particulars object created: " + db_entry_object.log_entry) + + # Get particulars from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch Particulars from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("Particulars fetched") + logger.info("Particulars fetched from db: " + db_entry_object.log_entry) + return db_entry_object.to_api_dict -def get_potential_consents_count(cursor=None, account_id=None): - data = randint(10, 100) - return cursor, data +def get_particulars(account_id=None): + """ + Get all Particulars -entries related to account + :param account_id: + :return: List of Particular dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") -def get_passive_services_count(cursor=None, account_id=None): - data = randint(10, 100) - return cursor, data + # Get table name + logger.info("Create db_entry_object") + db_entry_object = Particulars() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise -def get_passive_consents_count(cursor=None, account_id=None): - data = randint(10, 100) - return cursor, data + # Get primary keys for particulars + try: + cursor, id_list = get_primary_keys_by_account_id(cursor=cursor, account_id=account_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise + # Get Particulars from database + logger.info("Get Particulars from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting particulars with particular_id: " + str(id)) + db_entry_dict = get_particular(account_id=account_id, id=id) + db_entry_list.append(db_entry_dict) + logger.info("Particulars object added to list: " + json.dumps(db_entry_dict)) -def get_service_link_record_count(cursor=None, account_id=None): + return db_entry_list - check_account_id(account_id=account_id) +def update_particular(account_id=None, id=None, attributes=None, cursor=None): + """ + Update one particular entry at database identified by Account ID and Particulars ID + :param account_id: + :param id: + :return: Particular dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if attributes is None: + raise AttributeError("Provide attributes as parameter") + if not isinstance(attributes, dict): + raise AttributeError("attributes must be a dict") if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = Particulars(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create Particulars object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("Particulars object created: " + db_entry_object.log_entry) + + # Get particulars from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch Particulars from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("Particulars fetched") + logger.info("Particulars fetched from db: " + db_entry_object.log_entry) + + # Update Particulars object + if len(attributes) == 0: + logger.info("Empty attributes dict provided. Nothing to update.") + return db_entry_object.to_api_dict + else: + logger.info("Particulars object to update: " + db_entry_object.log_entry) + + # log provided attributes + for key, value in attributes.items(): + logger.debug("attributes[" + str(key) + "]: " + str(value)) + + # Update object attributes + if "lastname" in attributes: + logger.info("Updating lastname") + old_value = str(db_entry_object.lastname) + new_value = str(attributes.get("lastname", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.lastname = new_value + logger.info(db_entry_object.log_entry) + + if "firstname" in attributes: + logger.info("Updating firstname") + old_value = str(db_entry_object.firstname) + new_value = str(attributes.get("firstname", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.firstname = new_value + logger.info(db_entry_object.log_entry) + + if "img_url" in attributes: + logger.info("Updating img_url") + old_value = str(db_entry_object.img_url) + new_value = str(attributes.get("img_url", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.img_url = new_value + logger.info(db_entry_object.log_entry) + + if "date_of_birth" in attributes: + logger.info("Updating date_of_birth") + old_value = str(db_entry_object.date_of_birth) + new_value = str(attributes.get("date_of_birth", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.date_of_birth = new_value + logger.info(db_entry_object.log_entry) + + # Store updates + try: + cursor = db_entry_object.update_db(cursor=cursor) + ### + # Commit + db.connection.commit() + except Exception as exp: + error_title = "Failed to update Particulars to DB" + logger.error(error_title + ": " + repr(exp)) + logger.debug('commit failed: ' + repr(exp)) + logger.debug('--> rollback') + db.connection.rollback() + raise + else: + logger.debug("Committed") + logger.info("Particulars updated") + logger.info(db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +################################## +################################### +# Contacts +################################## +################################## +def get_contact(account_id=None, id=None, cursor=None): + """ + Get one contact entry from database by Account ID and contact ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = Contacts(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create contact object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("contact object created: " + db_entry_object.log_entry) + + # Get contact from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch contact from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("contact fetched") + logger.info("contact fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +def get_contacts(account_id=None): + """ + Get all contact -entries related to account + :param account_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + + # Get table name + logger.info("Create contact") + db_entry_object = Contacts() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + # Get DB cursor + try: cursor = get_db_cursor() - logger.debug('No DB cursor provided as call parameter. Getting new one.') + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise - cursor, data = get_service_link_record_count_by_account(cursor=cursor, account_id=account_id) + # Get primary keys for contacts + try: + cursor, id_list = get_primary_keys_by_account_id(cursor=cursor, account_id=account_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise - return cursor, data + # Get contacts from database + logger.info("Get contacts from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting contacts with contacts_id: " + str(id)) + db_entry_dict = get_contact(account_id=account_id, id=id) + db_entry_list.append(db_entry_dict) + logger.info("contact object added to list: " + json.dumps(db_entry_dict)) + return db_entry_list -def get_consent_record_count(cursor=None, account_id=None): - check_account_id(account_id=account_id) +def add_contact(account_id=None, attributes=None, cursor=None): + """ + Add one contacts entry at database identified by Account ID and ID + :param account_id: + :param id: + :return: Particular dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if attributes is None: + raise AttributeError("Provide attributes as parameter") + if not isinstance(attributes, dict): + raise AttributeError("attributes must be a dict") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Update contacts object + if len(attributes) == 0: + logger.info("Empty attributes dict provided. Nothing to add.") + raise StandardError("Not adding empty entry to database") + else: + # log provided attributes + for key, value in attributes.items(): + logger.debug("attributes[" + str(key) + "]: " + str(value)) + + # Create object + try: + db_entry_object = Contacts( + account_id=account_id, + address1=str(attributes.get("address1", "")), + address2=str(attributes.get("address2", "")), + postal_code=str(attributes.get("postalCode", "")), + city=str(attributes.get("city", "")), + state=str(attributes.get("state", "")), + country=str(attributes.get("country", "")), + type=str(attributes.get("type", "")), + prime=str(attributes.get("primary", "")) + ) + except Exception as exp: + error_title = "Failed to create contacts object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("contacts object created: " + db_entry_object.log_entry) + # Store updates + try: + cursor = db_entry_object.to_db(cursor=cursor) + ### + # Commit + db.connection.commit() + except Exception as exp: + error_title = "Failed to add contacts to DB" + logger.error(error_title + ": " + repr(exp)) + logger.debug('commit failed: ' + repr(exp)) + logger.debug('--> rollback') + db.connection.rollback() + raise + else: + logger.debug("Committed") + logger.info("contacts added") + logger.info(db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +def update_contact(account_id=None, id=None, attributes=None, cursor=None): + """ + Update one contacts entry at database identified by Account ID and ID + :param account_id: + :param id: + :return: Particular dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if attributes is None: + raise AttributeError("Provide attributes as parameter") + if not isinstance(attributes, dict): + raise AttributeError("attributes must be a dict") if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = Contacts(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create contacts object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("contacts object created: " + db_entry_object.log_entry) + + # Get contacts from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch contacts from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("contacts fetched") + logger.info("contacts fetched from db: " + db_entry_object.log_entry) + + # Update contacts object + if len(attributes) == 0: + logger.info("Empty attributes dict provided. Nothing to update.") + return db_entry_object.to_api_dict + else: + logger.info("contacts object to update: " + db_entry_object.log_entry) + + # log provided attributes + for key, value in attributes.items(): + logger.debug("attributes[" + str(key) + "]: " + str(value)) + + # Update object attributes + if "address1" in attributes: + logger.info("Updating address1") + old_value = str(db_entry_object.address1) + new_value = str(attributes.get("address1", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.address1 = new_value + logger.info(db_entry_object.log_entry) + + if "address2" in attributes: + logger.info("Updating address2") + old_value = str(db_entry_object.address2) + new_value = str(attributes.get("address2", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.address2 = new_value + logger.info(db_entry_object.log_entry) + + if "postalCode" in attributes: + logger.info("Updating postalCode") + old_value = str(db_entry_object.postal_code) + new_value = str(attributes.get("postalCode", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.postal_code = new_value + logger.info(db_entry_object.log_entry) + + if "city" in attributes: + logger.info("Updating city") + old_value = str(db_entry_object.city) + new_value = str(attributes.get("city", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.city = new_value + logger.info(db_entry_object.log_entry) + + if "state" in attributes: + logger.info("Updating state") + old_value = str(db_entry_object.state) + new_value = str(attributes.get("state", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.state = new_value + logger.info(db_entry_object.log_entry) + + if "country" in attributes: + logger.info("Updating country") + old_value = str(db_entry_object.country) + new_value = str(attributes.get("country", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.country = new_value + logger.info(db_entry_object.log_entry) + + if "type" in attributes: + logger.info("Updating type") + old_value = str(db_entry_object.type) + new_value = str(attributes.get("type", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.type = new_value + logger.info(db_entry_object.log_entry) + + if "primary" in attributes: + logger.info("Updating primary") + old_value = str(db_entry_object.prime) + new_value = str(attributes.get("primary", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.prime = new_value + logger.info(db_entry_object.log_entry) + + # Store updates + try: + cursor = db_entry_object.update_db(cursor=cursor) + ### + # Commit + db.connection.commit() + except Exception as exp: + error_title = "Failed to update contacts to DB" + logger.error(error_title + ": " + repr(exp)) + logger.debug('commit failed: ' + repr(exp)) + logger.debug('--> rollback') + db.connection.rollback() + raise + else: + logger.debug("Committed") + logger.info("contacts updated") + logger.info(db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +################################## +################################### +# Emails +################################## +################################## +def get_email(account_id=None, id=None, cursor=None): + """ + Get one email entry from database by Account ID and email ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = Email(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create email object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("email object created: " + db_entry_object.log_entry) + + # Get email from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch email from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("email fetched") + logger.info("email fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +def get_emails(account_id=None): + """ + Get all email -entries related to account + :param account_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + + # Get table name + logger.info("Create email") + db_entry_object = Email() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + # Get DB cursor + try: cursor = get_db_cursor() - logger.debug('No DB cursor provided as call parameter. Getting new one.') + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise - cursor, data = get_consent_record_count_by_account(cursor=cursor, account_id=account_id) + # Get primary keys for emails + try: + cursor, id_list = get_primary_keys_by_account_id(cursor=cursor, account_id=account_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise - return cursor, data + # Get emails from database + logger.info("Get emails from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting emails with emails_id: " + str(id)) + db_entry_dict = get_email(account_id=account_id, id=id) + db_entry_list.append(db_entry_dict) + logger.info("email object added to list: " + json.dumps(db_entry_dict)) + return db_entry_list -def get_contacts(cursor=None, account_id=None): - check_account_id(account_id=account_id) +def add_email(account_id=None, attributes=None, cursor=None): + """ + Add one email entry to database identified by Account ID and ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if attributes is None: + raise AttributeError("Provide attributes as parameter") + if not isinstance(attributes, dict): + raise AttributeError("attributes must be a dict") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Update emails object + if len(attributes) == 0: + logger.info("Empty attributes dict provided. Nothing to add.") + raise StandardError("Not adding empty entry to database") + else: + # log provided attributes + for key, value in attributes.items(): + logger.debug("attributes[" + str(key) + "]: " + str(value)) + + # Create object + try: + db_entry_object = Email( + account_id=account_id, + email=str(attributes.get("email", "")), + type=str(attributes.get("type", "")), + prime=str(attributes.get("primary", "")) + ) + except Exception as exp: + error_title = "Failed to create emails object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("emails object created: " + db_entry_object.log_entry) + # Store updates + try: + cursor = db_entry_object.to_db(cursor=cursor) + ### + # Commit + db.connection.commit() + except Exception as exp: + error_title = "Failed to add emails to DB" + logger.error(error_title + ": " + repr(exp)) + logger.debug('commit failed: ' + repr(exp)) + logger.debug('--> rollback') + db.connection.rollback() + raise + else: + logger.debug("Committed") + logger.info("emails added") + logger.info(db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +def update_email(account_id=None, id=None, attributes=None, cursor=None): + """ + Update one email entry at database identified by Account ID and ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if attributes is None: + raise AttributeError("Provide attributes as parameter") + if not isinstance(attributes, dict): + raise AttributeError("attributes must be a dict") if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = Email(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create email object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("email object created: " + db_entry_object.log_entry) + + # Get email from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch email from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("email fetched") + logger.info("email fetched from db: " + db_entry_object.log_entry) + + # Update email object + if len(attributes) == 0: + logger.info("Empty attributes dict provided. Nothing to update.") + return db_entry_object.to_api_dict + else: + logger.info("email object to update: " + db_entry_object.log_entry) + + # log provided attributes + for key, value in attributes.items(): + logger.debug("attributes[" + str(key) + "]: " + str(value)) + + # Update object attributes + if "email" in attributes: + logger.info("Updating email") + old_value = str(db_entry_object.email) + new_value = str(attributes.get("email", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.email = new_value + logger.info(db_entry_object.log_entry) + + if "type" in attributes: + logger.info("Updating type") + old_value = str(db_entry_object.type) + new_value = str(attributes.get("type", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.type = new_value + logger.info(db_entry_object.log_entry) + + if "primary" in attributes: + logger.info("Updating primary") + old_value = str(db_entry_object.prime) + new_value = str(attributes.get("primary", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.prime = new_value + logger.info(db_entry_object.log_entry) + + # Store updates + try: + cursor = db_entry_object.update_db(cursor=cursor) + ### + # Commit + db.connection.commit() + except Exception as exp: + error_title = "Failed to update email to DB" + logger.error(error_title + ": " + repr(exp)) + logger.debug('commit failed: ' + repr(exp)) + logger.debug('--> rollback') + db.connection.rollback() + raise + else: + logger.debug("Committed") + logger.info("email updated") + logger.info(db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + + + +################################## +################################### +# Telephones (numbers) +################################## +################################## +def get_telephone(account_id=None, id=None, cursor=None): + """ + Get one telephone entry from database by Account ID and telephone ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = Telephone(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create telephone object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("telephone object created: " + db_entry_object.log_entry) + + # Get telephone from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch telephone from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("telephone fetched") + logger.info("telephone fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +def get_telephones(account_id=None): + """ + Get all telephone -entries related to account + :param account_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + + # Get table name + logger.info("Create telephone") + db_entry_object = Telephone() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + # Get DB cursor + try: cursor = get_db_cursor() - logger.debug('No DB cursor provided as call parameter. Getting new one.') + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Get primary keys for telephones + try: + cursor, id_list = get_primary_keys_by_account_id(cursor=cursor, account_id=account_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise + + # Get telephones from database + logger.info("Get telephones from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting telephones with telephones_id: " + str(id)) + db_entry_dict = get_telephone(account_id=account_id, id=id) + db_entry_list.append(db_entry_dict) + logger.info("telephone object added to list: " + json.dumps(db_entry_dict)) + + return db_entry_list - cursor, data = get_contacts_by_account(cursor=cursor, account_id=account_id) - return cursor, data +def add_telephone(account_id=None, attributes=None, cursor=None): + """ + Add one telephone entry to database identified by Account ID and ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if attributes is None: + raise AttributeError("Provide attributes as parameter") + if not isinstance(attributes, dict): + raise AttributeError("attributes must be a dict") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Update telephone object + if len(attributes) == 0: + logger.info("Empty attributes dict provided. Nothing to add.") + raise StandardError("Not adding empty entry to database") + else: + # log provided attributes + for key, value in attributes.items(): + logger.debug("attributes[" + str(key) + "]: " + str(value)) + # Create object + try: + db_entry_object = Telephone( + account_id=account_id, + tel=str(attributes.get("tel", "")), + type=str(attributes.get("type", "")), + prime=str(attributes.get("primary", "")) + ) + except Exception as exp: + error_title = "Failed to create telephone object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("telephone object created: " + db_entry_object.log_entry) -def get_emails(cursor=None, account_id=None): + # Store updates + try: + cursor = db_entry_object.to_db(cursor=cursor) + ### + # Commit + db.connection.commit() + except Exception as exp: + error_title = "Failed to add telephone to DB" + logger.error(error_title + ": " + repr(exp)) + logger.debug('commit failed: ' + repr(exp)) + logger.debug('--> rollback') + db.connection.rollback() + raise + else: + logger.debug("Committed") + logger.info("telephone added") + logger.info(db_entry_object.log_entry) - check_account_id(account_id=account_id) + return db_entry_object.to_api_dict + +def update_telephone(account_id=None, id=None, attributes=None, cursor=None): + """ + Update one telephone entry at database identified by Account ID and ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if attributes is None: + raise AttributeError("Provide attributes as parameter") + if not isinstance(attributes, dict): + raise AttributeError("attributes must be a dict") if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = Telephone(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create telephone object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("telephone object created: " + db_entry_object.log_entry) + + # Get telephone from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch telephone from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("telephone fetched") + logger.info("telephone fetched from db: " + db_entry_object.log_entry) + + # Update telephone object + if len(attributes) == 0: + logger.info("Empty attributes dict provided. Nothing to update.") + return db_entry_object.to_api_dict + else: + logger.info("telephone object to update: " + db_entry_object.log_entry) + + # log provided attributes + for key, value in attributes.items(): + logger.debug("attributes[" + str(key) + "]: " + str(value)) + + # Update object attributes + if "tel" in attributes: + logger.info("Updating telephone") + old_value = str(db_entry_object.tel) + new_value = str(attributes.get("tel", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.tel = new_value + logger.info(db_entry_object.log_entry) + + if "type" in attributes: + logger.info("Updating type") + old_value = str(db_entry_object.type) + new_value = str(attributes.get("type", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.type = new_value + logger.info(db_entry_object.log_entry) + + if "primary" in attributes: + logger.info("Updating primary") + old_value = str(db_entry_object.prime) + new_value = str(attributes.get("primary", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.prime = new_value + logger.info(db_entry_object.log_entry) + + # Store updates + try: + cursor = db_entry_object.update_db(cursor=cursor) + ### + # Commit + db.connection.commit() + except Exception as exp: + error_title = "Failed to update telephone to DB" + logger.error(error_title + ": " + repr(exp)) + logger.debug('commit failed: ' + repr(exp)) + logger.debug('--> rollback') + db.connection.rollback() + raise + else: + logger.debug("Committed") + logger.info("telephone updated") + logger.info(db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + + +################################## +################################### +# Settings +################################## +################################## +def get_setting(account_id=None, id=None, cursor=None): + """ + Get one setting entry from database by Account ID and ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = Settings(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create setting object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("setting object created: " + db_entry_object.log_entry) + + # Get setting from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch setting from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("setting fetched") + logger.info("setting fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +def get_settings(account_id=None): + """ + Get all setting -entries related to account + :param account_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + + # Get table name + logger.info("Create setting") + db_entry_object = Settings() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + # Get DB cursor + try: cursor = get_db_cursor() - logger.debug('No DB cursor provided as call parameter. Getting new one.') + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Get primary keys for setting + try: + cursor, id_list = get_primary_keys_by_account_id(cursor=cursor, account_id=account_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise + + # Get setting from database + logger.info("Get setting from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting setting with setting_id: " + str(id)) + db_entry_dict = get_setting(account_id=account_id, id=id) + db_entry_list.append(db_entry_dict) + logger.info("setting object added to list: " + json.dumps(db_entry_dict)) - cursor, data = get_emails_by_account(cursor=cursor, account_id=account_id) + return db_entry_list - return cursor, data +def add_setting(account_id=None, attributes=None, cursor=None): + """ + Add one setting entry to database identified by Account ID and ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if attributes is None: + raise AttributeError("Provide attributes as parameter") + if not isinstance(attributes, dict): + raise AttributeError("attributes must be a dict") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise -def get_telephones(cursor=None, account_id=None): + # Update setting object + if len(attributes) == 0: + logger.info("Empty attributes dict provided. Nothing to add.") + raise StandardError("Not adding empty entry to database") + else: + # log provided attributes + for key, value in attributes.items(): + logger.debug("attributes[" + str(key) + "]: " + str(value)) - check_account_id(account_id=account_id) + # Create object + try: + db_entry_object = Settings( + account_id=account_id, + key=str(attributes.get("key", "")), + value=str(attributes.get("value", "")) + ) + except Exception as exp: + error_title = "Failed to create setting object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("setting object created: " + db_entry_object.log_entry) + # Store updates + try: + cursor = db_entry_object.to_db(cursor=cursor) + ### + # Commit + db.connection.commit() + except Exception as exp: + error_title = "Failed to add setting to DB" + logger.error(error_title + ": " + repr(exp)) + logger.debug('commit failed: ' + repr(exp)) + logger.debug('--> rollback') + db.connection.rollback() + raise + else: + logger.debug("Committed") + logger.info("setting added") + logger.info(db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +def update_setting(account_id=None, id=None, attributes=None, cursor=None): + """ + Update one setting entry at database identified by Account ID and ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if attributes is None: + raise AttributeError("Provide attributes as parameter") + if not isinstance(attributes, dict): + raise AttributeError("attributes must be a dict") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = Settings(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create setting object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("setting object created: " + db_entry_object.log_entry) + + # Get setting from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch setting from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("setting fetched") + logger.info("setting fetched from db: " + db_entry_object.log_entry) + + # Update setting object + if len(attributes) == 0: + logger.info("Empty attributes dict provided. Nothing to update.") + return db_entry_object.to_api_dict + else: + logger.info("setting object to update: " + db_entry_object.log_entry) + + # log provided attributes + for key, value in attributes.items(): + logger.debug("attributes[" + str(key) + "]: " + str(value)) + + # Update object attributes + if "key" in attributes: + logger.info("Updating key") + old_value = str(db_entry_object.key) + new_value = str(attributes.get("key", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.key = new_value + logger.info(db_entry_object.log_entry) + + if "value" in attributes: + logger.info("Updating value") + old_value = str(db_entry_object.value) + new_value = str(attributes.get("value", "None")) + logger.debug("Updating: " + old_value + " --> " + new_value) + db_entry_object.value = new_value + logger.info(db_entry_object.log_entry) + + # Store updates + try: + cursor = db_entry_object.update_db(cursor=cursor) + ### + # Commit + db.connection.commit() + except Exception as exp: + error_title = "Failed to update setting to DB" + logger.error(error_title + ": " + repr(exp)) + logger.debug('commit failed: ' + repr(exp)) + logger.debug('--> rollback') + db.connection.rollback() + raise + else: + logger.debug("Committed") + logger.info("setting updated") + logger.info(db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +################################## +################################### +# Event logs +################################## +################################## +def get_event_log(account_id=None, id=None, cursor=None): + """ + Get one event_log entry from database by Account ID and ID + :param account_id: + :param id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = EventLog(account_id=account_id, id=id) + except Exception as exp: + error_title = "Failed to create event_log object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("event_log object created: " + db_entry_object.log_entry) + + # Get event_log from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch event_log from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("event_log fetched") + logger.info("event_log fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_api_dict + + +def get_event_logs(account_id=None): + """ + Get all event_log -entries related to account + :param account_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + + # Get table name + logger.info("Create event_log") + db_entry_object = EventLog() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Get primary keys for event_log + try: + cursor, id_list = get_primary_keys_by_account_id(cursor=cursor, account_id=account_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise + + # Get event_log from database + logger.info("Get event_log from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting event_log with event_log_id: " + str(id)) + db_entry_dict = get_event_log(account_id=account_id, id=id) + db_entry_list.append(db_entry_dict) + logger.info("event_log object added to list: " + json.dumps(db_entry_dict)) + + return db_entry_list + + +################################## +################################### +# Service Link Records +################################## +################################## +def get_slr(account_id=None, slr_id=None, cursor=None): + """ + Get one slr entry from database by Account ID and ID + :param account_id: + :param slr_id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if id is None: + raise AttributeError("Provide id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = ServiceLinkRecord(account_id=account_id, service_link_record_id=slr_id) + except Exception as exp: + error_title = "Failed to create slr object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("slr object created: " + db_entry_object.log_entry) + + # Get slr from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch slr from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("slr fetched") + logger.info("slr fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_record_dict + + +def get_slrs(account_id=None): + """ + Get all slr -entries related to account + :param account_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + + # Get table name + logger.info("Create slr") + db_entry_object = ServiceLinkRecord() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Get primary keys for slr + try: + cursor, id_list = get_slr_ids(cursor=cursor, account_id=account_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise + + # Get slrs from database + logger.info("Get slrs from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting slr with slr_id: " + str(id)) + db_entry_dict = get_slr(account_id=account_id, slr_id=id) + db_entry_list.append(db_entry_dict) + logger.info("slr object added to list: " + json.dumps(db_entry_dict)) + + return db_entry_list + + +def get_record_ids(cursor=None, account_id=None): + """ + Fetches IDs for all record structures + :param cursor: + :param account_id: + :return: + """ if cursor is None: + raise AttributeError("Provide cursor as parameter") + if account_id is None: + raise AttributeError("Provide account_id as parameter") + + # Containers + record_id_container = {} + table_names = { + "slr": "", + "slsr": "", + "cr": "", + "csr": "" + } + + # Get table names + try: + logger.info("Table names") + # SLR + db_entry_object = ServiceLinkRecord() + table_names["slr"] = db_entry_object.table_name + # SLSR + db_entry_object = ServiceLinkStatusRecord() + table_names["slsr"] = db_entry_object.table_name + # CR + db_entry_object = ConsentRecord() + table_names["cr"] = db_entry_object.table_name + # CSR + db_entry_object = ConsentStatusRecord() + table_names["csr"] = db_entry_object.table_name + # + logger.info("Table names: " + json.dumps(table_names)) + except Exception as exp: + logger.error('Could not get database table names: ' + repr(exp)) + raise + + # Get primary keys for Service Link Records + try: + logger.info("Getting SLR IDs") + cursor, slr_id_list = get_slr_ids(cursor=cursor, account_id=account_id, table_name=table_names["slr"]) + except Exception as exp: + logger.error('Could not get slr primary key list: ' + repr(exp)) + raise + else: + logger.debug("Got following SLR IDs: " + json.dumps(slr_id_list)) + + # Get primary keys for Service Link Status Records and Consent Records + for slr_id in slr_id_list: + logger.debug("Looping through slr_id_list: " + json.dumps(slr_id_list)) + # Add Service Link Record IDs to record_container + try: + logger.info("Adding SLR IDs") + record_id_container[slr_id] = {"serviceLinkStatusRecords": {}, "consentRecords": {}} + except Exception as exp: + logger.error('Could not add slr_id: ' + str(slr_id) + ' to record_id_container: ' + repr(exp)) + raise + else: + logger.debug("Added SLR ID: " + str(slr_id)) + + # Get Service Link Status Record IDs + try: + logger.info("Getting SLSR IDs") + cursor, slsr_id_list = get_slsr_ids(cursor=cursor, slr_id=slr_id, table_name=table_names["slsr"]) + except Exception as exp: + logger.error('Could not get slsr primary key list: ' + repr(exp)) + raise + else: + logger.debug("Got following SLSR IDs: " + json.dumps(slsr_id_list)) + + # Add Service Link Status Record IDs to record_container + for slsr_id in slsr_id_list: + logger.debug("Looping through slsr_id_list: " + json.dumps(slsr_id_list)) + try: + logger.info("Adding SLSR IDs") + record_id_container[slr_id]["serviceLinkStatusRecords"][slsr_id] = {} + except Exception as exp: + logger.error('Could not add slsr_id: ' + str(slsr_id) + ' to record_id_container: ' + repr(exp)) + raise + else: + logger.debug("Added SLSR ID: " + str(slsr_id)) + + # Get Consent Record IDs + try: + logger.info("Getting CR IDs") + cursor, cr_id_list = get_cr_ids(cursor=cursor, slr_id=slr_id, table_name=table_names["cr"]) + except Exception as exp: + logger.error('Could not get cr primary key list: ' + repr(exp)) + raise + else: + logger.debug("Got following CR IDs: " + json.dumps(cr_id_list)) + + # Add Consent Record IDs to record_container + for cr_id in cr_id_list: + logger.debug("Looping through cr_id_list: " + json.dumps(cr_id_list)) + try: + logger.info("Adding CR IDs") + record_id_container[slr_id]["consentRecords"][cr_id] = {"consentStatusRecords": {}} + except Exception as exp: + logger.error('Could not add cr_id: ' + str(cr_id) + ' to record_id_container: ' + repr(exp)) + raise + else: + logger.debug("Added CR ID: " + str(cr_id)) + + # Get Consent Status Record IDs + try: + logger.info("Getting CSR IDs") + cursor, csr_id_list = get_csr_ids(cursor=cursor, cr_id=cr_id, table_name=table_names["csr"]) + except Exception as exp: + logger.error('Could not get csr primary key list: ' + repr(exp)) + raise + else: + logger.debug("Got following CSR IDs: " + json.dumps(csr_id_list)) + + # Add Consent Status Record IDs to record_container + for csr_id in csr_id_list: + logger.debug("Looping through csr_id_list: " + json.dumps(csr_id_list)) + try: + logger.info("Adding CSR IDs") + record_id_container[slr_id]["consentRecords"][cr_id]["consentStatusRecords"][csr_id] = {} + except Exception as exp: + logger.error('Could not add csr_id: ' + str(csr_id) + ' to record_id_container: ' + repr(exp)) + raise + else: + logger.debug("Added CSR ID: " + str(csr_id)) + + return record_id_container + + +def get_records(cursor=None, record_ids=None): + if cursor is None: + raise AttributeError("Provide cursor as parameter") + if record_ids is None: + raise AttributeError("Provide record_ids as parameter") + if not isinstance(record_ids, dict): + raise AttributeError("record_ids MUST be dict") + + logger.debug("Type of record_ids: " + repr(type(record_ids))) + + record_container = {} + + logger.info("Getting Records") + logger.info("record_ids: " + repr(record_ids)) + record_ids = dict(record_ids) + + # logger.info("Get Service Link Records") + # for slr in record_ids.iteritems(): + # logger.debug("slr: " + repr(slr)) + # logger.info("Looping through Service Link Record with ID: " + json.dumps(slr)) + + return record_container + + +def get_slrs_and_subcomponents(account_id=None): + """ + Get all slr -entries with sub elements (slsr, cr, csr) related to account + :param account_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + + # Containers + return_container = {} + record_id_container = {} + record_container = {} + + # Get DB cursor + try: cursor = get_db_cursor() - logger.debug('No DB cursor provided as call parameter. Getting new one.') + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + record_id_container = get_record_ids(cursor=cursor, account_id=account_id) + except Exception as exp: + logger.error('Could not get record id collection: ' + repr(exp)) + raise + + # TODO: Get Actual records from db + logger.info("################") + logger.info("################") + logger.info("################") + try: + record_container = get_records(cursor=cursor, record_ids=record_id_container) + except Exception as exp: + logger.error('Could not get record collection: ' + repr(exp)) + raise + + logger.info("################") + logger.info("################") + logger.info("################") + + return_container["record_id_container"] = record_id_container + return_container["record_container"] = record_container + + + # Get slrs from database + # logger.info("Get slrs from database") + # db_entry_list = [] + # for id in id_list: + # # TODO: try-except needed? + # logger.info("Getting slr with slr_id: " + str(id)) + # db_entry_dict = get_slr(account_id=account_id, slr_id=id) + # db_entry_list.append(db_entry_dict) + # logger.info("slr object added to list: " + json.dumps(db_entry_dict)) + + return return_container + + +################################## +################################### +# Service Link Status Records +################################## +################################## +def get_slsr(account_id=None, slr_id=None, slsr_id=None, cursor=None): + """ + Get one slsr entry from database by Account ID and ID + :param slr_id: + :param slsr_id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if slr_id is None: + raise AttributeError("Provide slr_id as parameter") + if slsr_id is None: + raise AttributeError("Provide slsr_id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Check if slr can be found with account_id and slr_id + try: + slr = get_slr(account_id=account_id, slr_id=slr_id) + except StandardError as exp: + logger.error(repr(exp)) + raise + except Exception as exp: + func_data = {'account_id': account_id, 'slr_id': slr_id} + title = "No SLR with: " + json.dumps(func_data) + logger.error(title) + raise StandardError(title + ": " + repr(exp)) + else: + logger.info("Found SLR: " + repr(slr)) + + try: + db_entry_object = ServiceLinkStatusRecord(service_link_status_record_id=slsr_id, service_link_record_id=slr_id) + except Exception as exp: + error_title = "Failed to create slsr object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("slsr object created: " + db_entry_object.log_entry) + + # Get slsr from DB + try: + logger.info("Get slsr from DB") + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch slsr from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("slsr fetched") + logger.info("slsr fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_record_dict + + +def get_slsrs(account_id=None, slr_id=None): + """ + Get all slsr -entries related to service link record + :param account_id: + :param slr_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if slr_id is None: + raise AttributeError("Provide slr_id as parameter") + + # Check if slr can be found with account_id and slr_id + try: + slr = get_slr(account_id=account_id, slr_id=slr_id) + except StandardError as exp: + logger.error(repr(exp)) + raise + except Exception as exp: + func_data = {'account_id': account_id, 'slr_id': slr_id} + title = "No SLR with: " + json.dumps(func_data) + logger.error(title) + raise StandardError(title + ": " + repr(exp)) + else: + logger.info("HEP") + logger.info("Found SLR: " + repr(slr)) + + # Get table name + logger.info("Create slsr") + db_entry_object = ServiceLinkStatusRecord() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Get primary keys for slsr + try: + cursor, id_list = get_slsr_ids(cursor=cursor, slr_id=slr_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise + + # Get slsrs from database + logger.info("Get slsrs from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting slsr with account_id: " + str(account_id) + " slr_id: " + str(slr_id) + " slsr_id: " + str(id)) + db_entry_dict = get_slsr(account_id=account_id, slr_id=slr_id, slsr_id=id) + db_entry_list.append(db_entry_dict) + logger.info("slsr object added to list: " + json.dumps(db_entry_dict)) + + return db_entry_list + + +################################## +################################### +# Consent Records +################################## +################################## +def get_cr(account_id=None, slr_id=None, cr_id=None, cursor=None): + """ + Get one cr entry from database by Account ID and ID + :param slr_id: + :param cr_id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if slr_id is None: + raise AttributeError("Provide slr_id as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Check if slr can be found with account_id and slr_id + try: + logger.info("Check if slr can be found with account_id and slr_id") + slr = get_slr(account_id=account_id, slr_id=slr_id) + except StandardError as exp: + logger.error(repr(exp)) + raise + except Exception as exp: + func_data = {'account_id': account_id, 'slr_id': slr_id} + title = "No SLR with: " + json.dumps(func_data) + logger.error(title) + raise StandardError(title + ": " + repr(exp)) + else: + logger.info("Found: " + repr(slr)) + + try: + db_entry_object = ConsentRecord(consent_id=cr_id, service_link_record_id=slr_id) + except Exception as exp: + error_title = "Failed to create cr object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("cr object created: " + db_entry_object.log_entry) + + # Get cr from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch cr from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("cr fetched") + logger.info("cr fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_record_dict + + +def get_crs(account_id=None, slr_id=None): + """ + Get all cr -entries related to service link record + :param account_id: + :param slr_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if slr_id is None: + raise AttributeError("Provide slr_id as parameter") + + # Check if slr can be found with account_id and slr_id + try: + slr = get_slr(account_id=account_id, slr_id=slr_id) + except StandardError as exp: + logger.error(repr(exp)) + raise + except Exception as exp: + func_data = {'account_id': account_id, 'slr_id': slr_id} + title = "No SLR with: " + json.dumps(func_data) + logger.error(title) + raise StandardError(title + ": " + repr(exp)) + else: + logger.info("Found SLR: " + repr(slr)) + + # Get table name + logger.info("Create cr") + db_entry_object = ConsentRecord() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Get primary keys for crs + try: + logger.info("Get primary keys for crs") + cursor, id_list = get_cr_ids(cursor=cursor, slr_id=slr_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise + else: + logger.info("primary keys for crs: " + repr(id_list)) + + # Get crs from database + logger.info("Get crs from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting cr with account_id: " + str(account_id) + " slr_id: " + str(slr_id) + " cr_id: " + str(id)) + db_entry_dict = get_cr(account_id=account_id, slr_id=slr_id, cr_id=id) + db_entry_list.append(db_entry_dict) + logger.info("cr object added to list: " + json.dumps(db_entry_dict)) + + return db_entry_list + + +################################## +################################### +# Consent Status Records +################################## +################################## +def get_csr(account_id=None, slr_id=None, cr_id=None, csr_id=None, cursor=None): + """ + Get one csr entry from database by Account ID and ID + :param slr_id: + :param cr_id: + :param csr_id: + :return: dict + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if slr_id is None: + raise AttributeError("Provide slr_id as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + if csr_id is None: + raise AttributeError("Provide csr_id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Check if cr can be found with account_id, slr_id and cr_id + try: + cr = get_cr(account_id=account_id, slr_id=slr_id, cr_id=cr_id) + except StandardError as exp: + logger.error(repr(exp)) + raise + except Exception as exp: + func_data = {'account_id': account_id, 'slr_id': slr_id, 'cr_id': cr_id} + title = "No CR with: " + json.dumps(func_data) + logger.error(title) + raise StandardError(title + ": " + repr(exp)) + else: + logger.info("Found: " + repr(cr)) + + try: + db_entry_object = ConsentStatusRecord(consent_record_id=cr_id, consent_status_record_id=csr_id) + except Exception as exp: + error_title = "Failed to create csr object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("csr object created: " + db_entry_object.log_entry) + + # Get csr from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch csr from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("csr fetched") + logger.info("csr fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_record_dict + + +def get_csrs(account_id=None, slr_id=None, cr_id=None): + """ + Get all csr -entries related to service link record + :param account_id: + :param slr_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if slr_id is None: + raise AttributeError("Provide slr_id as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + + # Check if cr can be found with account_id, slr_id and cr_id + try: + cr = get_cr(account_id=account_id, slr_id=slr_id, cr_id=cr_id) + except StandardError as exp: + logger.error(repr(exp)) + raise + except Exception as exp: + func_data = {'account_id': account_id, 'slr_id': slr_id, 'cr_id': cr_id} + title = "No CR with: " + json.dumps(func_data) + logger.error(title) + raise StandardError(title + ": " + repr(exp)) + else: + logger.info("Found: " + repr(cr)) + + # Get table name + logger.info("Create csr") + db_entry_object = ConsentStatusRecord() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Get primary keys for csrs + try: + cursor, id_list = get_csr_ids(cursor=cursor, cr_id=cr_id, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise + + # Get csrs from database + logger.info("Get csrs from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting csr with account_id: " + str(account_id) + " slr_id: " + str(slr_id) + " cr_id: " + str(cr_id) + " csr_id: " + str(id)) + db_entry_dict = get_csr(account_id=account_id, slr_id=slr_id, cr_id=cr_id, csr_id=id) + db_entry_list.append(db_entry_dict) + logger.info("csr object added to list: " + json.dumps(db_entry_dict)) + + return db_entry_list + + +################################## +################################## +# Account Export +################################## +################################## +def export_account(account_id=None): + """ + Export Account as JSON presentation + :param account_id: + :return: List of dicts + """ + if account_id is None: + raise AttributeError("Provide account_id as parameter") + + export = { + "type": "Account", + "id": account_id, + "attributes": {} + } + + export_attributes = {} + + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + ################################## + # Service Link Records + ################################## + title = "Service Link Records" + try: + logger.info(title) + entries = get_slrs_and_subcomponents(account_id=account_id) + export_attributes["serviceLinkRecords"] = entries + except IndexError as exp: + error_title = "Export of " + title + " failed. No entries in database." + logger.error(error_title + ': ' + repr(exp)) + export_attributes["serviceLinkRecords"] = {} + except Exception as exp: + error_title = "Export of " + title + " failed" + logger.error(error_title + ': ' + repr(exp)) + raise StandardError(title + ": " + repr(exp)) + else: + logger.info(title + ": " + json.dumps(entries)) + + ################################## + # Particulars + ################################## + # title = "Particulars" + # try: + # logger.info(title) + # entries = get_particulars(account_id=account_id) + # export_attributes["particulars"] = entries + # except IndexError as exp: + # error_title = "Export of " + title + " failed. No entries in database." + # logger.error(error_title + ': ' + repr(exp)) + # export_attributes["particulars"] = {} + # except Exception as exp: + # error_title = "Export of " + title + " failed" + # logger.error(error_title + ': ' + repr(exp)) + # raise StandardError(title + ": " + repr(exp)) + # else: + # logger.info(title + ": " + json.dumps(entries)) + # + # ################################## + # # Contacts + # ################################## + # title = "Contacts" + # try: + # logger.info(title) + # entries = get_contacts(account_id=account_id) + # export_attributes["contacts"] = entries + # except IndexError as exp: + # error_title = "Export of " + title + " failed. No entries in database." + # logger.error(error_title + ': ' + repr(exp)) + # export_attributes["contacts"] = {} + # except Exception as exp: + # error_title = "Export of " + title + " failed" + # logger.error(error_title + ': ' + repr(exp)) + # raise StandardError(title + ": " + repr(exp)) + # else: + # logger.info(title + ": " + json.dumps(entries)) + # + # ################################## + # # Emails + # ################################## + # title = "Emails" + # try: + # logger.info(title) + # entries = get_emails(account_id=account_id) + # export_attributes["emails"] = entries + # except IndexError as exp: + # error_title = "Export of " + title + " failed. No entries in database." + # logger.error(error_title + ': ' + repr(exp)) + # export_attributes["emails"] = {} + # except Exception as exp: + # error_title = "Export of " + title + " failed" + # logger.error(error_title + ': ' + repr(exp)) + # raise StandardError(title + ": " + repr(exp)) + # else: + # logger.info(title + ": " + json.dumps(entries)) + # + # ################################## + # # Telephones + # ################################## + # title = "Telephones" + # try: + # logger.info(title) + # entries = get_telephones(account_id=account_id) + # export_attributes["telephones"] = entries + # except IndexError as exp: + # error_title = "Export of " + title + " failed. No entries in database." + # logger.error(error_title + ': ' + repr(exp)) + # export_attributes["telephones"] = {} + # except Exception as exp: + # error_title = "Export of " + title + " failed" + # logger.error(error_title + ': ' + repr(exp)) + # raise StandardError(title + ": " + repr(exp)) + # else: + # logger.info(title + ": " + json.dumps(entries)) + # + # ################################## + # # Settings + # ################################## + # title = "Settings" + # try: + # logger.info(title) + # entries = get_settings(account_id=account_id) + # export_attributes["settings"] = entries + # except IndexError as exp: + # error_title = "Export of " + title + " failed. No entries in database." + # logger.error(error_title + ': ' + repr(exp)) + # export_attributes["settings"] = {} + # except Exception as exp: + # error_title = "Export of " + title + " failed" + # logger.error(error_title + ': ' + repr(exp)) + # raise StandardError(title + ": " + repr(exp)) + # else: + # logger.info(title + ": " + json.dumps(entries)) + # + # ################################## + # # Event logs + # ################################## + # title = "Event logs" + # try: + # logger.info(title) + # entries = get_event_logs(account_id=account_id) + # export_attributes["logs"] = {} + # export_attributes["logs"]["events"] = entries + # except IndexError as exp: + # error_title = "Export of " + title + " failed. No entries in database." + # logger.error(error_title + ': ' + repr(exp)) + # export_attributes["logs"] = {} + # export_attributes["logs"]["events"] = {} + # except Exception as exp: + # error_title = "Export of " + title + " failed" + # logger.error(error_title + ': ' + repr(exp)) + # raise StandardError(title + ": " + repr(exp)) + # else: + # logger.info(title + ": " + json.dumps(entries)) + + ################################## + ################################## + ################################## + # Preparing return content + ################################## + title = "export['attributes'] = export_attributes" + try: + logger.info(title) + export["attributes"] = export_attributes + except Exception as exp: + error_title = title + " failed" + logger.error(error_title + ': ' + repr(exp)) + raise StandardError(title + ": " + repr(exp)) + else: + logger.info("Content of export: " + json.dumps(export)) + + return export + + + - cursor, data = get_telephones_by_account(cursor=cursor, account_id=account_id) - return cursor, data diff --git a/Account/app/mod_account/models.py b/Account/app/mod_account/models.py index 1959f72..6d87c2e 100644 --- a/Account/app/mod_account/models.py +++ b/Account/app/mod_account/models.py @@ -13,7 +13,16 @@ # Import dependencies from marshmallow import Schema, fields, validates, ValidationError -from marshmallow.validate import Range, Regexp, ContainsOnly, Equal +from marshmallow.validate import Range, Regexp, ContainsOnly, Equal, OneOf, Length + +TYPE_LIST = ["Personal", "Work", "School", "Other"] # List that contains types entries +PRIMARY_LIST = ["True", "False"] # List that contains primary values + +STRING_MIN_LENGTH = 3 +STRING_MAX_LENGTH = 255 + +PWD_MIN_LENGTH = 4 +PWD_MAX_LENGTH = 20 GENERAL_STRING_MIN_LENGTH = 3 GENERAL_STRING_MAX_LENGTH = 100 @@ -36,7 +45,7 @@ LASTNAME_REGEX = GENERAL_REGEX class BaseSchema(Schema): - type = fields.Str() + type = fields.Str(validate=[Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH), OneOf(TYPE_LIST)]) class AccountSchema(BaseSchema): @@ -81,11 +90,174 @@ class AccountSchema(BaseSchema): acceptTermsOfService = fields.Bool(required=True, validate=[ContainsOnly(choices='True')]) -class AccountSchema2(BaseSchema): - username = fields.Str(required=True) - password = fields.Str(required=True) - firstName = fields.Str(required=True) - lastName = fields.Str(required=True) - email = fields.Email(required=True) +## +## +# Account creation +class AccountAttributes(Schema): + username = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + password = fields.Str(validate=Length(min=PWD_MIN_LENGTH, max=PWD_MAX_LENGTH)) + firstName = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + lastName = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + email = fields.Email(required=True, validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) dateOfBirth = fields.Date(required=True, error='Not valid date. Provide ISO8601-formatted date string.') acceptTermsOfService = fields.Str(required=True, validate=Equal("True")) + + +class AccountData(Schema): + type = fields.Str(required=True, validate=Equal("Account")) + id = fields.Str(validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=AccountAttributes, required=True) + + +class AccountSchema2(Schema): + data = fields.Nested(nested=AccountData, required=True) + + +## +## +# particulars +class ParticularsAttributes(Schema): + firstName = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + lastName = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + img = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + dateOfBirth = fields.Date(error='Not valid date. Provide ISO8601-formatted date string.') + + +class ParticularsData(Schema): + type = fields.Str(required=True, validate=Equal("Particular")) + id = fields.Str(validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=ParticularsAttributes, required=True) + + +class ParticularsSchema(Schema): + data = fields.Nested(nested=ParticularsData, required=True) + + +class ParticularsDataForUpdate(Schema): + type = fields.Str(required=True, validate=Equal("Particular")) + id = fields.Str(required=True, validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=ParticularsAttributes, required=True) + + +class ParticularsSchemaForUpdate(Schema): + data = fields.Nested(nested=ParticularsDataForUpdate, required=True) + + +## +## +# Contacts +class ContactsAttributes(Schema): + address1 = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + address2 = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + postalCode = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + city = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + state = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + country = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + type = fields.Str(validate=[Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH), OneOf(TYPE_LIST)]) + primary = fields.Str(validate=OneOf(PRIMARY_LIST)) # TODO: Not acting as Boolean for MySQL + + +class ContactsData(Schema): + type = fields.Str(required=True, validate=Equal("Contact")) + id = fields.Str(validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=ContactsAttributes, required=True) + + +class ContactsSchema(Schema): + data = fields.Nested(nested=ContactsData, required=True) + + +class ContactsDataForUpdate(Schema): + type = fields.Str(required=True, validate=Equal("Contact")) + id = fields.Str(required=True, validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=ContactsAttributes, required=True) + + +class ContactsSchemaForUpdate(Schema): + data = fields.Nested(nested=ContactsDataForUpdate, required=True) + + +## +## +# Telephone +class TelephonesAttributes(Schema): + tel = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + type = fields.Str(validate=[Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH), OneOf(TYPE_LIST)]) + primary = fields.Str(validate=OneOf(PRIMARY_LIST)) # TODO: Not acting as Boolean for MySQL + + +class TelephonesData(Schema): + type = fields.Str(required=True, validate=Equal("Telephone")) + id = fields.Str(validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=TelephonesAttributes, required=True) + + +class TelephonesSchema(Schema): + data = fields.Nested(nested=TelephonesData, required=True) + + +class TelephonesDataForUpdate(Schema): + type = fields.Str(required=True, validate=Equal("Telephone")) + id = fields.Str(required=True, validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=TelephonesAttributes, required=True) + + +class TelephonesSchemaForUpdate(Schema): + data = fields.Nested(nested=TelephonesDataForUpdate, required=True) + + +## +## +# Email +class EmailsAttributes(Schema): + email = fields.Email(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + type = fields.Str(validate=[Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH), OneOf(TYPE_LIST)]) + primary = fields.Str(validate=OneOf(PRIMARY_LIST)) # TODO: Not acting as Boolean for MySQL + + +class EmailsData(Schema): + type = fields.Str(required=True, validate=Equal("Email")) + id = fields.Str(validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=EmailsAttributes, required=True) + + +class EmailsSchema(Schema): + data = fields.Nested(nested=EmailsData, required=True) + + +class EmailsDataForUpdate(Schema): + type = fields.Str(required=True, validate=Equal("Email")) + id = fields.Str(required=True, validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=EmailsAttributes, required=True) + + +class EmailsSchemaForUpdate(Schema): + data = fields.Nested(nested=EmailsDataForUpdate, required=True) + + +## +## +# Settings +class SettingsAttributes(Schema): + key = fields.Str(validate=Length(min=STRING_MIN_LENGTH, max=STRING_MAX_LENGTH)) + value = fields.Str(validate=Length(min=2, max=STRING_MAX_LENGTH)) + + +class SettingsData(Schema): + type = fields.Str(required=True, validate=Equal("Setting")) + id = fields.Str(validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=SettingsAttributes, required=True) + + +class SettingsSchema(Schema): + data = fields.Nested(nested=SettingsData, required=True) + + +class SettingsDataForUpdate(Schema): + type = fields.Str(required=True, validate=Equal("Setting")) + id = fields.Str(required=True, validate=Length(max=STRING_MAX_LENGTH)) + attributes = fields.Nested(nested=SettingsAttributes, required=True) + + +class SettingsSchemaForUpdate(Schema): + data = fields.Nested(nested=SettingsDataForUpdate, required=True) diff --git a/Account/app/mod_account/services.py b/Account/app/mod_account/services.py index e513fd6..f0cea4d 100644 --- a/Account/app/mod_account/services.py +++ b/Account/app/mod_account/services.py @@ -16,178 +16,178 @@ logger = get_custom_logger('mod_account_services') - -def get_service_link_record_count_by_account(cursor=None, account_id=None): - if app.config["SUPER_DEBUG"]: - logger.debug('account_id: ' + repr(account_id)) - - ### - logger.debug('get_consent_record_count(account_id)') - if app.config["SUPER_DEBUG"]: - logger.debug('account_id: ' + repr(account_id)) - - sql_query = "SELECT count(MyDataAccount.ServiceLinkRecords.id) " \ - "FROM MyDataAccount.ServiceLinkRecords " \ - "WHERE MyDataAccount.ServiceLinkRecords.Accounts_id = '%s'" % (account_id) - - try: - cursor, count = execute_sql_select(cursor=cursor, sql_query=sql_query) - count = count[0][0] - except Exception as exp: - logger.error('Failed') - logger.debug('sql_query: ' + repr(exp)) - raise - else: - if app.config["SUPER_DEBUG"]: - logger.debug('contacts: ' + repr(count)) - - return cursor, count - - -def get_consent_record_count_by_account(cursor=None, account_id=None): - if app.config["SUPER_DEBUG"]: - logger.debug('account_id: ' + repr(account_id)) - - ### - logger.debug('get_consent_record_count(account_id)') - if app.config["SUPER_DEBUG"]: - logger.debug('account_id: ' + repr(account_id)) - - sql_query = "SELECT count(MyDataAccount.ConsentRecords.id) " \ - "FROM MyDataAccount.ConsentRecords " \ - "WHERE MyDataAccount.ConsentRecords.Accounts_id = '%s'" % (account_id) - - try: - cursor, count = execute_sql_select(cursor=cursor, sql_query=sql_query) - count = count[0][0] - except Exception as exp: - logger.error('Failed') - logger.debug('sql_query: ' + repr(exp)) - raise - else: - if app.config["SUPER_DEBUG"]: - logger.debug('contacts: ' + repr(count)) - - return cursor, count - - -def get_contacts_by_account(cursor=None, account_id=None): - - sql_query = "SELECT " \ - "MyDataAccount.Contacts.id, " \ - "MyDataAccount.Contacts.address1, " \ - "MyDataAccount.Contacts.address2, " \ - "MyDataAccount.Contacts.postalCode, " \ - "MyDataAccount.Contacts.city, " \ - "MyDataAccount.Contacts.state, " \ - "MyDataAccount.Contacts.country, " \ - "MyDataAccount.Contacts.typeEnum, " \ - "MyDataAccount.Contacts.prime " \ - "FROM MyDataAccount.Contacts " \ - "WHERE Accounts_id = ('%s')" % (account_id) - - try: - cursor, data = execute_sql_select(cursor=cursor, sql_query=sql_query) - - contacts = [] - - for entry in data: - contact_obj = Contacts( - id=entry[0], - address1=entry[1], - address2=entry[2], - postal_code=entry[3], - city=entry[4], - state=entry[5], - country=entry[6], - type=entry[7], - prime=entry[8] - ) - - contacts.append(contact_obj.to_dict) - - - except Exception as exp: - logger.error('Failed') - logger.debug('sql_query: ' + repr(exp)) - raise - else: - if app.config["SUPER_DEBUG"]: - logger.debug('contacts: ' + repr(contacts)) - - return cursor, contacts - - -def get_emails_by_account(cursor=None, account_id=None): - - sql_query = "SELECT " \ - "MyDataAccount.Emails.id, " \ - "MyDataAccount.Emails.email, " \ - "MyDataAccount.Emails.typeEnum, " \ - "MyDataAccount.Emails.prime " \ - "FROM MyDataAccount.Emails " \ - "WHERE Accounts_id = ('%s')" % (account_id) - - try: - cursor, data = execute_sql_select(cursor=cursor, sql_query=sql_query) - - emails = [] - - for entry in data: - email_obj = Email( - id=entry[0], - email=entry[1], - type=entry[2], - prime=entry[3] - ) - - emails.append(email_obj.to_dict) - - - except Exception as exp: - logger.error('Failed') - logger.debug('sql_query: ' + repr(exp)) - raise - else: - if app.config["SUPER_DEBUG"]: - logger.debug('contacts: ' + repr(emails)) - - return cursor, emails - - -def get_telephones_by_account(cursor=None, account_id=None): - - sql_query = "SELECT " \ - "MyDataAccount.Telephones.id, " \ - "MyDataAccount.Telephones.tel, " \ - "MyDataAccount.Telephones.typeEnum, " \ - "MyDataAccount.Telephones.prime " \ - "FROM MyDataAccount.Telephones " \ - "WHERE Accounts_id = ('%s')" % (account_id) - - try: - cursor, data = execute_sql_select(cursor=cursor, sql_query=sql_query) - - telephones = [] - - for entry in data: - telephone_obj = Telephone( - id=entry[0], - tel=entry[1], - type=entry[2], - prime=entry[3] - ) - - telephones.append(telephone_obj.to_dict) - - - except Exception as exp: - logger.error('Failed') - logger.debug('sql_query: ' + repr(exp)) - raise - else: - if app.config["SUPER_DEBUG"]: - logger.debug('contacts: ' + repr(telephones)) - - return cursor, telephones - +# +# def get_service_link_record_count_by_account(cursor=None, account_id=None): +# if app.config["SUPER_DEBUG"]: +# logger.debug('account_id: ' + repr(account_id)) +# +# ### +# logger.debug('get_consent_record_count(account_id)') +# if app.config["SUPER_DEBUG"]: +# logger.debug('account_id: ' + repr(account_id)) +# +# sql_query = "SELECT count(MyDataAccount.ServiceLinkRecords.id) " \ +# "FROM MyDataAccount.ServiceLinkRecords " \ +# "WHERE MyDataAccount.ServiceLinkRecords.Accounts_id = '%s'" % (account_id) +# +# try: +# cursor, count = execute_sql_select(cursor=cursor, sql_query=sql_query) +# count = count[0][0] +# except Exception as exp: +# logger.error('Failed') +# logger.debug('sql_query: ' + repr(exp)) +# raise +# else: +# if app.config["SUPER_DEBUG"]: +# logger.debug('contacts: ' + repr(count)) +# +# return cursor, count +# +# +# def get_consent_record_count_by_account(cursor=None, account_id=None): +# if app.config["SUPER_DEBUG"]: +# logger.debug('account_id: ' + repr(account_id)) +# +# ### +# logger.debug('get_consent_record_count(account_id)') +# if app.config["SUPER_DEBUG"]: +# logger.debug('account_id: ' + repr(account_id)) +# +# sql_query = "SELECT count(MyDataAccount.ConsentRecords.id) " \ +# "FROM MyDataAccount.ConsentRecords " \ +# "WHERE MyDataAccount.ConsentRecords.Accounts_id = '%s'" % (account_id) +# +# try: +# cursor, count = execute_sql_select(cursor=cursor, sql_query=sql_query) +# count = count[0][0] +# except Exception as exp: +# logger.error('Failed') +# logger.debug('sql_query: ' + repr(exp)) +# raise +# else: +# if app.config["SUPER_DEBUG"]: +# logger.debug('contacts: ' + repr(count)) +# +# return cursor, count +# +# +# def get_contacts_by_account(cursor=None, account_id=None): +# +# sql_query = "SELECT " \ +# "MyDataAccount.Contacts.id, " \ +# "MyDataAccount.Contacts.address1, " \ +# "MyDataAccount.Contacts.address2, " \ +# "MyDataAccount.Contacts.postalCode, " \ +# "MyDataAccount.Contacts.city, " \ +# "MyDataAccount.Contacts.state, " \ +# "MyDataAccount.Contacts.country, " \ +# "MyDataAccount.Contacts.typeEnum, " \ +# "MyDataAccount.Contacts.prime " \ +# "FROM MyDataAccount.Contacts " \ +# "WHERE Accounts_id = ('%s')" % (account_id) +# +# try: +# cursor, data = execute_sql_select(cursor=cursor, sql_query=sql_query) +# +# contacts = [] +# +# for entry in data: +# contact_obj = Contacts( +# id=entry[0], +# address1=entry[1], +# address2=entry[2], +# postal_code=entry[3], +# city=entry[4], +# state=entry[5], +# country=entry[6], +# type=entry[7], +# prime=entry[8] +# ) +# +# contacts.append(contact_obj.to_dict) +# +# +# except Exception as exp: +# logger.error('Failed') +# logger.debug('sql_query: ' + repr(exp)) +# raise +# else: +# if app.config["SUPER_DEBUG"]: +# logger.debug('contacts: ' + repr(contacts)) +# +# return cursor, contacts +# +# +# def get_emails_by_account(cursor=None, account_id=None): +# +# sql_query = "SELECT " \ +# "MyDataAccount.Emails.id, " \ +# "MyDataAccount.Emails.email, " \ +# "MyDataAccount.Emails.typeEnum, " \ +# "MyDataAccount.Emails.prime " \ +# "FROM MyDataAccount.Emails " \ +# "WHERE Accounts_id = ('%s')" % (account_id) +# +# try: +# cursor, data = execute_sql_select(cursor=cursor, sql_query=sql_query) +# +# emails = [] +# +# for entry in data: +# email_obj = Email( +# id=entry[0], +# email=entry[1], +# type=entry[2], +# prime=entry[3] +# ) +# +# emails.append(email_obj.to_dict) +# +# +# except Exception as exp: +# logger.error('Failed') +# logger.debug('sql_query: ' + repr(exp)) +# raise +# else: +# if app.config["SUPER_DEBUG"]: +# logger.debug('contacts: ' + repr(emails)) +# +# return cursor, emails +# +# +# def get_telephones_by_account(cursor=None, account_id=None): +# +# sql_query = "SELECT " \ +# "MyDataAccount.Telephones.id, " \ +# "MyDataAccount.Telephones.tel, " \ +# "MyDataAccount.Telephones.typeEnum, " \ +# "MyDataAccount.Telephones.prime " \ +# "FROM MyDataAccount.Telephones " \ +# "WHERE Accounts_id = ('%s')" % (account_id) +# +# try: +# cursor, data = execute_sql_select(cursor=cursor, sql_query=sql_query) +# +# telephones = [] +# +# for entry in data: +# telephone_obj = Telephone( +# id=entry[0], +# tel=entry[1], +# type=entry[2], +# prime=entry[3] +# ) +# +# telephones.append(telephone_obj.to_dict) +# +# +# except Exception as exp: +# logger.error('Failed') +# logger.debug('sql_query: ' + repr(exp)) +# raise +# else: +# if app.config["SUPER_DEBUG"]: +# logger.debug('contacts: ' + repr(telephones)) +# +# return cursor, telephones +# diff --git a/Account/app/mod_account/view_api.py b/Account/app/mod_account/view_api.py index 8fdbc8e..91b9f9c 100644 --- a/Account/app/mod_account/view_api.py +++ b/Account/app/mod_account/view_api.py @@ -19,14 +19,21 @@ # Import services from app.helpers import get_custom_logger, make_json_response, ApiError -from app.mod_account.controllers import get_service_link_record_count, get_consent_record_count, get_telephones, \ - get_emails, get_contacts, get_passive_consents_count, get_potential_services_count, get_potential_consents_count -from app.mod_account.models import AccountSchema2 +from app.mod_account.controllers import get_particulars, get_particular, verify_account_id_match, \ + update_particular, get_contacts, add_contact, get_contact, update_contact, get_emails, add_email, get_email, \ + update_email, get_telephone, update_telephone, get_telephones, add_telephone, get_settings, add_setting, get_setting, \ + update_setting, get_event_log, get_event_logs, get_slrs, get_slr, get_slsrs, get_slsr, get_cr, get_crs, get_csrs, \ + get_csr, export_account +from app.mod_account.models import AccountSchema2, ParticularsSchema, ContactsSchema, ContactsSchemaForUpdate, \ + EmailsSchema, EmailsSchemaForUpdate, TelephonesSchema, TelephonesSchemaForUpdate, SettingsSchema, \ + SettingsSchemaForUpdate from app.mod_api_auth.controllers import gen_account_api_key, requires_api_auth_user, provideApiKey from app.mod_blackbox.controllers import gen_account_key from app.mod_database.helpers import get_db_cursor from app.mod_database.models import Account, LocalIdentityPWD, LocalIdentity, Salt, Particulars, Email +from app.mod_api_auth.controllers import get_account_id_by_api_key + mod_account_api = Blueprint('account_api', __name__, template_folder='templates') # create logger with 'spam_application' @@ -39,14 +46,19 @@ def post(self): """ Example JSON { - "username": "jukkakukkulansukka", - "password": "kukka", - "firstName": "string", - "lastName": "string", - "email": "jukka@kukkula.sukka", - "dateOfBirth": "18-08-2016", - "acceptTermsOfService": "True" - } + "data": { + "type": "Account", + "attributes": { + 'firstName': 'Erkki', + 'lastName': 'Esimerkki', + 'dateOfBirth': '2016-05-31', + 'email': 'erkki.esimerkki@examlpe.org', + 'username': 'testUser', + 'password': 'Hello', + 'acceptTermsOfService': 'True' + } + } + } :return: """ @@ -75,13 +87,13 @@ def post(self): logger.debug("JSON validation -> OK") try: - username = json_data['username'] - password = json_data['password'] - firstName = json_data['firstName'] - lastName = json_data['lastName'] - email_address = json_data['email'] - dateOfBirth = json_data['dateOfBirth'] - acceptTermsOfService = json_data['acceptTermsOfService'] + username = json_data['data']['attributes']['username'] + password = json_data['data']['attributes']['password'] + firstName = json_data['data']['attributes']['firstName'] + lastName = json_data['data']['attributes']['lastName'] + email_address = json_data['data']['attributes']['email'] + dateOfBirth = json_data['data']['attributes']['dateOfBirth'] + acceptTermsOfService = json_data['data']['attributes']['acceptTermsOfService'] global_identifier = str(uuid.uuid4()) salt_str = str(bcrypt.gensalt()) @@ -194,13 +206,7 @@ def post(self): response_data['data'] = {} response_data['data']['type'] = "Account" response_data['data']['id'] = str(account.id) - response_data['data']['attributes'] = {} - response_data['data']['attributes']['username'] = username - response_data['data']['attributes']['firstName'] = firstName - response_data['data']['attributes']['lastName'] = lastName - response_data['data']['attributes']['email'] = email_address - response_data['data']['attributes']['dateOfBirth'] = dateOfBirth - response_data['data']['attributes']['acceptTermsOfService'] = acceptTermsOfService + response_data['data']['attributes'] = json_data['data']['attributes'] except Exception as exp: logger.error('Could not prepare response data: ' + repr(exp)) raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) @@ -213,35 +219,180 @@ def post(self): return make_json_response(data=response_data_dict, status_code=201) -class ExportAccount(Resource): +class AccountExport(Resource): @requires_api_auth_user def get(self, account_id): + logger.info("AccountExport") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Account Export + try: + logger.info("Exporting Account") + db_entries = export_account(account_id=account_id) + except Exception as exp: + error_title = "Account Export failed" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Account Export Succeed") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + +class AccountParticulars(Resource): + @requires_api_auth_user + def get(self, account_id): + logger.info("AccountParticulars") try: endpoint = str(api.url_for(self, account_id=account_id)) except Exception as exp: endpoint = str(__name__) try: + logger.info("Fetching Api-Key from Headers") api_key = request.headers.get('Api-Key') except Exception as exp: - logger.error("No ApiKey in headers") - logger.debug("No ApiKey in headers: " + repr(repr(exp))) + logger.error("No ApiKey in headers: " + repr(repr(exp))) return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) try: account_id = str(account_id) except Exception as exp: - raise ApiError(code=400, title="Unsupported account_id", detail=repr(exp), source=endpoint) + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Particulars + try: + logger.info("Fetching Particulars") + db_entries = get_particulars(account_id=account_id) + except Exception as exp: + error_title = "No Particulars found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Particulars Fetched") + logger.info("Particulars: ") # Response data container try: + db_entry_list = db_entries response_data = {} - response_data['meta'] = {} - response_data['meta']['activationInstructions'] = "Account Export" + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) - response_data['data'] = {} - response_data['data']['type'] = "Account" + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountParticular(Resource): + @requires_api_auth_user + def get(self, account_id, particulars_id): + logger.info("AccountParticulars") + try: + endpoint = str(api.url_for(self, account_id=account_id, particulars_id=particulars_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + particulars_id = str(particulars_id) + except Exception as exp: + error_title = "Unsupported particulars_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("particulars_id: " + particulars_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Particulars + try: + logger.info("Fetching Particulars") + db_entries = get_particular(account_id=account_id, id=particulars_id) + except Exception as exp: + error_title = "No Particulars found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Particulars Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries except Exception as exp: logger.error('Could not prepare response data: ' + repr(exp)) raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) @@ -251,9 +402,2192 @@ def get(self, account_id): response_data_dict = dict(response_data) logger.debug('response_data_dict: ' + repr(response_data_dict)) - return make_json_response(data=response_data_dict, status_code=201) + return make_json_response(data=response_data_dict, status_code=200) + + @requires_api_auth_user + def patch(self, account_id, particulars_id): + logger.info("AccountParticular") + try: + endpoint = str(api.url_for(self, account_id=account_id, particulars_id=particulars_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + particulars_id = str(particulars_id) + except Exception as exp: + error_title = "Unsupported particulars_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("particulars_id: " + particulars_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs from path and ApiKey are matching") + + # load JSON from payload + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = ParticularsSchema() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), source=endpoint) + else: + logger.debug("JSON validation -> OK") + + try: + particulars_id_from_payload = json_data['data'].get("id", "") + except Exception as exp: + error_title = "Could not get id from payload" + logger.error(error_title) + raise ApiError( + code=404, + title=error_title, + detail=repr(exp), + source=endpoint + ) + + # Check if particulars_id from path and payload are matching + if particulars_id != particulars_id_from_payload: + error_title = "Particulars IDs from path and payload are not matching" + compared_ids = {'IdFromPath': particulars_id, 'IdFromPayload': particulars_id_from_payload} + logger.error(error_title + ", " + json.dumps(compared_ids)) + raise ApiError( + code=403, + title=error_title, + detail=compared_ids, + source=endpoint + ) + else: + logger.info("Particulars IDs from path and payload are matching") + + # Collect data + try: + attributes = json_data['data']['attributes'] + except Exception as exp: + error_title = "Could not collect data" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + + # Update Particulars + try: + logger.info("Updating Particulars") + db_entries = update_particular(account_id=account_id, id=particulars_id, attributes=attributes) + except Exception as exp: + error_title = "No Particulars found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Particulars Updated") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountContacts(Resource): + @requires_api_auth_user + def get(self, account_id): + logger.info("AccountContacts") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Contacts + try: + logger.info("Fetching Contacts") + db_entries = get_contacts(account_id=account_id) + except Exception as exp: + error_title = "No Contacts found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Contacts Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + @requires_api_auth_user + def post(self, account_id): + logger.info("AccountContacts") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs from path and ApiKey are matching") + + # load JSON from payload + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = ContactsSchema() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), + source=endpoint) + else: + logger.debug("JSON validation -> OK") + + # Collect data + try: + attributes = json_data['data']['attributes'] + except Exception as exp: + error_title = "Could not collect data" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + + # Add Contact + try: + logger.info("Adding Contacts") + db_entries = add_contact(account_id=account_id, attributes=attributes) + except Exception as exp: + error_title = "Could not add Contact entry" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Contacts Updated") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=201) + + +class AccountContact(Resource): + @requires_api_auth_user + def get(self, account_id, contacts_id): + logger.info("AccountContact") + try: + endpoint = str(api.url_for(self, account_id=account_id, contacts_id=contacts_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + contacts_id = str(contacts_id) + except Exception as exp: + error_title = "Unsupported contacts_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("contacts_id: " + contacts_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Contacts + try: + logger.info("Fetching Contacts") + db_entries = get_contact(account_id=account_id, id=contacts_id) + except Exception as exp: + error_title = "No Contacts found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Contacts Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + @requires_api_auth_user + def patch(self, account_id, contacts_id): # TODO: Should be PATCH instead of PUT + logger.info("AccountContact") + try: + endpoint = str(api.url_for(self, account_id=account_id, contacts_id=contacts_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + contacts_id = str(contacts_id) + except Exception as exp: + error_title = "Unsupported contacts_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("contacts_id: " + contacts_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs from path and ApiKey are matching") + + # load JSON from payload + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = ContactsSchemaForUpdate() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), source=endpoint) + else: + logger.debug("JSON validation -> OK") + + try: + contacts_id_from_payload = json_data['data'].get("id", "") + except Exception as exp: + error_title = "Could not get id from payload" + logger.error(error_title) + raise ApiError( + code=404, + title=error_title, + detail=repr(exp), + source=endpoint + ) + + # Check if contacts_id from path and payload are matching + if contacts_id != contacts_id_from_payload: + error_title = "Contact IDs from path and payload are not matching" + compared_ids = {'IdFromPath': contacts_id, 'IdFromPayload': contacts_id_from_payload} + logger.error(error_title + ", " + json.dumps(compared_ids)) + raise ApiError( + code=403, + title=error_title, + detail=compared_ids, + source=endpoint + ) + else: + logger.info("Contact IDs from path and payload are matching") + + # Collect data + try: + attributes = json_data['data']['attributes'] + except Exception as exp: + error_title = "Could not collect data" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + + # Update Contact + try: + logger.info("Updating Contacts") + db_entries = update_contact(account_id=account_id, id=contacts_id, attributes=attributes) + except Exception as exp: + error_title = "No Contacts found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Contacts Updated") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountEmails(Resource): + @requires_api_auth_user + def get(self, account_id): + logger.info("AccountEmails") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Emails + try: + logger.info("Fetching Emails") + db_entries = get_emails(account_id=account_id) + except Exception as exp: + error_title = "No Emails found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Emails Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + @requires_api_auth_user + def post(self, account_id): + logger.info("AccountEmails") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs from path and ApiKey are matching") + + # load JSON from payload + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = EmailsSchema() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), + source=endpoint) + else: + logger.debug("JSON validation -> OK") + + # Collect data + try: + attributes = json_data['data']['attributes'] + except Exception as exp: + error_title = "Could not collect data" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + + # Add Email + try: + logger.info("Adding Email") + db_entries = add_email(account_id=account_id, attributes=attributes) + except Exception as exp: + error_title = "Could not add Email entry" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Email added") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=201) + + +class AccountEmail(Resource): + @requires_api_auth_user + def get(self, account_id, emails_id): + logger.info("AccountEmail") + try: + endpoint = str(api.url_for(self, account_id=account_id, emails_id=emails_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + emails_id = str(emails_id) + except Exception as exp: + error_title = "Unsupported emails_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("emails_id: " + emails_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Email + try: + logger.info("Fetching Email") + db_entries = get_email(account_id=account_id, id=emails_id) + except Exception as exp: + error_title = "No Email found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Email Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + @requires_api_auth_user + def patch(self, account_id, emails_id): # TODO: Should be PATCH instead of PUT + logger.info("AccountEmail") + try: + endpoint = str(api.url_for(self, account_id=account_id, emails_id=emails_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + emails_id = str(emails_id) + except Exception as exp: + error_title = "Unsupported emails_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("emails_id: " + emails_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs from path and ApiKey are matching") + + # load JSON from payload + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = EmailsSchemaForUpdate() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), source=endpoint) + else: + logger.debug("JSON validation -> OK") + + try: + emails_id_from_payload = json_data['data'].get("id", "") + except Exception as exp: + error_title = "Could not get id from payload" + logger.error(error_title) + raise ApiError( + code=404, + title=error_title, + detail=repr(exp), + source=endpoint + ) + + # Check if emails_id from path and payload are matching + if emails_id != emails_id_from_payload: + error_title = "Email IDs from path and payload are not matching" + compared_ids = {'IdFromPath': emails_id, 'IdFromPayload': emails_id_from_payload} + logger.error(error_title + ", " + json.dumps(compared_ids)) + raise ApiError( + code=403, + title=error_title, + detail=compared_ids, + source=endpoint + ) + else: + logger.info("Email IDs from path and payload are matching") + + # Collect data + try: + attributes = json_data['data']['attributes'] + except Exception as exp: + error_title = "Could not collect data" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + + # Update Email + try: + logger.info("Updating Emails") + db_entries = update_email(account_id=account_id, id=emails_id, attributes=attributes) + except Exception as exp: + # TODO: Error handling on more detailed level + error_title = "No Email found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Email Updated") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountTelephones(Resource): + @requires_api_auth_user + def get(self, account_id): + logger.info("AccountTelephones") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Telephones + try: + logger.info("Fetching Telephones") + db_entries = get_telephones(account_id=account_id) + except Exception as exp: + error_title = "No Telephones found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Telephones Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + @requires_api_auth_user + def post(self, account_id): + logger.info("AccountTelephones") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs from path and ApiKey are matching") + + # load JSON from payload + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = TelephonesSchema() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), + source=endpoint) + else: + logger.debug("JSON validation -> OK") + + # Collect data + try: + attributes = json_data['data']['attributes'] + except Exception as exp: + error_title = "Could not collect data" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + + # Add Telephone + try: + logger.info("Adding Telephone") + db_entries = add_telephone(account_id=account_id, attributes=attributes) + except Exception as exp: + error_title = "Could not add Telephone entry" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Telephone added") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=201) + + +class AccountTelephone(Resource): + @requires_api_auth_user + def get(self, account_id, telephones_id): + logger.info("AccountTelephone") + try: + endpoint = str(api.url_for(self, account_id=account_id, telephones_id=telephones_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + telephones_id = str(telephones_id) + except Exception as exp: + error_title = "Unsupported telephones_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("telephones_id: " + telephones_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Telephone + try: + logger.info("Fetching Telephone") + db_entries = get_telephone(account_id=account_id, id=telephones_id) + except Exception as exp: + error_title = "No Telephone found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Telephone Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + @requires_api_auth_user + def patch(self, account_id, telephones_id): # TODO: Should be PATCH instead of PUT + logger.info("AccountTelephone") + try: + endpoint = str(api.url_for(self, account_id=account_id, telephones_id=telephones_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + telephones_id = str(telephones_id) + except Exception as exp: + error_title = "Unsupported telephones_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("telephones_id: " + telephones_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs from path and ApiKey are matching") + + # load JSON from payload + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = TelephonesSchemaForUpdate() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), source=endpoint) + else: + logger.debug("JSON validation -> OK") + + try: + telephones_id_from_payload = json_data['data'].get("id", "") + except Exception as exp: + error_title = "Could not get id from payload" + logger.error(error_title) + raise ApiError( + code=404, + title=error_title, + detail=repr(exp), + source=endpoint + ) + + # Check if emails_id from path and payload are matching + if telephones_id != telephones_id_from_payload: + error_title = "Email IDs from path and payload are not matching" + compared_ids = {'IdFromPath': telephones_id, 'IdFromPayload': telephones_id_from_payload} + logger.error(error_title + ", " + json.dumps(compared_ids)) + raise ApiError( + code=403, + title=error_title, + detail=compared_ids, + source=endpoint + ) + else: + logger.info("Telephone IDs from path and payload are matching") + + # Collect data + try: + attributes = json_data['data']['attributes'] + except Exception as exp: + error_title = "Could not collect data" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + + # Update Telephone + try: + logger.info("Updating Telephone") + db_entries = update_telephone(account_id=account_id, id=telephones_id, attributes=attributes) + except Exception as exp: + # TODO: Error handling on more detailed level + error_title = "No Telephone found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Telephone Updated") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountSettings(Resource): + @requires_api_auth_user + def get(self, account_id): + logger.info("AccountSettings") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Settings + try: + logger.info("Fetching Settings") + db_entries = get_settings(account_id=account_id) + except Exception as exp: + error_title = "No Settings found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Settings Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + @requires_api_auth_user + def post(self, account_id): + logger.info("AccountSettings") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs from path and ApiKey are matching") + + # load JSON from payload + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = SettingsSchema() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), + source=endpoint) + else: + logger.debug("JSON validation -> OK") + + # Collect data + try: + attributes = json_data['data']['attributes'] + except Exception as exp: + error_title = "Could not collect data" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + + # Add Setting + try: + logger.info("Adding Setting") + db_entries = add_setting(account_id=account_id, attributes=attributes) + except Exception as exp: + error_title = "Could not add Setting entry" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Setting added") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=201) + + +class AccountSetting(Resource): + @requires_api_auth_user + def get(self, account_id, settings_id): + logger.info("AccountSetting") + try: + endpoint = str(api.url_for(self, account_id=account_id, settings_id=settings_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + settings_id = str(settings_id) + except Exception as exp: + error_title = "Unsupported settings_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("settings_id: " + settings_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get Setting + try: + logger.info("Fetching Setting") + db_entries = get_setting(account_id=account_id, id=settings_id) + except Exception as exp: + error_title = "No Setting found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Setting Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + @requires_api_auth_user + def patch(self, account_id, settings_id): # TODO: Should be PATCH instead of PUT + logger.info("AccountSetting") + try: + endpoint = str(api.url_for(self, account_id=account_id, settings_id=settings_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + settings_id = str(settings_id) + except Exception as exp: + error_title = "Unsupported settings_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("settings_id: " + settings_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs from path and ApiKey are matching") + + # load JSON from payload + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = SettingsSchemaForUpdate() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), source=endpoint) + else: + logger.debug("JSON validation -> OK") + + try: + settings_id_from_payload = json_data['data'].get("id", "") + except Exception as exp: + error_title = "Could not get id from payload" + logger.error(error_title) + raise ApiError( + code=404, + title=error_title, + detail=repr(exp), + source=endpoint + ) + + # Check if emails_id from path and payload are matching + if settings_id != settings_id_from_payload: + error_title = "Email IDs from path and payload are not matching" + compared_ids = {'IdFromPath': settings_id, 'IdFromPayload': settings_id_from_payload} + logger.error(error_title + ", " + json.dumps(compared_ids)) + raise ApiError( + code=403, + title=error_title, + detail=compared_ids, + source=endpoint + ) + else: + logger.info("Setting IDs from path and payload are matching") + + # Collect data + try: + attributes = json_data['data']['attributes'] + except Exception as exp: + error_title = "Could not collect data" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + + # Update Setting + try: + logger.info("Updating Setting") + db_entries = update_setting(account_id=account_id, id=settings_id, attributes=attributes) + except Exception as exp: + # TODO: Error handling on more detailed level + error_title = "No Setting found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Setting Updated") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountEventLogs(Resource): + @requires_api_auth_user + def get(self, account_id): + logger.info("AccountEventLogs") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get EventLog + try: + logger.info("Fetching EventLog") + db_entries = get_event_logs(account_id=account_id) + except Exception as exp: + error_title = "No EventLog found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("EventLog Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountEventLog(Resource): + @requires_api_auth_user + def get(self, account_id, event_log_id): + logger.info("AccountEventLog") + try: + endpoint = str(api.url_for(self, account_id=account_id, event_log_id=event_log_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + event_log_id = str(event_log_id) + except Exception as exp: + error_title = "Unsupported event_log_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("event_log_id: " + event_log_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get EventLog + try: + logger.info("Fetching EventLog") + db_entries = get_event_log(account_id=account_id, id=event_log_id) + except Exception as exp: + error_title = "No EventLog found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("EventLog Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountServiceLinkRecords(Resource): + @requires_api_auth_user + def get(self, account_id): + logger.info("AccountServiceLinkRecords") + try: + endpoint = str(api.url_for(self, account_id=account_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get ServiceLinkRecords + try: + logger.info("Fetching ServiceLinkRecords") + db_entries = get_slrs(account_id=account_id) + except Exception as exp: + error_title = "No ServiceLinkRecords found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("ServiceLinkRecords Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountServiceLinkRecord(Resource): + @requires_api_auth_user + def get(self, account_id, slr_id): + logger.info("AccountServiceLinkRecord") + try: + endpoint = str(api.url_for(self, account_id=account_id, slr_id=slr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + slr_id = str(slr_id) + except Exception as exp: + error_title = "Unsupported slr_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("slr_id: " + slr_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get ServiceLinkRecord + try: + logger.info("Fetching ServiceLinkRecord") + db_entries = get_slr(account_id=account_id, slr_id=slr_id) + except Exception as exp: + error_title = "No ServiceLinkRecord found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("ServiceLinkRecord Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountServiceLinkStatusRecords(Resource): + @requires_api_auth_user + def get(self, account_id, slr_id): + logger.info("AccountServiceLinkStatusRecords") + try: + endpoint = str(api.url_for(self, account_id=account_id, slr_id=slr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + slr_id = str(slr_id) + except Exception as exp: + error_title = "Unsupported slr_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("slr_id: " + slr_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get ServiceLinkStatusRecords + try: + logger.info("Fetching ServiceLinkStatusRecords") + db_entries = get_slsrs(account_id=account_id, slr_id=slr_id) + except StandardError as exp: + error_title = "ServiceLinkStatusRecords not accessible" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=403, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "No ServiceLinkStatusRecords found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("ServiceLinkStatusRecords Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountServiceLinkStatusRecord(Resource): + @requires_api_auth_user + def get(self, account_id, slr_id, slsr_id): + logger.info("AccountServiceLinkStatusRecord") + try: + endpoint = str(api.url_for(self, account_id=account_id, slr_id=slr_id, slsr_id=slsr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + slr_id = str(slr_id) + except Exception as exp: + error_title = "Unsupported slr_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("slr_id: " + slr_id) + + try: + slsr_id = str(slsr_id) + except Exception as exp: + error_title = "Unsupported slsr_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("slsr_id: " + slsr_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get ServiceLinkStatusRecord + try: + logger.info("Fetching ServiceLinkStatusRecord") + db_entries = get_slsr(account_id=account_id, slr_id=slr_id, slsr_id=slsr_id) + except StandardError as exp: + error_title = "ServiceLinkStatusRecords not accessible" + logger.error(error_title + repr(exp)) + raise ApiError(code=403, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "No tServiceLinkStatusRecord found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("ServiceLinkStatusRecord Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountConsentRecords(Resource): + @requires_api_auth_user + def get(self, account_id, slr_id): + logger.info("AccountConsentRecords") + try: + endpoint = str(api.url_for(self, account_id=account_id, slr_id=slr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + slr_id = str(slr_id) + except Exception as exp: + error_title = "Unsupported slr_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("slr_id: " + slr_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get ConsentRecords + try: + logger.info("Fetching ConsentRecords") + db_entries = get_crs(account_id=account_id, slr_id=slr_id) + except StandardError as exp: + error_title = "ConsentRecords not accessible" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=403, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "No ConsentRecords found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("ConsentRecords Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountConsentRecord(Resource): + @requires_api_auth_user + def get(self, account_id, slr_id, cr_id): + logger.info("AccountConsentRecord") + try: + endpoint = str(api.url_for(self, account_id=account_id, slr_id=slr_id, cr_id=cr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + slr_id = str(slr_id) + except Exception as exp: + error_title = "Unsupported slr_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("slr_id: " + slr_id) + + try: + cr_id = str(cr_id) + except Exception as exp: + error_title = "Unsupported cr_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("cr_id: " + cr_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get ServiceLinkStatusRecord + try: + logger.info("Fetching ConsentRecord") + db_entries = get_cr(account_id=account_id, slr_id=slr_id, cr_id=cr_id) + except StandardError as exp: + error_title = "ConsentRecord not accessible" + logger.error(error_title + repr(exp)) + raise ApiError(code=403, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "No ConsentRecord found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("ConsentRecord Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountConsentStatusRecords(Resource): + @requires_api_auth_user + def get(self, account_id, slr_id, cr_id): + logger.info("AccountConsentStatusRecords") + try: + endpoint = str(api.url_for(self, account_id=account_id, slr_id=slr_id, cr_id=cr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + slr_id = str(slr_id) + except Exception as exp: + error_title = "Unsupported slr_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("slr_id: " + slr_id) + + try: + cr_id = str(cr_id) + except Exception as exp: + error_title = "Unsupported cr_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("cr_id: " + cr_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get ConsentStatusRecords + try: + logger.info("Fetching ConsentStatusRecords") + db_entries = get_csrs(account_id=account_id, slr_id=slr_id, cr_id=cr_id) + except StandardError as exp: + error_title = "ConsentStatusRecords not accessible" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=403, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "No ConsentStatusRecords found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("ConsentStatusRecords Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class AccountConsentStatusRecord(Resource): + @requires_api_auth_user + def get(self, account_id, slr_id, cr_id, csr_id): + logger.info("AccountConsentStatusRecord") + try: + endpoint = str(api.url_for(self, account_id=account_id, slr_id=slr_id, cr_id=cr_id, csr_id=csr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + account_id = str(account_id) + except Exception as exp: + error_title = "Unsupported account_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_id: " + account_id) + + try: + slr_id = str(slr_id) + except Exception as exp: + error_title = "Unsupported slr_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("slr_id: " + slr_id) + + try: + cr_id = str(cr_id) + except Exception as exp: + error_title = "Unsupported cr_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("cr_id: " + cr_id) + + try: + csr_id = str(csr_id) + except Exception as exp: + error_title = "Unsupported csr_id" + logger.error(error_title + repr(exp)) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("csr_id: " + csr_id) + + # Check if Account IDs from path and ApiKey are matching + if verify_account_id_match(account_id=account_id, api_key=api_key, endpoint=endpoint): + logger.info("Account IDs are matching") + + # Get ConsentStatusRecord + try: + logger.info("Fetching ConsentStatusRecord") + db_entries = get_csr(account_id=account_id, slr_id=slr_id, cr_id=cr_id, csr_id=csr_id) + except StandardError as exp: + error_title = "ConsentStatusRecord not accessible" + logger.error(error_title + repr(exp)) + raise ApiError(code=403, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "No ConsentStatusRecord found" + logger.error(error_title) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("ConsentStatusRecord Fetched") + + # Response data container + try: + response_data = {} + response_data['data'] = db_entries + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + + +# Register resources +api.add_resource(Accounts, '/api/accounts/', '/', endpoint='/api/accounts/') +api.add_resource(AccountExport, '/api/accounts//export/', endpoint='account-export') +api.add_resource(AccountParticulars, '/api/accounts//particulars/', endpoint='account-particulars') +api.add_resource(AccountParticular, '/api/accounts//particulars//', endpoint='account-particular') +api.add_resource(AccountContacts, '/api/accounts//contacts/', endpoint='account-contacts') +api.add_resource(AccountContact, '/api/accounts//contacts//', endpoint='account-contact') +api.add_resource(AccountEmails, '/api/accounts//emails/', endpoint='account-emails') +api.add_resource(AccountEmail, '/api/accounts//emails//', endpoint='account-email') +api.add_resource(AccountTelephones, '/api/accounts//telephones/', endpoint='account-telephones') +api.add_resource(AccountTelephone, '/api/accounts//telephones//', endpoint='account-telephone') +api.add_resource(AccountSettings, '/api/accounts//settings/', endpoint='account-settings') +api.add_resource(AccountSetting, '/api/accounts//settings//', endpoint='account-setting') +api.add_resource(AccountEventLogs, '/api/accounts//logs/events/', endpoint='account-events') +api.add_resource(AccountEventLog, '/api/accounts//logs/events//', endpoint='account-event') +api.add_resource(AccountServiceLinkRecords, '/api/accounts//servicelinks/', endpoint='account-slrs') +api.add_resource(AccountServiceLinkRecord, '/api/accounts//servicelinks//', endpoint='account-slr') +api.add_resource(AccountServiceLinkStatusRecords, '/api/accounts//servicelinks//statuses/', endpoint='account-slsrs') +api.add_resource(AccountServiceLinkStatusRecord, '/api/accounts//servicelinks//statuses//', endpoint='account-slsr') +api.add_resource(AccountConsentRecords, '/api/accounts//servicelinks//consents/', endpoint='account-crs') +api.add_resource(AccountConsentRecord, '/api/accounts//servicelinks//consents//', endpoint='account-cr') +api.add_resource(AccountConsentStatusRecords, '/api/accounts//servicelinks//consents//statuses/', endpoint='account-csrs') +api.add_resource(AccountConsentStatusRecord, '/api/accounts//servicelinks//consents//statuses//', endpoint='account-csr') + -# Register resources -api.add_resource(Accounts, '/api/accounts/', '/', endpoint='/api/accounts/') -api.add_resource(ExportAccount, '/api/account//export/', endpoint='account-export') diff --git a/Account/app/mod_account/view_html.py b/Account/app/mod_account/view_html.py index 64ea0d9..50ab1d4 100644 --- a/Account/app/mod_account/view_html.py +++ b/Account/app/mod_account/view_html.py @@ -18,8 +18,6 @@ # Import services from app.helpers import get_custom_logger -from app.mod_account.controllers import get_service_link_record_count, get_consent_record_count, get_telephones, \ - get_emails, get_contacts, get_passive_consents_count, get_potential_services_count, get_potential_consents_count from app.mod_api_auth.controllers import get_account_api_key from app.mod_database.helpers import get_db_cursor @@ -47,66 +45,5 @@ def get(self): return make_response(render_template('profile/index.html', content_data=content_data), 200, headers) -class Details(Resource): - @login_required - def get(self): - - account_id = session['user_id'] - logger.debug('Account id: ' + account_id) - - cursor = get_db_cursor() - - cursor, service_link_record_count = get_service_link_record_count(cursor=cursor, account_id=account_id) - cursor, consent_count = get_consent_record_count(cursor=cursor, account_id=account_id) - - cursor, contacts = get_contacts(cursor=cursor, account_id=account_id) - cursor, emails = get_emails(cursor=cursor, account_id=account_id) - cursor, telephones = get_telephones(cursor=cursor, account_id=account_id) - - cursor, potential_services = get_potential_services_count(cursor=cursor, account_id=account_id) - cursor, potential_consents = get_potential_consents_count(cursor=cursor, account_id=account_id) - cursor, passive_services = get_potential_services_count(cursor=cursor, account_id=account_id) - cursor, passive_consents = get_passive_consents_count(cursor=cursor, account_id=account_id) - - content_data = { - 'service_link_record_count': service_link_record_count, - 'consent_count': consent_count, - 'contacts': contacts, - 'emails': emails, - 'telephones': telephones, - 'potential_services': potential_services, - 'potential_consents': potential_consents, - 'passive_services': passive_services, - 'passive_consents': passive_consents - } - - headers = {'Content-Type': 'text/html'} - return make_response(render_template('profile/details.html', content_data=content_data), 200, headers) - - -class Settings(Resource): - @login_required - def get(self): - account_id = session['user_id'] - logger.debug('Account id: ' + account_id) - - content_data = { - 'service_link_record_count': None, - 'consent_count': None, - 'contacts': None, - 'emails': None, - 'telephones': None, - 'potential_services': None, - 'potential_consents': None, - 'passive_services': None, - 'passive_consents': None - } - - headers = {'Content-Type': 'text/html'} - return make_response(render_template('profile/settings.html', content_data=content_data), 200, headers) - - # Register resources api.add_resource(Home, '/html/account/home/', '/', endpoint='home') -api.add_resource(Details, '/html/account/details/', endpoint='details') -api.add_resource(Settings, '/html/account/settings/', endpoint='settings') diff --git a/Account/app/mod_api_auth/controllers.py b/Account/app/mod_api_auth/controllers.py index 2988fc5..6dd84dc 100644 --- a/Account/app/mod_api_auth/controllers.py +++ b/Account/app/mod_api_auth/controllers.py @@ -99,27 +99,35 @@ def get_account_api_key(account_id=None): :param account_id: :return: API Key """ + logger.info("Get Account APIKey by Account ID") + if account_id is None: raise AttributeError("Provide account_id as parameter") try: + logger.info("Getting DB connection") connection = get_sqlite_connection() except Exception as exp: exp = append_description_to_exception(exp=exp, description='Could not get connection SQL database.') logger.error('Could not get connection SQL database: ' + repr(exp)) raise + else: + logger.info("Got DB connection") try: + logger.info("Getting DB cursor") cursor, connection = get_sqlite_cursor(connection=connection) except Exception as exp: exp = append_description_to_exception(exp=exp, description='Could not get cursor for database connection') logger.error('Could not get cursor for database connection: ' + repr(exp)) raise + else: + logger.info("Got DB cursor") try: cursor, api_key = get_api_key(account_id=account_id, cursor=cursor) except Exception as exp: - exp = append_description_to_exception(exp=exp, description='Could not API key from database') + exp = append_description_to_exception(exp=exp, description='Could not find API key from database') logger.error('Could not get API key from database: ' + repr(exp)) connection.rollback() connection.close() @@ -155,6 +163,7 @@ def get_account_id_by_api_key(api_key=None): raise try: + logger.info("Fetching Account ID") cursor, account_id = get_account_id(api_key=api_key, cursor=cursor) except Exception as exp: exp = append_description_to_exception(exp=exp, description='Could not Account ID from database') @@ -164,11 +173,13 @@ def get_account_id_by_api_key(api_key=None): raise else: connection.close() - logger.debug('Account ID fetched') + logger.info('Account ID fetched') + logger.info('account_id: ' + str(account_id)) return account_id def check_api_auth_user(api_key): + logger.info("Checking Api-Key") try: logger.debug("Fetching Account ID") account_id = get_account_id_by_api_key(api_key=api_key) @@ -220,17 +231,21 @@ def requires_api_auth_user(f): @wraps(f) def decorated(*args, **kwargs): api_key = None + logger.info("Verifying Api-Key") try: api_key = request.headers.get('Api-Key') if api_key is None: - raise AttributeError('No API Key in Request Headers') + raise AttributeError('No Api-Key in Request Headers') except Exception as exp: - logger.debug("No ApiKey in headers: " + repr(exp)) + logger.debug("No Api-Key in headers: " + repr(exp)) return provideApiKey() else: + logger.info("Provided Api-Key: " + str(api_key)) if not check_api_auth_user(api_key=api_key): - logger.debug("Wrong API Key") + logger.debug("Wrong Api-Key") return wrongApiKey() + logger.info("Correct Api-Key") + logger.info("User Authenticated") return f(*args, **kwargs) return decorated diff --git a/Account/app/mod_api_auth/view_api.py b/Account/app/mod_api_auth/view_api.py index fe13e77..0a7928f 100644 --- a/Account/app/mod_api_auth/view_api.py +++ b/Account/app/mod_api_auth/view_api.py @@ -18,8 +18,9 @@ from flask_restful import Resource, Api, reqparse from app import api -from app.helpers import get_custom_logger, make_json_response +from app.helpers import get_custom_logger, make_json_response, ApiError from app.mod_api_auth.controllers import get_account_api_key, get_api_key_sdk +from app.mod_api_auth.helpers import ApiKeyNotFoundError from app.mod_auth.helpers import get_account_id_by_username_and_password logger = get_custom_logger('mod_api_auth_view_api') @@ -37,14 +38,16 @@ def check_basic_auth(self, username, password): """ This function is called to check if a username password combination is valid. """ + logger.info("Checking username and password") user = get_account_id_by_username_and_password(username=username, password=password) logger.debug("User with following info: " + str(user)) if user is not None: self.account_id = user['account_id'] self.username = user['username'] - logger.debug("User info set") + logger.info("User authenticated") return True else: + logger.info("User not authenticated") return False @@ -53,21 +56,43 @@ def authenticate(): """Sends a 401 response that enables basic auth""" headers = {'WWW-Authenticate': 'Basic realm="Login Required"'} body = 'Could not verify your access level for that URL. \n You have to login with proper credentials' + logger.info("Authentication required") return make_response(body, 401, headers) def get(self): - # account_id = session['user_id'] - # logger.debug('Account id: ' + account_id) + try: + endpoint = str(api.url_for(self)) + except Exception as exp: + endpoint = str(__name__) + + logger.info("Authenticating user") auth = request.authorization if not auth or not self.check_basic_auth(auth.username, auth.password): return self.authenticate() - - api_key = get_account_api_key(account_id=self.account_id) + else: + logger.info("Authenticated") + + try: + api_key = get_account_api_key(account_id=self.account_id) + except ApiKeyNotFoundError as exp: + error_title = "ApiKey not found for authenticated user" + logger.error(error_title) + logger.error(repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "Could not get ApiKey for authenticated user" + logger.error(error_title) + logger.error(repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("account_id: " + str(self.account_id)) + logger.debug("api_key: " + str(api_key)) response_data = { - 'api_key': api_key + 'Api-Key': api_key, + 'account_id': str(self.account_id) } return make_json_response(data=response_data, status_code=200) diff --git a/Account/app/mod_auth/helpers.py b/Account/app/mod_auth/helpers.py index 241e7b7..4cc047e 100644 --- a/Account/app/mod_auth/helpers.py +++ b/Account/app/mod_auth/helpers.py @@ -191,7 +191,7 @@ def get_account_id_by_username_and_password(username=None, password=None): try: ### # User info by username - logger.debug('credentials') + logger.debug('User info by username from DB') sql_query = "SELECT " \ "MyDataAccount.LocalIdentities.Accounts_id, " \ "MyDataAccount.LocalIdentities.id, " \ @@ -230,17 +230,16 @@ def get_account_id_by_username_and_password(username=None, password=None): else: logger.debug('User found with given username: ' + username) - if app.config["SUPER_DEBUG"]: - logger.debug('account_id_from_db: ' + account_id_from_db) - logger.debug('identity_id_from_db: ' + identity_id_from_db) - logger.debug('username_from_db: ' + username_from_db) - logger.debug('password_from_db: ' + password_from_db) - logger.debug('salt_from_db: ' + salt_from_db) + logger.debug('account_id_from_db: ' + account_id_from_db) + logger.debug('identity_id_from_db: ' + identity_id_from_db) + logger.debug('username_from_db: ' + username_from_db) + logger.debug('password_from_db: ' + password_from_db) + logger.debug('salt_from_db: ' + salt_from_db) + logger.info("Checking password") if bcrypt.hashpw(password_to_check, salt_from_db) == password_from_db: - if app.config["SUPER_DEBUG"]: - logger.debug('Password hash from client: ' + bcrypt.hashpw(password_to_check, salt_from_db)) - logger.debug('Password hash from db : ' + password_from_db) + logger.debug('Password hash from client: ' + bcrypt.hashpw(password_to_check, salt_from_db)) + logger.debug('Password hash from db : ' + password_from_db) logger.debug('Authenticated') #cursor, user = get_account_by_id(cursor=cursor, account_id=int(account_id_from_db)) @@ -249,9 +248,8 @@ def get_account_id_by_username_and_password(username=None, password=None): return user else: - if app.config["SUPER_DEBUG"]: - logger.debug('Password hash from client: ' + bcrypt.hashpw(password_to_check, salt_from_db)) - logger.debug('Password hash from db : ' + password_from_db) + logger.debug('Password hash from client: ' + bcrypt.hashpw(password_to_check, salt_from_db)) + logger.debug('Password hash from db : ' + password_from_db) logger.debug('Not Authenticated') return None diff --git a/Account/app/mod_authorization/controllers.py b/Account/app/mod_authorization/controllers.py index 900e998..425dceb 100644 --- a/Account/app/mod_authorization/controllers.py +++ b/Account/app/mod_authorization/controllers.py @@ -21,15 +21,119 @@ # Import services from app.helpers import get_custom_logger, ApiError, get_utc_time from app.mod_blackbox.controllers import get_account_public_key, generate_and_sign_jws -from app.mod_database.helpers import get_db_cursor - +from app.mod_database.helpers import get_db_cursor, get_last_csr_id, get_csr_ids, get_account_id_by_csr_id # create logger with 'spam_application' -from app.mod_database.models import SurrogateId, ConsentRecord, ServiceLinkRecord +from app.mod_database.models import SurrogateId, ConsentRecord, ServiceLinkRecord, ConsentStatusRecord, Account logger = get_custom_logger(__name__) +def get_account_id_by_cr(cr_id=None, endpoint="get_account_id_by_cr(cr_id, endpoint)"): + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + + logger.info("Executing for: " + str(endpoint)) + + ## + # Account + try: + logger.info("Create Account object") + account_entry = Account() + except Exception as exp: + error_title = "Failed to create Account object" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("account_entry: " + account_entry.log_entry) + + # Get database table name for Consent Status Record + try: + logger.info("Get Account table name") + account_table_name = account_entry.table_name + except Exception as exp: + error_title = "Failed to get Account table name" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Got Account table name: " + str(account_table_name)) + + ## + # ServiceLinkRecord + try: + logger.info("Create ServiceLinkRecord object") + slr_entry = ServiceLinkRecord() + except Exception as exp: + error_title = "Failed to create ServiceLinkRecord object" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("slr_entry: " + slr_entry.log_entry) + + # Get database table name for Consent Status Record + try: + logger.info("Get ServiceLinkRecord table name") + slr_table_name = slr_entry.table_name + except Exception as exp: + error_title = "Failed to get ServiceLinkRecord table name" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Got ServiceLinkRecord table name: " + str(slr_table_name)) + + ## + # ConsentRecord + try: + logger.info("Create ConsentRecord object") + cr_entry = ConsentRecord() + except Exception as exp: + error_title = "Failed to create Consent Record object" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("cr_entry: " + cr_entry.log_entry) + + # Get database table name for Consent Status Record + try: + logger.info("Get Consent Record table name") + cr_table_name = cr_entry.table_name + except Exception as exp: + error_title = "Failed to get Consent Record table name" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Got Consent Record table name: " + str(cr_table_name)) + + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise ApiError(code=500, title="Failed to get database cursor", detail=repr(exp), source=endpoint) + + # Get Account ID + try: + logger.info("Get Account ID") + cursor, account_id = get_account_id_by_csr_id( + cursor=cursor, + cr_id=cr_id, + acc_table_name=account_table_name, + slr_table_name=slr_table_name, + cr_table_name=cr_table_name + ) + except IndexError as exp: + error_title = "Account ID Not Found" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "Failed to get Account ID" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Got Account ID: " + str(cr_table_name)) + return account_id + + def sign_cr(account_id=None, payload=None, endpoint="sign_slr(account_id, payload, endpoint)"): if account_id is None: raise AttributeError("Provide account_id as parameter") @@ -48,33 +152,26 @@ def sign_cr(account_id=None, payload=None, endpoint="sign_slr(account_id, payloa else: logger.info("Account owner's public key and kid fetched") - # Fill timestamp to created in slr - try: - timestamp_to_fill = get_utc_time() - except Exception as exp: - logger.error("Could not get UTC time: " + repr(exp)) - raise ApiError(code=500, title="Could not get UTC time", detail=repr(exp), source=endpoint) - else: - logger.info("timestamp_to_fill: " + timestamp_to_fill) - - try: - payload['common_part']['issued'] = timestamp_to_fill - except Exception as exp: - logger.error("Could not fill timestamp to created in cr: " + repr(exp)) - raise ApiError(code=500, title="Failed to fill timestamp to created in cr", detail=repr(exp), source=endpoint) - else: - logger.info("Timestamp filled to issued in cr") - # Sign cr try: - cr_signed = generate_and_sign_jws(account_id=account_id, jws_payload=json.dumps(payload)) + cr_signed_json = generate_and_sign_jws(account_id=account_id, jws_payload=json.dumps(payload)) except Exception as exp: logger.error('Could not create Consent Record: ' + repr(exp)) raise ApiError(code=500, title="Failed to create Consent Record", detail=repr(exp), source=endpoint) else: - logger.info('Service Link Record created and signed') - logger.debug('cr_signed: ' + cr_signed) - return cr_signed, timestamp_to_fill + logger.info('Consent Record created and signed') + logger.debug('cr_signed_json: ' + cr_signed_json) + try: + logger.info("Converting signed CR from json to dict") + cr_signed_dict = json.loads(cr_signed_json) + except Exception as exp: + logger.error('Could not convert signed CSR from json to dict: ' + repr(exp)) + raise ApiError(code=500, title="Failed to convert signed CSR from json to dict", detail=repr(exp), source=endpoint) + else: + logger.info('Converted signed CR from json to dict') + logger.debug('cr_signed_dict: ' + json.dumps(cr_signed_dict)) + + return cr_signed_dict def sign_csr(account_id=None, payload=None, endpoint="sign_csr(account_id, payload, endpoint)"): @@ -85,33 +182,26 @@ def sign_csr(account_id=None, payload=None, endpoint="sign_csr(account_id, paylo logger.info("Signing Service Link Status Record") - # Fill timestamp to created in slr - try: - timestamp_to_fill = get_utc_time() - except Exception as exp: - logger.error("Could not get UTC time: " + repr(exp)) - raise ApiError(code=500, title="Could not get UTC time", detail=repr(exp), source=endpoint) - else: - logger.info("timestamp_to_fill: " + timestamp_to_fill) - - try: - payload['iat'] = timestamp_to_fill - except Exception as exp: - logger.error("Could not fill timestamp to iat in csr_payload: " + repr(exp)) - raise ApiError(code=500, title="Failed to fill timestamp to iat in csr_payload", detail=repr(exp), source=endpoint) - else: - logger.info("Timestamp filled to created in csr_payload") - # Sign csr try: - csr_signed = generate_and_sign_jws(account_id=account_id, jws_payload=json.dumps(payload)) + csr_signed_json = generate_and_sign_jws(account_id=account_id, jws_payload=json.dumps(payload)) except Exception as exp: logger.error('Could not create Consent Status Record: ' + repr(exp)) raise ApiError(code=500, title="Failed to create Consent Status Record", detail=repr(exp), source=endpoint) else: - logger.info('SConsent Status Record created and signed') - logger.debug('csr_signed: ' + csr_signed) - return csr_signed, timestamp_to_fill + logger.info('Consent Status Record created and signed') + logger.debug('csr_signed_json: ' + csr_signed_json) + try: + logger.info("Converting signed CSR from json to dict") + csr_signed_dict = json.loads(csr_signed_json) + except Exception as exp: + logger.error('Could not convert signed CSR from json to dict: ' + repr(exp)) + raise ApiError(code=500, title="Failed to convert signed CSR from json to dict", detail=repr(exp), source=endpoint) + else: + logger.info('Converted signed CSR from json to dict') + logger.debug('csr_signed_dict: ' + json.dumps(csr_signed_dict)) + + return csr_signed_dict def store_cr_and_csr(source_slr_entry=None, sink_slr_entry=None, source_cr_entry=None, source_csr_entry=None, sink_cr_entry=None, sink_csr_entry=None, endpoint="store_cr_and_csr()"): @@ -138,102 +228,112 @@ def store_cr_and_csr(source_slr_entry=None, sink_slr_entry=None, source_cr_entry try: # Get Source's SLR from DB try: + logger.info("Get Source SLR from database") cursor = source_slr_entry.from_db(cursor=cursor) except Exception as exp: error_title = "Failed to fetch Source's SLR from DB" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("source_slr_entry: " + source_slr_entry.log_entry) # Get Sink's SLR from DB try: + logger.info("Get Sink SLR from database") cursor = sink_slr_entry.from_db(cursor=cursor) except Exception as exp: error_title = "Failed to fetch Sink's SLR from DB" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_slr_entry: " + sink_slr_entry.log_entry) # Get Source's SLR ID try: + logger.info("Source SLR ID to Source CR") source_cr_entry.service_link_records_id = source_slr_entry.id except Exception as exp: error_title = "Failed to fetch Source's Service Link Record ID" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("source_cr_entry: " + source_cr_entry.log_entry) # Get Sink's SLR ID try: + logger.info("Sink SLR ID to Sink CR") sink_cr_entry.service_link_records_id = sink_slr_entry.id except Exception as exp: error_title = "Failed to fetch Sink's Service Link Record ID" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_cr_entry: " + sink_cr_entry.log_entry) # Store Source CR try: + logger.info("Store Source CR") cursor = source_cr_entry.to_db(cursor=cursor) except Exception as exp: error_title = "Failed to store Source's Consent Record" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("source_cr_entry: " + source_cr_entry.log_entry) # Link Source's CSR with it's CR try: + logger.info("Source CR ID to Source CSR") source_csr_entry.consent_records_id = source_cr_entry.id except Exception as exp: error_title = "Failed to link Source's CSR with it's CR" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug(source_csr_entry.log_entry) # Store Source CSR try: + logger.info("Store Source CSR") cursor = source_csr_entry.to_db(cursor=cursor) except Exception as exp: error_title = "Failed to store Source's Consent Status Record" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("source_csr_entry: " + source_csr_entry.log_entry) # Store Sink CR try: + logger.info("Store Sink CR") cursor = sink_cr_entry.to_db(cursor=cursor) except Exception as exp: error_title = "Failed to store Sink's Consent Record" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_cr_entry: " + sink_cr_entry.log_entry) # Link Sink's CSR with it's CR try: + logger.info("Sink CR ID to Sink CSR") sink_csr_entry.consent_records_id = sink_cr_entry.id except Exception as exp: error_title = "Failed to link Sink's CSR with it's CR" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_csr_entry: " + sink_csr_entry.log_entry) # Store Sink CSR try: + logger.info("Store Sink CSR") cursor = sink_csr_entry.to_db(cursor=cursor) except Exception as exp: error_title = "Failed to store Sink's Consent Status Record" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_csr_entry: " + sink_csr_entry.log_entry) # Commit @@ -246,23 +346,7 @@ def store_cr_and_csr(source_slr_entry=None, sink_slr_entry=None, source_cr_entry raise else: logger.info("CR's and CSR's commited") - - try: - data = { - 'source': { - 'CR': source_cr_entry.to_dict, - 'CSR': source_csr_entry.to_dict - }, - 'sink': { - 'CR': sink_cr_entry.to_dict, - 'CSR': sink_csr_entry.to_dict - } - } - except Exception as exp: - logger.error("Could not construct data object: "+ repr(exp)) - data = {} - else: - return data + return source_cr_entry, source_csr_entry, sink_cr_entry, sink_csr_entry def get_auth_token_data(sink_cr_object=None, endpoint="get_auth_token_data()"): @@ -278,69 +362,477 @@ def get_auth_token_data(sink_cr_object=None, endpoint="get_auth_token_data()"): # Get Sink's CR from DB try: + logger.info("Get Sink's CR from DB") cursor = sink_cr_object.from_db(cursor=cursor) except Exception as exp: error_title = "Failed to fetch Sink's CR from DB" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_cr_object: " + sink_cr_object.log_entry) # Get required id's from Sink's CR try: + logger.info("Get required id's from Sink's CR") sink_rs_id = str(sink_cr_object.resource_set_id) sink_slr_primary_key = str(sink_cr_object.service_link_records_id) except Exception as exp: error_title = "Failed to get id's from Sink's CR" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_rs_id: " + sink_rs_id) # Init Source's Consent Record Object try: + logger.info("Init Source's Consent Record Object") source_cr_entry = ConsentRecord(resource_set_id=sink_rs_id, role="Source") except Exception as exp: error_title = "Failed to create Source's Consent Record object" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("source_cr_entry: " + source_cr_entry.log_entry) # Get Source's Consent Record from DB - source_cr = {} try: + logger.info("Get Source's Consent Record from DB") cursor = source_cr_entry.from_db(cursor=cursor) - source_cr = source_cr_entry.consent_record except Exception as exp: error_title = "Failed to fetch Source's CR from DB" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("source_cr_entry: " + source_cr_entry.log_entry) - logger.debug("source_cr: " + json.dumps(source_cr)) # Init Sink's Service Link Record Object try: + logger.info("Init Sink's Service Link Record Object") sink_slr_entry = ServiceLinkRecord(id=sink_slr_primary_key) except Exception as exp: error_title = "Failed to create Source's Service Link Record object" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: - logger.debug("source_cr_entry: " + source_cr_entry.log_entry) + else: + logger.debug("sink_slr_entry: " + sink_slr_entry.log_entry) # Get Source's Consent Record from DB - sink_slr = {} try: + logger.info("Get Source's Consent Record from DB") cursor = sink_slr_entry.from_db(cursor=cursor) - sink_slr = sink_slr_entry.service_link_record except Exception as exp: error_title = "Failed to fetch Sink's SLR from DB" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_slr_entry: " + sink_slr_entry.log_entry) - logger.debug("sink_slr: " + json.dumps(sink_slr)) - return source_cr, json.loads(sink_slr) + return source_cr_entry, sink_slr_entry + + +def get_last_cr_status(cr_id=None, endpoint="get_last_cr_status()"): + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise ApiError(code=500, title="Failed to get database cursor", detail=repr(exp), source=endpoint) + + # Init Consent Record Object + try: + logger.info("Create ConsentRecord object") + cr_entry = ConsentRecord(consent_id=cr_id) + logger.info(cr_entry.log_entry) + except Exception as exp: + error_title = "Failed to create Consent Record object" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("sink_cr_entry: " + cr_entry.log_entry) + + # Get Consent Record from DB + try: + cursor = cr_entry.from_db(cursor=cursor) + except IndexError as exp: + error_title = "Consent Record not found from DB with given ID" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "Failed to fetch Consent Record from DB" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("cr_entry: " + cr_entry.log_entry) + + # Get Consent Record ID of cr_entry + try: + cr_entry_id = cr_entry.consent_id + except Exception as exp: + error_title = "Failed to get Consent Record ID from object" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("cr_entry_id: " + str(cr_entry_id)) + + # Create Consent Status Record object + try: + csr_entry = ConsentStatusRecord() + except Exception as exp: + error_title = "Failed to create Consent Status Record object" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("csr_entry: " + csr_entry.log_entry) + + # Get database table name for Consent Status Record + try: + logger.info("Get Consent Status Record table name") + csr_table_name = csr_entry.table_name + except Exception as exp: + error_title = "Failed to get Consent Status Record table name" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Got Consent Status Record table name: " + str(csr_table_name)) + + # Get Consent Status Record ID + try: + cursor, csr_id = get_last_csr_id(cursor=cursor, cr_id=cr_id, table_name=csr_table_name) + except IndexError as exp: + error_title = "Consent Status Record not found from DB with given Consent Record ID" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "Failed to get last Consent Status Record ID from database" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("csr_id: " + str(csr_id)) + + # Append ID to Consent Status Record Object + try: + logger.info("Append ID to Consent Status Record object: " + csr_entry.log_entry) + csr_entry.consent_status_record_id = csr_id + except Exception as exp: + error_title = "Failed to append ID to Consent Status Record object" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Appended ID to Consent Status Record object: " + csr_entry.log_entry) + + # Get Consent Status Record from DB + try: + cursor = csr_entry.from_db(cursor=cursor) + except IndexError as exp: + error_title = "Consent Record not found from DB with given ID" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "Failed to fetch Consent Record from DB" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("csr_entry: " + csr_entry.log_entry) + + return csr_entry + + +def add_csr(cr_id=None, csr_payload=None, endpoint="add_csr()"): + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + if csr_payload is None: + raise AttributeError("Provide csr_payload as parameter") + + ###### + # Base information + #### + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise ApiError(code=500, title="Failed to get database cursor", detail=repr(exp), source=endpoint) + + # IDs from CSR payload + try: + logger.info("Fetching IDs from CSR payload") + csr_surrogate_id = csr_payload['surrogate_id'] + csr_cr_id = csr_payload['cr_id'] + csr_prev_record_id = csr_payload['prev_record_id'] + csr_record_id = csr_payload['record_id'] + csr_consent_status = csr_payload['consent_status'] + csr_issued = csr_payload['iat'] + except Exception as exp: + error_title = "Could not fetch IDs from CSR payload" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Fetched IDs from CSR payload") + + # Verify that cr_id and csr_cr_id are the same + if cr_id != csr_cr_id: + error_title = "cr_id from URI and cr_id from payload are not identical" + logger.error(error_title + " | cr_id from URI: " + str(cr_id) + ", cr_id from payload: " + str(csr_cr_id)) + raise ApiError(code=400, title=error_title, source=endpoint) + else: + logger.info("Identical IDs: cr_id from URI: " + str(cr_id) + ", cr_id from payload: " + str(csr_cr_id)) + + + ###### + # Account ID + #### + try: + logger.info("Get Account ID by CSR_ID") + account_id = get_account_id_by_cr(cr_id=cr_id, endpoint=endpoint) + except Exception as exp: + logger.error("Could not Account ID by CSR_ID: " + repr(exp)) + raise + else: + logger.info("account_id: " + str(account_id)) + + ###### + # Sign + #### + # Sign CSR + try: + logger.info("Sign CSR") + csr_signed = sign_csr(account_id=account_id, payload=csr_payload, endpoint=endpoint) + except Exception as exp: + logger.error("Could not sign Source's CSR: " + repr(exp)) + raise + else: + logger.info("Source CR signed") + + ########### + # Entries # + ########### + # Existing Consent Record + ### + # Init Consent Record Object + try: + logger.info("Create ConsentRecord object") + cr_entry = ConsentRecord(consent_id=cr_id) + except Exception as exp: + error_title = "Failed to create Consent Record object" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("sink_cr_entry: " + cr_entry.log_entry) + + # Get Consent Record from DB + try: + logger.info("Get Consent Record from DB") + cursor = cr_entry.from_db(cursor=cursor) + except IndexError as exp: + error_title = "Consent Record not found from DB with given ID" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "Failed to fetch Consent Record from DB" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("cr_entry: " + cr_entry.log_entry) + + # Get primary key of Consent Record database entry + try: + logger.info("Get primary key of Consent Record database entry") + cr_entry_primary_key = cr_entry.id + except Exception as exp: + error_title = "Failed to get primary key of Consent Record database entry" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("cr_entry_primary_key: " + str(cr_entry_primary_key)) + + # CSR + try: + logger.info("Create ConsentStatusRecord object") + csr_entry = ConsentStatusRecord( + consent_status_record_id=csr_record_id, + status=csr_consent_status, + consent_status_record=csr_signed, + consent_record_id=cr_id, + issued_at=int(csr_issued), + prev_record_id=csr_prev_record_id, + consent_records_id=int(cr_entry_primary_key) + ) + except Exception as exp: + error_title = "Failed to create Source's Consent Status Record object" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("csr_entry: " + csr_entry.log_entry) + + ########### + # Store # + ########### + # CSR + + # Get database table name for Consent Status Record + try: + logger.info("Get Consent Status Record table name") + csr_table_name = csr_entry.table_name + except Exception as exp: + error_title = "Failed to get Consent Status Record table name" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("Got Consent Status Record table name: " + str(csr_table_name)) + + # Store CSR + try: + logger.info("Store ConsentStatusRecord") + try: + cursor = csr_entry.to_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to store Consent Status Record" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("csr_entry: " + csr_entry.log_entry) + + # Commit + db.connection.commit() + except Exception as exp: + logger.error('Consent Status Record Commit failed: ' + repr(exp)) + db.connection.rollback() + logger.error('--> rollback') + raise + else: + logger.info("Consent Status Record commited") + + return csr_entry + + +def get_csr(cr_id=None, csr_id=None, cursor=None): + """ + Get one csr entry from database by Account ID and ID + :param slr_id: + :param cr_id: + :param csr_id: + :return: dict + """ + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + if csr_id is None: + raise AttributeError("Provide csr_id as parameter") + if cursor is None: + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + try: + db_entry_object = ConsentStatusRecord(consent_record_id=cr_id, consent_status_record_id=csr_id) + except Exception as exp: + error_title = "Failed to create csr object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("csr object created: " + db_entry_object.log_entry) + + # Get csr from DB + try: + cursor = db_entry_object.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch csr from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("csr fetched") + logger.info("csr fetched from db: " + db_entry_object.log_entry) + + return db_entry_object.to_record_dict + + +def get_csrs(cr_id=None, last_csr_id=None): + """ + Get all csr -entries related to service link record + :param cr_id: + :return: List of dicts + """ + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + + # Get DB cursor + try: + cursor = get_db_cursor() + except Exception as exp: + logger.error('Could not get database cursor: ' + repr(exp)) + raise + + # Get CSR limit if necessary + if last_csr_id is None: + logger.info("No limiting CSR ID provided") + csr_primary_key = None + else: + csr_limit_id = last_csr_id + logger.info("csr_limit_id: " + str(csr_limit_id)) + + # Get primary key of limiting CSR + try: + logger.info("Create CSR object") + csr_entry = ConsentStatusRecord(consent_record_id=cr_id, consent_status_record_id=last_csr_id) + except Exception as exp: + error_title = "Failed to create csr object" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("csr object created: " + csr_entry.log_entry) + + # Get csr from DB + try: + cursor = csr_entry.from_db(cursor=cursor) + except Exception as exp: + error_title = "Failed to fetch csr from DB" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.info("csr fetched") + logger.info("csr fetched from db: " + csr_entry.log_entry) + + # Get primary key of Consent Record database entry + try: + logger.info("Get primary key of Consent Record database entry") + csr_primary_key = csr_entry.id + except Exception as exp: + error_title = "Failed to get primary key of Consent Record database entry" + logger.error(error_title + ": " + repr(exp)) + raise + + logger.debug("csr_primary_key: " + str(csr_primary_key)) + + # Get primary keys for csrs + try: + # Get table name + logger.info("Create csr") + db_entry_object = ConsentStatusRecord() + logger.info(db_entry_object.log_entry) + logger.info("Get table name") + table_name = db_entry_object.table_name + logger.info("Got table name: " + str(table_name)) + + cursor, id_list = get_csr_ids(cursor=cursor, cr_id=cr_id, csr_primary_key=csr_primary_key, table_name=table_name) + except Exception as exp: + logger.error('Could not get primary key list: ' + repr(exp)) + raise + + # Get csrs from database + logger.info("Get csrs from database") + db_entry_list = [] + for id in id_list: + # TODO: try-except needed? + logger.info("Getting csr with cr_id: " + str(cr_id) + " csr_id: " + str(id)) + db_entry_dict = get_csr(cr_id=cr_id, csr_id=id) + db_entry_list.append(db_entry_dict) + logger.info("csr object added to list: " + json.dumps(db_entry_dict)) + + return db_entry_list + + diff --git a/Account/app/mod_authorization/models.py b/Account/app/mod_authorization/models.py index 03be7f4..f98eeb3 100644 --- a/Account/app/mod_authorization/models.py +++ b/Account/app/mod_authorization/models.py @@ -15,14 +15,16 @@ from marshmallow import Schema, fields from marshmallow.validate import Equal, OneOf +STATUS_LIST = ["Active", "Disabled", "Withdrawn"] # List that contains status entries +# Consent Status Records class ConsentStatusAttributes(Schema): record_id = fields.Str(required=True) - account_id = fields.Str(required=True) + surrogate_id = fields.Str(required=True) cr_id = fields.Str(required=True) - consent_status = fields.Str(required=True) - iat = fields.Str(required=True) + consent_status = fields.Str(required=True, validate=OneOf(STATUS_LIST)) + iat = fields.Int(required=True) prev_record_id = fields.Str(required=True) @@ -31,19 +33,7 @@ class ConsentStatusPayload(Schema): attributes = fields.Nested(nested=ConsentStatusAttributes, required=True) -class CommonConsentAttributes(Schema): - version_number = fields.Str(required=True) - cr_id = fields.Str(required=True) - surrogate_id = fields.Str(required=True) - rs_id = fields.Str(required=True) - slr_id = fields.Str(required=True) - issued = fields.Str(required=True) - not_before = fields.Str(required=True) - not_after = fields.Str(required=True) - issued_at = fields.Str(required=True) - subject_id = fields.Str(required=True) - - +# Consent Records class DataSet(Schema): dataset_id = fields.Str(required=True) distribution_id = fields.Str(required=True) @@ -58,34 +48,69 @@ class ResourceSetDescription(Schema): resource_set = fields.Nested(nested=ResourceSet, required=True) +class SourceCommonConsentAttributes(Schema): + version = fields.Str(required=True) + cr_id = fields.Str(required=True) + surrogate_id = fields.Str(required=True) + rs_description = fields.Nested(nested=ResourceSetDescription, required=True) + slr_id = fields.Str(required=True) + iat = fields.Int(required=True) + nbf = fields.Int(required=True) + exp = fields.Int(required=True) + operator = fields.Str(required=True) + subject_id = fields.Str(required=True) + role = fields.Str(required=True, validate=Equal("Source")) + + +class SinkCommonConsentAttributes(Schema): + version = fields.Str(required=True) + cr_id = fields.Str(required=True) + surrogate_id = fields.Str(required=True) + rs_description = fields.Nested(nested=ResourceSetDescription, required=True) + slr_id = fields.Str(required=True) + iat = fields.Int(required=True) + nbf = fields.Int(required=True) + exp = fields.Int(required=True) + operator = fields.Str(required=True) + subject_id = fields.Str(required=True) + role = fields.Str(required=True, validate=Equal("Sink")) + + class SourceRoleSpecificAttributes(Schema): - role = fields.Str(required=True, validate=OneOf(["Source", "InternalProcessing"])) - auth_token_issuer_key = fields.Dict(required=True) - resource_set_description = fields.Nested(nested=ResourceSetDescription, required=True) + pop_key = fields.Dict(required=True) + token_issuer_key = fields.Dict(required=True) -class UsageRules(Schema): - rule = fields.Str(required=True) +# class UsageRules(Schema): +# rule = fields.Str(required=True) class SinkRoleSpecificAttributes(Schema): - role = fields.Str(required=True, validate=OneOf(["Sink", "InternalProcessing"])) #usage_rules = fields.Nested(nested=UsageRules, only=UsageRules.rule, many=True, required=True) usage_rules = fields.Field(required=True) + source_cr_id = fields.Str(required=True) -class SinkConsentAttributes(Schema): - common_part = fields.Nested(nested=CommonConsentAttributes, required=True) - role_specific_part = fields.Nested(nested=SinkRoleSpecificAttributes, required=True) +class ConsentReceiptPart(Schema): ki_cr = fields.Dict(required=True) + + +class ExtensionPart(Schema): extensions = fields.Dict(required=True) +class SinkConsentAttributes(Schema): + common_part = fields.Nested(nested=SinkCommonConsentAttributes, required=True) + role_specific_part = fields.Nested(nested=SinkRoleSpecificAttributes, required=True) + consent_receipt_part = fields.Nested(nested=ConsentReceiptPart, required=True) + extension_part = fields.Nested(nested=ExtensionPart, required=True) + + class SourceConsentAttributes(Schema): - common_part = fields.Nested(nested=CommonConsentAttributes, required=True) + common_part = fields.Nested(nested=SourceCommonConsentAttributes, required=True) role_specific_part = fields.Nested(nested=SourceRoleSpecificAttributes, required=True) - ki_cr = fields.Dict(required=True) - extensions = fields.Dict(required=True) + consent_receipt_part = fields.Nested(nested=ConsentReceiptPart, required=True) + extension_part = fields.Nested(nested=ExtensionPart, required=True) class SourceConsentPayload(Schema): @@ -116,3 +141,7 @@ class NewConsentData(Schema): class NewConsent(Schema): data = fields.Nested(nested=NewConsentData, required=True) + +class NewConsentStatus(Schema): + data = fields.Nested(nested=ConsentStatusPayload, required=True) + diff --git a/Account/app/mod_authorization/view_api.py b/Account/app/mod_authorization/view_api.py index e998f43..2255d12 100644 --- a/Account/app/mod_authorization/view_api.py +++ b/Account/app/mod_authorization/view_api.py @@ -37,8 +37,9 @@ verify_jws_signature_with_jwk from app.mod_database.helpers import get_db_cursor from app.mod_database.models import ServiceLinkRecord, ServiceLinkStatusRecord, ConsentRecord, ConsentStatusRecord -from app.mod_authorization.controllers import sign_cr, sign_csr, store_cr_and_csr, get_auth_token_data -from app.mod_authorization.models import NewConsent +from app.mod_authorization.controllers import sign_cr, sign_csr, store_cr_and_csr, get_auth_token_data, \ + get_last_cr_status, add_csr, get_csrs +from app.mod_authorization.models import NewConsent, NewConsentStatus mod_authorization_api = Blueprint('authorization_api', __name__, template_folder='templates') @@ -143,11 +144,11 @@ def post(self, account_id, source_slr_id, sink_slr_id): # Source CR try: source_cr_cr_id = source_cr_payload['common_part']['cr_id'] - source_cr_rs_id = source_cr_payload['common_part']['rs_id'] + source_cr_rs_id = source_cr_payload['common_part']['rs_description']['resource_set']['rs_id'] source_cr_slr_id = source_cr_payload['common_part']['slr_id'] source_cr_subject_id = source_cr_payload['common_part']['subject_id'] source_cr_surrogate_id = source_cr_payload['common_part']['surrogate_id'] - source_cr_role = source_cr_payload['role_specific_part']['role'] + source_cr_role = source_cr_payload['common_part']['role'] except Exception as exp: error_title = "Could not fetch IDs from Source CR payload" raise @@ -155,11 +156,12 @@ def post(self, account_id, source_slr_id, sink_slr_id): # Source CSR try: - source_csr_surrogate_id = source_csr_payload['account_id'] + source_csr_surrogate_id = source_csr_payload['surrogate_id'] source_csr_cr_id = source_csr_payload['cr_id'] source_csr_prev_record_id = source_csr_payload['prev_record_id'] source_csr_record_id = source_csr_payload['record_id'] source_csr_consent_status = source_csr_payload['consent_status'] + source_csr_issued = source_csr_payload['iat'] except Exception as exp: error_title = "Could not fetch IDs from Source CSR payload" raise @@ -167,22 +169,23 @@ def post(self, account_id, source_slr_id, sink_slr_id): # Sink CR try: sink_cr_cr_id = sink_cr_payload['common_part']['cr_id'] - sink_cr_rs_id = sink_cr_payload['common_part']['rs_id'] + sink_cr_rs_id = sink_cr_payload['common_part']['rs_description']['resource_set']['rs_id'] sink_cr_slr_id = sink_cr_payload['common_part']['slr_id'] sink_cr_subject_id = sink_cr_payload['common_part']['subject_id'] sink_cr_surrogate_id = sink_cr_payload['common_part']['surrogate_id'] - sink_cr_role = sink_cr_payload['role_specific_part']['role'] + sink_cr_role = sink_cr_payload['common_part']['role'] except Exception as exp: error_title = "Could not fetch IDs from Sink CR payload" raise # Sink CSR try: - sink_csr_surrogate_id = sink_csr_payload['account_id'] + sink_csr_surrogate_id = sink_csr_payload['surrogate_id'] sink_csr_cr_id = sink_csr_payload['cr_id'] sink_csr_prev_record_id = sink_csr_payload['prev_record_id'] sink_csr_record_id = sink_csr_payload['record_id'] sink_csr_consent_status = sink_csr_payload['consent_status'] + sink_csr_issued = sink_csr_payload['iat'] except Exception as exp: error_title = "Could not fetch IDs from Sink CSR payload" raise @@ -200,46 +203,52 @@ def post(self, account_id, source_slr_id, sink_slr_id): # Sign Source CR try: - source_cr_signed, source_cr_issued = sign_cr(account_id=account_id, payload=source_cr_payload, endpoint=endpoint) + source_cr_signed = sign_cr(account_id=account_id, payload=source_cr_payload, endpoint=endpoint) except Exception as exp: logger.error("Could not sign Source's CR: " + repr(exp)) raise else: logger.info("Source CR signed") + logger.debug("source_cr_signed: " + json.dumps(source_cr_signed)) # Sign Source CSR try: - source_csr_signed, source_csr_issued = sign_csr(account_id=account_id, payload=source_csr_payload, endpoint=endpoint) + source_csr_signed = sign_csr(account_id=account_id, payload=source_csr_payload, endpoint=endpoint) except Exception as exp: logger.error("Could not sign Source's CSR: " + repr(exp)) raise else: - logger.info("Source CR signed") + logger.info("Source CSR signed") + logger.debug("source_csr_signed: " + json.dumps(source_csr_signed)) # Sign Sink CR try: - sink_cr_signed, sink_cr_issued = sign_cr(account_id=account_id, payload=sink_cr_payload, endpoint=endpoint) + sink_cr_signed = sign_cr(account_id=account_id, payload=sink_cr_payload, endpoint=endpoint) except Exception as exp: logger.error("Could not sign Source's CR: " + repr(exp)) raise else: logger.info("Sink's CR signed") + logger.debug("sink_cr_signed: " + json.dumps(sink_cr_signed)) # Sign Sink CSR try: - sink_csr_signed, sink_csr_issued = sign_csr(account_id=account_id, payload=sink_csr_payload, endpoint=endpoint) + sink_csr_signed = sign_csr(account_id=account_id, payload=sink_csr_payload, endpoint=endpoint) except Exception as exp: logger.error("Could not sign Sink's CSR: " + repr(exp)) raise else: logger.info("Sink's CSR signed") + logger.debug("sink_csr_signed: " + json.dumps(sink_csr_signed)) ######### # Store # ######### + logger.info("Creating objects to store") # Source SLR try: + logger.info("Creating Source SLR") source_slr_entry = ServiceLinkRecord( surrogate_id=source_cr_surrogate_id, account_id=account_id, @@ -249,9 +258,13 @@ def post(self, account_id, source_slr_id, sink_slr_id): error_title = "Failed to create Source's Service Link Record object" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("source_slr_entry created") + logger.info("source_slr_entry: " + source_slr_entry.log_entry) # Sink SLR try: + logger.info("Creating Sink SLR") sink_slr_entry = ServiceLinkRecord( surrogate_id=sink_cr_surrogate_id, account_id=account_id, @@ -261,9 +274,13 @@ def post(self, account_id, source_slr_id, sink_slr_id): error_title = "Failed to create Sink's Service Link Record object" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("sink_slr_entry created") + logger.info("sink_slr_entry: " + sink_slr_entry.log_entry) # Source CR try: + logger.info("Creating Source CR") source_cr_entry = ConsentRecord( consent_record=source_cr_signed, consent_id=source_cr_cr_id, @@ -277,9 +294,13 @@ def post(self, account_id, source_slr_id, sink_slr_id): error_title = "Failed to create Source's Consent Record object" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("source_cr_entry created") + logger.info("source_cr_entry: " + source_cr_entry.log_entry) # Sink CR try: + logger.info("Creating Sink CR") sink_cr_entry = ConsentRecord( consent_record=sink_cr_signed, consent_id=sink_cr_cr_id, @@ -293,10 +314,15 @@ def post(self, account_id, source_slr_id, sink_slr_id): error_title = "Failed to create Sink's Consent Record object" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("sink_cr_entry created") + logger.info("sink_cr_entry: " + sink_cr_entry.log_entry) # Source CSR try: + logger.info("Creating Source CSR") source_csr_entry = ConsentStatusRecord( + consent_status_record_id=source_csr_record_id, status=source_csr_consent_status, consent_status_record=source_csr_signed, consent_record_id=source_csr_cr_id, @@ -307,10 +333,15 @@ def post(self, account_id, source_slr_id, sink_slr_id): error_title = "Failed to create Source's Consent Status Record object" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("source_csr_entry created") + logger.info("source_csr_entry: " + source_csr_entry.log_entry) # Sink CSR try: + logger.info("Creating Sink CSR") sink_csr_entry = ConsentStatusRecord( + consent_status_record_id=sink_csr_record_id, status=sink_csr_consent_status, consent_status_record=sink_csr_signed, consent_record_id=sink_csr_cr_id, @@ -321,10 +352,14 @@ def post(self, account_id, source_slr_id, sink_slr_id): error_title = "Failed to create Sink's Consent Status Record object" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("sink_csr_entry created") + logger.info("sink_csr_entry: " + sink_csr_entry.log_entry) # Store CRs and CSRs try: - db_meta = store_cr_and_csr( + logger.info("About to store Consent Records and Consent Status Records") + stored_source_cr_entry, stored_source_csr_entry, stored_sink_cr_entry, stored_sink_csr_entry = store_cr_and_csr( source_slr_entry=source_slr_entry, sink_slr_entry=sink_slr_entry, source_cr_entry=source_cr_entry, @@ -339,7 +374,10 @@ def post(self, account_id, source_slr_id, sink_slr_id): raise else: logger.info("Stored Consent Record and Consent Status Record") - logger.debug("DB Meta: " + json.dumps(db_meta)) + logger.info("Source CR: " + stored_source_cr_entry.log_entry) + logger.info("Source CSR: " + stored_source_csr_entry.log_entry) + logger.info("Sink CR: " + stored_sink_cr_entry.log_entry) + logger.info("Sink CSR: " + stored_sink_csr_entry.log_entry) # Response data container try: @@ -350,33 +388,33 @@ def post(self, account_id, source_slr_id, sink_slr_id): response_data['data']['source']['consentRecord'] = {} response_data['data']['source']['consentRecord']['type'] = "ConsentRecord" response_data['data']['source']['consentRecord']['attributes'] = {} - response_data['data']['source']['consentRecord']['attributes']['cr'] = json.loads(source_cr_signed) + response_data['data']['source']['consentRecord']['attributes']['cr'] = stored_source_cr_entry.to_record_dict response_data['data']['source']['consentStatusRecord'] = {} response_data['data']['source']['consentStatusRecord']['type'] = "ConsentStatusRecord" response_data['data']['source']['consentStatusRecord']['attributes'] = {} - response_data['data']['source']['consentStatusRecord']['attributes']['csr'] = json.loads(source_csr_signed) + response_data['data']['source']['consentStatusRecord']['attributes']['csr'] = stored_source_csr_entry.to_record_dict response_data['data']['sink'] = {} response_data['data']['sink']['consentRecord'] = {} response_data['data']['sink']['consentRecord']['type'] = "ConsentRecord" response_data['data']['sink']['consentRecord']['attributes'] = {} - response_data['data']['sink']['consentRecord']['attributes']['cr'] = json.loads(sink_cr_signed) + response_data['data']['sink']['consentRecord']['attributes']['cr'] = stored_sink_cr_entry.to_record_dict response_data['data']['sink']['consentStatusRecord'] = {} response_data['data']['sink']['consentStatusRecord']['type'] = "ConsentStatusRecord" response_data['data']['sink']['consentStatusRecord']['attributes'] = {} - response_data['data']['sink']['consentStatusRecord']['attributes']['csr'] = json.loads(sink_csr_signed) + response_data['data']['sink']['consentStatusRecord']['attributes']['csr'] = stored_sink_csr_entry.to_record_dict except Exception as exp: logger.error('Could not prepare response data: ' + repr(exp)) raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) else: logger.info('Response data ready') - logger.debug('response_data: ' + repr(response_data)) + logger.debug('response_data: ' + json.dumps(response_data)) response_data_dict = dict(response_data) - logger.debug('response_data_dict: ' + repr(response_data_dict)) + logger.debug('response_data_dict: ' + json.dumps(response_data_dict)) return make_json_response(data=response_data_dict, status_code=201) @@ -400,7 +438,7 @@ def get(self, sink_cr_id): sink_cr_id = str(sink_cr_id) except Exception as exp: raise ApiError(code=400, title="Unsupported sink_cr_id", detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_cr_id: " + repr(sink_cr_id)) # Init Sink's Consent Record Object @@ -410,21 +448,18 @@ def get(self, sink_cr_id): error_title = "Failed to create Sink's Consent Record object" logger.error(error_title + ": " + repr(exp)) raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: + else: logger.debug("sink_cr_entry: " + sink_cr_entry.log_entry) - source_cr = {} - sink_slr = {} try: source_cr, sink_slr = get_auth_token_data(sink_cr_object=sink_cr_entry) except Exception as exp: error_title = "Failed to get Authorization token data" logger.error(error_title + ": " + repr(exp)) - #raise raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) - finally: - logger.debug("source_cr: " + json.dumps(source_cr)) - logger.debug("sink_slr: " + json.dumps(sink_slr)) + else: + logger.debug("source_cr: " + source_cr.log_entry) + logger.debug("sink_slr: " + sink_slr.log_entry) # Response data container @@ -436,27 +471,226 @@ def get(self, sink_cr_id): response_data['data']['source']['consentRecord'] = {} response_data['data']['source']['consentRecord']['type'] = "ConsentRecord" response_data['data']['source']['consentRecord']['attributes'] = {} - response_data['data']['source']['consentRecord']['attributes']['cr'] = source_cr + response_data['data']['source']['consentRecord']['attributes']['cr'] = source_cr.to_record_dict response_data['data']['sink'] = {} response_data['data']['sink']['serviceLinkRecord'] = {} response_data['data']['sink']['serviceLinkRecord']['type'] = "ServiceLinkRecord" response_data['data']['sink']['serviceLinkRecord']['attributes'] = {} - response_data['data']['sink']['serviceLinkRecord']['attributes']['slr'] = sink_slr + response_data['data']['sink']['serviceLinkRecord']['attributes']['slr'] = sink_slr.to_record_dict except Exception as exp: logger.error('Could not prepare response data: ' + repr(exp)) raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) else: logger.info('Response data ready') - logger.debug('response_data: ' + repr(response_data)) + logger.debug('response_data: ' + json.dumps(response_data)) response_data_dict = dict(response_data) - logger.debug('response_data_dict: ' + repr(response_data_dict)) + logger.debug('response_data_dict: ' + json.dumps(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class LastCrStatus(Resource): + @requires_api_auth_sdk + def get(self, cr_id): + + try: + endpoint = str(api.url_for(self, cr_id=cr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers") + logger.debug("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + + try: + cr_id = str(cr_id) + except Exception as exp: + raise ApiError(code=400, title="Unsupported cr_id", detail=repr(exp), source=endpoint) + else: + logger.debug("cr_id: " + repr(cr_id)) + + # Get last Consent Status Record + try: + last_csr_object = get_last_cr_status(cr_id=cr_id) + except Exception as exp: + error_title = "Failed to get last Consent Status Record of Consent" + logger.error(error_title + ": " + repr(exp)) + raise + else: + logger.debug("last_cr_status_object: " + last_csr_object.log_entry) + + # Response data container + try: + response_data = {} + response_data['data'] = last_csr_object.to_record_dict + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + json.dumps(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + json.dumps(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) + + +class CrStatus(Resource): + @requires_api_auth_sdk + def post(self, cr_id): + logger.info("CrStatus") + try: + endpoint = str(api.url_for(self, cr_id=cr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers") + logger.debug("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + + try: + cr_id = str(cr_id) + except Exception as exp: + raise ApiError(code=400, title="Unsupported cr_id", detail=repr(exp), source=endpoint) + else: + logger.debug("cr_id: " + repr(cr_id)) + + # load JSON + json_data = request.get_json() + if not json_data: + error_detail = {'0': 'Set application/json as Content-Type', '1': 'Provide json payload'} + raise ApiError(code=400, title="No input data provided", detail=error_detail, source=endpoint) + else: + logger.debug("json_data: " + json.dumps(json_data)) + + # Validate payload content + schema = NewConsentStatus() + schema_validation_result = schema.load(json_data) + + # Check validation errors + if schema_validation_result.errors: + logger.error("Invalid payload") + raise ApiError(code=400, title="Invalid payload", detail=dict(schema_validation_result.errors), source=endpoint) + else: + logger.debug("JSON validation -> OK") + + # Payload + # Consent Status Record + try: + csr_payload = json_data['data']['attributes'] + except Exception as exp: + raise ApiError(code=400, title="Could not fetch source_csr_payload from json", detail=repr(exp), source=endpoint) + else: + logger.debug("Got csr_payload: " + json.dumps(csr_payload)) + + # + # Create new Consent Status Record + try: + new_csr_object = add_csr(cr_id=cr_id, csr_payload=csr_payload, endpoint=endpoint) + except ApiError as exp: + error_title = "Failed to add new Consent Status Record for Consent" + logger.error(error_title + ": " + repr(exp)) + raise + except Exception as exp: + error_title = "Unexpected error. Failed to add new Consent Status Record for Consent" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=500, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.debug("new_csr_object: " + new_csr_object.log_entry) + + # Response data container + try: + response_data = {} + response_data['data'] = new_csr_object.to_record_dict + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + json.dumps(response_data)) + + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + json.dumps(response_data_dict)) return make_json_response(data=response_data_dict, status_code=201) + @requires_api_auth_sdk + def get(self, cr_id): + logger.info("CrStatus") + try: + endpoint = str(api.url_for(self, cr_id=cr_id)) + except Exception as exp: + endpoint = str(__name__) + + try: + logger.info("Fetching Api-Key from Headers") + api_key = request.headers.get('Api-Key') + except Exception as exp: + logger.error("No ApiKey in headers: " + repr(repr(exp))) + return provideApiKey(endpoint=endpoint) + else: + logger.info("Api-Key: " + api_key) + + try: + cr_id = str(cr_id) + except Exception as exp: + error_title = "Unsupported cr_id" + logger.error(error_title) + raise ApiError(code=400, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("cr_id: " + cr_id) + + # Get last CSR ID from query parameters + try: + logger.info("Get last CSR ID from query parameters") + last_csr_id = request.args.get('csr_id', None) + except Exception as exp: + error_title = "Unexpected error when getting last CSR ID from query parameters" + logger.error(error_title + " " + repr(exp)) + raise ApiError(code=403, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("last_csr_id: " + repr(last_csr_id)) + + # Get ConsentStatusRecords + try: + logger.info("Fetching ConsentStatusRecords") + db_entries = get_csrs(cr_id=cr_id, last_csr_id=last_csr_id) + except StandardError as exp: + error_title = "ConsentStatusRecords not accessible" + logger.error(error_title + ": " + repr(exp)) + raise ApiError(code=403, title=error_title, detail=repr(exp), source=endpoint) + except Exception as exp: + error_title = "No ConsentStatusRecords found" + logger.error(error_title + repr(exp)) + raise ApiError(code=404, title=error_title, detail=repr(exp), source=endpoint) + else: + logger.info("ConsentStatusRecords Fetched") + + # Response data container + try: + db_entry_list = db_entries + response_data = {} + response_data['data'] = db_entry_list + except Exception as exp: + logger.error('Could not prepare response data: ' + repr(exp)) + raise ApiError(code=500, title="Could not prepare response data", detail=repr(exp), source=endpoint) + else: + logger.info('Response data ready') + logger.debug('response_data: ' + repr(response_data)) + response_data_dict = dict(response_data) + logger.debug('response_data_dict: ' + repr(response_data_dict)) + return make_json_response(data=response_data_dict, status_code=200) # Register resources api.add_resource(ConsentSignAndStore, '/api/account//servicelink///consent/', endpoint='mydata-authorization') api.add_resource(AuthorizationTokenData, '/api/consent//authorizationtoken/', endpoint='mydata-authorizationtoken') +api.add_resource(LastCrStatus, '/api/consent//status/last/', endpoint='mydata-last-cr') +api.add_resource(CrStatus, '/api/consent//status/', endpoint='mydata-csr') diff --git a/Account/app/mod_blackbox/controllers.py b/Account/app/mod_blackbox/controllers.py index 2942852..56d0498 100644 --- a/Account/app/mod_blackbox/controllers.py +++ b/Account/app/mod_blackbox/controllers.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ -Minimum viable Key management +Minimum viable Key management. NOT FOR PRODUCTION USE. __author__ = "Jani Yli-Kantola" __copyright__ = "" @@ -283,11 +283,7 @@ def sign_jws_with_jwk(account_id=None, jws_json_to_sign=None): if account_id is None: raise AttributeError("Provide account_id or as parameter") if jws_json_to_sign is None: - # raise AttributeError("Provide jws_to_sign or as parameter") - # Fake request payload to json - # TODO: Following two lines, NOT FOR PRODUCTION - jws_json_to_sign = json.dumps(SLR_PAYLOAD['slr']) - logger.info("No jws_json_to_sign provided as parameter. Using SLR_PAYLOAD -template instead.") + raise AttributeError("Provide jws_json_to_sign or as parameter") # jws_json_to_sign to dict try: @@ -419,10 +415,7 @@ def verify_jws_signature_with_jwk(account_id=None, jws_json_to_verify=None): if account_id is None: raise AttributeError("Provide account_id or as parameter") if jws_json_to_verify is None: - # raise AttributeError("Provide jws_to_sign or as parameter") - # TODO: Following two lines, NOT FOR PRODUCTION - jws_json_to_verify = sign_jws_with_jwk(account_id=account_id) - logger.info("No jws_json_to_sign provided as parameter. Using SLR_PAYLOAD -template instead.") + raise AttributeError("Provide jws_json_to_verify or as parameter") # Prepare JWS for signing try: @@ -480,10 +473,7 @@ def generate_and_sign_jws(account_id=None, jws_payload=None): if account_id is None: raise AttributeError("Provide account_id or as parameter") if jws_payload is None: - # raise AttributeError("Provide jws_to_sign or as parameter") - # TODO: Following two lines, NOT FOR PRODUCTION - jws_payload = CR_CSR_PAYLOAD['sink']['cr'] - logger.info("No jws_payload provided as parameter. Using CR_CSR_PAYLOAD -template instead.") + raise AttributeError("Provide jws_payload or as parameter") # Prepare database connection try: diff --git a/Account/app/mod_blackbox/helpers.py b/Account/app/mod_blackbox/helpers.py index 5ff1c4b..c716f75 100644 --- a/Account/app/mod_blackbox/helpers.py +++ b/Account/app/mod_blackbox/helpers.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- """ +Minimum viable Key management. NOT FOR PRODUCTION USE. + + __author__ = "Jani Yli-Kantola" __copyright__ = "" __credits__ = ["Harri Hirvonsalo", "Aleksi Palomäki"] diff --git a/Account/app/mod_blackbox/services.py b/Account/app/mod_blackbox/services.py index ab3ef75..85f5071 100644 --- a/Account/app/mod_blackbox/services.py +++ b/Account/app/mod_blackbox/services.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- """ +Minimum viable Key management. NOT FOR PRODUCTION USE. + + __author__ = "Jani Yli-Kantola" __copyright__ = "" __credits__ = ["Harri Hirvonsalo", "Aleksi Palomäki"] @@ -68,7 +71,7 @@ def init_sqlite_db(connection=None): id INTEGER PRIMARY KEY AUTOINCREMENT, kid TEXT UNIQUE NOT NULL, account_id INTEGER UNIQUE NOT NULL, - jws_key BLOB NOT NULL + jwk BLOB NOT NULL );''' try: @@ -229,7 +232,7 @@ def store_jwk_to_db(account_id=None, account_kid=None, account_key=None, cursor= if cursor is None: raise AttributeError("Provide cursor as parameter") - sql_query = "INSERT INTO account_keys (kid, account_id, jws_key) VALUES ('%s', '%s', '%s')" % \ + sql_query = "INSERT INTO account_keys (kid, account_id, jwk) VALUES ('%s', '%s', '%s')" % \ (account_kid, account_id, account_key) try: @@ -258,8 +261,7 @@ def get_key(account_id=None, cursor=None): jwk_dict = {} - # TODO: Fix field name in SQL jws_key-> jwk - sql_query = "SELECT id, kid, account_id, jws_key FROM account_keys WHERE account_id='%s' ORDER BY id DESC LIMIT 1" % (account_id) + sql_query = "SELECT id, kid, account_id, jwk FROM account_keys WHERE account_id='%s' ORDER BY id DESC LIMIT 1" % (account_id) try: cursor, data = execute_sql_select(sql_query=sql_query, cursor=cursor) @@ -472,11 +474,10 @@ def jws_generate(payload=None): if payload is None: raise AttributeError("Provide payload as parameter") - payload_json = json.dumps(payload) - logger.debug('payload_json: ' + payload_json) + logger.debug('payload: ' + payload) try: - jws_object = jws.JWS(payload=payload_json) + jws_object = jws.JWS(payload=payload) except Exception as exp: exp = append_description_to_exception(exp=exp, description='Could not generate JWS object with payload') logger.error('Could not generate JWS object with payload: ' + repr(exp)) @@ -576,7 +577,7 @@ def jws_sign(account_id=None, account_kid=None, jws_object=None, jwk_object=None raise AttributeError("Provide alg as parameter") try: - unprotected_header = {'kid': account_kid, 'jwk': json.loads(jwk_public_json)} + unprotected_header = {'kid': account_kid} protected_header = {'alg': alg} unprotected_header_json = json.dumps(unprotected_header) protected_header_json = json.dumps(protected_header) diff --git a/Account/app/mod_database/helpers.py b/Account/app/mod_database/helpers.py index 73c6fd6..9929732 100644 --- a/Account/app/mod_database/helpers.py +++ b/Account/app/mod_database/helpers.py @@ -9,10 +9,26 @@ # create logger with 'spam_application' from app.helpers import get_custom_logger -logger = get_custom_logger('mod_database_helpers') +logger = get_custom_logger(__name__) + + +def log_query(sql_query=None, arguments=None): + logger.info("Executing") + if sql_query is None: + raise AttributeError("Provide sql_query as parameter") + if arguments is None: + raise AttributeError("Provide arguments as parameter") + + logger.debug('sql_query: ' + repr(sql_query)) + + for index in range(len(arguments)): + logger.debug("arguments[" + str(index) + "]: " + str(arguments[index])) + + logger.debug('SQL query to execute: ' + repr(sql_query % arguments)) def get_db_cursor(): + logger.info("Executing") try: cursor = db.connection.cursor() except Exception as exp: @@ -32,6 +48,7 @@ def execute_sql_insert(cursor, sql_query): INSERT to MySQL """ + logger.info("Executing") last_id = "" @@ -66,18 +83,17 @@ def execute_sql_insert_2(cursor, sql_query, arguments): INSERT to MySQL """ + logger.info("Executing") last_id = "" - logger.debug('sql_query: ' + str(sql_query)) - - for index in range(len(arguments)): - logger.debug("arguments[" + str(index) + "]: " + str(arguments[index])) + log_query(sql_query=sql_query, arguments=arguments) try: # Should be done like here: http://stackoverflow.com/questions/3617052/escape-string-python-for-mysql/27575399#27575399 cursor.execute(sql_query, (arguments)) - + logger.debug("Executed SQL query: " + str(cursor._last_executed)) + logger.debug("Affected rows: " + str(cursor.rowcount)) except Exception as exp: logger.debug('Error in SQL query execution: ' + repr(exp)) raise @@ -93,6 +109,35 @@ def execute_sql_insert_2(cursor, sql_query, arguments): return cursor, last_id +def execute_sql_update(cursor, sql_query, arguments): + """ + :param arguments: + :param cursor: + :param sql_query: + :return: cursor: + + INSERT to MySQL + """ + logger.info("Executing") + + logger.debug('sql_query: ' + str(sql_query)) + + for index in range(len(arguments)): + logger.debug("arguments[" + str(index) + "]: " + str(arguments[index])) + + try: + # Should be done like here: http://stackoverflow.com/questions/3617052/escape-string-python-for-mysql/27575399#27575399 + cursor.execute(sql_query, (arguments)) + logger.debug("Executed SQL query: " + str(cursor._last_executed)) + logger.debug("Affected rows SQL query: " + str(cursor.rowcount)) + except Exception as exp: + logger.debug('Error in SQL query execution: ' + repr(exp)) + raise + else: + logger.debug('db entry updated') + return cursor + + def execute_sql_select(cursor=None, sql_query=None): """ :param cursor: @@ -102,6 +147,7 @@ def execute_sql_select(cursor=None, sql_query=None): SELECT from MySQL """ + logger.info("Executing") if app.config["SUPER_DEBUG"]: logger.debug('sql_query: ' + repr(sql_query)) @@ -134,13 +180,15 @@ def execute_sql_select_2(cursor=None, sql_query=None, arguments=None): SELECT from MySQL """ + logger.info("Executing") - if app.config["SUPER_DEBUG"]: - logger.debug('sql_query: ' + repr(sql_query)) + log_query(sql_query=sql_query, arguments=arguments) try: - cursor.execute(sql_query, (arguments)) + cursor.execute(sql_query, (arguments)) + logger.debug("Executed SQL query: " + str(cursor._last_executed)) + logger.debug("Affected rows: " + str(cursor.rowcount)) except Exception as exp: logger.debug('Error in SQL query execution: ' + repr(exp)) raise @@ -151,8 +199,7 @@ def execute_sql_select_2(cursor=None, sql_query=None, arguments=None): logger.debug('cursor.fetchall() failed: ' + repr(exp)) data = 'No content' - if app.config["SUPER_DEBUG"]: - logger.debug('data ' + repr(data)) + logger.debug('data ' + repr(data)) return cursor, data @@ -166,6 +213,7 @@ def execute_sql_count(cursor=None, sql_query=None): SELECT from MySQL """ + logger.info("Executing") consent_count = 0 @@ -201,6 +249,7 @@ def drop_table_content(): Drop table content """ + logger.info("Executing") try: cursor = get_db_cursor() @@ -211,8 +260,9 @@ def drop_table_content(): sql_query = "SELECT Concat('TRUNCATE TABLE ',table_schema,'.',TABLE_NAME, ';') " \ "FROM INFORMATION_SCHEMA.TABLES where table_schema in ('MyDataAccount');" - sql_query1 = "SELECT Concat('DELETE FROM ',table_schema,'.',TABLE_NAME, '; ALTER TABLE ',table_schema,'.',TABLE_NAME, ' AUTO_INCREMENT = 1;') " \ - "FROM INFORMATION_SCHEMA.TABLES where table_schema in ('MyDataAccount');" + # sql_query1 = "SELECT Concat('DELETE FROM ',table_schema,'.',TABLE_NAME, '; ALTER TABLE ',table_schema,'.',TABLE_NAME, ' AUTO_INCREMENT = 1;') " \ + # "FROM INFORMATION_SCHEMA.TABLES where table_schema in ('MyDataAccount');" + # TODO: Remove two upper rows try: cursor.execute(sql_query) @@ -248,3 +298,314 @@ def drop_table_content(): cursor.execute("SET FOREIGN_KEY_CHECKS = 1;") return True + + +def get_primary_keys_by_account_id(cursor=None, account_id=None, table_name=None): + logger.info("Executing") + if cursor is None: + raise AttributeError("Provide cursor as parameter") + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if table_name is None: + raise AttributeError("Provide table_name as parameter") + + sql_query = "SELECT id " \ + "FROM " + table_name + " " \ + "WHERE Accounts_id LIKE %s;" + + arguments = ( + '%' + str(account_id) + '%', + ) + + try: + cursor, data = execute_sql_select_2(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.debug("Got data: " + repr(data)) + + if len(data) == 0: + logger.error("IndexError('DB query returned no results')") + raise IndexError("DB query returned no results") + + logger.debug("Got data[0]: " + repr(data[0])) + data_list = list(data[0]) + logger.info("Got data_list: " + repr(data_list)) + + for i in range(len(data_list)): + data_list[i] = str(data_list[i]) + + id_list = data_list + logger.info("Got id_list: " + repr(id_list)) + + return cursor, id_list + + +def get_slr_ids(cursor=None, account_id=None, table_name=None): + logger.info("Executing") + if cursor is None: + raise AttributeError("Provide cursor as parameter") + if account_id is None: + raise AttributeError("Provide account_id as parameter") + if table_name is None: + raise AttributeError("Provide table_name as parameter") + + sql_query = "SELECT serviceLinkRecordId " \ + "FROM " + table_name + " " \ + "WHERE Accounts_id LIKE %s;" + + arguments = ( + '%' + str(account_id) + '%', + ) + + try: + cursor, data = execute_sql_select_2(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.debug("Got data: " + repr(data)) + #logger.debug("Got data[0]: " + repr(data[0])) + data_list = list(data) + logger.info("Got data_list: " + repr(data_list)) + + if len(data) == 0: + logger.error("IndexError('DB query returned no results')") + raise IndexError("DB query returned no results") + + for i in range(len(data_list)): + data_list[i] = str(data_list[i][0]) + logger.info("Formatted data_list: " + repr(data_list)) + + id_list = data_list + logger.info("Got id_list: " + repr(id_list)) + + return cursor, id_list + + +def get_slsr_ids(cursor=None, slr_id=None, table_name=None): + logger.info("Executing") + if cursor is None: + raise AttributeError("Provide cursor as parameter") + if slr_id is None: + raise AttributeError("Provide slr_id as parameter") + if table_name is None: + raise AttributeError("Provide table_name as parameter") + + sql_query = "SELECT serviceLinkStatusRecordId " \ + "FROM " + table_name + " " \ + "WHERE serviceLinkRecordId LIKE %s;" + + arguments = ( + '%' + str(slr_id) + '%', + ) + + try: + cursor, data = execute_sql_select_2(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.debug("Got data: " + repr(data)) + + if len(data) == 0: + logger.error("IndexError('DB query returned no results')") + raise IndexError("DB query returned no results") + + logger.debug("Got data[0]: " + repr(data[0])) + data_list = list(data[0]) + logger.info("Got data_list: " + repr(data_list)) + + for i in range(len(data_list)): + data_list[i] = str(data_list[i]) + + id_list = data_list + logger.info("Got id_list: " + repr(id_list)) + + return cursor, id_list + + +def get_cr_ids(cursor=None, slr_id=None, table_name=None): + logger.info("Executing") + if cursor is None: + raise AttributeError("Provide cursor as parameter") + if slr_id is None: + raise AttributeError("Provide slr_id as parameter") + if table_name is None: + raise AttributeError("Provide table_name as parameter") + + sql_query = "SELECT consentRecordId " \ + "FROM " + table_name + " " \ + "WHERE serviceLinkRecordId LIKE %s;" + + arguments = ( + '%' + str(slr_id) + '%', + ) + + try: + cursor, data = execute_sql_select_2(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.debug("Got data: " + repr(data)) + + if len(data) == 0: + logger.error("IndexError('DB query returned no results')") + raise IndexError("DB query returned no results") + + logger.debug("Got data[0]: " + repr(data[0])) + data_list = list(data[0]) + logger.info("Got data_list: " + repr(data_list)) + + for i in range(len(data_list)): + data_list[i] = str(data_list[i]) + + id_list = data_list + logger.info("Got id_list: " + repr(id_list)) + + return cursor, id_list + + +def get_csr_ids(cursor=None, cr_id=None, csr_primary_key=None, table_name=None): + logger.info("Executing") + if cursor is None: + raise AttributeError("Provide cursor as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + if table_name is None: + raise AttributeError("Provide table_name as parameter") + if csr_primary_key is None: + sql_query = "SELECT consentStatusRecordId " \ + "FROM " + table_name + " " \ + "WHERE consentRecordId LIKE %s;" + + arguments = ( + '%' + str(cr_id) + '%', + ) + else: + sql_query = "SELECT consentStatusRecordId " \ + "FROM " + table_name + " " \ + "WHERE consentRecordId LIKE %s AND id > %s;" + + arguments = ( + '%' + str(cr_id) + '%', + int(csr_primary_key), + ) + + try: + cursor, data = execute_sql_select_2(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.debug("Got data: " + repr(data)) + + if len(data) == 0: + logger.error("IndexError('DB query returned no results')") + raise IndexError("DB query returned no results") + + logger.debug("Got data[0]: " + repr(data[0])) + data_list = list(data) + logger.info("Got data_list: " + repr(data_list)) + + for i in range(len(data_list)): + data_list[i] = str(data_list[i][-1]) + + id_list = data_list + logger.info("Got id_list: " + repr(id_list)) + return cursor, id_list + + +def get_last_csr_id(cursor=None, cr_id=None, table_name=None): + logger.info("Executing") + if cursor is None: + raise AttributeError("Provide cursor as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + if table_name is None: + raise AttributeError("Provide table_name as parameter") + + sql_query = "SELECT consentStatusRecordId " \ + "FROM " + table_name + " " \ + "WHERE consentRecordId LIKE %s " \ + "ORDER BY id DESC " \ + "LIMIT 1;" + + arguments = ( + '%' + str(cr_id) + '%', + ) + + try: + cursor, data = execute_sql_select_2(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.debug("Got data: " + repr(data)) + + if len(data) == 0: + logger.error("IndexError('DB query returned no results')") + raise IndexError("DB query returned no results") + + logger.debug("Got data[0]: " + repr(data[0])) + data_list = list(data[0]) + logger.info("Got data_list: " + repr(data_list)) + + entry_id = str(data_list[0]) + logger.info("Got entry_id: " + repr(entry_id)) + + return cursor, entry_id + + +def get_account_id_by_csr_id(cursor=None, cr_id=None, acc_table_name=None, slr_table_name=None, cr_table_name=None): + logger.info("Executing") + if cursor is None: + raise AttributeError("Provide cursor as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + if acc_table_name is None: + raise AttributeError("Provide acc_table_name as parameter") + if slr_table_name is None: + raise AttributeError("Provide slr_table_name as parameter") + if cr_table_name is None: + raise AttributeError("Provide cr_table_name as parameter") + + + sql_query = "SELECT `Accounts`.`id` " \ + "FROM " + acc_table_name + " " \ + "INNER JOIN " + slr_table_name + " on " + acc_table_name + ".`id` = " + slr_table_name + ".`Accounts_id` " \ + "INNER JOIN " + cr_table_name + " on " + slr_table_name + ".`id` = " + cr_table_name + ".`ServiceLinkRecords_id` " \ + "WHERE " + cr_table_name + ".`consentRecordId` LIKE %s " \ + "LIMIT 1;" + + arguments = ( + '%' + str(cr_id) + '%', + ) + + try: + cursor, data = execute_sql_select_2(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.debug("Got data: " + repr(data)) + + if len(data) == 0: + logger.error("IndexError('DB query returned no results')") + raise IndexError("DB query returned no results") + + logger.debug("Got data[0]: " + repr(data[0])) + data_list = list(data[0]) + logger.info("Got data_list: " + repr(data_list)) + + entry_id = str(data_list[0]) + logger.info("Got entry_id: " + repr(entry_id)) + + return cursor, entry_id + + + + + diff --git a/Account/app/mod_database/models.py b/Account/app/mod_database/models.py index f33e96a..b4cce83 100644 --- a/Account/app/mod_database/models.py +++ b/Account/app/mod_database/models.py @@ -6,13 +6,14 @@ import bcrypt # https://github.com/pyca/bcrypt/, https://pypi.python.org/pypi/bcrypt/2.0.0 # Import the database object from the main app module +import datetime from flask import json from app import db, api, login_manager, app # create logger with 'spam_application' from app.helpers import get_custom_logger -from app.mod_database.helpers import execute_sql_insert, execute_sql_insert_2, execute_sql_select_2 +from app.mod_database.helpers import execute_sql_insert, execute_sql_insert_2, execute_sql_select_2, execute_sql_update logger = get_custom_logger(__name__) @@ -24,14 +25,24 @@ class Account(): id = None global_identifier = None activated = None + table_name = "" + deleted = "" - def __init__(self, id="", global_identifyer="", activated=""): + def __init__(self, id="", global_identifyer="", activated="", deleted=0, table_name="MyDataAccount.Accounts"): if id is not None: self.id = id if global_identifyer is not None: self.global_identifier = global_identifyer if activated is not None: self.activated = activated + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -63,8 +74,10 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] + del dictionary['deleted'] + del dictionary['table_name'] return dictionary @property @@ -77,10 +90,14 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO Accounts (globalIdenttifyer) VALUES ('%s')" % (self.global_identifier) + sql_query = "INSERT INTO " + self.table_name + " (globalIdentifier) VALUES (%s)" + + arguments = ( + str(self.global_identifier), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -92,11 +109,9 @@ def from_db(self, cursor=None): if cursor is None: raise AttributeError("Provide cursor as parameter") - # TODO: Don't allow if role is only criteria - - sql_query = "SELECT id, globalIdenttifyer, activated " \ - "FROM MyDataAccount.Accounts " \ - "WHERE id LIKE %s AND globalIdenttifyer LIKE %s AND activated LIKE %s;" + sql_query = "SELECT id, globalIdentifier, activated " \ + "FROM " + self.table_name + " " \ + "WHERE id LIKE %s AND globalIdentifier LIKE %s AND activated LIKE %s;" arguments = ( '%' + str(self.id) + '%', @@ -133,8 +148,10 @@ class LocalIdentity(): username = None pwd_id = None accounts_id = None + table_name = "" + deleted = "" - def __init__(self, id="", username="", pwd_id="", accounts_id=""): + def __init__(self, id="", username="", pwd_id="", accounts_id="", deleted=0, table_name="MyDataAccount.LocalIdentities"): if id is not None: self.id = id if username is not None: @@ -143,6 +160,14 @@ def __init__(self, id="", username="", pwd_id="", accounts_id=""): self.pwd_id = pwd_id if accounts_id is not None: self.accounts_id = accounts_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -182,9 +207,11 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['accounts_id'] + del dictionary['table_name'] + del dictionary['deleted'] return dictionary @property @@ -197,12 +224,17 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO LocalIdentities (username, Accounts_id, LocalIdentityPWDs_id) " \ - "VALUES ('%s', '%s', '%s')" % \ - (self.username, self.accounts_id, self.pwd_id) + sql_query = "INSERT INTO " + self.table_name + " (username, Accounts_id, LocalIdentityPWDs_id) " \ + "VALUES (%s, %s, %s)" + + arguments = ( + str(self.username), + int(self.accounts_id), + str(self.pwd_id), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -217,7 +249,7 @@ def from_db(self, cursor=None): # TODO: Don't allow if role is only criteria sql_query = "SELECT id, username, LocalIdentityPWDs_id, Accounts_id " \ - "FROM MyDataAccount.LocalIdentities " \ + "FROM " + self.table_name + " " \ "WHERE id LIKE %s AND username LIKE %s AND LocalIdentityPWDs_id LIKE %s AND Accounts_id LIKE %s;" arguments = ( @@ -254,12 +286,22 @@ def from_db(self, cursor=None): class LocalIdentityPWD(): id = None password = None + table_name = "" + deleted = "" - def __init__(self, id="", password=""): + def __init__(self, id="", password="", deleted=0, table_name="MyDataAccount.LocalIdentityPWDs"): if id is not None: self.id = id if password is not None: self.password = password + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -283,8 +325,10 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] + del dictionary['table_name'] + del dictionary['deleted'] return dictionary @property @@ -297,10 +341,14 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO LocalIdentityPWDs (password) VALUES ('%s')" % (self.password) + sql_query = "INSERT INTO " + self.table_name + " (password) VALUES (%s)" + + arguments = ( + str(self.password), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -315,7 +363,7 @@ def from_db(self, cursor=None): # TODO: Don't allow if role is only criteria sql_query = "SELECT id, password " \ - "FROM MyDataAccount.LocalIdentityPWDs " \ + "FROM " + self.table_name + " " \ "WHERE id LIKE %s AND password LIKE %s;" arguments = ( @@ -350,8 +398,10 @@ class OneTimeCookie(): created = None updated = None identity_id = None + table_name = "" + deleted = "" - def __init__(self, id="", cookie="", used="", created="", updated="", identity_id=""): + def __init__(self, id="", cookie="", used="", created="", updated="", identity_id="", deleted=0, table_name="MyDataAccount.OneTimeCookies"): if id is not None: self.id = id if cookie is not None: @@ -364,6 +414,14 @@ def __init__(self, id="", cookie="", used="", created="", updated="", identity_i self.updated = updated if identity_id is not None: self.identity_id = identity_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -419,8 +477,10 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] + del dictionary['table_name'] + del dictionary['deleted'] return dictionary @property @@ -433,12 +493,16 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO OneTimeCookie (oneTimeCookie, LocalIdentities_id) " \ - "VALUES ('%s', '%s')" % \ - (self.cookie, self.identity_id) + sql_query = "INSERT INTO " + self.table_name + " (oneTimeCookie, LocalIdentities_id) " \ + "VALUES (%s, %s)" + + arguments = ( + str(self.cookie), + int(self.identity_id), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -450,10 +514,8 @@ def from_db(self, cursor=None): if cursor is None: raise AttributeError("Provide cursor as parameter") - # TODO: Don't allow if role is only criteria - sql_query = "SELECT id, oneTimeCookie, used, created, updated, LocalIdentities_id " \ - "FROM MyDataAccount.OneTimeCookies " \ + "FROM " + self.table_name + " " \ "WHERE id LIKE %s AND oneTimeCookie LIKE %s AND used LIKE %s AND created LIKE %s " \ "AND updated LIKE %s AND LocalIdentities_id LIKE %s;" @@ -498,14 +560,24 @@ class Salt(): id = None salt = None identity_id = None + table_name = "" + deleted = "" - def __init__(self, id="", salt="", identity_id=""): + def __init__(self, id="", salt="", identity_id="", deleted=0, table_name="MyDataAccount.Salts"): if id is not None: self.id = id if salt is not None: self.salt = salt if identity_id is not None: self.identity_id = identity_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -537,8 +609,10 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] + del dictionary['table_name'] + del dictionary['deleted'] return dictionary @property @@ -551,11 +625,15 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO Salts (salt, LocalIdentities_id) VALUES ('%s', '%s')" % \ - (self.salt, self.identity_id) + sql_query = "INSERT INTO " + self.table_name + " (salt, LocalIdentities_id) VALUES (%s, %s)" + + arguments = ( + str(self.salt), + int(self.identity_id), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -570,7 +648,7 @@ def from_db(self, cursor=None): # TODO: Don't allow if role is only criteria sql_query = "SELECT id, salt, LocalIdentities_id " \ - "FROM MyDataAccount.Salts " \ + "FROM " + self.table_name + " " \ "WHERE id LIKE %s AND salt LIKE %s AND LocalIdentities_id LIKE %s;" arguments = ( @@ -610,8 +688,10 @@ class Particulars(): date_of_birth = None img_url = None account_id = None + table_name = "" + deleted = "" - def __init__(self, id="", firstname="", lastname="", date_of_birth="", img_url=app.config['AVATAR_URL'], account_id=""): + def __init__(self, id="", firstname="", lastname="", date_of_birth="", img_url=app.config['AVATAR_URL'], account_id="", deleted=0, table_name="MyDataAccount.Particulars"): if id is not None: self.id = id if firstname is not None: @@ -624,6 +704,14 @@ def __init__(self, id="", firstname="", lastname="", date_of_birth="", img_url=a self.img_url = str(img_url) if account_id is not None: self.account_id = account_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -679,13 +767,25 @@ def full_name(self): @property def to_dict(self): + if isinstance(self.date_of_birth, datetime.date): + self.date_of_birth = self.date_of_birth.strftime("%Y-%m-%d") return self.__dict__ @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['account_id'] + del dictionary['table_name'] + del dictionary['deleted'] + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "Particular" + dictionary['id'] = str(self.id) + dictionary['attributes'] = self.to_dict_external return dictionary @property @@ -702,11 +802,19 @@ def __repr__(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO Particulars (firstname, lastname, dateOfBirth, img_url, Accounts_id) " \ - "VALUES ('%s', '%s', STR_TO_DATE('%s', '%%d-%%m-%%Y'), '%s', '%s')" % \ - (self.firstname, self.lastname, self.date_of_birth, self.img_url, self.account_id) + sql_query = "INSERT INTO " + self.table_name + " (firstname, lastname, dateOfBirth, img_url, Accounts_id) " \ + "VALUES (%s, %s, STR_TO_DATE(%s, '%%Y-%%m-%%d'), %s, %s)" + + arguments = ( + str(self.firstname), + str(self.lastname), + str(self.date_of_birth), + str(self.img_url), + int(self.account_id), + ) + try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -714,23 +822,55 @@ def to_db(self, cursor=""): self.id = last_id return cursor + def update_db(self, cursor=""): + + sql_query = "UPDATE " + self.table_name + " SET firstname=%s, lastname=%s, dateOfBirth=STR_TO_DATE(%s, '%%Y-%%m-%%d'), img_url=%s " \ + "WHERE id=%s AND Accounts_id=%s" + + arguments = ( + str(self.firstname), + str(self.lastname), + str(self.date_of_birth), + str(self.img_url), + str(self.id), + str(self.account_id), + ) + + try: + cursor = execute_sql_update(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.info("SQL query executed") + return cursor + def from_db(self, cursor=None): if cursor is None: raise AttributeError("Provide cursor as parameter") - # TODO: Don't allow if role is only criteria + # Querying with all data disabled due formatting problems + # TODO: Enable Querying with Date + # sql_query = "SELECT id, firstname, lastname, dateOfBirth, img_url, Accounts_id " \ + # "FROM " + self.table_name + " " \ + # "WHERE id LIKE %s AND firstname LIKE %s AND lastname LIKE %s AND dateOfBirth LIKE %s " \ + # "AND img_url LIKE %s AND Accounts_id LIKE %s;" + # + # arguments = ( + # '%' + str(self.id) + '%', + # '%' + str(self.firstname) + '%', + # '%' + str(self.lastname) + '%', + # '%' + str(self.date_of_birth) + '%', + # '%' + str(self.img_url) + '%', + # '%' + str(self.account_id) + '%', + # ) sql_query = "SELECT id, firstname, lastname, dateOfBirth, img_url, Accounts_id " \ - "FROM MyDataAccount.Particulars " \ - "WHERE id LIKE %s AND firstname LIKE %s AND lastname LIKE %s AND dateOfBirth LIKE %s " \ - "AND img_url LIKE %s AND Accounts_id LIKE %s;" + "FROM " + self.table_name + " " \ + "WHERE id LIKE %s AND Accounts_id LIKE %s;" arguments = ( '%' + str(self.id) + '%', - '%' + str(self.firstname) + '%', - '%' + str(self.lastname) + '%', - '%' + str(self.date_of_birth) + '%', - '%' + str(self.img_url) + '%', '%' + str(self.account_id) + '%', ) @@ -768,8 +908,10 @@ class Email(): type = None prime = None account_id = None + table_name = "" + deleted = "" - def __init__(self, id="", email="", type="Personal", prime="", account_id=""): + def __init__(self, id="", email="", type="Personal", prime="", account_id="", deleted=0, table_name="MyDataAccount.Emails"): if id is not None: self.id = id if email is not None: @@ -777,9 +919,20 @@ def __init__(self, id="", email="", type="Personal", prime="", account_id=""): if type is not None: self.type = type if prime is not None: - self.prime = prime + if prime == "True": + self.prime = 1 + else: + self.prime = 0 if account_id is not None: self.account_id = account_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -827,9 +980,23 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['account_id'] + del dictionary['table_name'] + del dictionary['deleted'] + if dictionary['prime'] == 1: + dictionary['prime'] = "True" + elif dictionary['prime'] == 0: + dictionary['prime'] = "False" + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "Email" + dictionary['id'] = str(self.id) + dictionary['attributes'] = self.to_dict_external return dictionary @property @@ -842,12 +1009,18 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO Emails (email, typeEnum, prime, Accounts_id) " \ - "VALUES ('%s', '%s', '%s', '%s')" % \ - (self.email, self.type, self.prime, self.account_id) + sql_query = "INSERT INTO " + self.table_name + " (email, entryType, prime, Accounts_id) " \ + "VALUES (%s, %s, %s, %s)" + + arguments = ( + str(self.email), + str(self.type), + str(self.prime), + int(self.account_id), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -859,17 +1032,12 @@ def from_db(self, cursor=None): if cursor is None: raise AttributeError("Provide cursor as parameter") - # TODO: Don't allow if role is only criteria - - sql_query = "SELECT id, email, typeEnum, prime, Accounts_id " \ - "FROM MyDataAccount.Particulars " \ - "WHERE id LIKE %s AND email LIKE %s AND typeEnum LIKE %s AND prime LIKE %s AND Accounts_id LIKE %s;" + sql_query = "SELECT id, email, entryType, prime, Accounts_id " \ + "FROM " + self.table_name + " " \ + "WHERE id LIKE %s AND Accounts_id LIKE %s;" arguments = ( '%' + str(self.id) + '%', - '%' + str(self.email) + '%', - '%' + str(self.type) + '%', - '%' + str(self.prime) + '%', '%' + str(self.account_id) + '%', ) @@ -897,6 +1065,28 @@ def from_db(self, cursor=None): return cursor + def update_db(self, cursor=""): + + sql_query = "UPDATE " + self.table_name + " SET email=%s, entryType=%s, prime=%s " \ + "WHERE id=%s AND Accounts_id=%s" + + arguments = ( + str(self.email), + str(self.type), + str(self.prime), + str(self.id), + str(self.account_id), + ) + + try: + cursor = execute_sql_update(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.info("SQL query executed") + return cursor + ##################### class Telephone(): @@ -905,8 +1095,10 @@ class Telephone(): type = None prime = None account_id = None + table_name = "" + deleted = "" - def __init__(self, id="", tel="", type="Personal", prime="", account_id=""): + def __init__(self, id="", tel="", type="Personal", prime="", account_id="", deleted=0, table_name="MyDataAccount.Telephones"): if id is not None: self.id = id if tel is not None: @@ -914,9 +1106,20 @@ def __init__(self, id="", tel="", type="Personal", prime="", account_id=""): if type is not None: self.type = type if prime is not None: - self.prime = prime + if prime == "True": + self.prime = 1 + else: + self.prime = 0 if account_id is not None: self.account_id = account_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -964,9 +1167,23 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['account_id'] + del dictionary['table_name'] + del dictionary['deleted'] + if dictionary['prime'] == 1: + dictionary['prime'] = "True" + elif dictionary['prime'] == 0: + dictionary['prime'] = "False" + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "Telephone" + dictionary['id'] = str(self.id) + dictionary['attributes'] = self.to_dict_external return dictionary @property @@ -979,12 +1196,18 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO Telephones (tel, typeEnum, prime, Accounts_id) " \ - "VALUES ('%s', '%s', '%s', '%s')" % \ - (self.tel, self.type, self.prime, self.account_id) + sql_query = "INSERT INTO " + self.table_name + " (tel, entryType, prime, Accounts_id) " \ + "VALUES (%s, %s, %s, %s)" + + arguments = ( + str(self.tel), + str(self.type), + str(self.prime), + int(self.account_id), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -998,15 +1221,12 @@ def from_db(self, cursor=None): # TODO: Don't allow if role is only criteria - sql_query = "SELECT id, tel, typeEnum, prime, Accounts_id " \ - "FROM MyDataAccount.Particulars " \ - "WHERE id LIKE %s AND tel LIKE %s AND typeEnum LIKE %s AND prime LIKE %s AND Accounts_id LIKE %s;" + sql_query = "SELECT id, tel, entryType, prime, Accounts_id " \ + "FROM " + self.table_name + " " \ + "WHERE id LIKE %s AND Accounts_id LIKE %s;" arguments = ( '%' + str(self.id) + '%', - '%' + str(self.tel) + '%', - '%' + str(self.type) + '%', - '%' + str(self.prime) + '%', '%' + str(self.account_id) + '%', ) @@ -1034,6 +1254,28 @@ def from_db(self, cursor=None): return cursor + def update_db(self, cursor=""): + + sql_query = "UPDATE " + self.table_name + " SET tel=%s, entryType=%s, prime=%s " \ + "WHERE id=%s AND Accounts_id=%s" + + arguments = ( + str(self.tel), + str(self.type), + str(self.prime), + str(self.id), + str(self.account_id), + ) + + try: + cursor = execute_sql_update(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.info("SQL query executed") + return cursor + ##################### class Settings(): @@ -1041,8 +1283,10 @@ class Settings(): key = None value = None account_id = None + table_name = "" + deleted = "" - def __init__(self, id="", key="", value="", account_id=""): + def __init__(self, id="", key="", value="", account_id="", deleted=0, table_name="MyDataAccount.Settings"): if id is not None: self.id = id if key is not None: @@ -1053,6 +1297,14 @@ def __init__(self, id="", key="", value="", account_id=""): self.value = value if account_id is not None: self.account_id = account_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -1092,9 +1344,19 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['account_id'] + del dictionary['table_name'] + del dictionary['deleted'] + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "Setting" + dictionary['id'] = str(self.id) + dictionary['attributes'] = self.to_dict_external return dictionary @property @@ -1107,12 +1369,17 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO Settings (prefLang, timezone, Accounts_id) " \ - "VALUES ('%s', '%s', '%s')" % \ - (self.pref_lang, self.timezone, self.account_id) + sql_query = "INSERT INTO " + self.table_name + " (setting_key, setting_value, Accounts_id) " \ + "VALUES (%s, %s, %s)" + + arguments = ( + str(self.key), + str(self.value), + int(self.account_id), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -1124,14 +1391,12 @@ def from_db(self, cursor=None): if cursor is None: raise AttributeError("Provide cursor as parameter") - sql_query = "SELECT id, key, value, Accounts_id " \ - "FROM MyDataAccount.Settings " \ - "WHERE id LIKE %s AND key LIKE %s AND value LIKE %s AND Accounts_id LIKE %s;" + sql_query = "SELECT id, setting_key, setting_value, Accounts_id " \ + "FROM " + self.table_name + " " \ + "WHERE id LIKE %s AND Accounts_id LIKE %s;" arguments = ( '%' + str(self.id) + '%', - '%' + str(self.key) + '%', - '%' + str(self.value) + '%', '%' + str(self.account_id) + '%', ) @@ -1157,6 +1422,27 @@ def from_db(self, cursor=None): return cursor + def update_db(self, cursor=""): + + sql_query = "UPDATE " + self.table_name + " SET setting_key=%s, setting_value=%s " \ + "WHERE id=%s AND Accounts_id=%s" + + arguments = ( + str(self.key), + str(self.value), + str(self.id), + str(self.account_id), + ) + + try: + cursor = execute_sql_update(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.info("SQL query executed") + return cursor + ##################### class EventLog(): @@ -1165,8 +1451,10 @@ class EventLog(): event = None created = None account_id = None + table_name = "" + deleted = "" - def __init__(self, id="", actor="", event="", created="", account_id=""): + def __init__(self, id="", actor="", event="", created="", account_id="", deleted=0, table_name="MyDataAccount.EventLogs"): if id is not None: self.id = id if actor is not None: @@ -1177,6 +1465,14 @@ def __init__(self, id="", actor="", event="", created="", account_id=""): self.created = created if account_id is not None: self.account_id = account_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -1210,6 +1506,14 @@ def created(self): def created(self, value): self.created = value + @property + def table_name(self): + return self.table_name + + @table_name.setter + def table_name(self, value): + self._table_name = value + @property def account_id(self): return self.account_id @@ -1224,9 +1528,19 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['account_id'] + del dictionary['table_name'] + del dictionary['deleted'] + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "Event" + dictionary['id'] = str(self.id) + dictionary['attributes'] = self.to_dict_external return dictionary @property @@ -1239,12 +1553,18 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO EventLogs (actor, event, created, Accounts_id) " \ - "VALUES ('%s', '%s', '%s', '%s')" % \ - (self.actor, self.event, self.created, self.account_id) + sql_query = "INSERT INTO " + self.table_name + " (actor, event, created, Accounts_id) " \ + "VALUES (%s, %s, %s, %s)" + + arguments = ( + str(self.actor), + str(self.event), + int(self.created), + int(self.account_id), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -1256,17 +1576,12 @@ def from_db(self, cursor=None): if cursor is None: raise AttributeError("Provide cursor as parameter") - # TODO: Don't allow if role is only criteria - sql_query = "SELECT id, actor, event, created, Accounts_id " \ - "FROM MyDataAccount.EventLogs " \ - "WHERE id LIKE %s AND actor LIKE %s AND event LIKE %s AND created LIKE %s AND Accounts_id LIKE %s;" + "FROM " + self.table_name + " " \ + "WHERE id LIKE %s AND Accounts_id LIKE %s;" arguments = ( '%' + str(self.id) + '%', - '%' + str(self.actor) + '%', - '%' + str(self.event) + '%', - '%' + str(self.created) + '%', '%' + str(self.account_id) + '%', ) @@ -1292,6 +1607,14 @@ def from_db(self, cursor=None): self.created = data[0][3] self.account_id = data[4] + try: + event_copy = self.event + logger.info("event to dict") + self.event = json.loads(self.event) + except Exception as exp: + logger.info("Could not event consent_status_record to dict. Using original") + self.event = event_copy + return cursor @@ -1304,11 +1627,13 @@ class Contacts(): city = None state = None country = None - typeEnum = None + entryType = None prime = None account_id = None + table_name = "" + deleted = "" - def __init__(self, id="", address1="", address2="", postal_code="", city="", state="", country="", type="Personal", prime="", account_id=""): + def __init__(self, id="", address1="", address2="", postal_code="", city="", state="", country="", type="Personal", prime="", account_id="", deleted=0, table_name="MyDataAccount.Contacts"): if id is not None: self.id = id if address1 is not None: @@ -1326,9 +1651,20 @@ def __init__(self, id="", address1="", address2="", postal_code="", city="", sta if type is not None: self.type = type if prime is not None: - self.prime = prime + if prime == "True": + self.prime = 1 + else: + self.prime = 0 if account_id is not None: self.account_id = account_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -1416,9 +1752,23 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['account_id'] + del dictionary['table_name'] + del dictionary['deleted'] + if dictionary['prime'] == 1: + dictionary['prime'] = "True" + elif dictionary['prime'] == 0: + dictionary['prime'] = "False" + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "Contact" + dictionary['id'] = str(self.id) + dictionary['attributes'] = self.to_dict_external return dictionary @property @@ -1431,12 +1781,23 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO Contacts (address1, address2, postalCode, city, state, country, typeEnum, prime, Accounts_id) " \ - "VALUES ('%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')" % \ - (self.address1, self.address2, self.postal_code, self.city, self.state, self.country, self.type, self.prime, self.account_id) + sql_query = "INSERT INTO " + self.table_name + " (address1, address2, postalCode, city, state, country, entryType, prime, Accounts_id) " \ + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)" + + arguments = ( + str(self.address1), + str(self.address2), + str(self.postal_code), + str(self.city), + str(self.state), + str(self.country), + str(self.type), + str(self.prime), + int(self.account_id), + ) try: - cursor, last_id = execute_sql_insert(cursor=cursor, sql_query=sql_query) + cursor, last_id = execute_sql_insert_2(cursor=cursor, sql_query=sql_query, arguments=arguments) except Exception as exp: logger.debug('sql_query: ' + repr(exp)) raise @@ -1444,28 +1805,44 @@ def to_db(self, cursor=""): self.id = last_id return cursor + def update_db(self, cursor=""): + + sql_query = "UPDATE " + self.table_name + " SET address1=%s, address2=%s, postalCode=%s, city=%s, state=%s, " \ + "country=%s, entryType=%s, prime=%s " \ + "WHERE id=%s AND Accounts_id=%s" + + arguments = ( + str(self.address1), + str(self.address2), + str(self.postal_code), + str(self.city), + str(self.state), + str(self.country), + str(self.type), + str(self.prime), + str(self.id), + str(self.account_id), + ) + + try: + cursor = execute_sql_update(cursor=cursor, sql_query=sql_query, arguments=arguments) + except Exception as exp: + logger.debug('sql_query: ' + repr(exp)) + raise + else: + logger.info("SQL query executed") + return cursor + def from_db(self, cursor=None): if cursor is None: raise AttributeError("Provide cursor as parameter") - # TODO: Don't allow if role is only criteria - - sql_query = "SELECT id, address1, address2, postal_code, city, state, country, typeEnum, prime, Accounts_id " \ - "FROM MyDataAccount.Contacts " \ - "WHERE id LIKE %s AND address1 LIKE %s AND address2 LIKE %s AND postal_code LIKE %s " \ - "AND city LIKE %s AND state LIKE %s AND country LIKE %s AND typeEnum LIKE %s " \ - "AND prime LIKE %s AND Accounts_id LIKE %s;" + sql_query = "SELECT id, address1, address2, postalCode, city, state, country, entryType, prime, Accounts_id " \ + "FROM " + self.table_name + " " \ + "WHERE id LIKE %s AND Accounts_id LIKE %s;" arguments = ( '%' + str(self.id) + '%', - '%' + str(self.address1) + '%', - '%' + str(self.address2) + '%', - '%' + str(self.postal_code) + '%', - '%' + str(self.city) + '%', - '%' + str(self.state) + '%', - '%' + str(self.country) + '%', - '%' + str(self.typeEnum) + '%', - '%' + str(self.prime) + '%', '%' + str(self.account_id) + '%', ) @@ -1513,8 +1890,10 @@ class ServiceLinkRecord(): surrogate_id = None operator_id = None account_id = None + table_name = "" + deleted = "" - def __init__(self, id="", service_link_record="", service_link_record_id="", service_id="", surrogate_id="", operator_id="", account_id=""): + def __init__(self, id="", service_link_record="", service_link_record_id="", service_id="", surrogate_id="", operator_id="", account_id="", deleted=0, table_name="MyDataAccount.ServiceLinkRecords"): if id is not None: self.id = id if service_link_record is not None: @@ -1529,6 +1908,14 @@ def __init__(self, id="", service_link_record="", service_link_record_id="", ser self.surrogate_id = surrogate_id if account_id is not None: self.account_id = account_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -1592,9 +1979,33 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['account_id'] + del dictionary['table_name'] + del dictionary['deleted'] + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "ServiceLinkRecord" + dictionary['id'] = str(self.service_link_record_id) + dictionary['attributes'] = self.to_dict_external + return dictionary + + @property + def to_record_dict_external(self): + dictionary = {} + dictionary["slr"] = self.service_link_record + return dictionary + + @property + def to_record_dict(self): + dictionary = {} + dictionary['type'] = "ServiceLinkRecord" + dictionary['id'] = str(self.service_link_record_id) + dictionary['attributes'] = self.to_record_dict_external return dictionary @property @@ -1609,10 +2020,10 @@ def to_db(self, cursor=""): # http://stackoverflow.com/questions/3617052/escape-string-python-for-mysql/27575399#27575399 # sql_query = "INSERT INTO ServiceLinkRecords (serviceLinkRecord, serviceLinkRecordId, serviceId, surrogateId, operatorId, Accounts_id) " \ - # "VALUES ('%s', '%s', '%s', '%s', '%s', '%s')" % \ + # "VALUES (%s, %s, %s, %s, %s, %s)" % \ # (self.service_link_record, self.service_link_record_id, self.service_id, self.surrogate_id, self.operator_id, self.account_id) - sql_query = "INSERT INTO ServiceLinkRecords (" \ + sql_query = "INSERT INTO " + self.table_name + " (" \ "serviceLinkRecord, " \ "serviceLinkRecordId, " \ "serviceId, " \ @@ -1622,12 +2033,12 @@ def to_db(self, cursor=""): ") VALUES (%s, %s, %s, %s, %s, %s)" arguments = ( - str(self.service_link_record), + json.dumps(self.service_link_record), str(self.service_link_record_id), str(self.service_id), str(self.surrogate_id), str(self.operator_id), - str(self.account_id), + int(self.account_id), ) try: @@ -1645,7 +2056,7 @@ def from_db(self, cursor=""): # TODO: Don't allow if role is only criteria sql_query = "SELECT id, serviceLinkRecord, Accounts_id, serviceLinkRecordId, serviceId, surrogateId, operatorId " \ - "FROM MyDataAccount.ServiceLinkRecords " \ + "FROM " + self.table_name + " " \ "WHERE id LIKE %s AND serviceLinkRecord LIKE %s AND serviceLinkRecordId LIKE %s AND " \ "serviceId LIKE %s AND surrogateId LIKE %s AND operatorId LIKE %s AND Accounts_id LIKE %s;" @@ -1684,6 +2095,16 @@ def from_db(self, cursor=""): self.service_id = data[4] self.surrogate_id = data[5] self.operator_id = data[6] + + try: + slr_copy = self.service_link_record + logger.info("service_link_record to dict") + self.service_link_record = json.loads(self.service_link_record) + except Exception as exp: + attribute_type = type(self.service_link_record) + logger.info("Could not convert service_link_record to dict. Type of attribute: " + repr(attribute_type) + " Using original" + repr(attribute_type) + " Using original: " + repr(exp)) + self.service_link_record = slr_copy + return cursor @@ -1696,8 +2117,10 @@ class ServiceLinkStatusRecord(): issued_at = None prev_record_id = None service_link_records_id = None + table_name = "" + deleted = "" - def __init__(self, id="", service_link_status_record_id="", status="", service_link_status_record="", service_link_record_id="", issued_at="", prev_record_id="", service_link_records_id=""): + def __init__(self, id="", service_link_status_record_id="", status="", service_link_status_record="", service_link_record_id="", issued_at="", prev_record_id="", service_link_records_id="", deleted=0, table_name="MyDataAccount.ServiceLinkStatusRecords"): if id is not None: self.id = id if service_link_status_record_id is not None: @@ -1714,6 +2137,14 @@ def __init__(self, id="", service_link_status_record_id="", status="", service_l self.prev_record_id = prev_record_id if service_link_records_id is not None: self.service_link_records_id = service_link_records_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -1785,9 +2216,33 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['service_link_records_id'] + del dictionary['table_name'] + del dictionary['deleted'] + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "ServiceLinkStatusRecord" + dictionary['id'] = str(self.service_link_status_record_id) + dictionary['attributes'] = self.to_dict_external + return dictionary + + @property + def to_record_dict_external(self): + dictionary = {} + dictionary["slsr"] = self.service_link_status_record + return dictionary + + @property + def to_record_dict(self): + dictionary = {} + dictionary['type'] = "ServiceLinkStatusRecord" + dictionary['id'] = str(self.service_link_status_record_id) + dictionary['attributes'] = self.to_record_dict_external return dictionary @property @@ -1801,10 +2256,10 @@ def log_entry(self): def to_db(self, cursor=""): # sql_query = "INSERT INTO ServiceLinkRecords (serviceLinkStatusRecordId, status, serviceLinkStatusRecord, ServiceLinkRecords_id, serviceLinkRecordId, issued_at, prevRecordId) " \ - # "VALUES ('%s','%s', '%s', '%s', '%s', '%s', '%s')" % \ + # "VALUES (%s,%s, %s, %s, %s, %s, %s)" % \ # (self.service_link_status_record_id, self.status, self.service_link_status_record, self.service_link_records_id, self.service_link_record_id, self.issued_at, self.prev_record_id) - sql_query = "INSERT INTO ServiceLinkStatusRecords (" \ + sql_query = "INSERT INTO " + self.table_name + " (" \ "serviceLinkStatusRecordId, " \ "serviceLinkStatus, " \ "serviceLinkStatusRecord, " \ @@ -1817,10 +2272,10 @@ def to_db(self, cursor=""): arguments = ( str(self.service_link_status_record_id), str(self.status), - str(self.service_link_status_record), + json.dumps(self.service_link_status_record), int(self.service_link_records_id), str(self.service_link_record_id), - str(self.issued_at), + int(self.issued_at), str(self.prev_record_id), ) @@ -1842,7 +2297,7 @@ def from_db(self, cursor=None): sql_query = "SELECT id, serviceLinkStatus, serviceLinkStatusRecord, ServiceLinkRecords_id, serviceLinkRecordId, " \ "issued_at, prevRecordId, serviceLinkStatusRecordId " \ - "FROM MyDataAccount.ServiceLinkStatusRecords " \ + "FROM " + self.table_name + " " \ "WHERE id LIKE %s AND serviceLinkStatus LIKE %s AND serviceLinkStatusRecord LIKE %s AND " \ "ServiceLinkRecords_id LIKE %s AND serviceLinkRecordId LIKE %s AND issued_at LIKE %s AND " \ "prevRecordId LIKE %s AND serviceLinkStatusRecordId LIKE %s;" @@ -1886,21 +2341,37 @@ def from_db(self, cursor=None): self.prev_record_id = data[6] self.service_link_status_record_id = data[7] + try: + slsr_copy = self.service_link_status_record + logger.info("service_link_status_record to dict") + self.service_link_status_record = json.loads(self.service_link_status_record) + except Exception as exp: + attribute_type = type(self.service_link_status_record) + logger.info("Could not convert service_link_status_record to dict. Type of attribute: " + repr(attribute_type) + " Using original" + repr(attribute_type) + " Using original: " + repr(exp)) + self.service_link_status_record = slsr_copy + return cursor class SurrogateId(): # TODO: Rename to SlrIDs or similar + # TODO: How to react if slr is deleted? surrogate_id = None servicelinkrecord_id = None service_id = None account_id = None + table_name = "" + deleted = "" - def __init__(self, service_id=None, account_id=None): + def __init__(self, service_id=None, account_id=None, deleted=0, table_name="MyDataAccount.ServiceLinkRecords"): if service_id is not None: self.service_id = service_id if account_id is not None: self.account_id = account_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted @property def surrogate_id(self): @@ -1932,10 +2403,18 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] return dictionary + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "SurrogateId" + dictionary['id'] = str(self.id) + dictionary['attributes'] = self.to_dict_external + return dictionary + @property def to_json(self): return json.dumps(self.to_dict) @@ -1947,7 +2426,7 @@ def log_entry(self): def from_db(self, cursor=""): sql_query = "SELECT surrogateId, serviceLinkRecordId " \ - "FROM MyDataAccount.ServiceLinkRecords " \ + "FROM " + self.table_name + " " \ "WHERE serviceId LIKE %s AND Accounts_id LIKE %s ORDER BY id DESC LIMIT 1;" arguments = ( @@ -1984,8 +2463,10 @@ class ConsentRecord(): subject_id = None service_link_records_id = None role = None + table_name = "" + deleted = "" - def __init__(self, id="", consent_record="", consent_id="", surrogate_id="", resource_set_id="", service_link_record_id="", subject_id="", service_link_records_id="", role=""): + def __init__(self, id="", consent_record="", consent_id="", surrogate_id="", resource_set_id="", service_link_record_id="", subject_id="", service_link_records_id="", role="", deleted=0, table_name="MyDataAccount.ConsentRecords"): self.id = id self.consent_record = consent_record self.surrogate_id = surrogate_id @@ -1995,6 +2476,14 @@ def __init__(self, id="", consent_record="", consent_id="", surrogate_id="", res self.subject_id = subject_id self.service_link_records_id = service_link_records_id self.role = role + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -2074,9 +2563,33 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['service_link_records_id'] + del dictionary['table_name'] + del dictionary['deleted'] + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "ConsentRecord" + dictionary['id'] = str(self.consent_id) + dictionary['attributes'] = self.to_dict_external + return dictionary + + @property + def to_record_dict_external(self): + dictionary = {} + dictionary["cr"] = self.consent_record + return dictionary + + @property + def to_record_dict(self): + dictionary = {} + dictionary['type'] = "ConsentRecord" + dictionary['id'] = str(self.consent_id) + dictionary['attributes'] = self.to_record_dict_external return dictionary @property @@ -2089,7 +2602,7 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO ConsentRecords (" \ + sql_query = "INSERT INTO " + self.table_name + " (" \ "consentRecord, " \ "surrogateId, " \ "consentRecordId, " \ @@ -2101,13 +2614,13 @@ def to_db(self, cursor=""): ") VALUES (%s, %s, %s, %s, %s, %s, %s, %s)" arguments = ( - str(self.consent_record), + json.dumps(self.consent_record), str(self.surrogate_id), str(self.consent_id), str(self.resource_set_id), str(self.service_link_record_id), str(self.subject_id), - str(self.service_link_records_id), + int(self.service_link_records_id), str(self.role), ) @@ -2126,7 +2639,7 @@ def from_db(self, cursor=""): # TODO: Don't allow if role is only criteria sql_query = "SELECT id, consentRecord, ServiceLinkRecords_id, surrogateId, consentRecordId, ResourceSetId, serviceLinkRecordId, subjectId, role " \ - "FROM MyDataAccount.ConsentRecords " \ + "FROM " + self.table_name + " " \ "WHERE id LIKE %s AND ServiceLinkRecords_id LIKE %s AND surrogateId LIKE %s AND " \ "consentRecordId LIKE %s AND ResourceSetId LIKE %s AND serviceLinkRecordId LIKE %s AND " \ "subjectId LIKE %s AND role LIKE %s;" @@ -2150,7 +2663,7 @@ def from_db(self, cursor=""): else: logger.debug("Got data: " + repr(data)) if len(data) == 0: - raise IndexError("Surrogate Id and serviceLinkRecordId could not be found with provided information") + raise IndexError("Consent Record could not be found with provided information") if len(data[0]): self.id = data[0][0] self.consent_record = data[0][1] @@ -2173,10 +2686,13 @@ def from_db(self, cursor=""): self.role = data[8] try: + cr_copy = self.consent_record + logger.info("consent_record to dict") self.consent_record = json.loads(self.consent_record) except Exception as exp: - logger.debug('Could not load json from consent_record: ' + repr(exp)) - raise + attribute_type = type(self.consent_record) + logger.error("Could not convert consent_record to dict. Type of attribute: " + repr(attribute_type) + " Using original: " + repr(exp)) + self.consent_record = cr_copy return cursor @@ -2184,15 +2700,20 @@ def from_db(self, cursor=""): class ConsentStatusRecord(): id = None status = None + consent_status_record_id = None consent_status_record = None consent_records_id = None consent_record_id = None issued_at = None prev_record_id = None + table_name = "" + deleted = "" - def __init__(self, id="", status="", consent_status_record="", consent_records_id="", consent_record_id="", issued_at="", prev_record_id=""): + def __init__(self, id="", consent_status_record_id="", status="", consent_status_record="", consent_records_id="", consent_record_id="", issued_at="", prev_record_id="", deleted=0, table_name="MyDataAccount.ConsentStatusRecords"): if id is not None: self.id = id + if consent_status_record_id is not None: + self.consent_status_record_id = consent_status_record_id if status is not None: self.status = status if consent_status_record is not None: @@ -2205,6 +2726,14 @@ def __init__(self, id="", status="", consent_status_record="", consent_records_i self.issued_at = issued_at if prev_record_id is not None: self.prev_record_id = prev_record_id + if table_name is not None: + self.table_name = table_name + if deleted is not None: + self.deleted = deleted + + @property + def table_name(self): + return self.table_name @property def id(self): @@ -2214,6 +2743,14 @@ def id(self): def id(self, value): self.id = value + @property + def consent_status_record_id(self): + return self.consent_status_record_id + + @consent_status_record_id.setter + def consent_status_record_id(self, value): + self.consent_status_record_id = value + @property def status(self): return self.status @@ -2268,9 +2805,33 @@ def to_dict(self): @property def to_dict_external(self): - dictionary = self.__dict__ + dictionary = self.to_dict del dictionary['id'] del dictionary['consent_records_id'] + del dictionary['table_name'] + del dictionary['deleted'] + return dictionary + + @property + def to_api_dict(self): + dictionary = {} + dictionary['type'] = "ConsentStatusRecord" + dictionary['id'] = str(self.consent_status_record_id) + dictionary['attributes'] = self.to_dict_external + return dictionary + + @property + def to_record_dict_external(self): + dictionary = {} + dictionary["csr"] = self.consent_status_record + return dictionary + + @property + def to_record_dict(self): + dictionary = {} + dictionary['type'] = "ConsentStatusRecord" + dictionary['id'] = str(self.consent_status_record_id) + dictionary['attributes'] = self.to_record_dict_external return dictionary @property @@ -2283,21 +2844,23 @@ def log_entry(self): def to_db(self, cursor=""): - sql_query = "INSERT INTO ConsentStatusRecords (" \ + sql_query = "INSERT INTO " + self.table_name + " (" \ + "consentStatusRecordId, " \ "consentStatus, " \ "consentStatusRecord, " \ "ConsentRecords_id, " \ "consentRecordId, " \ "issued_at, " \ "prevRecordId" \ - ") VALUES (%s, %s, %s, %s, %s, %s)" + ") VALUES (%s, %s, %s, %s, %s, %s, %s)" arguments = ( + str(self.consent_status_record_id), str(self.status), - str(self.consent_status_record), - str(self.consent_records_id), + json.dumps(self.consent_status_record), + int(self.consent_records_id), str(self.consent_record_id), - str(self.issued_at), + int(self.issued_at), str(self.prev_record_id), ) @@ -2317,15 +2880,16 @@ def from_db(self, cursor=None): # TODO: Don't allow if role is only criteria - sql_query = "SELECT id, consentStatus, consentStatusRecord, ConsentRecords_id, consentRecordId, " \ - "issued_at, prevRecordId " \ - "FROM MyDataAccount.ConsentStatusRecords " \ - "WHERE id LIKE %s AND consentStatus LIKE %s AND consentStatusRecord LIKE %s AND " \ - "ConsentRecords_id LIKE %s AND consentRecordId LIKE %s AND issued_at LIKE %s AND " \ - "prevRecordId LIKE %s;" + sql_query = "SELECT id, consentStatusRecordId, consentStatus, consentStatusRecord, ConsentRecords_id, " \ + "consentRecordId, issued_at, prevRecordId " \ + "FROM " + self.table_name + " " \ + "WHERE id LIKE %s AND consentStatusRecordId LIKE %s AND consentStatus LIKE %s " \ + "AND consentStatusRecord LIKE %s AND ConsentRecords_id LIKE %s AND " \ + "consentRecordId LIKE %s AND issued_at LIKE %s AND prevRecordId LIKE %s;" arguments = ( '%' + str(self.id) + '%', + '%' + str(self.consent_status_record_id) + '%', '%' + str(self.status) + '%', '%' + str(self.consent_status_record) + '%', '%' + str(self.consent_records_id) + '%', @@ -2345,20 +2909,31 @@ def from_db(self, cursor=None): raise IndexError("DB query returned no results") if len(data[0]): self.id = data[0][0] - self.status = data[0][1] - self.consent_status_record = data[0][2] - self.consent_records_id = data[0][3] - self.consent_record_id = data[0][4] - self.issued_at = data[0][5] - self.prev_record_id = data[0][6] + self.consent_status_record_id = data[0][1] + self.status = data[0][2] + self.consent_status_record = data[0][3] + self.consent_records_id = data[0][4] + self.consent_record_id = data[0][5] + self.issued_at = data[0][6] + self.prev_record_id = data[0][7] else: self.id = data[0] - self.status = data[1] - self.consent_status_record = data[2] - self.consent_records_id = data[3] - self.consent_record_id = data[4] - self.issued_at = data[5] - self.prev_record_id = data[6] + self.consent_status_record_id = data[1] + self.status = data[2] + self.consent_status_record = data[3] + self.consent_records_id = data[4] + self.consent_record_id = data[5] + self.issued_at = data[6] + self.prev_record_id = data[7] + + try: + csr_copy = self.consent_status_record + logger.info("consent_status_record to dict") + self.consent_status_record = json.loads(self.consent_status_record) + except Exception as exp: + attribute_type = type(self.consent_status_record) + logger.error("Could not convert consent_status_record to dict. Type of attribute: " + repr(attribute_type) + " Using original: " + repr(exp)) + self.consent_status_record = csr_copy return cursor diff --git a/Account/app/mod_service/controllers.py b/Account/app/mod_service/controllers.py index 13c8c7e..d949849 100644 --- a/Account/app/mod_service/controllers.py +++ b/Account/app/mod_service/controllers.py @@ -48,7 +48,6 @@ def sign_slr(account_id=None, slr_payload=None, endpoint="sign_slr(account_id, s raise ApiError(code=500, title="Failed to get account owner's public key", detail=repr(exp), source=endpoint) else: logger.info("Account owner's public key and kid fetched") - finally: logger.debug("account_public_key: " + account_public_key_log_entry) # Fill Account key to cr_keys @@ -62,37 +61,28 @@ def sign_slr(account_id=None, slr_payload=None, endpoint="sign_slr(account_id, s else: logger.info("Account owner's public key added to cr_keys") - # Fill timestamp to created in slr - try: - timestamp_to_fill = get_utc_time() - except Exception as exp: - logger.error("Could not get UTC time: " + repr(exp)) - raise ApiError(code=500, title="Could not get UTC time", detail=repr(exp), source=endpoint) - else: - logger.info("timestamp_to_fill: " + timestamp_to_fill) - - timestamp_to_fill = int(time()) - try: - slr_payload['created'] = timestamp_to_fill - except Exception as exp: - logger.error("Could not fill timestamp to created in slr: " + repr(exp)) - raise ApiError(code=500, title="Failed to fill timestamp to created in slr", detail=repr(exp), source=endpoint) - else: - logger.info("Timestamp filled to created in slr") - # Sign slr slr_signed = {} try: - slr_signed = generate_and_sign_jws(account_id=account_id, jws_payload=json.dumps(slr_payload)) + slr_signed_json = generate_and_sign_jws(account_id=account_id, jws_payload=json.dumps(slr_payload)) except Exception as exp: logger.error('Could not create Service Link Record: ' + repr(exp)) raise ApiError(code=500, title="Failed to create Service Link Record", detail=repr(exp), source=endpoint) else: logger.info('Service Link Record created and signed') - return slr_signed - finally: logger.debug("slr_payload: " + json.dumps(slr_payload)) - logger.debug("slr_signed: " + slr_signed) + logger.debug("slr_signed_json: " + slr_signed_json) + try: + logger.info("Converting signed CSR from json to dict") + slr_signed_dict = json.loads(slr_signed_json) + except Exception as exp: + logger.error('Could not convert signed SLR from json to dict: ' + repr(exp)) + raise ApiError(code=500, title="Failed to convert signed SLR from json to dict", detail=repr(exp), source=endpoint) + else: + logger.info('Converted signed SLR from json to dict') + logger.debug('slr_signed_dict: ' + json.dumps(slr_signed_dict)) + + return slr_signed_dict def sign_ssr(account_id=None, ssr_payload=None, endpoint="sign_ssr(account_id, slr_payload, endpoint)"): @@ -103,36 +93,28 @@ def sign_ssr(account_id=None, ssr_payload=None, endpoint="sign_ssr(account_id, s logger.info("Signing Service Link Status Record") - # Fill timestamp to created in slr - try: - timestamp_to_fill = get_utc_time() - except Exception as exp: - logger.error("Could not get UTC time: " + repr(exp)) - raise ApiError(code=500, title="Could not get UTC time", detail=repr(exp), source=endpoint) - else: - logger.info("timestamp_to_fill: " + timestamp_to_fill) - - try: - ssr_payload['iat'] = timestamp_to_fill - except Exception as exp: - logger.error("Could not fill timestamp to iat in ssr_payload: " + repr(exp)) - raise ApiError(code=500, title="Failed to fill timestamp to iat in ssr_payload", detail=repr(exp), source=endpoint) - else: - logger.info("Timestamp filled to created in ssr_payload") - # Sign ssr ssr_signed = {} try: - ssr_signed = generate_and_sign_jws(account_id=account_id, jws_payload=json.dumps(ssr_payload)) + ssr_signed_json = generate_and_sign_jws(account_id=account_id, jws_payload=json.dumps(ssr_payload)) except Exception as exp: logger.error('Could not create Service Link Status Record: ' + repr(exp)) raise ApiError(code=500, title="Failed to create Service Link Record", detail=repr(exp), source=endpoint) else: logger.info('Service Link Status Record created and signed') - return ssr_signed, timestamp_to_fill - finally: logger.debug("ssr_payload: " + json.dumps(ssr_payload)) - logger.debug("ssr_signed: " + ssr_signed) + logger.debug("ssr_signed_json: " + ssr_signed_json) + try: + logger.info("Converting signed CSR from json to dict") + ssr_signed_dict = json.loads(ssr_signed_json) + except Exception as exp: + logger.error('Could not convert signed SLR from json to dict: ' + repr(exp)) + raise ApiError(code=500, title="Failed to convert signed SLR from json to dict", detail=repr(exp), source=endpoint) + else: + logger.info('Converted signed SLR from json to dict') + logger.debug('ssr_signed_dict: ' + json.dumps(ssr_signed_dict)) + + return ssr_signed_dict def store_slr_and_ssr(slr_entry=None, ssr_entry=None, endpoint="sign_ssr(account_id, slr_payload, endpoint)"): @@ -157,20 +139,19 @@ def store_slr_and_ssr(slr_entry=None, ssr_entry=None, endpoint="sign_ssr(account cursor = ssr_entry.to_db(cursor=cursor) - data = {'slr_id': slr_id, 'ssr_id': ssr_entry.id} + #data = {'slr_id': slr_id, 'ssr_id': ssr_entry.id} db.connection.commit() except Exception as exp: - logger.debug('commit failed: ' + repr(exp)) + logger.debug('Slr and Ssr commit failed: ' + repr(exp)) db.connection.rollback() logger.debug('--> rollback') raise ApiError(code=500, title="Failed to store slr and ssr", detail=repr(exp), source=endpoint) else: logger.debug('Slr and Ssr commited') - return data - finally: logger.debug("slr_entry: " + slr_entry.log_entry) logger.debug("ssr_entry: " + ssr_entry.log_entry) + return slr_entry, ssr_entry def get_surrogate_id_by_account_and_service(account_id=None, service_id=None, endpoint="(get_surrogate_id_by_account_and_Service)"): @@ -187,7 +168,6 @@ def get_surrogate_id_by_account_and_service(account_id=None, service_id=None, en raise else: logger.info("SurrogateId object created") - finally: logger.debug("sur_id_obj: " + sur_id_obj.log_entry) # Get DB cursor @@ -204,8 +184,6 @@ def get_surrogate_id_by_account_and_service(account_id=None, service_id=None, en raise else: logger.debug("Got sur_id_obj:" + json.dumps(sur_id_obj.to_dict)) - return sur_id_obj.to_dict - finally: logger.debug("sur_id_obj: " + sur_id_obj.log_entry) - + return sur_id_obj.to_dict diff --git a/Account/app/mod_service/models.py b/Account/app/mod_service/models.py index e740d9c..f154122 100644 --- a/Account/app/mod_service/models.py +++ b/Account/app/mod_service/models.py @@ -16,16 +16,18 @@ from marshmallow.validate import Equal, OneOf +STATUS_LIST = ["Active", "Removed"] # List that contains status entries + + class SlrAttributes(Schema): version = fields.Str(required=True) link_id = fields.Str(required=True) operator_id = fields.Str(required=True) service_id = fields.Str(required=True) surrogate_id = fields.Str(required=True) - token_key = fields.Dict(required=True) operator_key = fields.Dict(required=True) cr_keys = fields.Str(required=True) - created = fields.Str(required=True) + iat = fields.Int(required=True) class SurrogateAttributes(Schema): @@ -33,6 +35,7 @@ class SurrogateAttributes(Schema): service_id = fields.Str(required=True) account_id = fields.Str(required=True) + class SlrContent(Schema): type = fields.Str(required=True, validate=Equal("ServiceLinkRecord")) attributes = fields.Nested(nested=SlrAttributes, required=True) @@ -56,10 +59,10 @@ class NewServiceLink(Schema): ############ class SsrAttributes(Schema): record_id = fields.Str(required=True) - account_id = fields.Str(required=True) + surrogate_id = fields.Str(required=True) slr_id = fields.Str(required=True) - sl_status = fields.Str(required=True, validate=OneOf(["Active", "Removed"])) - iat = fields.Str(required=True) + sl_status = fields.Str(required=True, validate=OneOf(STATUS_LIST)) + iat = fields.Int(required=True) prev_record_id = fields.Str(required=True) diff --git a/Account/app/mod_service/view_api.py b/Account/app/mod_service/view_api.py index ef14b86..7230169 100644 --- a/Account/app/mod_service/view_api.py +++ b/Account/app/mod_service/view_api.py @@ -136,7 +136,7 @@ def post(self, account_id): # Sign SLR try: - slr_signed = sign_slr(account_id=account_id, slr_payload=slr_payload, endpoint=str(endpoint)) + slr_signed_dict = sign_slr(account_id=account_id, slr_payload=slr_payload, endpoint=str(endpoint)) except Exception as exp: logger.error("Could not sign SLR") logger.debug("Could not sign SLR: " + repr(exp)) @@ -150,7 +150,7 @@ def post(self, account_id): response_data['data']['slr'] = {} response_data['data']['slr']['type'] = "ServiceLinkRecord" response_data['data']['slr']['attributes'] = {} - response_data['data']['slr']['attributes']['slr'] = json.loads(slr_signed) + response_data['data']['slr']['attributes']['slr'] = slr_signed_dict response_data['data']['surrogate_id'] = surrogate_id except Exception as exp: logger.error('Could not prepare response data: ' + repr(exp)) @@ -225,7 +225,7 @@ def post(self, account_id): # Decode slr payload try: - print (json.dumps(json_data)) + #print (json.dumps(json_data)) slr_payload_encoded = slr['payload'] slr_payload_encoded += '=' * (-len(slr_payload_encoded) % 4) # Fix incorrect padding, base64 slr_payload_decoded = b64decode(slr_payload_encoded).replace('\\', '').replace('"{', '{').replace('}"', '}') @@ -301,6 +301,14 @@ def post(self, account_id): else: logger.debug("Got prev_ssr_id: " + str(prev_ssr_id)) + # Get iat + try: + ssr_iat = int(ssr_payload['iat']) + except Exception as exp: + raise ApiError(code=400, title="Could not fetch iat from ssr_payload", detail=repr(exp), source=endpoint) + else: + logger.debug("Got iat: " + str(prev_ssr_id)) + # # Get code try: @@ -331,7 +339,7 @@ def post(self, account_id): # Sign Ssr try: - ssr_signed, ssr_iat = sign_ssr(account_id=account_id, ssr_payload=ssr_payload, endpoint=str(endpoint)) + ssr_signed = sign_ssr(account_id=account_id, ssr_payload=ssr_payload, endpoint=str(endpoint)) except Exception as exp: logger.error("Could not sign Ssr") logger.debug("Could not sign Ssr: " + repr(exp)) @@ -342,7 +350,7 @@ def post(self, account_id): logger.info("Storing Service Link Record and Service Link Status Record") try: slr_entry = ServiceLinkRecord( - service_link_record=json.dumps(slr), + service_link_record=slr, service_link_record_id=slr_id, service_id=service_id, surrogate_id=surrogate_id, @@ -367,14 +375,15 @@ def post(self, account_id): raise ApiError(code=500, title="Failed to create Service Link Status Record object", detail=repr(exp), source=endpoint) try: - db_meta = store_slr_and_ssr(slr_entry=slr_entry, ssr_entry=ssr_entry, endpoint=str(endpoint)) + stored_slr_entry, stored_ssr_entry = store_slr_and_ssr(slr_entry=slr_entry, ssr_entry=ssr_entry, endpoint=str(endpoint)) except Exception as exp: logger.error("Could not store Service Link Record and Service Link Status Record") logger.debug("Could not store SLR and Ssr: " + repr(exp)) raise else: logger.info("Stored Service Link Record and Service Link Status Record") - logger.debug("DB Meta: " + json.dumps(db_meta)) + logger.debug("stored_slr_entry: " + stored_slr_entry.log_entry) + logger.debug("stored_ssr_entry: " + stored_ssr_entry.log_entry) # Response data container try: @@ -383,15 +392,9 @@ def post(self, account_id): response_data['data'] = {} - response_data['data']['slr'] = {} - response_data['data']['slr']['type'] = "ServiceLinkRecord" - response_data['data']['slr']['attributes'] = {} - response_data['data']['slr']['attributes']['slr'] = slr + response_data['data']['slr'] = stored_slr_entry.to_record_dict - response_data['data']['ssr'] = {} - response_data['data']['ssr']['type'] = "ServiceLinkStatusRecord" - response_data['data']['ssr']['attributes'] = {} - response_data['data']['ssr']['attributes']['ssr'] = json.loads(ssr_signed) + response_data['data']['ssr'] = stored_ssr_entry.to_record_dict response_data['data']['surrogate_id'] = surrogate_id except Exception as exp: diff --git a/Account/app/mod_system/controllers.py b/Account/app/mod_system/controllers.py index 07764c5..08d6051 100644 --- a/Account/app/mod_system/controllers.py +++ b/Account/app/mod_system/controllers.py @@ -98,38 +98,54 @@ def get(self, secret=None): logger.info("Initing MySQL") json_data = [ { - 'firstName': 'Erkki', - 'lastName': 'Esimerkki', - 'dateOfBirth': '31-05-2016', - 'email': 'erkki.esimerkki@examlpe.org', - 'username': 'testUser', - 'password': 'Hello', - 'acceptTermsOfService': 'True' + "data": { + "type": "Account", + "attributes": { + 'firstName': 'Erkki', + 'lastName': 'Esimerkki', + 'dateOfBirth': '2016-04-29', + 'email': 'erkki.esimerkki@examlpe.org', + 'username': 'testUser', + 'password': 'Hello', + 'acceptTermsOfService': 'True' + } + } }, { - 'firstName': 'Iso', - 'lastName': 'Pasi', - 'dateOfBirth': '31-05-2016', - 'email': 'iso.pasi@examlpe.org', - 'username': 'pasi', - 'password': '0nk0va', - 'acceptTermsOfService': 'True' + "data": { + "type": "Account", + "attributes": { + 'firstName': 'Iso', + 'lastName': 'Pasi', + 'dateOfBirth': '2016-08-12', + 'email': 'iso.pasi@examlpe.org', + 'username': 'pasi', + 'password': '0nk0va', + 'acceptTermsOfService': 'True' + } + } }, { - 'firstName': 'Dude', - 'lastName': 'Dudeson', - 'dateOfBirth': '31-05-2016', - 'email': 'dude.dudeson@examlpe.org', - 'username': 'mydata', - 'password': 'Hello', - 'acceptTermsOfService': 'True' + "data": { + "type": "Account", + "attributes": { + 'firstName': 'Dude', + 'lastName': 'Dudeson', + 'dateOfBirth': '2016-05-31', + 'email': 'dude.dudeson@examlpe.org', + 'username': 'mydata', + 'password': 'Hello', + 'acceptTermsOfService': 'True' + } + } } ] + form_data = [ { 'firstname': 'Erkki', 'lastname': 'Esimerkki', - 'dateofbirth': '31-05-2016', + 'dateofbirth': '2016-05-31', 'email': 'erkki.esimerkki@examlpe.org', 'username': 'testUser', 'password': 'Hello' @@ -137,7 +153,7 @@ def get(self, secret=None): { 'firstname': 'Iso', 'lastname': 'Pasi', - 'dateofbirth': '31-05-2016', + 'dateofbirth': '2016-05-31', 'email': 'iso.pasi@examlpe.org', 'username': 'pasi', 'password': '0nk0va' @@ -145,7 +161,7 @@ def get(self, secret=None): { 'firstname': 'Dude', 'lastname': 'Dudeson', - 'dateofbirth': '31-05-2016', + 'dateofbirth': '2016-05-31', 'email': 'dude.dudeson@examlpe.org', 'username': 'mydata', 'password': 'Hello' @@ -160,7 +176,7 @@ def get(self, secret=None): logger.debug("Posting: " + str(url)) logger.debug("##########") - logger.debug("Creating: " + repr(form_data[0])) + logger.debug("Creating: " + repr(json_data[0])) #r = requests.post(url, data=form_data[0]) r = requests.post(url, json=json_data[0], headers=headers) logger.debug("Response status: " + str(r.status_code)) diff --git a/Account/config.py b/Account/config.py index 86b3678..48bc693 100644 --- a/Account/config.py +++ b/Account/config.py @@ -34,7 +34,7 @@ #MYSQL_READ_DEFAULT_FILE = '' # MySQL configuration file to read, see the MySQL documentation for mysql_options(). #MYSQL_USE_UNICODE = '' # If True, CHAR and VARCHAR and TEXT columns are returned as Unicode strings, using the configured character set. MYSQL_CHARSET = 'utf8' # If present, the connection character set will be changed to this character set, if they are not equal. Default: utf-8 -#MYSQL_SQL_MODE = '' # If present, the session SQL mode will be set to the given string. +MYSQL_SQL_MODE = 'TRADITIONAL' # If present, the session SQL mode will be set to the given string. #MYSQL_CURSORCLASS = '' # If present, the cursor class will be set to the given string. diff --git a/Account/doc/api/account_api_external.yaml b/Account/doc/api/account_api_external.yaml index 7715084..777a9fd 100644 --- a/Account/doc/api/account_api_external.yaml +++ b/Account/doc/api/account_api_external.yaml @@ -4,7 +4,7 @@ info: title: 'Digital Health Revolution - MyData Account' description: ' #### MyData-SDK - MyData Account API - External ' - version: '1.2' + version: '1.2.1' contact: url: 'https://github.com/HIIT/mydata-stack' license: @@ -1779,28 +1779,23 @@ paths: definitions: errors: - type: array - items: - type: object - properties: - status: - type: string - description: HTTP status code as string value. - code: - type: integer - description: HTTP status code - title: - type: string - description: Title of error message. - detail: - type: string - description: Detailed error message. - source: - type: object - properties: - pointer: - type: string - description: Source URI + type: object + properties: + status: + type: string + description: HTTP status code as string value. + code: + type: integer + description: HTTP status code + title: + type: string + description: Title of error message. + detail: + type: string + description: Detailed error message. + source: + type: string + description: Source URI apiKeyResponse: type: object @@ -1808,6 +1803,9 @@ definitions: Api-Key: type: string description: ApiKey + account_id: + type: string + description: Account ID newAccount: type: object diff --git a/Account/doc/api/account_api_internal.yaml b/Account/doc/api/account_api_internal.yaml index 3fa407c..cb82c5e 100644 --- a/Account/doc/api/account_api_internal.yaml +++ b/Account/doc/api/account_api_internal.yaml @@ -4,7 +4,7 @@ info: title: 'Digital Health Revolution - MyData Account' description: ' #### MyData-SDK - MyData Account API - Internal ' - version: '1.2' + version: '1.2.1' contact: url: 'https://github.com/HIIT/mydata-stack' license: @@ -303,6 +303,134 @@ paths: $ref: '#/definitions/errors' + /consent/{cr_id}/status/last/: + get: + security: + - internalApiKeyAuth: [] + description: "Get last Status Record of Consent" + + parameters: + - name: cr_id + in: path + type: string + description: "Consent Record ID" + required: true + + tags: + - Consent + - Authorizationtoken + + responses: + '200': + description: 'Last Status Record of Consent' + schema: + $ref: '#/definitions/ConsentStatusRecordResponse' + '400': + description: Bad Request + schema: + $ref: '#/definitions/errors' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/errors' + '403': + description: Forbidden + schema: + $ref: '#/definitions/errors' + '500': + description: Internal Server error + schema: + $ref: '#/definitions/errors' + + + /consent/{cr_id}/status/: + get: + security: + - internalApiKeyAuth: [] + description: "Get missing Consent Status Records" + + parameters: + - name: cr_id + in: path + type: string + description: "Consent Record ID" + required: true + - name: csr_id + in: query + type: string + description: "Last valid Consent Status Record ID" + required: true + + tags: + - Consent + - Status + + responses: + '200': + description: 'Array of Consent Status Record objects' + schema: + $ref: '#/definitions/MissingConsentStatusRecordResponse' + '400': + description: Bad Request + schema: + $ref: '#/definitions/errors' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/errors' + '403': + description: Forbidden + schema: + $ref: '#/definitions/errors' + '500': + description: Internal Server error + schema: + $ref: '#/definitions/errors' + + post: + security: + - internalApiKeyAuth: [] + description: "Issue new status" + + parameters: + - name: cr_id + in: path + type: string + description: "Consent Record ID" + required: true + - name: csr_payload + in: body + description: "Consent Record payload" + required: true + schema: + $ref: '#/definitions/newConsentStatus' + + tags: + - Consent + - Status + + responses: + '200': + description: 'New Consent Status Record' + schema: + $ref: '#/definitions/ConsentStatusRecordResponse' + '400': + description: Bad Request + schema: + $ref: '#/definitions/errors' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/errors' + '403': + description: Forbidden + schema: + $ref: '#/definitions/errors' + '500': + description: Internal Server error + schema: + $ref: '#/definitions/errors' + definitions: @@ -324,25 +452,23 @@ definitions: $ref: '#/definitions/Slr' errors: - type: array - items: - type: object - properties: - status: - type: string - description: HTTP status code as string value. - code: - type: integer - description: HTTP status code - title: - type: string - description: Title of error message. - detail: - type: string - description: Detailed error message. - source: - type: string - description: Source URI + type: object + properties: + status: + type: string + description: HTTP status code as string value. + code: + type: integer + description: HTTP status code + title: + type: string + description: Title of error message. + detail: + type: string + description: Detailed error message. + source: + type: string + description: Source URI apiKeyResponse: type: object @@ -377,6 +503,9 @@ definitions: type: type: string description: "Resource type: 'ServiceLinkRecord'" + id: + type: string + description: "ID of resource" attributes: type: object properties: @@ -390,6 +519,9 @@ definitions: type: type: string description: "Resource type: 'ServiceLinkStatusRecord'" + id: + type: string + description: "ID of resource" attributes: type: object properties: @@ -516,6 +648,9 @@ definitions: type: type: string description: "Resource type: 'ConsentRecord'" + id: + type: string + description: "ID of resource" attributes: type: object properties: @@ -529,6 +664,9 @@ definitions: type: type: string description: "Resource type: 'ConsentStatusRecord'" + id: + type: string + description: "ID of resource" attributes: type: object properties: @@ -577,3 +715,19 @@ definitions: $ref: '#/definitions/ConsentRecord' consentStatusRecord: $ref: '#/definitions/ConsentStatusRecord' + + + ConsentStatusRecordResponse: + type: object + properties: + data: + $ref: '#/definitions/ConsentStatusRecord' + + + MissingConsentStatusRecordResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/ConsentStatusRecord' diff --git a/Account/doc/database/MyDataAccount-DBinit.sql b/Account/doc/database/MyDataAccount-DBinit.sql old mode 100644 new mode 100755 index 998946e..db3dfbb --- a/Account/doc/database/MyDataAccount-DBinit.sql +++ b/Account/doc/database/MyDataAccount-DBinit.sql @@ -1,11 +1,11 @@ -- MySQL Script generated by MySQL Workbench --- 09/14/16 10:31:08 +-- 11/17/16 16:42:54 -- Model: New Model Version: 1.0 -- MySQL Workbench Forward Engineering SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; -SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE=''; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL'; -- ----------------------------------------------------- -- Schema MyDataAccount @@ -24,10 +24,11 @@ DROP TABLE IF EXISTS `MyDataAccount`.`Accounts` ; CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Accounts` ( `id` INT NOT NULL AUTO_INCREMENT, - `globalIdenttifyer` VARCHAR(255) NOT NULL, + `globalIdentifier` VARCHAR(255) NOT NULL, `activated` TINYINT(1) NOT NULL DEFAULT 0, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), - UNIQUE INDEX `globalIdenttifyer_UNIQUE` (`globalIdenttifyer` ASC)) + UNIQUE INDEX `globalIdenttifyer_UNIQUE` (`globalIdentifier` ASC)) ENGINE = InnoDB; @@ -42,6 +43,7 @@ CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Particulars` ( `lastname` VARCHAR(255) NOT NULL, `dateOfBirth` DATE NULL DEFAULT NULL, `img_url` VARCHAR(255) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, `Accounts_id` INT NOT NULL, PRIMARY KEY (`id`), INDEX `fk_Particulars_Accounts1_idx` (`Accounts_id` ASC), @@ -66,6 +68,7 @@ CREATE TABLE IF NOT EXISTS `MyDataAccount`.`ServiceLinkRecords` ( `serviceId` VARCHAR(255) NOT NULL, `surrogateId` VARCHAR(255) NOT NULL, `operatorId` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `fk_ServiceLinkRecords_Accounts1_idx` (`Accounts_id` ASC), UNIQUE INDEX `serviceLinkRecordId_UNIQUE` (`serviceLinkRecordId` ASC), @@ -92,10 +95,10 @@ CREATE TABLE IF NOT EXISTS `MyDataAccount`.`ConsentRecords` ( `serviceLinkRecordId` VARCHAR(255) NOT NULL, `subjectId` VARCHAR(255) NOT NULL, `role` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `fk_ConsentRecords_ServiceLinkRecords1_idx` (`ServiceLinkRecords_id` ASC), - UNIQUE INDEX `ConsentRecordId_UNIQUE` (`consentRecordId` ASC), - UNIQUE INDEX `serviceLinkRecordId_UNIQUE` (`serviceLinkRecordId` ASC), + UNIQUE INDEX `consentRecordId_UNIQUE` (`consentRecordId` ASC), CONSTRAINT `fk_ConsentRecords_ServiceLinkRecords1` FOREIGN KEY (`ServiceLinkRecords_id`) REFERENCES `MyDataAccount`.`ServiceLinkRecords` (`id`) @@ -112,6 +115,7 @@ DROP TABLE IF EXISTS `MyDataAccount`.`LocalIdentityPWDs` ; CREATE TABLE IF NOT EXISTS `MyDataAccount`.`LocalIdentityPWDs` ( `id` INT NOT NULL AUTO_INCREMENT, `password` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`)) ENGINE = InnoDB; @@ -126,6 +130,7 @@ CREATE TABLE IF NOT EXISTS `MyDataAccount`.`LocalIdentities` ( `username` VARCHAR(255) NOT NULL, `LocalIdentityPWDs_id` INT NOT NULL, `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE INDEX `username_UNIQUE` (`username` ASC), INDEX `fk_LocalIdentities_LocalIdentityPWDs1_idx` (`LocalIdentityPWDs_id` ASC), @@ -151,6 +156,7 @@ DROP TABLE IF EXISTS `MyDataAccount`.`RemoteIdentityProviders` ; CREATE TABLE IF NOT EXISTS `MyDataAccount`.`RemoteIdentityProviders` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`)) ENGINE = InnoDB; @@ -165,6 +171,7 @@ CREATE TABLE IF NOT EXISTS `MyDataAccount`.`RemoteIdentities` ( `remoteUniqueId` VARCHAR(255) NOT NULL, `Accounts_id` INT NOT NULL, `RemoteIdentityProviders_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE INDEX `opdenIdIdentifyer_UNIQUE` (`remoteUniqueId` ASC), INDEX `fk_RemoteIdentities_Accounts1_idx` (`Accounts_id` ASC), @@ -191,6 +198,7 @@ CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Salts` ( `id` INT NOT NULL AUTO_INCREMENT, `salt` VARCHAR(255) NOT NULL, `LocalIdentities_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE INDEX `hash_UNIQUE` (`salt` ASC), INDEX `fk_Salts_LocalIdentities1_idx` (`LocalIdentities_id` ASC), @@ -209,9 +217,10 @@ DROP TABLE IF EXISTS `MyDataAccount`.`Settings` ; CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Settings` ( `id` INT NOT NULL AUTO_INCREMENT, - `key` VARCHAR(255) NOT NULL, - `value` VARCHAR(255) NOT NULL, + `setting_key` VARCHAR(255) NOT NULL, + `setting_value` VARCHAR(255) NOT NULL, `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `fk_Settings_Accounts1_idx` (`Accounts_id` ASC), CONSTRAINT `fk_Settings_Accounts1` @@ -230,14 +239,15 @@ DROP TABLE IF EXISTS `MyDataAccount`.`Contacts` ; CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Contacts` ( `id` INT NOT NULL AUTO_INCREMENT, `address1` VARCHAR(255) NULL DEFAULT NULL, - `address2` VARCHAR(255) NULL DEFAULT NULL, + `address2` VARCHAR(255) NULL, `postalCode` VARCHAR(255) NULL DEFAULT NULL, `city` VARCHAR(255) NULL DEFAULT NULL, `state` VARCHAR(255) NULL DEFAULT NULL, `country` VARCHAR(255) NULL DEFAULT NULL, - `typeEnum` ENUM('Personal', 'Work', 'School', 'Other') NOT NULL, + `entryType` VARCHAR(255) NOT NULL, `prime` TINYINT(1) NOT NULL DEFAULT 0, `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `fk_Contacts_Accounts1_idx` (`Accounts_id` ASC), CONSTRAINT `fk_Contacts_Accounts1` @@ -255,14 +265,17 @@ DROP TABLE IF EXISTS `MyDataAccount`.`ConsentStatusRecords` ; CREATE TABLE IF NOT EXISTS `MyDataAccount`.`ConsentStatusRecords` ( `id` INT NOT NULL AUTO_INCREMENT, - `consentStatus` ENUM('Active', 'Paused', 'Withdrawn', 'NoSLR') NOT NULL, + `consentStatusRecordId` VARCHAR(255) NOT NULL, + `consentStatus` VARCHAR(255) NOT NULL, `consentStatusRecord` BLOB NOT NULL, `ConsentRecords_id` INT NOT NULL, `consentRecordId` VARCHAR(255) NOT NULL, - `issued_at` VARCHAR(255) NOT NULL, + `issued_at` BIGINT NOT NULL, `prevRecordId` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `fk_ConsentStatusRecords_ConsentRecords1_idx` (`ConsentRecords_id` ASC), + UNIQUE INDEX `consentStatusRecordId_UNIQUE` (`consentStatusRecordId` ASC), CONSTRAINT `fk_ConsentStatusRecords_ConsentRecords1` FOREIGN KEY (`ConsentRecords_id`) REFERENCES `MyDataAccount`.`ConsentRecords` (`id`) @@ -282,9 +295,10 @@ CREATE TABLE IF NOT EXISTS `MyDataAccount`.`ServiceLinkStatusRecords` ( `serviceLinkStatusRecord` BLOB NOT NULL, `ServiceLinkRecords_id` INT NOT NULL, `serviceLinkRecordId` VARCHAR(255) NOT NULL, - `issued_at` VARCHAR(255) NOT NULL, + `issued_at` BIGINT NOT NULL, `prevRecordId` VARCHAR(255) NOT NULL, `serviceLinkStatusRecordId` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `fk_ServiceLinkStatusRecords_ServiceLinkRecords1_idx` (`ServiceLinkRecords_id` ASC), UNIQUE INDEX `serviceLinkStatusRecordId_UNIQUE` (`serviceLinkStatusRecordId` ASC), @@ -303,10 +317,11 @@ DROP TABLE IF EXISTS `MyDataAccount`.`EventLogs` ; CREATE TABLE IF NOT EXISTS `MyDataAccount`.`EventLogs` ( `id` INT NOT NULL AUTO_INCREMENT, - `actor` ENUM('User', 'Operator', 'Service') NOT NULL, + `actor` VARCHAR(255) NOT NULL, `event` BLOB NOT NULL, - `created` TIMESTAMP NOT NULL, + `created` BIGINT NOT NULL, `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `fk_EventLogs_Accounts1_idx` (`Accounts_id` ASC), CONSTRAINT `fk_EventLogs_Accounts1` @@ -325,9 +340,10 @@ DROP TABLE IF EXISTS `MyDataAccount`.`Emails` ; CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Emails` ( `id` INT NOT NULL AUTO_INCREMENT, `email` VARCHAR(255) NULL DEFAULT NULL, - `typeEnum` ENUM('Personal', 'Work', 'School', 'Other') NOT NULL, + `entryType` VARCHAR(255) NOT NULL, `prime` TINYINT(1) NOT NULL DEFAULT 0, `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `fk_Emails_Accounts1_idx` (`Accounts_id` ASC), CONSTRAINT `fk_Emails_Accounts1` @@ -346,9 +362,10 @@ DROP TABLE IF EXISTS `MyDataAccount`.`Telephones` ; CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Telephones` ( `id` INT NOT NULL AUTO_INCREMENT, `tel` VARCHAR(255) NULL DEFAULT NULL, - `typeEnum` ENUM('Personal', 'Work', 'School', 'Other') NOT NULL, + `entryType` VARCHAR(255) NOT NULL, `prime` TINYINT(1) NOT NULL DEFAULT 0, `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `fk_Telephones_Accounts1_idx` (`Accounts_id` ASC), CONSTRAINT `fk_Telephones_Accounts1` @@ -371,6 +388,7 @@ CREATE TABLE IF NOT EXISTS `MyDataAccount`.`OneTimeCookies` ( `created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `LocalIdentities_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, UNIQUE INDEX `oneTimeCookie_UNIQUE` (`oneTimeCookie` ASC), PRIMARY KEY (`id`), INDEX `fk_OneTimeCookies_LocalIdentities1_idx` (`LocalIdentities_id` ASC), @@ -385,6 +403,3 @@ ENGINE = InnoDB; SET SQL_MODE=@OLD_SQL_MODE; SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; - -GRANT CREATE TEMPORARY TABLES, DELETE, DROP, INSERT, LOCK TABLES, SELECT, UPDATE ON MyDataAccount.* TO 'mydataaccount'@'%'; -FLUSH PRIVILEGES; diff --git a/Account/doc/database/MyDataAccount-UserInit.sql b/Account/doc/database/MyDataAccount-UserInit.sql new file mode 100644 index 0000000..042a923 --- /dev/null +++ b/Account/doc/database/MyDataAccount-UserInit.sql @@ -0,0 +1,11 @@ +-- MySQL Script +-- 09/14/16 10:31:08 + +REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'mydataaccount'@'%'; +DROP USER 'mydataaccount'@'%'; +DELETE FROM mysql.user WHERE user='mydataaccount'; +FLUSH PRIVILEGES; + +CREATE USER 'mydataaccount'@'%' IDENTIFIED BY 'wr8gabrA'; +GRANT CREATE TEMPORARY TABLES, DELETE, DROP, INSERT, LOCK TABLES, SELECT, UPDATE ON MyDataAccount.* TO 'mydataaccount'@'%'; +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/Account/doc/database/MyDataAccount.mwb b/Account/doc/database/MyDataAccount.mwb old mode 100644 new mode 100755 index 906cfaa9a3ab8130c8ca8c3b354ceff71fb6a2a5..35a3086c9e179138423aa7e13a065128d52c7554 GIT binary patch literal 27724 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 literal 23501 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 aySw^0LKHclglOP;05xZ4aG-Ah>Hh!{{SN5> diff --git a/Account/doc/database/MyDataAccount.png b/Account/doc/database/MyDataAccount.png old mode 100644 new mode 100755 index 2a930f3983df276116a5cf25b5baf86196bc7b81..133e909888690c39769f8a8e13d0cc5ef2c97946 GIT binary patch literal 111152 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 \ + $APP_INSTALL_PATH/config.py + +# Try to start whatever was given as a parameter to "docker run" -command +exec "$@" diff --git a/Account/operator_emulator/operator_emulator.py b/Account/operator_emulator/operator_emulator.py index 401980e..d403189 100644 --- a/Account/operator_emulator/operator_emulator.py +++ b/Account/operator_emulator/operator_emulator.py @@ -17,6 +17,8 @@ import requests import time + +import sys from requests.auth import HTTPBasicAuth import json @@ -46,9 +48,26 @@ source_csr_id = "SOURCE-CSR-" + str(uuid4()) sink_csr_id = "SINK-CSR-" + str(uuid4()) +source_csr_id_new = "SOURCE-CSR-NEW-" + str(uuid4()) +source_csr_id_new_2 = "SOURCE-CSR-NEW2-" + str(uuid4()) + rs_id = "RS-ID-" + str(uuid4()) -not_before = str(time.time()) -not_after = str(time.time() + (60*60*24*7)) + +epoch = int(time.time()) + +source_slr_iat = epoch +sink_slr_iat = epoch +source_slsr_iat = epoch +sink_slsr_iat = epoch +cr_not_before = epoch +cr_not_after = epoch + (60*60*24*7) +csr_not_before = epoch +csr_not_after = epoch + (60*60*24*7) +source_cr_iat = epoch +sink_cr_iat = epoch +source_csr_iat = epoch +sink_csr_iat = epoch + distribution_id = "DISTRIBUTION-ID-" + str(uuid4()) dataset_id = "DATASET-ID-" + str(uuid4()) @@ -63,15 +82,6 @@ "operator_id": operator_id, "service_id": source_service_id, "surrogate_id": source_surrogate_id, - "token_key": { - "key": { - "y": "FFuMENxef5suGtcBz4PWXt_KvRUHdURU5kH7EI5GZj8", - "x": "5IxIntzP7SPShzbGVW6dVYQlMsJ9kg9rjrE5Z3B6fmg", - "kid": "SRVMGNT-IDK3Y", - "crv": "P-256", - "kty": "EC" - } - }, "operator_key": { "key": { "y": "FFuMENxef5suGtcBz4PWXt_KvRUHdURU5kH7EI5GZj8", @@ -82,7 +92,7 @@ } }, "cr_keys": "", - "created": "" + "iat": source_slr_iat } }, "surrogate_id": { @@ -107,15 +117,6 @@ "operator_id": operator_id, "service_id": sink_service_id, "surrogate_id": sink_surrogate_id, - "token_key": { - "key": { - "y": "FFuMENxef5suGtcBz4PWXt_KvRUHdURU5kH7EI5GZj8", - "x": "5IxIntzP7SPShzbGVW6dVYQlMsJ9kg9rjrE5Z3B6fmg", - "kid": "SRVMGNT-IDK3Y", - "crv": "P-256", - "kty": "EC" - } - }, "operator_key": { "key": { "y": "FFuMENxef5suGtcBz4PWXt_KvRUHdURU5kH7EI5GZj8", @@ -126,7 +127,7 @@ } }, "cr_keys": "", - "created": "" + "iat": sink_slr_iat } }, "surrogate_id": { @@ -152,10 +153,10 @@ "ssr": { "attributes": { "record_id": source_ssr_id, - "account_id": source_surrogate_id, + "surrogate_id": source_surrogate_id, "slr_id": source_slr_id, "sl_status": "Active", - "iat": "", + "iat": source_slsr_iat, "prev_record_id": "NULL" }, "type": "ServiceLinkStatusRecord" @@ -183,10 +184,10 @@ "ssr": { "attributes": { "record_id": sink_ssr_id, - "account_id": sink_surrogate_id, + "surrogate_id": sink_surrogate_id, "slr_id": sink_slr_id, "sl_status": "Active", - "iat": "", + "iat": sink_slsr_iat, "prev_record_id": "NULL" }, "type": "ServiceLinkStatusRecord" @@ -209,21 +210,10 @@ "type": "ConsentRecord", "attributes": { "common_part": { - "version_number": "1.2", + "version": "1.2", "cr_id": source_cr_id, "surrogate_id": source_surrogate_id, - "rs_id": rs_id, - "slr_id": source_slr_id, - "issued": "timestamp", - "not_before": not_before, - "not_after": not_after, - "issued_at": operator_id, - "subject_id": source_service_id - }, - "role_specific_part": { - "role": "Source", - "auth_token_issuer_key": {}, - "resource_set_description": { + "rs_description": { "resource_set": { "rs_id": rs_id, "dataset": [ @@ -237,20 +227,51 @@ } ] } + }, + "slr_id": source_slr_id, + "iat": source_cr_iat, + "nbf": cr_not_before, + "exp": cr_not_after, + "operator": operator_id, + "subject_id": source_service_id, + "role": "Source" + }, + "role_specific_part": { + "pop_key": { + "key": { + "y": "FFuMENxef5suGtcBz4PWXt_KvRUHdURU5kH7EI5GZj8", + "x": "5IxIntzP7SPShzbGVW6dVYQlMsJ9kg9rjrE5Z3B6fmg", + "kid": "SRVMGNT-IDK3Y", + "crv": "P-256", + "kty": "EC" + } + }, + "token_issuer_key": { + "key": { + "y": "FFuMENxef5suGtcBz4PWXt_KvRUHdURU5kH7EI5GZj8", + "x": "5IxIntzP7SPShzbGVW6dVYQlMsJ9kg9rjrE5Z3B6fmg", + "kid": "SRVMGNT-IDK3Y", + "crv": "P-256", + "kty": "EC" + } } }, - "ki_cr": {}, - "extensions": {} + "consent_receipt_part": { + "ki_cr": {} + }, + "extension_part": { + "extensions": {} + } } }, "consentStatusRecordPayload": { "type": "ConsentStatusRecord", "attributes": { "record_id": source_csr_id, - "account_id": source_surrogate_id, + "surrogate_id": source_surrogate_id, "cr_id": source_cr_id, "consent_status": "Active", - "iat": "timestamp", + "iat": source_csr_iat, "prev_record_id": "Null" } } @@ -260,37 +281,56 @@ "type": "ConsentRecord", "attributes": { "common_part": { - "version_number": "1.2", + "version": "1.2", "cr_id": sink_cr_id, "surrogate_id": sink_surrogate_id, - "rs_id": rs_id, + "rs_description": { + "resource_set": { + "rs_id": rs_id, + "dataset": [ + { + "dataset_id": dataset_id + "_1", + "distribution_id": distribution_id + "_1" + }, + { + "dataset_id": dataset_id + "_2", + "distribution_id": distribution_id + "_2" + } + ] + } + }, "slr_id": sink_slr_id, - "issued": "timestamp", - "not_before": not_before, - "not_after": not_after, - "issued_at": operator_id, - "subject_id": sink_service_id + "iat": sink_cr_iat, + "nbf": cr_not_before, + "exp": cr_not_after, + "operator": operator_id, + "subject_id": sink_service_id, + "role": "Sink" }, "role_specific_part": { - "role": "Sink", + "source_cr_id": source_cr_id, "usage_rules": [ "Rule 1", "Rule 2", "Rule 3" ] }, - "ki_cr": {}, - "extensions": {} + "consent_receipt_part": { + "ki_cr": {} + }, + "extension_part": { + "extensions": {} + } } }, "consentStatusRecordPayload": { "type": "ConsentStatusRecord", "attributes": { "record_id": sink_csr_id, - "account_id": sink_surrogate_id, + "surrogate_id": sink_surrogate_id, "cr_id": sink_cr_id, "consent_status": "Active", - "iat": "timestamp", + "iat": sink_csr_iat, "prev_record_id": "Null" } } @@ -298,6 +338,34 @@ } } +source_change_cr_status_payload = { + "data": { + "type": "ConsentStatusRecord", + "attributes": { + "record_id": source_csr_id_new, + "surrogate_id": source_surrogate_id, + "cr_id": source_cr_id, + "consent_status": "Disabled", + "iat": source_csr_iat, + "prev_record_id": source_csr_id + } + } + } + +source_change_cr_status_payload_2 = { + "data": { + "type": "ConsentStatusRecord", + "attributes": { + "record_id": source_csr_id_new_2, + "surrogate_id": source_surrogate_id, + "cr_id": source_cr_id, + "consent_status": "Active", + "iat": source_csr_iat, + "prev_record_id": source_csr_id_new + } + } + } + def slr_sign(host=None, account_id=None, headers=None, data=None): if host is None: @@ -314,7 +382,7 @@ def slr_sign(host=None, account_id=None, headers=None, data=None): print("Request") print("Endpoint: " + endpoint) - print("Payload: " + json.dumps(data, indent=3)) + print("Payload: " + json.dumps(data)) req = requests.post(url, headers=headers, json=data) status_code = str(req.status_code) @@ -348,7 +416,7 @@ def slr_verify(host=None, account_id=None, headers=None, slr_to_verify=None, dat print("Request") print("Endpoint: " + endpoint) - print("Payload: " + json.dumps(data, indent=3)) + print("Payload: " + json.dumps(data)) req = requests.post(url, headers=headers, json=data) status_code = str(req.status_code) @@ -399,7 +467,103 @@ def give_consent(host=None, account_id=None, source_slr_id=None, sink_slr_id=Non print("Request") print("Endpoint: " + endpoint) - print("Payload: " + json.dumps(data, indent=3)) + print("Payload: " + json.dumps(data)) + + req = requests.post(url, headers=headers, json=data) + status_code = str(req.status_code) + response_data = json.loads(req.text) + + return status_code, response_data + + +# Get Authorization token data +def get_auth_token_data(host=None, headers=None, cr_id=None): + if host is None: + raise AttributeError("Provide host as parameter") + if headers is None: + raise AttributeError("Provide headers as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + + endpoint = "/api/consent/" + str(cr_id) + "/authorizationtoken/" + url = host + endpoint + + print("Request") + print("Endpoint: " + endpoint) + + req = requests.get(url, headers=headers) + status_code = str(req.status_code) + print("status_code:" + status_code) + response_data = json.loads(req.text) + + return status_code, response_data + + +# Get last CR status +def get_last_cr_status(host=None, headers=None, cr_id=None): + if host is None: + raise AttributeError("Provide host as parameter") + if headers is None: + raise AttributeError("Provide headers as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + + endpoint = "/api/consent/" + str(cr_id) + "/status/last/" + url = host + endpoint + + print("Request") + print("Endpoint: " + endpoint) + + req = requests.get(url, headers=headers) + status_code = str(req.status_code) + print("status_code:" + status_code) + response_data = json.loads(req.text) + + return status_code, response_data + + +# Get Missing CR statuses +def get_cr_statuses(host=None, headers=None, cr_id=None, last_csr_id=None): + if host is None: + raise AttributeError("Provide host as parameter") + if headers is None: + raise AttributeError("Provide headers as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + if last_csr_id is None: + endpoint = "/api/consent/" + str(cr_id) + "/status/" + else: + endpoint = "/api/consent/" + str(cr_id) + "/status/?csr_id=" + last_csr_id + + url = host + endpoint + + print("Request") + print("Endpoint: " + endpoint) + + req = requests.get(url, headers=headers) + status_code = str(req.status_code) + print("status_code:" + status_code) + response_data = json.loads(req.text) + + return status_code, response_data + + +def change_consent_status(host=None, cr_id=None, headers=None, data=None): + if host is None: + raise AttributeError("Provide host as parameter") + if cr_id is None: + raise AttributeError("Provide cr_id as parameter") + if headers is None: + raise AttributeError("Provide headers as parameter") + if data is None: + raise AttributeError("Provide consent_data as parameter") + + endpoint = "/api/consent/" + str(cr_id) + "/status/" + url = host + endpoint + + print("Request") + print("Endpoint: " + endpoint) + print("Payload: " + json.dumps(data)) req = requests.post(url, headers=headers, json=data) status_code = str(req.status_code) @@ -415,37 +579,41 @@ def give_consent(host=None, account_id=None, source_slr_id=None, sink_slr_id=Non except Exception as exp: error_title = "Source SLR filed" print(error_title + ": " + repr(exp)) + raise else: - request_statuses.append("Source SLR: " + source_slr[0]) + request_statuses.append("Source SLR: " + source_slr[0] + " | " + json.dumps(source_slr[1])) print ("Response: " + source_slr[0]) - print (json.dumps(source_slr[1], indent=3)) + print (json.dumps(source_slr[1])) -# Sink SLR sign + +# Source SLR verify print ("------------------------------------") -print("Sink SLR") +print("Source SLR verify") try: - sink_slr = slr_sign(host=account_host, account_id=account_id, headers=headers, data=sink_slr_payload) + source_slr_verified = slr_verify(host=account_host, account_id=account_id, headers=headers, slr_to_verify=source_slr[1]['data']['slr']['attributes']['slr'], data_template=source_ssr_payload) except Exception as exp: - error_title = "Sink SLR filed" + error_title = "Source SLR verification filed" print(error_title + ": " + repr(exp)) + raise else: - request_statuses.append("Sink SLR: " + sink_slr[0]) - print ("Response: " + sink_slr[0]) - print (json.dumps(sink_slr[1], indent=3)) + request_statuses.append("Source SLR verify: " + source_slr_verified[0] + " | " + json.dumps(source_slr_verified[1])) + print ("Response: " + source_slr_verified[0]) + print (json.dumps(source_slr_verified[1])) -# Source SLR verify +# Sink SLR sign print ("------------------------------------") -print("Source SLR verify") +print("Sink SLR") try: - source_slr_verified = slr_verify(host=account_host, account_id=account_id, headers=headers, slr_to_verify=source_slr[1]['data']['slr']['attributes']['slr'], data_template=source_ssr_payload) + sink_slr = slr_sign(host=account_host, account_id=account_id, headers=headers, data=sink_slr_payload) except Exception as exp: - error_title = "Source SLR verification filed" + error_title = "Sink SLR filed" print(error_title + ": " + repr(exp)) + raise else: - request_statuses.append("Source SLR verify: " + source_slr_verified[0]) - print ("Response: " + source_slr_verified[0]) - print (json.dumps(source_slr_verified[1], indent=3)) + request_statuses.append("Sink SLR: " + sink_slr[0] + " | " + json.dumps(sink_slr[1])) + print ("Response: " + sink_slr[0]) + print (json.dumps(sink_slr[1])) # Sink SLR verify print ("------------------------------------") @@ -455,10 +623,11 @@ def give_consent(host=None, account_id=None, source_slr_id=None, sink_slr_id=Non except Exception as exp: error_title = "Sink SLR verification filed" print(error_title + ": " + repr(exp)) + raise else: - request_statuses.append("Sink SLR verify: " + sink_slr_verified[0]) + request_statuses.append("Sink SLR verify: " + sink_slr_verified[0] + " | " + json.dumps(sink_slr_verified[1])) print ("Response: " + sink_slr_verified[0]) - print (json.dumps(sink_slr_verified[1], indent=3)) + print (json.dumps(sink_slr_verified[1])) # Surrogate Source @@ -469,10 +638,11 @@ def give_consent(host=None, account_id=None, source_slr_id=None, sink_slr_id=Non except Exception as exp: error_title = "Consenting failed" print(error_title + ": " + repr(exp)) + raise else: - request_statuses.append("Get Surrogate Source: " + sur[0]) + request_statuses.append("Get Surrogate Source: " + sur[0] + " | " + json.dumps(sur[1])) print ("Response: " + sur[0]) - print (json.dumps(sur[1], indent=3)) + print (json.dumps(sur[1])) # Surrogate Sink @@ -483,10 +653,11 @@ def give_consent(host=None, account_id=None, source_slr_id=None, sink_slr_id=Non except Exception as exp: error_title = "Consenting failed" print(error_title + ": " + repr(exp)) + raise else: - request_statuses.append("Get Surrogate Sink: " + sur[0]) + request_statuses.append("Get Surrogate Sink: " + sur[0] + " | " + json.dumps(sur[1])) print ("Response: " + sur[0]) - print (json.dumps(sur[1], indent=3)) + print (json.dumps(sur[1])) # Give consent @@ -497,10 +668,107 @@ def give_consent(host=None, account_id=None, source_slr_id=None, sink_slr_id=Non except Exception as exp: error_title = "Consenting failed" print(error_title + ": " + repr(exp)) + raise else: - request_statuses.append("Give Consent: " + consenting[0]) + request_statuses.append("Give Consent: " + consenting[0] + " | " + json.dumps(consenting[1])) print ("Response: " + consenting[0]) - print (json.dumps(consenting[1], indent=3)) + print (json.dumps(consenting[1])) + + +# Get Authorization token +print ("------------------------------------") +print("Get Authorization token") + +try: + token = get_auth_token_data(host=account_host, cr_id=sink_cr_id, headers=headers) +except Exception as exp: + error_title = "Could not get Authorization token" + print(error_title + ": " + repr(exp)) + raise +else: + request_statuses.append("Authorization token: " + token[0] + " | " + json.dumps(token[1])) + print ("Response: " + token[0]) + print (json.dumps(token[1])) + + +# Get last CR Status +print ("------------------------------------") +print("Get last CR Status") + +try: + last_cr = get_last_cr_status(host=account_host, cr_id=source_cr_id, headers=headers) +except Exception as exp: + error_title = "Could not get last CR Status" + print(error_title + ": " + repr(exp)) + raise +else: + request_statuses.append("Last CR Status: " + last_cr[0] + " | " + json.dumps(last_cr[1])) + print ("Response: " + last_cr[0]) + print (json.dumps(last_cr[1])) + + +# Change CR Status +print ("------------------------------------") +print("Change CR Status") + +try: + new_cr = change_consent_status(host=account_host, cr_id=source_cr_id, headers=headers, data=source_change_cr_status_payload) +except Exception as exp: + error_title = "Could not change CR Status" + print(error_title + ": " + repr(exp)) + raise +else: + request_statuses.append("Change CR Status: " + new_cr[0] + " | " + json.dumps(new_cr[1])) + print ("Response: " + new_cr[0]) + print (json.dumps(new_cr[1])) + +# +# Change CR Status again +print ("------------------------------------") +print("Change CR Status") + +try: + new_cr = change_consent_status(host=account_host, cr_id=source_cr_id, headers=headers, data=source_change_cr_status_payload_2) +except Exception as exp: + error_title = "Could not change CR Status" + print(error_title + ": " + repr(exp)) + raise +else: + request_statuses.append("Change CR Status: " + new_cr[0] + " | " + json.dumps(new_cr[1])) + print ("Response: " + new_cr[0]) + print (json.dumps(new_cr[1])) + + +# Get new last CR Status +print ("------------------------------------") +print("Get new last CR Status") + +try: + last_cr = get_last_cr_status(host=account_host, cr_id=source_cr_id, headers=headers) +except Exception as exp: + error_title = "Could not get new last CR Status" + print(error_title + ": " + repr(exp)) + raise +else: + request_statuses.append("New last CR Status: " + last_cr[0] + " | " + json.dumps(last_cr[1])) + print ("Response: " + last_cr[0]) + print (json.dumps(last_cr[1])) + + +# Get missing CR Statuses +print ("------------------------------------") +print("Get missing CR Statuses") + +try: + last_cr = get_cr_statuses(host=account_host, cr_id=source_cr_id, last_csr_id=source_csr_id_new, headers=headers) +except Exception as exp: + error_title = "Could not get missing CR Statuses" + print(error_title + ": " + repr(exp)) + raise +else: + request_statuses.append("Missing CR Statuses: " + last_cr[0] + " | " + json.dumps(last_cr[1])) + print ("Response: " + last_cr[0]) + print (json.dumps(last_cr[1])) print ("------------------------------------") diff --git a/Account/operator_emulator/ui_emulator.py b/Account/operator_emulator/ui_emulator.py new file mode 100644 index 0000000..62384ec --- /dev/null +++ b/Account/operator_emulator/ui_emulator.py @@ -0,0 +1,911 @@ +# -*- coding: utf-8 -*- + +""" +Minimum viable account - MyData Operator UI Emulator + +__author__ = "Jani Yli-Kantola" +__copyright__ = "Digital Health Revolution (c) 2016" +__credits__ = ["Harri Hirvonsalo", "Aleksi Palomäki"] +__license__ = "MIT" +__version__ = "0.0.1" +__maintainer__ = "Jani Yli-Kantola" +__contact__ = "https://github.com/HIIT/mydata-stack" +__status__ = "Development" +__date__ = 12.8.2016 +""" +from uuid import uuid4 + +import requests +import time +from requests.auth import HTTPBasicAuth +import json + +request_statuses = [] + +account_ip = "http://127.0.0.1" +account_port = "8080" +account_host = account_ip+":"+account_port +headers = {'Content-Type': 'application/json'} + +account_id = "" +particular_id = "" +contacts_id = "" + + +predefined_account_username = "testUser" +predefined_account_password = "Hello" + +username = "example_username-" + str(uuid4()) +password = "example_password" + +account_template = { + "data": { + "type": "Account", + "attributes": { + 'firstName': 'ExampleFirstName', + 'lastName': 'ExampleLastName', + 'dateOfBirth': '2010-05-14', + 'email': username + '@examlpe.org', + 'username': username, + 'password': password, + 'acceptTermsOfService': 'True' + } + } +} + +particular_template_for_patch = { + "data": { + "type": "Particular", + "attributes": { + 'lastname': 'NewExampleLastName' + } + } +} + +contact_template = { + "data": { + "type": "Contact", + "attributes": { + 'address1': 'Example address 1', + 'address2': 'Example address 2', + 'postalCode': '97584', + 'city': 'Example city', + 'state': 'Example state', + 'country': 'Example country', + 'type': 'Personal', + 'primary': 'True' + } + } +} + +contact_template_for_patch = { + "data": { + "type": "Contact", + "attributes": { + 'address1': 'Example address 1', + 'address2': 'Example address 2', + 'postalCode': '65784', + 'city': 'Example city', + 'state': 'Example state', + 'country': 'Example country', + 'type': 'Personal', + 'primary': 'False' + } + } +} + +email_template = { + "data": { + "type": "Email", + "attributes": { + 'email': 'erkki@example.com', + 'type': 'Personal', + 'primary': 'True' + } + } +} + +email_template_for_patch = { + "data": { + "type": "Email", + "attributes": { + 'email': 'pasi@example.org', + 'type': 'School', + 'primary': 'False' + } + } +} + +telephone_template = { + "data": { + "type": "Telephone", + "attributes": { + 'tel': '0501234567', + 'type': 'Personal', + 'primary': 'True' + } + } +} + +telephone_template_for_patch = { + "data": { + "type": "Telephone", + "attributes": { + 'tel': '+358 50 123 4567', + 'type': 'School', + 'primary': 'False' + } + } +} + +setting_template = { + "data": { + "type": "Setting", + "attributes": { + 'key': 'lang', + 'value': 'fi' + } + } +} + +setting_template_for_patch = { + "data": { + "type": "Setting", + "attributes": { + 'key': 'lang', + 'value': 'se' + } + } +} + + + +def post(host=None, endpoint=None, headers=None, data=None): + if host is None: + raise AttributeError("Provide host as parameter") + if endpoint is None: + raise AttributeError("Provide endpoint as parameter") + if headers is None: + raise AttributeError("Provide headers as parameter") + if data is None: + raise AttributeError("Provide data as parameter") + + url = host + endpoint + print("Endpoint: " + endpoint) + print("Headers: " + json.dumps(headers)) + print("Payload: " + json.dumps(data)) + + req = requests.post(url, headers=headers, json=data) + status_code = str(req.status_code) + print ("Response status: " + str(req.status_code)) + try: + response_data = json.loads(req.text) + except Exception as exp: + print(repr(exp)) + print("req.text: " + repr(req.text)) + response_data = repr(req.text) + + return status_code, response_data + + +def patch(host=None, endpoint=None, headers=None, data=None): + if host is None: + raise AttributeError("Provide host as parameter") + if endpoint is None: + raise AttributeError("Provide endpoint as parameter") + if headers is None: + raise AttributeError("Provide headers as parameter") + if data is None: + raise AttributeError("Provide data as parameter") + + url = host + endpoint + print("Endpoint: " + endpoint) + print("Headers: " + json.dumps(headers)) + print("Payload: " + json.dumps(data)) + + req = requests.patch(url, headers=headers, json=data) + status_code = str(req.status_code) + print ("Response status: " + str(req.status_code)) + try: + response_data = json.loads(req.text) + except Exception as exp: + print(repr(exp)) + print("req.text: " + repr(req.text)) + response_data = repr(req.text) + + return status_code, response_data + + +def get(host=None, endpoint=None, headers=None, username=None, password=None): + if host is None: + raise AttributeError("Provide host as parameter") + if endpoint is None: + raise AttributeError("Provide endpoint as parameter") + if headers is None: + raise AttributeError("Provide headers as parameter") + + url = host + endpoint + print("Endpoint: " + endpoint) + print("Headers: " + json.dumps(headers)) + + if username is not None and password is not None: + req = requests.get(url, headers=headers, auth=HTTPBasicAuth(username=username, password=password)) + else: + req = requests.get(url, headers=headers) + status_code = str(req.status_code) + print ("Response status: " + str(req.status_code)) + try: + response_data = json.loads(req.text) + except Exception as exp: + print(repr(exp)) + print("req.text: " + repr(req.text)) + response_data = repr(req.text) + + return status_code, response_data + + +######### Actions + +################################## +# Create Account and Authenticate +################################## +label = "# \n# Create Account and Authenticate \n#################################" +print(label) +request_statuses.append(label) + +if not predefined_account_username and not predefined_account_password: + # + # Create Account + title = "Create Account" + print(title) + try: + account = post(host=account_host, endpoint="/api/accounts/", headers=headers, data=account_template) + except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise + else: + request_response = title + ": " + account[0] + ": " + json.dumps(account[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + account_id = str(account[1]['data'].get("id", "None")) + print ("Response " + account[0] + ": " + json.dumps(account[1])) + print ("Account ID: " + account_id) + +else: + print("Using predefined account") + username = predefined_account_username + password = predefined_account_password + +# +# Authenticate +print ("------------------------------------") +title = "Authenticate" +print(title) +try: + api_auth = get(host=account_host, endpoint="/api/auth/user/", headers=headers, username=username, password=password) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + api_auth[0] + ": " + json.dumps(api_auth[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + apikey = str(api_auth[1].get("Api-Key", "None")) + account_id = str(api_auth[1].get("account_id", "None")) + headers['Api-Key'] = apikey + print ("Response " + api_auth[0] + ": " + json.dumps(api_auth[1])) + print ("apikey: " + apikey) + +# +# ################################## +# # PARTICULARS +# ################################## +label = "# \n# PARTICULARS \n#################################" +print(label) +request_statuses.append(label) + +title = "List Particulars" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/particulars/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + particular_id = str(entries[1]['data'][0].get("id", "None")) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + print ("particular_id: " + particular_id) + + +print ("------------------------------------") +title = "One Particular" +print(title) +try: + entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/particulars/" + particular_id + "/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entry[0] + ": " + json.dumps(entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + entry[0] + ": " + json.dumps(entry[1])) + print ("particular_id: " + str(entry[1]['data'].get("id", "None"))) + + +print ("------------------------------------") +title = "Patch Particular" +print(title) +try: + particular_template_for_patch['data']['id'] = str(particular_id) + updated_entry = patch(host=account_host, endpoint="/api/accounts/" + account_id + "/particulars/" + particular_id + "/", headers=headers, data=particular_template_for_patch) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + updated_entry[0] + ": " + json.dumps(updated_entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + updated_entry[0] + ": " + json.dumps(updated_entry[1])) + + +# ################################## +# # CONTACTS +# ################################## +label = "# \n# CONTACTS \n#################################" +print(label) +request_statuses.append(label) + +title = "Add Contact" +print(title) +try: + new_entry = post(host=account_host, endpoint="/api/accounts/" + account_id + "/contacts/", headers=headers, data=contact_template) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + new_entry[0] + ": " + json.dumps(new_entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + new_entry[0] + ": " + json.dumps(new_entry[1])) + +print ("------------------------------------") +title = "List Contacts" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/contacts/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + contacts_id = str(entries[1]['data'][0].get("id", "None")) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + print ("contacts_id: " + contacts_id) + + +print ("------------------------------------") +title = "One Contact" +print(title) +try: + entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/contacts/" + contacts_id + "/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_statuses.append(title + ": " + entry[0] + ": " + json.dumps(entry[1])) + print ("Response " + entry[0] + ": " + json.dumps(entry[1])) + print ("contacts_id: " + str(entry[1]['data'].get("id", "None"))) + + +print ("------------------------------------") +title = "Patch Contact" +print(title) +try: + contact_template_for_patch['data']['id'] = str(contacts_id) + updated_entry = patch(host=account_host, endpoint="/api/accounts/" + account_id + "/contacts/" + contacts_id + "/", headers=headers, data=contact_template_for_patch) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + updated_entry[0] + ": " + json.dumps(updated_entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + updated_entry[0] + ": " + json.dumps(updated_entry[1])) + + +# ################################## +# # EMAIL +# ################################## +label = "# \n# EMAIL \n#################################" +print(label) +request_statuses.append(label) + +title = "Add Email" +print(title) +try: + new_entry = post(host=account_host, endpoint="/api/accounts/" + account_id + "/emails/", headers=headers, data=email_template) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + new_entry[0] + ": " + json.dumps(new_entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + new_entry[0] + ": " + json.dumps(new_entry[1])) + +print ("------------------------------------") +title = "List Emails" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/emails/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + email_id = str(entries[1]['data'][0].get("id", "None")) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + print ("email_id: " + email_id) + + +print ("------------------------------------") +title = "One Email" +print(title) +try: + entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/emails/" + email_id + "/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_statuses.append(title + ": " + entry[0] + ": " + json.dumps(entry[1])) + print ("Response " + entry[0] + ": " + json.dumps(entry[1])) + print ("email_id: " + str(entry[1]['data'].get("id", "None"))) + + +print ("------------------------------------") +title = "Patch Email" +print(title) +try: + email_template_for_patch['data']['id'] = str(email_id) + updated_entry = patch(host=account_host, endpoint="/api/accounts/" + account_id + "/emails/" + email_id + "/", headers=headers, data=email_template_for_patch) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + updated_entry[0] + ": " + json.dumps(updated_entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + updated_entry[0] + ": " + json.dumps(updated_entry[1])) + + +# ################################## +# # TELEPHONE +# ################################## +label = "# \n# TELEPHONE \n#################################" +print(label) +request_statuses.append(label) + +title = "Add Telephone" +print(title) +try: + new_entry = post(host=account_host, endpoint="/api/accounts/" + account_id + "/telephones/", headers=headers, data=telephone_template) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + new_entry[0] + ": " + json.dumps(new_entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + new_entry[0] + ": " + json.dumps(new_entry[1])) + +print ("------------------------------------") +title = "List Telephones" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/telephones/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + telephones_id = str(entries[1]['data'][0].get("id", "None")) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + print ("telephones_id: " + telephones_id) + + +print ("------------------------------------") +title = "One Telephone" +print(title) +try: + entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/telephones/" + telephones_id + "/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_statuses.append(title + ": " + entry[0] + ": " + json.dumps(entry[1])) + print ("Response " + entry[0] + ": " + json.dumps(entry[1])) + print ("telephones_id: " + str(entry[1]['data'].get("id", "None"))) + + +print ("------------------------------------") +title = "Patch Telephone" +print(title) +try: + telephone_template_for_patch['data']['id'] = str(telephones_id) + updated_entry = patch(host=account_host, endpoint="/api/accounts/" + account_id + "/telephones/" + telephones_id + "/", headers=headers, data=telephone_template_for_patch) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + updated_entry[0] + ": " + json.dumps(updated_entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + updated_entry[0] + ": " + json.dumps(updated_entry[1])) + + +# ################################## +# # SETTINGS +# ################################## +label = "# \n# SETTINGS \n#################################" +print(label) +request_statuses.append(label) + +title = "Add Setting" +print(title) +try: + new_entry = post(host=account_host, endpoint="/api/accounts/" + account_id + "/settings/", headers=headers, data=setting_template) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + new_entry[0] + ": " + json.dumps(new_entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + new_entry[0] + ": " + json.dumps(new_entry[1])) + +print ("------------------------------------") +title = "List Settings" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/settings/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + settings_id = str(entries[1]['data'][0].get("id", "None")) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + print ("settings_id: " + settings_id) + + +print ("------------------------------------") +title = "One Setting" +print(title) +try: + entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/settings/" + settings_id + "/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_statuses.append(title + ": " + entry[0] + ": " + json.dumps(entry[1])) + print ("Response " + entry[0] + ": " + json.dumps(entry[1])) + print ("settings_id: " + str(entry[1]['data'].get("id", "None"))) + + +print ("------------------------------------") +title = "Patch Setting" +print(title) +try: + setting_template_for_patch['data']['id'] = str(settings_id) + updated_entry = patch(host=account_host, endpoint="/api/accounts/" + account_id + "/settings/" + settings_id + "/", headers=headers, data=setting_template_for_patch) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + updated_entry[0] + ": " + json.dumps(updated_entry[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + updated_entry[0] + ": " + json.dumps(updated_entry[1])) + + +# ################################## +# # EVENT LOGS +# ################################## +# # label = "# \n# EVENT LOGS \n#################################" +# # print(label) +# # request_statuses.append(label) +# # +# # print ("------------------------------------") +# # title = "List Events" +# # print(title) +# # try: +# # entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/logs/events/", headers=headers) +# # except Exception as exp: +# # print(title + ": " + repr(exp)) +# # request_response = title + ": " + repr(exp) +# # request_statuses.append(request_response) +# # raise +# # else: +# # request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) +# # print('request_response: ' + request_response) +# # request_statuses.append(request_response) +# # event_log_id = str(entries[1]['data'][0].get("id", "None")) +# # print ("Response " + new_entry[0] + ": " + json.dumps(new_entry[1])) +# # print ("event_log_id: " + event_log_id) +# # +# # +# # print ("------------------------------------") +# # title = "One Event" +# # print(title) +# # try: +# # entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/logs/events/" + event_log_id + "/", headers=headers) +# # except Exception as exp: +# # print(title + ": " + repr(exp)) +# # request_response = title + ": " + repr(exp) +# # request_statuses.append(request_response) +# # raise +# # else: +# # request_statuses.append(title + ": " + entry[0] + ": " + json.dumps(entry[1])) +# # print ("Response " + entry[0] + ": " + json.dumps(entry[1])) +# # print ("event_log_id: " + str(entry[1]['data'].get("id", "None"))) +# +# +# ################################## +# # Service Link Records +# ################################## +label = "# \n# Service Link Records \n#################################" +print(label) +request_statuses.append(label) + +print ("------------------------------------") +title = "Service Link Records" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/servicelinks/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + slr_id = str(entries[1]['data'][0].get("id", "None")) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + print ("slr_id: " + slr_id) + + +print ("------------------------------------") +title = "One Service Link Record" +print(title) +try: + entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/servicelinks/" + slr_id + "/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_statuses.append(title + ": " + entry[0] + ": " + json.dumps(entry[1])) + print ("Response " + entry[0] + ": " + json.dumps(entry[1])) + print ("slr_id: " + str(entry[1]['data'].get("id", "None"))) + + +################################## +# Service Link Status Records +################################## +label = "# \n# Service Link Status Records \n#################################" +print(label) +request_statuses.append(label) + +print ("------------------------------------") +title = "Service Link Status Records" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/servicelinks/" + slr_id + "/statuses/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + slsr_id = str(entries[1]['data'][0].get("id", "None")) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + print ("slsr_id: " + slsr_id) + + +print ("------------------------------------") +title = "One Service Link Status Record" +print(title) +try: + entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/servicelinks/" + slr_id + "/statuses/" + slsr_id + "/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_statuses.append(title + ": " + entry[0] + ": " + json.dumps(entry[1])) + print ("Response " + entry[0] + ": " + json.dumps(entry[1])) + print ("slsr_id: " + str(entry[1]['data'].get("id", "None"))) + + +################################## +# Consent Records +################################## +label = "# \n# Consent Records \n#################################" +print(label) +request_statuses.append(label) + +print ("------------------------------------") +title = "Consent Records" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/servicelinks/" + slr_id + "/consents/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + cr_id = str(entries[1]['data'][0].get("id", "None")) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + print ("cr_id: " + cr_id) + + +print ("------------------------------------") +title = "One Consent Record" +print(title) +try: + entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/servicelinks/" + slr_id + "/consents/" + cr_id + "/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_statuses.append(title + ": " + entry[0] + ": " + json.dumps(entry[1])) + print ("Response " + entry[0] + ": " + json.dumps(entry[1])) + print ("cr_id: " + str(entry[1]['data'].get("id", "None"))) + + +################################## +# Consent Status Records +################################## +label = "# \n# Consent Status Records \n#################################" +print(label) +request_statuses.append(label) + +print ("------------------------------------") +title = "Consent Status Records" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/servicelinks/" + slr_id + "/consents/" + cr_id + "/statuses/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + csr_id = str(entries[1]['data'][0].get("id", "None")) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + print ("csr_id: " + csr_id) + + +print ("------------------------------------") +title = "One Consent Status Record" +print(title) +try: + entry = get(host=account_host, endpoint="/api/accounts/" + account_id + "/servicelinks/" + slr_id + "/consents/" + cr_id + "/statuses/" + csr_id + "/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_statuses.append(title + ": " + entry[0] + ": " + json.dumps(entry[1])) + request_statuses.append("csr_id: " + str(entry[1]['data'].get("id", "None"))) + print ("Response " + entry[0] + ": " + json.dumps(entry[1])) + print ("csr_id: " + str(entry[1]['data'].get("id", "None"))) + + +################################## +# Export Account +################################## +label = "# \n# Account Export \n#################################" +print(label) +request_statuses.append(label) + +print ("------------------------------------") +title = "Account Export" +print(title) +try: + entries = get(host=account_host, endpoint="/api/accounts/" + account_id + "/export/", headers=headers) +except Exception as exp: + print(title + ": " + repr(exp)) + request_response = title + ": " + repr(exp) + request_statuses.append(request_response) + raise +else: + request_response = title + ": " + entries[0] + ": " + json.dumps(entries[1]) + print('request_response: ' + request_response) + request_statuses.append(request_response) + print ("Response " + entries[0] + ": " + json.dumps(entries[1])) + + + +################################# +################################# +################################# +################################# +# REPORT # +################################# +print ("=====================================") +print("Request report") +for request in request_statuses: + print(request) + diff --git a/Dockerfile-overholt b/Dockerfile-overholt new file mode 100644 index 0000000..718fa54 --- /dev/null +++ b/Dockerfile-overholt @@ -0,0 +1,105 @@ +FROM python:2.7 +MAINTAINER hjhsalo + +# NOTE: Baseimage python:2.7 already contains latest pip + +# TODO: Compile cryptography (and everything else pip related) elsewhere and +# get rid of "build-essential libssl-dev libffi-dev python-dev" +# Maybe according to these instructions: +# https://glyph.twistedmatrix.com/2015/03/docker-deploy-double-dutch.html + +# TODO: Double check and think about the order of commands. Should application +# specific stuff be moved to the end of the file? +# What are actually application specific? etc. + +# TODO: Have brainstorming session on how to properly setup EXPOSE ports, hosts, etc. +# Now it is difficult to come up with sensible defaults. +# Remember to check out what Docker Compose offers. + +# TODO: Make a new user and usergroup. +# Now everything including the ENTRYPOINT is being run as root which is bad +# practise and for example uWSGI complains about this. + +### +# Install +# Specific structure where a single RUN is used to execute everything. +# Based on Docker Best practices -document. To force cache busting. +# https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#/apt-get +# NOTE: python-mysql.connector is MyData Account specific dependency. +RUN apt-get update && apt-get install -y \ + build-essential \ + libffi-dev \ + libssl-dev \ + python-dev \ + celeryd \ + && rm -rf /var/lib/apt/lists/* + + +### +# Create a installation directory into the container +ARG APP_INSTALL_PATH=/mydata-sdk-components +ENV APP_INSTALL_PATH ${APP_INSTALL_PATH:-/mydata-sdk-components} + +RUN mkdir -p $APP_INSTALL_PATH + +# Change current directory inside the container / image to this path. +WORKDIR $APP_INSTALL_PATH + +ARG OVERHOLT_APPLICATION_PATH=/ +ENV OVERHOLT_APPLICATION_PATH ${OVERHOLT_APPLICATION_PATH:-/} + + +### +# Install application specific Python-dependencies. + +# NOTE: If you have multiple Dockerfile steps that use different files from +# your context, COPY them individually, rather than all at once. This will +# ensure that each step’s build cache is only invalidated (forcing the step +# to be re-run) if the specifically required files change. +# https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#/add-or-copy +COPY $OVERHOLT_APPLICATION_PATH/requirements.txt /tmp/ +RUN pip install --requirement /tmp/requirements.txt + +# NOTE: j2cli is needed to preprocess config files based on values +# environment variables +# https://github.com/kolypto/j2cli +# https://tryolabs.com/blog/2015/03/26/configurable-docker-containers-for-multiple-environments/ +RUN pip install j2cli + +# Copy everything (including previously copied filed and folders) from directory +# where Overholt -application is located to current WORKDIR inside container. +# Remember that must be inside the context of the build: +# http://serverfault.com/a/666154 +COPY .$OVERHOLT_APPLICATION_PATH .$OVERHOLT_APPLICATION_PATH + + +#### These will probably be removed when we start using uwsgi for all python applications +# Install a init-system in order to avoid python processes to return 137 (in response to SIGKILL) +#RUN apt-get update && apt-get install -y \ +# curl \ +# && rm -rf /var/lib/apt/lists/* + +ENV DUMB_INIT_VERSION v1.1.3 +ENV DUMB_INIT_WOVERSION 1.1.3 +RUN curl -SL https://github.com/Yelp/dumb-init/releases/download/${DUMB_INIT_VERSION}/dumb-init_${DUMB_INIT_WOVERSION}_amd64.deb \ + -o dumb-init_${DUMB_INIT_WOVERSION}_amd64.deb \ + && dpkg -i dumb-init_*.deb + +### +# Configure and run the application using entrypoint.sh. +# NOTE: Content of CMD are the default parameters passed to entrypoint.sh. +# These can be overwritten on "docker run " +# https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#/entrypoint +COPY ./docker-entrypoint-overholt.sh / + +#ENTRYPOINT ["/docker-entrypoint-overholt.sh"] + +#### These will probably be removed when we start using uwsgi for all python applications +ENTRYPOINT ["/usr/bin/dumb-init", "--"] + +WORKDIR $APP_INSTALL_PATH$OVERHOLT_APPLICATION_PATH + +# NOTE: Maybe this should be replaced with something that doesn't run anything +# and the command below should go to compose.yml ?? +#CMD ["sh", "-c", "python $APP_INSTALL_PATH${OVERHOLT_APPLICATION_PATH}/wsgi.py"] +CMD ["/docker-entrypoint-overholt.sh", "sh", "-c", "python $APP_INSTALL_PATH${OVERHOLT_APPLICATION_PATH}/wsgi.py"] diff --git a/Operator_Components/Operator_CR/auth_token.py b/Operator_Components/Operator_CR/auth_token.py index 29d2f53..927c059 100644 --- a/Operator_Components/Operator_CR/auth_token.py +++ b/Operator_Components/Operator_CR/auth_token.py @@ -13,7 +13,7 @@ api = Api() api.init_app(api_CR_blueprint) debug_log = logging.getLogger("debug") - +logger = logging.getLogger("sequence") class AuthToken(Resource): def __init__(self): super(AuthToken, self).__init__() @@ -48,7 +48,7 @@ def get(self, cr_id): trace=traceback.format_exc(limit=100).splitlines()) debug_log.debug(dumps(result, indent=2)) token = self.gen_auth_token(result) - + debug_log.info(dumps(result, indent=2)) return {"auth_token" : token} diff --git a/Operator_Components/Operator_CR/consent_form.py b/Operator_Components/Operator_CR/consent_form.py index d48e1b1..188e95b 100644 --- a/Operator_Components/Operator_CR/consent_form.py +++ b/Operator_Components/Operator_CR/consent_form.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- +from datetime import datetime +import time + __author__ = 'alpaloma' import logging import traceback -from json import dumps +from json import dumps, loads from DetailedHTTPException import DetailedHTTPException, error_handler from Templates import ServiceRegistryHandler, Consent_form_Out, Sequences from flask import request, Blueprint, current_app from flask_restful import Resource, Api from helpers import AccountManagerHandler, Helpers -from tasks import CR_installer - +from op_tasks import CR_installer +from requests import post logger = logging.getLogger("sequence") debug_log = logging.getLogger("debug") @@ -18,9 +21,6 @@ api = Api() api.init_app(api_CR_blueprint) -SH = ServiceRegistryHandler() -getService = SH.getService - sq = Sequences("Operator_Components Mgmnt", {}) Operator_public_key = {} class ConsentFormHandler(Resource): @@ -30,14 +30,15 @@ def __init__(self): self.am_user = current_app.config["ACCOUNT_MANAGEMENT_USER"] self.am_password = current_app.config["ACCOUNT_MANAGEMENT_PASSWORD"] self.timeout = current_app.config["TIMEOUT"] + self.debug_mode = current_app.config["DEBUG_MODE"] try: self.AM = AccountManagerHandler(self.am_url, self.am_user, self.am_password, self.timeout) except Exception as e: debug_log.warn("Initialization of AccountManager failed. We will crash later but note it here.\n{}".format(repr(e))) - + self.SH = ServiceRegistryHandler(current_app.config["SERVICE_REGISTRY_SEARCH_DOMAIN"], current_app.config["SERVICE_REGISTRY_SEARCH_ENDPOINT"]) + self.getService = self.SH.getService self.Helpers = Helpers(current_app.config) - - + self.operator_url = current_app.config["OPERATOR_URL"] @error_handler def get(self, account_id): @@ -48,15 +49,51 @@ def get(self, account_id): service_ids = request.args sq.task("Fetch services") - sink = getService(service_ids["sink"]) - _consent_form["sink"]["service_id"] = sink["name"] - source = getService(service_ids["source"]) - _consent_form["source"]["service_id"] = source["name"] + sink = self.getService(service_ids["sink"]) + _consent_form["sink"]["service_id"] = sink["serviceId"] + purposes = _consent_form["sink"]["dataset"][0]["purposes"] # TODO replace this once Service registry stops being stupid. + _consent_form["sink"]["dataset"] = [] # Clear out template. + for dataset in sink["serviceDescription"]["serviceDataDescription"][0]["dataset"]: + item = { + "dataset_id": dataset["datasetId"], + "title": dataset["title"], + "description": dataset["description"], + "keyword": dataset["keyword"], + "publisher": dataset["publisher"], + "purposes": dataset["purpose"] + } + + _consent_form["sink"]["dataset"].append(item) + + + source = self.getService(service_ids["source"]) + _consent_form["source"]["service_id"] = source["serviceId"] + _consent_form["source"]["dataset"] = [] # Clear out template. + for dataset in source["serviceDescription"]["serviceDataDescription"][0]["dataset"]: + item = { + "dataset_id": dataset["datasetId"], + "title": dataset["title"], + "description": dataset["description"], + "keyword": dataset["keyword"], + "publisher": dataset["publisher"], + "distribution": { + "distribution_id": dataset["distribution"][0]["distributionId"], + "access_url": "{}{}{}".format(source["serviceInstance"][0]["domain"], + source["serviceInstance"][0]["serviceAccessEndPoint"][ + "serviceAccessURI"] + , dataset["distribution"][0]["accessURL"]), + + } + } + _consent_form["source"]["dataset"].append(item) + sq.task("Generate RS_ID") - sq.task("Store RS_ID") - rs_id = self.Helpers.gen_rs_id(source["name"]) + source_domain = source["serviceInstance"][0]["domain"] + source_access_uri = source["serviceInstance"][0]["serviceAccessEndPoint"]["serviceAccessURI"] + rs_id = self.Helpers.gen_rs_id(source["serviceInstance"][0]["domain"]) + sq.task("Store RS_ID") _consent_form["source"]["rs_id"] = rs_id sq.reply_to("UI", msg="Consent Form+RS_ID") @@ -82,7 +119,7 @@ def post(self, account_id): detail="RS_ID could not be validated.", status=403) - sq.send_to("Account Mgmt", "GET surrogate_id & slr_id") + sq.send_to("Account Manager", "GET surrogate_id & slr_id") try: sink_sur = self.AM.getSUR_ID(sink_srv_id, account_id) source_sur = self.AM.getSUR_ID(source_srv_id, account_id) @@ -100,20 +137,57 @@ def post(self, account_id): slr_id_source, surrogate_id_source = source_sur["data"]["surrogate_id"]["attributes"]["servicelinkrecord_id"],\ source_sur["data"]["surrogate_id"]["attributes"]["surrogate_id"] # One for Sink, one for Source + sink_keys = self.Helpers.get_service_keys(surrogate_id_sink) + try: + sink_key = loads(sink_keys[0]) + except IndexError as e: + raise DetailedHTTPException(status=500, + title="Fetching service keys for sink has failed.", + detail="Couldn't find keys for surrogate id ({}).".format(surrogate_id_sink), + trace=traceback.format_exc(limit=100).splitlines()) + debug_log.info("Sink keys:\n{}".format(dumps(sink_key, indent=2))) + sink_pop_key = sink_key["pop_key"] # Generate common_cr for both sink and source. sq.task("Generate common CR") - common_cr_source = self.Helpers.gen_cr_common(surrogate_id_source, _consent_form["source"]["rs_id"], slr_id_source) - common_cr_sink = self.Helpers.gen_cr_common(surrogate_id_sink, _consent_form["source"]["rs_id"], slr_id_sink) + + issued = int(time.time()) #datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + not_before = int(time.time()) #datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") # TODO: This and not after are Optional, who says when to put them? + not_after = int(time.time()+current_app.config["NOT_AFTER_INTERVAL"]) #datetime.fromtimestamp(time.time()+current_app.config["NOT_AFTER_INTERVAL"]).strftime("%Y-%m-%dT%H:%M:%SZ") + operator_id = current_app.config["UID"] + + + common_cr_source = self.Helpers.gen_cr_common(surrogate_id_source, + _consent_form["source"]["rs_id"], + slr_id_source, + issued, + not_before, + not_after, + source_srv_id, + operator_id, + "Source") + + common_cr_sink = self.Helpers.gen_cr_common(surrogate_id_sink, + _consent_form["source"]["rs_id"], + slr_id_sink, + issued, + not_before, + not_after, + sink_srv_id, + operator_id, + "Sink") sq.task("Generate ki_cr") ki_cr = self.Helpers.Gen_ki_cr(self) sq.task("Generate CR for sink") - sink_cr = self.Helpers.gen_cr_sink(common_cr_sink, _consent_form) + sink_cr = self.Helpers.gen_cr_sink(common_cr_sink, _consent_form, common_cr_source["cr_id"]) sq.task("Generate CR for source") source_cr = self.Helpers.gen_cr_source(common_cr_source, _consent_form, - Operator_public_key) + sink_pop_key) + + sink_cr["cr"]["common_part"]["rs_description"] = source_cr["cr"]["common_part"]["rs_description"] + debug_log.info(sink_cr) debug_log.info(source_cr) sq.task("Generate CSR's") @@ -122,8 +196,30 @@ def post(self, account_id): source_csr = self.Helpers.gen_csr(surrogate_id_source, source_cr["cr"]["common_part"]["cr_id"], "Active", "null") - sq.send_to("Account Mgmt", "Send CR/CSR to sign and store") + sq.send_to("Account Manager", "Send CR/CSR to sign and store") result = self.AM.signAndstore(sink_cr, sink_csr, source_cr, source_csr, account_id) + + # TODO: These are debugging and testing calls, remove them once operation is verified. + if self.debug_mode: + own_addr = self.operator_url #request.url_root.rstrip(request.script_root) + debug_log.info("Our own address is: {}".format(own_addr)) + req = post(own_addr+"/api/1.2/cr/account_id/{}/service/{}/consent/{}/status/Disabled" + .format(surrogate_id_source, source_srv_id, common_cr_source["cr_id"])) + + debug_log.info("Changed csr status, request status ({}) reason ({}) and the following content:\n{}".format( + req.status_code, + req.reason, + dumps(loads(req.content), indent=2) + )) + req = post(own_addr+"/api/1.2/cr/account_id/{}/service/{}/consent/{}/status/Active" + .format(surrogate_id_source, source_srv_id, common_cr_source["cr_id"])) + debug_log.info("Changed csr status, request status ({}) reason ({}) and the following content:\n{}".format( + req.status_code, + req.reason, + dumps(loads(req.content), indent=2) + )) + + debug_log.info(dumps(result, indent=3)) sink_cr = result["data"]["sink"]["consentRecord"]["attributes"]["cr"] sink_csr = result["data"]["sink"]["consentStatusRecord"]["attributes"]["csr"] @@ -135,11 +231,11 @@ def post(self, account_id): crs_csrs_payload = {"sink": {"cr": sink_cr, "csr": sink_csr}, "source": {"cr": source_cr, "csr": source_csr}} #logger.info("Going to Celery task") - sq.send_to("Sink", "Post CR-Sink, CSR-Sink") - sq.send_to("Source", "Post CR-Source, CSR-Source") + sq.send_to("Service_Components Mgmnt (Sink)", "Post CR-Sink, CSR-Sink") + sq.send_to("Service_Components Mgmnt (Source)", "Post CR-Source, CSR-Source") debug_log.info(dumps(crs_csrs_payload, indent=2)) - CR_installer.delay(crs_csrs_payload, SH.getService_url(sink_srv_id), SH.getService_url(source_srv_id)) + CR_installer.delay(crs_csrs_payload, self.SH.getService_url(sink_srv_id), self.SH.getService_url(source_srv_id)) return {"status": 201, "msg": "CREATED"}, 201 diff --git a/Operator_Components/Operator_CR/introspection.py b/Operator_Components/Operator_CR/introspection.py new file mode 100644 index 0000000..bcd8f51 --- /dev/null +++ b/Operator_Components/Operator_CR/introspection.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +import logging +import traceback +from json import dumps + +from DetailedHTTPException import error_handler, DetailedHTTPException +from flask import Blueprint, current_app +from flask_restful import Api, Resource +from helpers import AccountManagerHandler +from helpers import Helpers + +api_CR_blueprint = Blueprint("api_Introspection_blueprint", __name__) +api = Api() +api.init_app(api_CR_blueprint) +debug_log = logging.getLogger("debug") +logger = logging.getLogger("sequence") +class Introspection(Resource): + def __init__(self): + super(Introspection, self).__init__() + self.am_url = current_app.config["ACCOUNT_MANAGEMENT_URL"] + self.am_user = current_app.config["ACCOUNT_MANAGEMENT_USER"] + self.am_password = current_app.config["ACCOUNT_MANAGEMENT_PASSWORD"] + self.timeout = current_app.config["TIMEOUT"] + try: + self.AM = AccountManagerHandler(self.am_url, self.am_user, self.am_password, self.timeout) + except Exception as e: + debug_log.warn("Initialization of AccountManager failed. We will crash later but note it here.\n{}".format(repr(e))) + helper_object = Helpers(current_app.config) + + @error_handler + def get(self, cr_id): + '''post + + :return: Returns latest csr for source + ''' + try: + debug_log.info("We received introspection request for cr_id ({})".format(cr_id)) + result = self.AM.get_last_csr(cr_id) + except AttributeError as e: + raise DetailedHTTPException(status=502, + title="It would seem initiating Account Manager Handler has failed.", + detail="Account Manager might be down or unresponsive.", + trace=traceback.format_exc(limit=100).splitlines()) + debug_log.info(dumps(result)) + return result + +class Introspection_Missing(Resource): + def __init__(self): + super(Introspection_Missing, self).__init__() + self.am_url = current_app.config["ACCOUNT_MANAGEMENT_URL"] + self.am_user = current_app.config["ACCOUNT_MANAGEMENT_USER"] + self.am_password = current_app.config["ACCOUNT_MANAGEMENT_PASSWORD"] + self.timeout = current_app.config["TIMEOUT"] + try: + self.AM = AccountManagerHandler(self.am_url, self.am_user, self.am_password, self.timeout) + except Exception as e: + debug_log.warn("Initialization of AccountManager failed. We will crash later but note it here.\n{}".format(repr(e))) + helper_object = Helpers(current_app.config) + + @error_handler + def get(self, cr_id, csr_id): + '''get + + :return: Returns latest csr for source + ''' + try: + debug_log.info("We received introspection request for cr_id ({})".format(cr_id)) + result = self.AM.get_missing_csr(cr_id, csr_id) + except AttributeError as e: + raise DetailedHTTPException(status=502, + title="It would seem initiating Account Manager Handler has failed.", + detail="Account Manager might be down or unresponsive.", + trace=traceback.format_exc(limit=100).splitlines()) + debug_log.info(dumps(result)) + return result + +api.add_resource(Introspection, '/introspection/') +api.add_resource(Introspection_Missing, '/consent//missing_since/') diff --git a/Operator_Components/Operator_CR/status_change.py b/Operator_Components/Operator_CR/status_change.py new file mode 100644 index 0000000..30de2b7 --- /dev/null +++ b/Operator_Components/Operator_CR/status_change.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +import logging +import traceback +from json import dumps + +from DetailedHTTPException import error_handler, DetailedHTTPException +from flask import Blueprint, current_app +from flask_restful import Api, Resource +from helpers import AccountManagerHandler +from helpers import Helpers + +api_CR_blueprint = Blueprint("api_Status_Change_blueprint", __name__) +api = Api() +api.init_app(api_CR_blueprint) +debug_log = logging.getLogger("debug") +logger = logging.getLogger("sequence") +class Status_Change(Resource): + def __init__(self): + super(Status_Change, self).__init__() + self.am_url = current_app.config["ACCOUNT_MANAGEMENT_URL"] + self.am_user = current_app.config["ACCOUNT_MANAGEMENT_USER"] + self.am_password = current_app.config["ACCOUNT_MANAGEMENT_PASSWORD"] + self.timeout = current_app.config["TIMEOUT"] + try: + self.AM = AccountManagerHandler(self.am_url, self.am_user, self.am_password, self.timeout) + except Exception as e: + debug_log.warn("Initialization of AccountManager failed. We will crash later but note it here.\n{}".format(repr(e))) + self.helper_object = Helpers(current_app.config) + + @error_handler + def post(self, acc_id, srv_id, cr_id, new_status): + '''post + + :return: Returns latest csr for source + ''' + try: + debug_log.info("We received status change request for cr_id ({}) for srv_id ({}) on account ({})" + .format(cr_id, srv_id, acc_id)) + # TODO: Do we need srv_id for anything? + # TODO: How do we authorize this request? Who is allowed to make it? + # Get previous_csr_id + previous_csr_id = self.AM.get_last_csr(cr_id)["csr_id"] + csr_payload = self.helper_object.gen_csr(acc_id, cr_id, new_status, previous_csr_id) + debug_log.info("Created CSR payload:\n {}".format(csr_payload)) + result = self.AM.create_new_csr(cr_id, csr_payload) + + # result = self.AM.get_last_csr(cr_id) + except AttributeError as e: + raise DetailedHTTPException(status=502, + title="It would seem initiating Account Manager Handler has failed.", + detail="Account Manager might be down or unresponsive.", + trace=traceback.format_exc(limit=100).splitlines()) + # debug_log.info(dumps(result)) + return {"status": "OK"} # result + + +api.add_resource(Status_Change, '/account_id//service//consent//status/') diff --git a/Operator_Components/Operator_SLR/registerSur.py b/Operator_Components/Operator_SLR/registerSur.py index 4cb9ab5..048adf6 100644 --- a/Operator_Components/Operator_SLR/registerSur.py +++ b/Operator_Components/Operator_SLR/registerSur.py @@ -3,9 +3,8 @@ import logging import traceback from json import loads, dumps, load, dump -from requests import post from uuid import uuid4 as guid - +import time from DetailedHTTPException import DetailedHTTPException, error_handler from Templates import ServiceRegistryHandler, Sequences from flask import request, Blueprint, current_app @@ -13,6 +12,7 @@ from flask_restful import Resource, Api from helpers import AccountManagerHandler, Helpers from jwcrypto import jwk +from requests import post api_SLR_RegisterSur = Blueprint("api_SLR_RegisterSur", __name__) @@ -43,53 +43,25 @@ class RegisterSur(Resource): def __init__(self): super(RegisterSur, self).__init__() self.app = current_app - #print(current_app.config) - keysize = current_app.config["KEYSIZE"] - cert_key_path = current_app.config["CERT_KEY_PATH"] - self.request_timeout = current_app.config["TIMEOUT"] - - SUPER_DEBUG = True + self.Helpers = Helpers(self.app.config) account_id = "ACC-ID-RANDOM" - user_account_id = account_id + "_" + str(guid()) - - # Keys need to come from somewhere else instead of being generated each time. - gen = {"generate": "EC", "cvr": "P-256", "kid": user_account_id} - gen3 = {"generate": "RSA", "size": keysize, "kid": account_id} - operator_key = jwk.JWK(**gen3) - try: - with open(cert_key_path, "r") as cert_file: - operator_key2 = jwk.JWK(**loads(load(cert_file))) - operator_key = operator_key2 - except Exception as e: - print(e) - with open(cert_key_path, "w+") as cert_file: - dump(operator_key.export(), cert_file, indent=2) - - # Template to send the key to key server - template = {account_id: {"cr_keys": loads(operator_key.export_public()), - "token_keys": loads(operator_key.export_public()) - } - } - # post("http://localhost:6666/key", json=template) + self.operator_key = self.Helpers.get_key() + self.request_timeout = self.app.config["TIMEOUT"] self.payload = \ { "version": "1.2", "link_id": "", "operator_id": account_id, - "service_id": "SRV-SH14W4S3", # How do we know this? + "service_id": "", "surrogate_id": "", - "token_key": "", - "operator_key": loads(operator_key.export_public()), + "operator_key": self.operator_key["pub"], "cr_keys": "", - "created": "" # time.time(), + "iat": int(time.time()), # TODO: set to iat when Account version used supports it } debug_log.info(dumps(self.payload, indent=3)) - - protti = {"alg": "RS256"} - headeri = {"kid": user_account_id, "jwk": loads(operator_key.export_public())} - self.service_registry_handler = ServiceRegistryHandler() + self.service_registry_handler = ServiceRegistryHandler(current_app.config["SERVICE_REGISTRY_SEARCH_DOMAIN"], current_app.config["SERVICE_REGISTRY_SEARCH_ENDPOINT"]) self.am_url = current_app.config["ACCOUNT_MANAGEMENT_URL"] self.am_user = current_app.config["ACCOUNT_MANAGEMENT_USER"] self.am_password = current_app.config["ACCOUNT_MANAGEMENT_PASSWORD"] @@ -99,7 +71,7 @@ def __init__(self): except Exception as e: debug_log.warn("Initialization of AccountManager failed. We will crash later but note it here.\n{}".format(repr(e))) - self.Helpers = Helpers(current_app.config) + self.query_db = self.Helpers.query_db @@ -111,27 +83,41 @@ def post(self): js = request.json sq.task("Load account_id and service_id from database") - for code_json in self.query_db("select * from session_store where code = ?;", [js["code"]]): - debug_log.debug("{} {}".format(type(code_json), code_json)) - account_id = loads(code_json["json"])["account_id"] - self.payload["service_id"] = loads(code_json["json"])["service_id"] + query = self.query_db("select * from session_store where code=%s;", (js["code"],)) + debug_log.info(type(query)) + debug_log.info(query) + dict_query = loads(query) + debug_log.debug("{} {}".format(type(query), query)) + account_id = dict_query["account_id"] + self.payload["service_id"] = dict_query["service_id"] # Check Surrogate_ID exists. # Fill token_key try: sq.task("Verify surrogate_id and token_key exist") + token_key = js["token_key"] self.payload["surrogate_id"] = js["surrogate_id"] - self.payload["token_key"] = {"key": js["token_key"]} + #self.payload["token_key"] = {"key": token_key} + + sq.task("Store surrogate_id and keys for CR steps later on.") + key_template = {"token_key": token_key, + "pop_key": token_key} # TODO: Get pop_key here? + + self.Helpers.store_service_key_json(kid=token_key["kid"], surrogate_id=js["surrogate_id"], key_json=key_template) except Exception as e: + debug_log.exception(e) raise DetailedHTTPException(exception=e, detail={"msg": "Received Invalid JSON that may not contain surrogate_id", "json": js}) + #sq.task("Fetch and fill token_issuer_keys") + # TODO: Token keys separetely when the time is right. + #self.payload["token_issuer_keys"][0] = self.Helpers.get_key()["pub"] # Create template self.payload["link_id"] = str(guid()) # TODO: Currently you can generate endlessly new slr even if one exists already - sq.task("Fill template for Account Mgmnt") + sq.task("Fill template for Account Manager") template = {"code": js["code"], - "data":{ + "data": { "slr": { "type": "ServiceLinkRecord", "attributes": self.payload, diff --git a/Operator_Components/Operator_SLR/start.py b/Operator_Components/Operator_SLR/start.py index 637ba7e..0ad2d92 100644 --- a/Operator_Components/Operator_SLR/start.py +++ b/Operator_Components/Operator_SLR/start.py @@ -48,7 +48,7 @@ class Start(Resource): def __init__(self): super(Start, self).__init__() self.app = current_app - self.service_registry_handler = ServiceRegistryHandler() + self.service_registry_handler = ServiceRegistryHandler(current_app.config["SERVICE_REGISTRY_SEARCH_DOMAIN"], current_app.config["SERVICE_REGISTRY_SEARCH_ENDPOINT"]) self.request_timeout = current_app.config["TIMEOUT"] self.helper = Helpers(current_app.config) self.store_session = self.helper.store_session @@ -60,14 +60,16 @@ def get(self, account_id, service_id): to_store = {} # We want to store some information for later parts of flow. # This address needs to be fetched somewhere to support multiple services - service_mgmnt_address = self.service_registry_handler.getService_url(service_id) - + service_json = self.service_registry_handler.getService(service_id) + service_domain = service_json["serviceInstance"][0]["domain"] + service_access_uri = service_json["serviceInstance"][0]["serviceAccessEndPoint"]["serviceAccessURI"] + service_login_uri = service_json["serviceInstance"][0]["loginUri"] # Endpoint address should be fetched somewhere as well so we can re-use the service address later easily. - endpoint = "/api/1.2/slr/code" - + endpoint = "/slr/code" # TODO: Comment above + endpoint = "{}{}{}".format(service_domain, service_access_uri, endpoint) sq.send_to("Service_Components Mgmnt", "Fetch code from service_mgmnt") - result = get("{}{}".format(service_mgmnt_address, endpoint), timeout=self.request_timeout) + result = get(endpoint, timeout=self.request_timeout) code_status = result.status_code sq.task("Check code request is valid") @@ -101,8 +103,9 @@ def get(self, account_id, service_id): try: endpoint = "/api/1.2/slr/login" + endpoint = "{}{}{}".format(service_domain, service_access_uri, service_login_uri) sq.send_to("Service_Components Mgmnt", "Redirect user to Service_Components Mgmnt login") - result = post("{}{}".format(service_mgmnt_address, endpoint), json=code, timeout=self.request_timeout) + result = post(endpoint, json=code, timeout=self.request_timeout) debug_log.info("####Response to this end point: {}\n{}".format(result.status_code, result.text)) if not result.ok: raise DetailedHTTPException(status=result.status_code, diff --git a/Operator_Components/Operator_SLR/verify.py b/Operator_Components/Operator_SLR/verify.py index 954e53d..ef77709 100644 --- a/Operator_Components/Operator_SLR/verify.py +++ b/Operator_Components/Operator_SLR/verify.py @@ -136,22 +136,25 @@ def post(self): content = decode(payload.encode()) sq.task("Load decoded payload as python dict") - payload = loads(loads(content.decode("utf-8"))) # TODO: Figure out why we get str out of loads the first time? + payload = loads(content.decode("utf-8")) # TODO: Figure out why we get str out of loads the first time? debug_log.info(payload) debug_log.info(type(payload)) sq.task("Fetch link_id from decoded payload") slr_id = payload["link_id"] - + code = request.json["data"]["code"].decode() + debug_log.info(code) + debug_log.info(request.json["data"]["code"]) try: ## # Verify SLR with key from Service_Components Management ## sq.task("Load account_id from database") - for code_json in self.query_db("select * from session_store where code = ?;", - [request.json["data"]["code"]]): - debug_log.debug("{} {}".format(type(code_json), code_json)) - account_id = loads(code_json["json"])["account_id"] + query = self.query_db("select * from session_store where code=%s;", (request.json["data"]["code"],)) + debug_log.info(query) + dict_query = loads(query) + debug_log.info("{} {}".format(type(dict_query), dict_query)) + account_id = dict_query["account_id"] debug_log.info("################Verify########################") debug_log.info(dumps(request.json)) diff --git a/Operator_Components/Templates.py b/Operator_Components/Templates.py index b4455f4..2b1d228 100644 --- a/Operator_Components/Templates.py +++ b/Operator_Components/Templates.py @@ -24,7 +24,7 @@ "rs_id": "String", "dataset": [ { - "datase_id": "String", + "dataset_id": "String", "title": "String", "description": "String", "keyword": [], @@ -72,23 +72,41 @@ from instance.settings import SERVICE_URL +from requests import get class ServiceRegistryHandler: - def __init__(self): + def __init__(self, domain, endpoint): # Here could be some code to setup where ServiceRegistry is located etc + # TODO: Get this from config or such. + # self.registry_url = "http://178.62.229.148:8081"+"/api/v1/services/" + self.registry_url = domain + endpoint #"/api/v1/services/" pass def getService(self, service_id): - return Services[str(service_id)] + try: + debug_log.info("Making request GET {}{}".format(self.registry_url, service_id)) + req = get(self.registry_url+service_id) + service = req.json() + debug_log.info(service) + service = service[0] + except Exception as e: + debug_log.exception(e) + raise e + return service def getService_url(self, service_id): debug_log.info("getService_url got {} of type {} as parameter.".format(service_id, type(service_id))) if isinstance(service_id, unicode): service_id = service_id.encode() - services = { - "1": SERVICE_URL, - "2": SERVICE_URL# Our Service_Mgmnt - } - return services[service_id] + try: + service = get(self.registry_url+service_id).json() + debug_log.info(service_id) + service = service[0] + except Exception as e: + debug_log.exception(e) + raise e + url = service["serviceInstance"][0]["domain"] + + return url diff --git a/Operator_Components/db_handler.py b/Operator_Components/db_handler.py index 4aa43bb..14eb29d 100644 --- a/Operator_Components/db_handler.py +++ b/Operator_Components/db_handler.py @@ -1,35 +1,18 @@ # -*- coding: utf-8 -*- -import sqlite3 +import logging +import MySQLdb -def get_db(db_path): +debug_log = logging.getLogger("debug") +def get_db(host, user, password, database, port): db = None if db is None: - db = sqlite3.connect(db_path) - db.row_factory = sqlite3.Row - - try: - init_db(db) - except Exception as e: - pass + db = MySQLdb.connect(host=host, user=user, passwd=password, db=database, port=port, sql_mode="TRADITIONAL") return db - def make_dicts(cursor, row): return dict((cursor.description[idx][0], value) for idx, value in enumerate(row)) -def init_db(conn): - # create db for codes - conn.execute('''CREATE TABLE cr_tbl - (rs_id TEXT PRIMARY KEY NOT NULL, - json TEXT NOT NULL);''') - conn.execute('''CREATE TABLE rs_id_tbl - (rs_id TEXT PRIMARY KEY NOT NULL, - used BOOL NOT NULL);''') - conn.execute('''CREATE TABLE session_store - (code TEXT PRIMARY KEY NOT NULL, - json TEXT NOT NULL);''') - conn.commit() diff --git a/Operator_Components/doc/api/swagger_Operator_CR.yml b/Operator_Components/doc/api/swagger_Operator_CR.yml index c5e792c..a2ce342 100644 --- a/Operator_Components/doc/api/swagger_Operator_CR.yml +++ b/Operator_Components/doc/api/swagger_Operator_CR.yml @@ -39,9 +39,13 @@ paths: # Expected responses for this operation: responses: 200: - description: "Returns 200 OK or Error message" + description: "Returns 200 OK" schema: $ref: "#/definitions/Consent_FormReply" + 500: + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." + schema: + $ref: "#/definitions/errors" post: # Describe this verb here. Note: you can use markdown @@ -77,10 +81,10 @@ paths: $ref: "#/definitions/Consent_FormReply" # Expected responses for this operation: responses: - 200: - description: "Returns 200 OK or Error message" + 201: + description: "Returns 201 Created" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" 502: @@ -100,17 +104,114 @@ paths: parameters: - name: "cr_id" in: "path" - description: "Unique ID consent record" + description: "Unique ID of consent record" required: true type: "string" format: "uuid4" responses: 200: - description: "returns 200 OK and Auth Token, or Error message" + description: "returns 200 OK and Auth Token" schema: $ref: "#/definitions/Auth_TokenReply" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." + schema: + $ref: "#/definitions/errors" + 502: + description: "Bad Gateway" + schema: + $ref: "#/definitions/errors" + + /introspection/{cr_id}: + get: + tags: + - "Operator" + - "Source" + description: "Gets last csr for given cr_id" + parameters: + - name: "cr_id" + in: "path" + description: "Unique ID of consent record" + required: true + type: "string" + + responses: + 200: + description: "returns 200 OK and latest csr_id" + schema: + $ref: "#/definitions/IntrospectionReply" + 500: + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." + schema: + $ref: "#/definitions/errors" + 502: + description: "Bad Gateway" + schema: + $ref: "#/definitions/errors" + + /consent/{cr_id}/missing_since/{csr_id}: + get: + tags: + - "Operator" + - "Source" + description: "Gets new csr's for given cr since given csr_id" + parameters: + - name: "cr_id" + in: "path" + description: "Unique ID of consent record" + required: true + type: "string" + - name: "csr_id" + in: "path" + description: "Unique ID of consent status record" + required: true + type: "string" + responses: + 200: + description: "returns 200 OK and new csr's since given csr_id" + schema: + $ref: "#/definitions/IntrospectionMissingReply" + 500: + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." + schema: + $ref: "#/definitions/errors" + 502: + description: "Bad Gateway" + schema: + $ref: "#/definitions/errors" + + /account_id/{acc_id}/service/{srv_id}/consent/{cr_id}/status/{new_status}: + post: + tags: + - "Operator" + - "Source" + description: "Change status of consent" + parameters: + - name: "acc_id" + in: "path" + description: "Unique Surrogate id for service" + required: true + type: "string" + - name: "srv_id" + in: "path" + description: "Unique ID of service id" + required: true + type: "string" + - name: "cr_id" + in: "path" + description: "Unique ID of consent record" + required: true + type: "string" + - name: "new_status" + in: "path" + description: "new status as Active/Disabled/Withdrawn" + required: true + type: "string" + responses: + 200: + description: "returns 200 OK" + 500: + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" 502: @@ -146,6 +247,27 @@ definitions: type: string description: string containing auth_token + IntrospectionReply: + type: object + properties: + csr_id: + type: string + description: string containing csr_id + + IntrospectionMissingReply: + type: object + properties: + missing_csr: + type: object + properties: + data: + type: array + items: + type: object + properties: + attributes: + type: object + description: Missing csr DataSet_Sink: type: object @@ -169,10 +291,10 @@ definitions: type: object properties: required: - type: string + type: boolean description: boolean containing required selected: - type: string + type: boolean description: boolean containing selected title: type: string @@ -213,7 +335,7 @@ definitions: type: string description: string containing access_url selected: - type: string + type: boolean description: boolean containing selected datase_id: type: string diff --git a/Operator_Components/doc/api/swagger_Operator_SLR.yml b/Operator_Components/doc/api/swagger_Operator_SLR.yml index 4b75ae2..4b3a47e 100644 --- a/Operator_Components/doc/api/swagger_Operator_SLR.yml +++ b/Operator_Components/doc/api/swagger_Operator_SLR.yml @@ -25,7 +25,7 @@ paths: $ref: "#/definitions/LinkParams" responses: 200: - description: "Returns 200 OK or Error message" + description: "Returns 200 OK" 500: description: "Internal server error" schema: @@ -39,8 +39,8 @@ paths: get: tags: - "Operator" - description: "Entry point for creating new SLR with service\nWill takes a UUID\ - \ of service to link with as paramater" + description: "Entry point for creating new SLR with service.\nWill take UUID\ + \ of service to link with as paramater. This will start chain of events." parameters: - name: "account_id" in: "path" @@ -58,7 +58,7 @@ paths: 200: description: "Returns 200 OK" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" 504: @@ -96,7 +96,7 @@ paths: schema: $ref: "#/definitions/VerifyResponse" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" 502: diff --git a/Operator_Components/doc/database/Operator_Components-DBinit.sql b/Operator_Components/doc/database/Operator_Components-DBinit.sql new file mode 100644 index 0000000..2025b6f --- /dev/null +++ b/Operator_Components/doc/database/Operator_Components-DBinit.sql @@ -0,0 +1,83 @@ +-- MySQL Script generated by MySQL Workbench +-- to 15. syyskuuta 2016 15.32.11 +-- Model: New Model Version: 1.0 +-- MySQL Workbench Forward Engineering + +SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; +SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL,ALLOW_INVALID_DATES'; + +-- ----------------------------------------------------- +-- Schema mydb +-- ----------------------------------------------------- +-- ----------------------------------------------------- +-- Schema db_Operator +-- ----------------------------------------------------- + +-- ----------------------------------------------------- +-- Schema db_Operator +-- ----------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS `db_Operator` DEFAULT CHARACTER SET utf8 ; +USE `db_Operator` ; + +-- ----------------------------------------------------- +-- Table `db_Operator`.`cr_tbl` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Operator`.`cr_tbl` ; + +CREATE TABLE IF NOT EXISTS `db_Operator`.`cr_tbl` ( + `rs_id` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + PRIMARY KEY (`rs_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Operator`.`rs_id_tbl` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Operator`.`rs_id_tbl` ; + +CREATE TABLE IF NOT EXISTS `db_Operator`.`rs_id_tbl` ( + `rs_id` LONGTEXT NOT NULL, + `used` TINYINT(1) NOT NULL, + PRIMARY KEY (`rs_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Operator`.`session_store` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Operator`.`session_store` ; + +CREATE TABLE IF NOT EXISTS `db_Operator`.`session_store` ( + `code` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + PRIMARY KEY (`code`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + +-- ----------------------------------------------------- +-- Table `db_Operator`.`keys_tbl` TODO: Check this, used to have kid as PK but would cause fails since service gives same key for all surrogates atm. +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Operator`.`service_keys_tbl` ; + +CREATE TABLE IF NOT EXISTS `db_Operator`.`service_keys_tbl` ( + `kid` LONGTEXT NOT NULL, + `surrogate_id` LONGTEXT NOT NULL, + `key_json` LONGTEXT NOT NULL, + PRIMARY KEY (`surrogate_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + + + +SET SQL_MODE=@OLD_SQL_MODE; +SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; +SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; + +CREATE USER 'operator'@'%' IDENTIFIED BY 'MynorcA'; +GRANT CREATE TEMPORARY TABLES, DELETE, DROP, INSERT, LOCK TABLES, SELECT, UPDATE ON db_Operator.* TO 'operator'@'%'; +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/Operator_Components/doc/database/Operator_Components_db_image-v001.png b/Operator_Components/doc/database/Operator_Components_db_image-v001.png new file mode 100644 index 0000000000000000000000000000000000000000..6fb8ce5d0f7fad417666af1ad73b405cb5028278 GIT binary patch literal 16871 zcmdtKby!thyZ^gLk&u*>25FF%4#`D#iy+;dQc4I&NJ@8iEV?mhq#LBAr5n!Rd7gLg z-`?-u*E!eq`|nItbh_qRbBuAJdMB zWJ|qXpG}^!volTBozyL9CN0T_*@TluKb{RM>m(p3WH@dVkmz&^38vuzhGWn z-AhYOUfI|%8>B?^4aBB=q@t{>tfy6h>+Ocp>I$!wOab?CF+J|s2#J|GTVr26TzL*3 z4=Xw$q0bu~eB3i}#eJ1Lsi`&mVrl~~Z`RwOk1E%UC4w}pxX(Y;x6r+wryjo^N*5|o zwpU2Zd%8VsBqY?`R9vmZ+m_6Jd2Wd*e-L`8 zmqHv!Rh3fUu7qxffx&QCcTs_ekf+wD$9LM13<=46499Y!j3y*u%*~}vD&+O49t_~I z%zK_j0oh8Ml$hdDt!)cU3X0YRNpAOX{xH&Cd%9z}qrazR8VSmByiB{?>Q_F0${o=O zpKP)J!9Gz|uV!xH$OvoWqpq;r;72^54(j4r0JQA2K{9T7CTfMRUrQRA|a|^cpq-{40om zw#%kddl>^;LLa;uo!}b7scmtyCy%+G%hAW#;%YtK@&#UIA)d0a6rcPL1o)|l{sO1m z>so9f7@-F)H`koWBvuQpxhkQS`E>Z5+pd4&4r7MNdRpqzD7hZcp|M$w7eCq%jOTVA>9rTB zU|=#Myt=kOP%@$W`YJin(rSK&D}LwC^cqlkIDmSA{y3TzG4KY)fw-6Y)GpgJj1_H#9V9B;NEPRfCJf zvt)!VP$0^>5vRDE^POE63`uHTAm-r%-H!aj}iRS*2ses624-_7RO4`2Q? z@5>NelCyQRAgcEizV&~Qd{+2gFi!qG9DF%_xXjk|ClCv6#xyDFgqt(Nw5M=AyT5`7 zKHz^(Z18kolUMrMwdA36uWTt};_UhuBJ=8zH!}KKTCUC{a^GFT%xTuMh}E8WwKOT8 z+fC=NYh!^qF~gHx3){B4W|;N%K4W6a{JKB

meo#9=h* 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 op_celery.log & + nohup celery -A op_tasks worker > op_celery.log & nohup python wsgi.py > op_flask.log & diff --git a/Operator_Components/factory.py b/Operator_Components/factory.py index da07400..5865d78 100644 --- a/Operator_Components/factory.py +++ b/Operator_Components/factory.py @@ -15,7 +15,7 @@ def create_app(package_name, package_path, settings_override=None, - register_security_blueprint=False): + register_security_blueprint=False, register_bps=True): """Returns a :class:`Flask` application instance configured with common functionality for the Overholt platform. :param package_name: application package name @@ -28,8 +28,9 @@ def create_app(package_name, package_path, settings_override=None, app = Flask(package_name, instance_relative_config=True) app.config.from_pyfile('settings.py', silent=False) app.config.from_object(settings_override) - - rv, apis =register_blueprints(app, package_name, package_path) + apis = None + if register_bps: + rv, apis =register_blueprints(app, package_name, package_path) return app, apis @@ -38,7 +39,7 @@ def create_celery_app(app=None): if app is not None: app = app else: - app, apis = create_app('operator_component', os.path.dirname(__file__)) + app, apis = create_app('op_queue', os.path.dirname(__file__), register_bps=False) celery = Celery(__name__, broker=app.config['SELERY_BROKER_URL']) celery.conf.update(app.config) TaskBase = celery.Task diff --git a/Operator_Components/helpers.py b/Operator_Components/helpers.py index 37603cc..2f2e5a3 100644 --- a/Operator_Components/helpers.py +++ b/Operator_Components/helpers.py @@ -1,22 +1,21 @@ # -*- coding: utf-8 -*- -import pkgutil import importlib +import logging +import pkgutil +import time +from base64 import urlsafe_b64decode as decode +from sqlite3 import IntegrityError +from Crypto.PublicKey.RSA import importKey as import_rsa_key from flask import Blueprint from flask_restful import Api -from Crypto.PublicKey.RSA import importKey as import_rsa_key -from base64 import urlsafe_b64decode as decode +from Templates import ServiceRegistryHandler import db_handler as db_handler -from sqlite3 import OperationalError, IntegrityError -import time -import logging + debug_log = logging.getLogger("debug") from datetime import datetime - - - def read_key(path, password=None, ): ## # Read RSA key from PEM file and return JWK object of it. @@ -60,9 +59,6 @@ def read_key(path, password=None, ): return jwssa - - - def register_blueprints(app, package_name, package_path): """Register all Blueprint instances on the specified Flask application found in all modules for the specified package. @@ -83,32 +79,34 @@ def register_blueprints(app, package_name, package_path): apis.append(item) return rv, apis + from jwcrypto import jwt, jwk -#from Templates import SLR_tool -from json import dumps, loads, dump, load +# from Templates import SLR_tool +from json import dumps, dump, load from uuid import uuid4 as guid - from requests import get, post from json import loads from core import DetailedHTTPException -class AccountManagerHandler: + +class AccountManagerHandler: def __init__(self, account_management_url, account_management_username, account_management_password, timeout): self.username = account_management_username - self.password = account_management_password # possibly we don't need this here, does it matter? + self.password = account_management_password # possibly we don't need this here, does it matter? self.url = account_management_url self.timeout = timeout self.endpoint = { - "token": "api/auth/sdk/", - "surrogate": "api/account/{account_id}/service/{service_id}/surrogate/", - "sign_slr": "api/account/{account_id}/servicelink/", - "verify_slr": "api/account/{account_id}/servicelink/verify/", - "sign_consent": "api/account/consent/sign/", - "consent": "api/account/{account_id}/servicelink/{source_slr_id}/{sink_slr_id}/consent/", - "auth_token": "api/consent/{sink_cr_id}/authorizationtoken/"} - req = get(self.url+self.endpoint["token"], auth=(self.username, self.password), timeout=timeout) - + "token": "api/auth/sdk/", + "surrogate": "api/account/{account_id}/service/{service_id}/surrogate/", + "sign_slr": "api/account/{account_id}/servicelink/", + "verify_slr": "api/account/{account_id}/servicelink/verify/", + "sign_consent": "api/account/consent/sign/", + "consent": "api/account/{account_id}/servicelink/{source_slr_id}/{sink_slr_id}/consent/", + "auth_token": "api/consent/{sink_cr_id}/authorizationtoken/", + "last_csr": "api/consent/{cr_id}/status/last/", + "new_csr": "api/consent/{cr_id}/status/"} # Works as path to GET missing csr and POST new ones + req = get(self.url + self.endpoint["token"], auth=(self.username, self.password), timeout=timeout) # check if the request for token succeeded debug_log.debug("{} {} {}".format(req.status_code, req.reason, req.text)) @@ -116,10 +114,12 @@ def __init__(self, account_management_url, account_management_username, account_ self.token = loads(req.text)["api_key"] else: raise DetailedHTTPException(status=req.status_code, - detail={"msg":"Getting account management token failed.","content": req.content}, + detail={"msg": "Getting account management token failed.", + "content": req.content}, title=req.reason) - # Here could be some code to setup where AccountManager is located etc, get these from ServiceRegistry? + # Here could be some code to setup where AccountManager is located etc, get these from ServiceRegistry? + def get_AuthTokenInfo(self, cr_id): req = get(self.url + self.endpoint["auth_token"] .replace("{sink_cr_id}", cr_id), @@ -128,34 +128,103 @@ def get_AuthTokenInfo(self, cr_id): templ = loads(req.text) else: raise DetailedHTTPException(status=req.status_code, - detail={"msg": "Getting AuthToken info from account management failed.","content": req.content}, + detail={"msg": "Getting AuthToken info from account management failed.", + "content": req.content}, title=req.reason) return templ def getSUR_ID(self, service_id, account_id): - debug_log.debug(""+self.url+self.endpoint["surrogate"].replace("{account_id}",account_id).replace("{service_id}", service_id)) + debug_log.debug( + "" + self.url + self.endpoint["surrogate"].replace("{account_id}", account_id).replace("{service_id}", + service_id)) - req = get(self.url+self.endpoint["surrogate"].replace("{account_id}",account_id).replace("{service_id}", service_id), + req = get(self.url + self.endpoint["surrogate"].replace("{account_id}", account_id).replace("{service_id}", + service_id), headers={'Api-Key': self.token}, timeout=self.timeout) if req.ok: templ = loads(req.text) else: raise DetailedHTTPException(status=req.status_code, - detail={"msg": "Getting surrogate_id from account management failed.", "content": req.content}, + detail={"msg": "Getting surrogate_id from account management failed.", + "content": req.content}, title=req.reason) return templ + def get_last_csr(self, cr_id): + endpoint_url = self.url + self.endpoint["last_csr"].replace("{cr_id}", cr_id) + debug_log.debug("" + endpoint_url) + + req = get(endpoint_url, + headers={'Api-Key': self.token}, + timeout=self.timeout) + if req.ok: + templ = loads(req.text) + tool = SLR_tool() + payload = tool.decrypt_payload(templ["data"]["attributes"]["csr"]["payload"]) + debug_log.info("Got CSR payload from account:\n{}".format(dumps(payload, indent=2))) + csr_id = payload["record_id"] + return {"csr_id": csr_id} + else: + raise DetailedHTTPException(status=req.status_code, + detail={"msg": "Getting last csr from account management failed.", + "content": req.content}, + title=req.reason) + + def create_new_csr(self, cr_id, payload): + endpoint_url = self.url + self.endpoint["new_csr"].replace("{cr_id}", cr_id) + debug_log.debug("" + endpoint_url) + payload = {"data": {"attributes": payload, "type": "ConsentStatusRecord"}} + req = post(endpoint_url, json=payload, + headers={'Api-Key': self.token}, + timeout=self.timeout) + if req.ok: + templ = loads(req.text) + #tool = SLR_tool() + #payload = tool.decrypt_payload(templ["data"]["attributes"]["csr"]["payload"]) + debug_log.info("Created CSR:\n{}".format(dumps(templ, indent=2))) + #csr_id = payload["record_id"] + + return {"csr": templ} + else: + raise DetailedHTTPException(status=req.status_code, + detail={"msg": "Creating new csr at account management failed.", + "content": req.content}, + title=req.reason) + + def get_missing_csr(self, cr_id, csr_id): + endpoint_url = self.url + self.endpoint["new_csr"].replace("{cr_id}", cr_id) + debug_log.debug("" + endpoint_url) + payload = {"csr_id": csr_id} + req = get(endpoint_url, params=payload, + headers={'Api-Key': self.token}, + timeout=self.timeout) + if req.ok: + templ = loads(req.text) + #tool = SLR_tool() + #payload = tool.decrypt_payload(templ["data"]["attributes"]["csr"]["payload"]) + debug_log.info("Fetched missing CSR:\n{}".format(dumps(templ, indent=2))) + #csr_id = payload["record_id"] + + return {"missing_csr": templ} + else: + raise DetailedHTTPException(status=req.status_code, + detail={"msg": "Creating new csr at account management failed.", + "content": req.content}, + title=req.reason) + def sign_slr(self, template, account_id): - templu =template - req = post(self.url+self.endpoint["sign_slr"].replace("{account_id}", account_id), json=templu, headers={'Api-Key': self.token}, timeout=self.timeout) + templu = template + req = post(self.url + self.endpoint["sign_slr"].replace("{account_id}", account_id), json=templu, + headers={'Api-Key': self.token}, timeout=self.timeout) debug_log.debug("API token: {}".format(self.token)) debug_log.debug("{} {} {} {}".format(req.status_code, req.reason, req.text, req.content)) if req.ok: templ = loads(req.text) else: raise DetailedHTTPException(status=req.status_code, - detail={"msg": "Getting surrogate_id from account management failed.","content": loads(req.text)}, + detail={"msg": "Getting surrogate_id from account management failed.", + "content": loads(req.text)}, title=req.reason) debug_log.debug(templ) @@ -173,16 +242,18 @@ def verify_slr(self, payload, code, slr, account_id): }, "ssr": { "attributes": { + "version": "1.2", + "surrogate_id": payload["surrogate_id"], "record_id": str(guid()), "account_id": account_id, "slr_id": payload["link_id"], "sl_status": "Active", - "iat": "", + "iat": int(time.time()), "prev_record_id": "NULL" }, "type": "ServiceLinkStatusRecord" }, - "surrogate_id":{ + "surrogate_id": { "attributes": { "account_id": "2", "service_id": payload["service_id"], @@ -192,7 +263,10 @@ def verify_slr(self, payload, code, slr, account_id): } } } - req = post(self.url + self.endpoint["verify_slr"].replace("{account_id}", account_id), json=templa, headers={'Api-Key': self.token}, timeout=self.timeout) + debug_log.info("Template sent to Account Manager:") + debug_log.info(dumps(templa, indent=2)) + req = post(self.url + self.endpoint["verify_slr"].replace("{account_id}", account_id), json=templa, + headers={'Api-Key': self.token}, timeout=self.timeout) return req def signAndstore(self, sink_cr, sink_csr, source_cr, source_csr, account_id): @@ -207,34 +281,33 @@ def signAndstore(self, sink_cr, sink_csr, source_cr, source_csr, account_id): } template = { - "data": { + "data": { "source": { - "consentRecordPayload": { - "type": "ConsentRecord", - "attributes": source_cr["cr"] - }, - "consentStatusRecordPayload": { - "type": "ConsentStatusRecord", - "attributes": source_csr, - } + "consentRecordPayload": { + "type": "ConsentRecord", + "attributes": source_cr["cr"] + }, + "consentStatusRecordPayload": { + "type": "ConsentStatusRecord", + "attributes": source_csr, + } }, "sink": { - "consentRecordPayload": { - "type": "ConsentRecord", - "attributes": sink_cr["cr"], - }, - "consentStatusRecordPayload": { - "type": "ConsentStatusRecord", - "attributes": sink_csr, - }, + "consentRecordPayload": { + "type": "ConsentRecord", + "attributes": sink_cr["cr"], + }, + "consentStatusRecordPayload": { + "type": "ConsentStatusRecord", + "attributes": sink_csr, + }, }, - }, - } - + }, + } slr_id_sink = template["data"]["sink"]["consentRecordPayload"]["attributes"]["common_part"]["slr_id"] slr_id_source = template["data"]["source"]["consentRecordPayload"]["attributes"]["common_part"]["slr_id"] - #print(type(slr_id_source), type(slr_id_sink), account_id) + # print(type(slr_id_source), type(slr_id_sink), account_id) debug_log.debug(dumps(template, indent=2)) req = post(self.url + self.endpoint["consent"].replace("{account_id}", account_id) .replace("{source_slr_id}", slr_id_source). @@ -247,7 +320,8 @@ def signAndstore(self, sink_cr, sink_csr, source_cr, source_csr, account_id): debug_log.debug(dumps(loads(req.text), indent=2)) else: raise DetailedHTTPException(status=req.status_code, - detail={"msg": "Getting surrogate_id from account management failed.", "content": loads(req.text)}, + detail={"msg": "Getting surrogate_id from account management failed.", + "content": loads(req.text)}, title=req.reason) return loads(req.text) @@ -255,9 +329,39 @@ def signAndstore(self, sink_cr, sink_csr, source_cr, source_csr, account_id): class Helpers: def __init__(self, app_config): - self.db_path = app_config["DATABASE_PATH"] + self.host = app_config["MYSQL_HOST"] self.cert_key_path = app_config["CERT_KEY_PATH"] self.keysize = app_config["KEYSIZE"] + self.user = app_config["MYSQL_USER"] + self.passwd = app_config["MYSQL_PASSWORD"] + self.db = app_config["MYSQL_DB"] + self.port = app_config["MYSQL_PORT"] + self.operator_id = app_config["UID"] + self.not_after_interval = app_config["NOT_AFTER_INTERVAL"] + self.service_registry_search_domain = app_config["SERVICE_REGISTRY_SEARCH_DOMAIN"] + self.service_registry_search_endpoint = app_config["SERVICE_REGISTRY_SEARCH_ENDPOINT"] + + def get_key(self): + keysize = self.keysize + cert_key_path = self.cert_key_path + gen3 = {"generate": "RSA", "size": keysize, "kid": self.operator_id} + operator_key = jwk.JWK(**gen3) + try: + with open(cert_key_path, "r") as cert_file: + operator_key2 = jwk.JWK(**loads(load(cert_file))) + operator_key = operator_key2 + except Exception as e: + debug_log.error(e) + with open(cert_key_path, "w+") as cert_file: + dump(operator_key.export(), cert_file, indent=2) + public_key = loads(operator_key.export_public()) + full_key = loads(operator_key.export()) + protti = {"alg": "RS256"} + headeri = {"kid": self.operator_id, "jwk": public_key} + return {"pub": public_key, + "key": full_key, + "prot": protti, + "header": headeri} def validate_rs_id(self, rs_id): ## @@ -265,72 +369,150 @@ def validate_rs_id(self, rs_id): ## return self.change_rs_id_status(rs_id, True) + # TODO: This should return list, now returns single object. + def get_service_keys(self, surrogate_id): + """ + + """ + storage_rows = self.query_db_multiple("select * from service_keys_tbl where surrogate_id = %s;", + (surrogate_id,)) + list_of_keys = [] + for item in storage_rows: + list_of_keys.append(item[2]) + + debug_log.info("Found keys:\n {}".format(list_of_keys)) + return list_of_keys + + def get_service_key(self, surrogate_id, kid): + """ + + """ + storage_row = self.query_db_multiple("select * from service_keys_tbl where surrogate_id = %s and kid = %s;", + (surrogate_id, kid,), one=True) + # Third item in this tuple should be the key JSON {token_key: {}, pop_key:{}} + key_json_from_db = loads(storage_row[2]) + + return key_json_from_db + + def store_service_key_json(self, kid, surrogate_id, key_json): + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() + cursor.execute("INSERT INTO service_keys_tbl (kid, surrogate_id, key_json) \ + VALUES (%s, %s, %s);", (kid, surrogate_id, dumps(key_json))) + db.commit() +# cursor.execute("UPDATE service_keys_tbl SET key_json=%s WHERE kid=%s ;", (dumps(key_json), kid)) +# db.commit() + debug_log.info("Stored key_json({}) for surrogate_id({}) into DB".format(key_json, surrogate_id)) + cursor.close() + def storeRS_ID(self, rs_id): - db = db_handler.get_db(self.db_path) - try: - db_handler.init_db(db) - except OperationalError: - pass + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() rs_id_status = False - db.execute("INSERT INTO rs_id_tbl (rs_id, used) \ - VALUES (?, ?)", [rs_id, rs_id_status]) + cursor.execute("INSERT INTO rs_id_tbl (rs_id, used) \ + VALUES (%s, %s)", (rs_id, rs_id_status)) db.commit() - db.close() + debug_log.info("Stored RS_ID({}) into DB".format(rs_id)) + cursor.close() def change_rs_id_status(self, rs_id, status): - db = db_handler.get_db(self.db_path) - try: - db_handler.init_db(db) - except OperationalError: - pass - for rs_id_object in self.query_db("select * from rs_id_tbl where rs_id = ?;", [rs_id]): - rs_id_from_db = rs_id_object["rs_id"] - status_from_db = bool(rs_id_object["used"]) - status_is_unused = status_from_db == False - if (status_is_unused): - db.execute("UPDATE rs_id_tbl SET used=? WHERE rs_id=? ;", [status, rs_id]) - db.commit() - db.close() - return True - else: - db.close() - return False - + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() + query = cursor.execute("select * from rs_id_tbl where rs_id=%s;", (rs_id,)) + result = cursor.fetchone() + rs_id = result[0] + used = result[1] + debug_log.info(result) + status_from_db = bool(used) + status_is_unused = status_from_db is False + if status_is_unused: + cursor.execute("UPDATE rs_id_tbl SET used=%s WHERE rs_id=%s ;", (status, rs_id)) + db.commit() + cursor.close() + return True + else: + cursor.close() + return False def store_session(self, DictionaryToStore): - db = db_handler.get_db(self.db_path) - try: - db_handler.init_db(db) - except OperationalError: - pass + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() debug_log.info(DictionaryToStore) for key in DictionaryToStore: debug_log.info(key) try: - db.execute("INSERT INTO session_store (code,json) \ - VALUES (?, ?)", [key, dumps(DictionaryToStore[key])]) + cursor.execute("INSERT INTO session_store (code,json) \ + VALUES (%s, %s)", (key, dumps(DictionaryToStore[key]))) db.commit() - db.close() + # db.close() except IntegrityError as e: - db.execute("UPDATE session_store SET json=? WHERE code=? ;", [dumps(DictionaryToStore[key]), key]) + cursor.execute("UPDATE session_store SET json=%s WHERE code=%s ;", (dumps(DictionaryToStore[key]), key)) db.commit() - db.close() + # db.close() + db.close() - def query_db(self, query, args=(), one=False): - db = db_handler.get_db(self.db_path) - cur = db.execute(query, args) - rv = cur.fetchall() - cur.close() - return (rv[0] if rv else None) if one else rv + def query_db(self, query, args=()): + ''' + Simple queries to DB + :param query: SQL query + :param args: Arguments to inject into the query + :return: Single hit for the given query + ''' + + result = self.query_db_multiple(query, args=args, one=True) + if result is not None: + return result[1] + else: + return None + + def query_db_multiple(self, query, args=(), one=False): + ''' + Simple queries to DB + :param query: SQL query + :param args: Arguments to inject into the query + :return: all hits for the given query + ''' + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() + cur = cursor.execute(query, args) + if one: + try: + rv = cursor.fetchone() # Returns tuple + debug_log.info(rv) + if rv is not None: + db.close() + return rv # The second value in the tuple. + else: + return None + except Exception as e: + debug_log.exception(e) + debug_log.info(cur) + db.close() + return None + else: + try: + rv = cursor.fetchall() # Returns tuple + debug_log.info(rv) + if rv is not None: + db.close() + return rv # This should be list of tuples [(1,2,3), (3,4,5)...] + else: + return None + except Exception as e: + debug_log.exception(e) + debug_log.info(cur) + db.close() + return None - def gen_rs_id(self, source_name): + def gen_rs_id(self, source_URI): ## # Something to check state here? # Also store RS_ID in DB around here. ## - rs_id = "{}_{}".format(source_name, str(guid())) + + rs_id = "{}{}".format(source_URI.replace("http://", "").replace("https://", ""), str(guid())) self.storeRS_ID(rs_id) return rs_id @@ -340,75 +522,96 @@ def store_consent_form(self, consent_form): ## return True - def gen_cr_common(self, sur_id, rs_ID, slr_id): + def gen_cr_common(self, sur_id, rs_ID, slr_id, issued, not_before, not_after, subject_id, operator_id, role): ## # Return common part of CR + # Some of these fields are filled in consent_form.py ## common_cr = { - "version_number": "String", + "version": "1.2", "cr_id": str(guid()), "surrogate_id": sur_id, "rs_id": rs_ID, "slr_id": slr_id, - "issued": "String", - "not_before": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S %Z "), - "not_after": datetime.fromtimestamp(time.time()+2592000).strftime("%Y-%m-%dT%H:%M:%S %Z "), - "issued_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S %Z "), - "subject_id": "String" # TODO: Should this really be in common_cr? + "iat": issued, + "nbf": not_before, + "exp": not_after, + "operator": operator_id, + "subject_id": subject_id, # TODO: Should this really be in common_cr? + "role": role } return common_cr - def gen_cr_sink(self, common_CR, consent_form): + def gen_cr_sink(self, common_CR, consent_form, source_cr_id): _rules = [] common_CR["subject_id"] = consent_form["sink"]["service_id"] # This iters trough all datasets, iters though all purposes in those data sets, and add title to # _rules. It seems to be enough efficient for this purpose. - [[_rules.append(purpose["title"]) for purpose in dataset["purposes"] # 2 - if purpose["selected"] == True or purpose["required"] == True] # 3 - for dataset in consent_form["sink"]["dataset"]] # 1 + # [[_rules.append(purpose["title"]) for purpose in dataset["purposes"] # 2 + # if purpose["selected"] == True or purpose["required"] == True] # 3 + # for dataset in consent_form["sink"]["dataset"]] # 1 + for dataset in consent_form["sink"]["dataset"]: + for purpose in dataset["purposes"]: + _rules.append(purpose) - _rules = list(set(_rules)) # Remove duplicates + _rules = list(set(_rules)) # Remove duplicates _tmpl = {"cr": { "common_part": common_CR, "role_specific_part": { - "role": "Sink", + "source_cr_id": source_cr_id, "usage_rules": _rules }, - "ki_cr": {}, # TODO: Rename ki_cr - "extensions": {} + "consent_receipt_part": {"ki_cr": {}}, + "extension_part": {"extensions": {}} } } return _tmpl - def gen_cr_source(self, common_CR, consent_form, Operator_public_key): + def gen_cr_source(self, common_CR, consent_form, + sink_pop_key): # TODO: Operator_public key is now fetched with function. common_CR["subject_id"] = consent_form["source"]["service_id"] - _tmpl = {"cr": { - "common_part": common_CR, - "role_specific_part": { - "role": "Source", - "auth_token_issuer_key": Operator_public_key, - "resource_set_description": { + rs_description = \ + { + "rs_description": { "resource_set": { - "rs_id": "String", + "rs_id": consent_form["source"]["rs_id"], "dataset": [ { "dataset_id": "String", - "distribution_id": "String" - } - ] + "distribution_id": "String", + "distribution_url": "" + } + ] } - } + } + } + common_CR.update(rs_description) + _tmpl = {"cr": { + "common_part": common_CR, + "role_specific_part": { + "pop_key": sink_pop_key, + "token_issuer_key": self.get_key()["pub"], }, - "ki_cr": {}, - "extensions": {} + "consent_receipt_part": {"ki_cr": {}}, + "extension_part": {"extensions": {}} } } + _tmpl["cr"]["common_part"]["rs_description"]["resource_set"]["dataset"] = [] + + for dataset in consent_form["source"]["dataset"]: + dt_tmp = { + "dataset_id": dataset["dataset_id"], + "distribution_id": dataset["distribution"]["distribution_id"], + "distribution_url": dataset["distribution"]["access_url"] + } + _tmpl["cr"]["common_part"]["rs_description"]["resource_set"]["dataset"].append(dt_tmp) + return _tmpl def Gen_ki_cr(self, everything): @@ -417,10 +620,10 @@ def Gen_ki_cr(self, everything): def gen_csr(self, account_id, consent_record_id, consent_status, previous_record_id): _tmpl = { "record_id": str(guid()), - "account_id": account_id, + "surrogate_id": account_id, "cr_id": consent_record_id, "consent_status": consent_status, # "Active/Disabled/Withdrawn", - "iat": "", + "iat": int(time.time()), "prev_record_id": previous_record_id, } return _tmpl @@ -444,15 +647,19 @@ def gen_auth_token(self, auth_token_info): header = {"typ": "JWT", "alg": "HS256"} # Claims - payload = {"iss": slrt.get_operator_key(), # Operator_Key - "sub": slrt.get_sink_key(), # Service_Components(Sink) Key - "aud": slrt.get_dataset(), # Hard to build real - "exp": datetime.fromtimestamp(time.time()+2592000).strftime("%Y-%m-%dT%H:%M:%S %Z "), # 30 days in seconds + srv_handler = ServiceRegistryHandler(self.service_registry_search_domain, self.service_registry_search_endpoint) + payload = {"iss": self.operator_id, # Operator ID, + "cnf": {"kid": slrt.get_source_cr_id()}, + "aud": srv_handler.getService_url(slrt.get_source_service_id()), + "exp": int(time.time() + self.not_after_interval), + # datetime.fromtimestamp(time.time()+2592000).strftime("%Y-%m-%dT%H:%M:%S %Z"), # 30 days in seconds # Experiation time of token on or after which token MUST NOT be accepted - "nbf": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S %Z "), # The time before which token MUST NOT be accepted - "iat": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S %Z "), # The time which the JWT was issued + "nbf": int(time.time()), + # datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S %Z"), # The time before which token MUST NOT be accepted + "iat": int(time.time()), + # datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S %Z"), # The time which the JWT was issued "jti": str(guid()), # JWT id claim provides a unique identifier for the JWT - "rs_id": slrt.get_rs_id(), # Resource set id that was assigned in the linked Consent Record + "pi_id": slrt.get_source_cr_id(), # Resource set id that was assigned in the linked Consent Record } debug_log.debug(dumps(payload, indent=2)) key = operator_key @@ -464,87 +671,91 @@ def gen_auth_token(self, auth_token_info): token.make_signed_token(key) return token.serialize() + class SLR_tool: def __init__(self): self.slr = { - "data": { + "data": { "source": { - "consentRecord": { - "attributes": { - "cr": { - "payload": "IntcImNvbW1vbl9wYXJ0XCI6IHtcInNscl9pZFwiOiBcIjcwZjQwNTM1LTY2NzgtNDY1My1hZTdlLWJmMmU1MTc3NGFlNVwiLCBcInZlcnNpb25fbnVtYmVyXCI6IFwiU3RyaW5nXCIsIFwicnNfaWRcIjogXCIyXzM2MWNhOTY5LWMyNTktNDVkOS1iZWUwLTlmMzg4NmY2MjA1NlwiLCBcImNyX2lkXCI6IFwiMDQ3MmEyZTMtZGI2Yy00MTA5LWE1N2EtYzI1YWY5Y2IxNDUxXCIsIFwibm90X2FmdGVyXCI6IFwiU3RyaW5nXCIsIFwic3Vycm9nYXRlX2lkXCI6IFwiZTAyNTE3ZjgtNzkzZi00ZDNkLTg0MGEtNzJhNzFiN2E0OTViXzJcIiwgXCJub3RfYmVmb3JlXCI6IFwiU3RyaW5nXCIsIFwiaXNzdWVkXCI6IDE0NzE2MDQ0MDcsIFwiaXNzdWVkX2F0XCI6IFwiU3RyaW5nXCIsIFwic3ViamVjdF9pZFwiOiBcIjJcIn0sIFwicm9sZV9zcGVjaWZpY19wYXJ0XCI6IHtcImF1dGhfdG9rZW5faXNzdWVyX2tleVwiOiB7fSwgXCJyb2xlXCI6IFwiU291cmNlXCIsIFwicmVzb3VyY2Vfc2V0X2Rlc2NyaXB0aW9uXCI6IHtcInJlc291cmNlX3NldFwiOiB7XCJyc19pZFwiOiBcIlN0cmluZ1wiLCBcImRhdGFzZXRcIjogW3tcImRpc3RyaWJ1dGlvbl9pZFwiOiBcIlN0cmluZ1wiLCBcImRhdGFzZXRfaWRcIjogXCJTdHJpbmdcIn1dfX19LCBcImV4dGVuc2lvbnNcIjoge30sIFwibXZjclwiOiB7fX0i", - "signature": "JuZ_7tNcxO7_P9SGbBptllfVHNuZ2pQQZ4FLJeQISKBgA8pCra3i9Z81VbcachhLwnSBvv1qVVEuFEm5lnHR_g", - "protected": "eyJhbGciOiAiRVMyNTYifQ", - "header": { - "jwk": { - "x": "GfJCOXimGb3ZW4IJJIlKUZeoj8GCW7YYJRZgHuYUsds", - "crv": "P-256", - "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae", - "kty": "EC", - "y": "XIpGIZ7bz7uaoj_9L05CQSOw6VykuD6bK4r_OMVQSao" - }, - "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" - } - } - }, - "type": "ConsentRecord" - } + "consentRecord": { + "attributes": { + "cr": { + "payload": "IntcImNvbW1vbl9wYXJ0XCI6IHtcInNscl9pZFwiOiBcIjcwZjQwNTM1LTY2NzgtNDY1My1hZTdlLWJmMmU1MTc3NGFlNVwiLCBcInZlcnNpb25fbnVtYmVyXCI6IFwiU3RyaW5nXCIsIFwicnNfaWRcIjogXCIyXzM2MWNhOTY5LWMyNTktNDVkOS1iZWUwLTlmMzg4NmY2MjA1NlwiLCBcImNyX2lkXCI6IFwiMDQ3MmEyZTMtZGI2Yy00MTA5LWE1N2EtYzI1YWY5Y2IxNDUxXCIsIFwibm90X2FmdGVyXCI6IFwiU3RyaW5nXCIsIFwic3Vycm9nYXRlX2lkXCI6IFwiZTAyNTE3ZjgtNzkzZi00ZDNkLTg0MGEtNzJhNzFiN2E0OTViXzJcIiwgXCJub3RfYmVmb3JlXCI6IFwiU3RyaW5nXCIsIFwiaXNzdWVkXCI6IDE0NzE2MDQ0MDcsIFwiaXNzdWVkX2F0XCI6IFwiU3RyaW5nXCIsIFwic3ViamVjdF9pZFwiOiBcIjJcIn0sIFwicm9sZV9zcGVjaWZpY19wYXJ0XCI6IHtcImF1dGhfdG9rZW5faXNzdWVyX2tleVwiOiB7fSwgXCJyb2xlXCI6IFwiU291cmNlXCIsIFwicmVzb3VyY2Vfc2V0X2Rlc2NyaXB0aW9uXCI6IHtcInJlc291cmNlX3NldFwiOiB7XCJyc19pZFwiOiBcIlN0cmluZ1wiLCBcImRhdGFzZXRcIjogW3tcImRpc3RyaWJ1dGlvbl9pZFwiOiBcIlN0cmluZ1wiLCBcImRhdGFzZXRfaWRcIjogXCJTdHJpbmdcIn1dfX19LCBcImV4dGVuc2lvbnNcIjoge30sIFwibXZjclwiOiB7fX0i", + "signature": "JuZ_7tNcxO7_P9SGbBptllfVHNuZ2pQQZ4FLJeQISKBgA8pCra3i9Z81VbcachhLwnSBvv1qVVEuFEm5lnHR_g", + "protected": "eyJhbGciOiAiRVMyNTYifQ", + "header": { + "jwk": { + "x": "GfJCOXimGb3ZW4IJJIlKUZeoj8GCW7YYJRZgHuYUsds", + "crv": "P-256", + "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae", + "kty": "EC", + "y": "XIpGIZ7bz7uaoj_9L05CQSOw6VykuD6bK4r_OMVQSao" + }, + "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" + } + } + }, + "type": "ConsentRecord" + } }, "sink": { - "serviceLinkRecord": { - "attributes": { - "slr": { - "signatures": [ - { - "signature": "aQB65Kv07kL9Q62INPZXMsNJuvfsEa0OuAI9c83DBTFK8cn1qFhDNZ76vVl84B0wImt3RgsPITNJiW3OvIGdag", - "protected": "eyJhbGciOiAiRVMyNTYifQ", - "header": { - "jwk": { - "x": "GfJCOXimGb3ZW4IJJIlKUZeoj8GCW7YYJRZgHuYUsds", - "crv": "P-256", - "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae", - "kty": "EC", - "y": "XIpGIZ7bz7uaoj_9L05CQSOw6VykuD6bK4r_OMVQSao" - }, - "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" + "serviceLinkRecord": { + "attributes": { + "slr": { + "signatures": [ + { + "signature": "aQB65Kv07kL9Q62INPZXMsNJuvfsEa0OuAI9c83DBTFK8cn1qFhDNZ76vVl84B0wImt3RgsPITNJiW3OvIGdag", + "protected": "eyJhbGciOiAiRVMyNTYifQ", + "header": { + "jwk": { + "x": "GfJCOXimGb3ZW4IJJIlKUZeoj8GCW7YYJRZgHuYUsds", + "crv": "P-256", + "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae", + "kty": "EC", + "y": "XIpGIZ7bz7uaoj_9L05CQSOw6VykuD6bK4r_OMVQSao" + }, + "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" + } + }, + { + "signature": "MOBfIeQ6G4Bg6-4Q9v-Ta6_6Otd7sfXBg3YqVimtT0aL-9apMHl-i2lsuOKRySpe-tXnjQKoawjHpP8rTprqcG677TF0AbhS91LLepUsxt-NwdxnkhjDI8TSew0uVBirjY8-ZHYpLinu0ZMtAGoV-0WLuBPC-RBVqgOUQusJQSAfNyb5lpq2bTo7Xkry41XlrjdbE6tXMuGHmc2Hy9eytNf13597Q0xC0cOOlw92A92WT-6J9PLg4oArLgpBe8Tgc2GZp392DyyKvmTVENxEL1WgS5TlsxdKTH8tCSXwq5pWwkmm3Rnxfk3GUgV8hVaz0r3n1xX7EQKboondOpPeosOnpMu4ZrvoDB5aZz0KGTWuVqE7tHmVsG4lLQlww_e2KpTXfmxzLcpsOm_IfsyE-obI4_Dqi60ArjQ-kcMF6Djb0S-i1-PI-vEbSavYbcKdSjWVB1Z5-pw1rfch3inB2t5uzgjXVdipLH_jLvEUx0RrmRtG7Lq_cyJiV4wRW_YVgZbjVFZqwdsygo9-hg7YO9v-GgZr7d3z7nD6M1z4vJbJfmjXjt--2UtoY71DskxFDHUzajaMuwKiM1uBXt_TIUo3gEIM6xTpB5OEDHqN67aRTmhxK-Hqn1iHAxbnilcNjXIULiEfPQuAIpQWelO6j5drRzmyt04yIgrWQqQ5oFA", + "protected": "eyJhbGciOiAiUlMyNTYifQ", + "header": { + "jwk": { + "n": "v6QswzNJbJj2b9mE3IvPYDZx8K6MiJBDI9RJ1SwEWw0NsblAlxew3YdxvpE0iIfA-G5MHm5sG7DOmNCC9baILosVnG8UGI2QMfhZ8R4Vg-WlKQmGs_jNYaUnD2lr_gs6DTrzmfsYj_UH4NHCCm9CTW-f1s4vMpFaYAPWfTCK2OogBX0BH3f_Q8lFXmdllLN0lT5p18QY9xa9hqWkIbAOPH3Tv66kfJHdSbKeT7HqOeKRj4aBH_kokJWZcMmQAHYPuR2Y46nDQdYKRt822tmEONalupSzNdEErlSzKZ5uPileqIAitHTG0QFzL1ZfiqfI861nrKlFi3LOhXGzk_skXZYZGvLLAZ1TtBIUcM97VyBlJVNRpK9fypLyHN3ezxuazwwZ4gi8-T39E2Xpr0TRj5eVfoflau6LF4MgwQTs6PyKzkwKlcipTcrmMMhoT9MYNih_Sb2E7qlF_gXEfgFzcXO8AkArwGoNlpvYdZdNyu4u6mviH7-ZK6YnkudI6qRCrbG7sYltGXO809NdSnGklMqXDSvghlgHvagLyXJ4C8geRH_9aGzYVjweYmwQxgBMFtpvzotd1KIoeFkKFIXf1p9P02AwgQJSVTdVHltNU9Vkom-TLcO3SZ5FvpC5W1hS67bkD_qStQPWAZ-RtWH0QkjJFGdQVLdK07uZNkSVee8", + "kid": "SRVMGNT-RSA-4096", + "e": "AQAB", + "kty": "RSA" + }, + "kid": "SRVMGNT-RSA-4096" + } + } + ], + "payload": "IntcIm9wZXJhdG9yX2lkXCI6IFwiQUNDLUlELVJBTkRPTVwiLCBcImNyZWF0ZWRcIjogMTQ3MTYwNDQwNSwgXCJzdXJyb2dhdGVfaWRcIjogXCJkMTJjN2UyOC04NzRiLTQwNDAtYmVjNS02NzkzYTYwMzhjMTlfMlwiLCBcInRva2VuX2tleVwiOiB7XCJrZXlcIjoge1wiblwiOiBcInY2UXN3ek5KYkpqMmI5bUUzSXZQWURaeDhLNk1pSkJESTlSSjFTd0VXdzBOc2JsQWx4ZXczWWR4dnBFMGlJZkEtRzVNSG01c0c3RE9tTkNDOWJhSUxvc1ZuRzhVR0kyUU1maFo4UjRWZy1XbEtRbUdzX2pOWWFVbkQybHJfZ3M2RFRyem1mc1lqX1VINE5IQ0NtOUNUVy1mMXM0dk1wRmFZQVBXZlRDSzJPb2dCWDBCSDNmX1E4bEZYbWRsbExOMGxUNXAxOFFZOXhhOWhxV2tJYkFPUEgzVHY2NmtmSkhkU2JLZVQ3SHFPZUtSajRhQkhfa29rSldaY01tUUFIWVB1UjJZNDZuRFFkWUtSdDgyMnRtRU9OYWx1cFN6TmRFRXJsU3pLWjV1UGlsZXFJQWl0SFRHMFFGekwxWmZpcWZJODYxbnJLbEZpM0xPaFhHemtfc2tYWllaR3ZMTEFaMVR0QklVY005N1Z5QmxKVk5ScEs5ZnlwTHlITjNlenh1YXp3d1o0Z2k4LVQzOUUyWHByMFRSajVlVmZvZmxhdTZMRjRNZ3dRVHM2UHlLemt3S2xjaXBUY3JtTU1ob1Q5TVlOaWhfU2IyRTdxbEZfZ1hFZmdGemNYTzhBa0Fyd0dvTmxwdllkWmROeXU0dTZtdmlINy1aSzZZbmt1ZEk2cVJDcmJHN3NZbHRHWE84MDlOZFNuR2tsTXFYRFN2Z2hsZ0h2YWdMeVhKNEM4Z2VSSF85YUd6WVZqd2VZbXdReGdCTUZ0cHZ6b3RkMUtJb2VGa0tGSVhmMXA5UDAyQXdnUUpTVlRkVkhsdE5VOVZrb20tVExjTzNTWjVGdnBDNVcxaFM2N2JrRF9xU3RRUFdBWi1SdFdIMFFrakpGR2RRVkxkSzA3dVpOa1NWZWU4XCIsIFwiZVwiOiBcIkFRQUJcIiwgXCJrdHlcIjogXCJSU0FcIiwgXCJraWRcIjogXCJTUlZNR05ULVJTQS00MDk2XCJ9fSwgXCJsaW5rX2lkXCI6IFwiYTk4ZDg4Y2ItZDA3ZS00YTMyLTk3ODctY2IzODgxZDBiMDZlXCIsIFwib3BlcmF0b3Jfa2V5XCI6IHtcInVzZVwiOiBcInNpZ1wiLCBcImVcIjogXCJBUUFCXCIsIFwia3R5XCI6IFwiUlNBXCIsIFwiblwiOiBcIndITUFwQ2FVSkZpcHlGU2NUNzgxd2VuTm5mbU5jVkQxZTBmSFhfcmVfcWFTNWZvQkJzN1c0aWE1bnVxNjVFQWJKdWFxaGVPR2FEamVIaVU4V1Q5cWdnYks5cTY4SXZUTDN1bjN6R2o5WmQ3N3MySXdzNE1BSW1EeWN3Rml0aDE2M3lxdW9ETXFMX1YySXl5Mm45Uzloa1M5ZkV6cXJsZ01sYklnczJtVkJpNmdWVTJwYnJTN0gxUGFSV194YlFSX1puN19laV9uOFdlWFA1d2NEX3NJYldNa1NCc3VVZ21jam9XM1ktNW1ERDJWYmRFejJFbWtZaTlHZmstcDlBenlVbk56ZkIyTE1jSk1aekpWUWNYaUdCTzdrcG9uRkEwY3VIMV9CR0NsZXJ6Mnh2TWxXdjlPVnZzN3ZDTmRlQV9mano2eloyMUtadVo0RG1nZzBrOTRsd1wifSwgXCJ2ZXJzaW9uXCI6IFwiMS4yXCIsIFwiY3Jfa2V5c1wiOiBbe1wieVwiOiBcIlhJcEdJWjdiejd1YW9qXzlMMDVDUVNPdzZWeWt1RDZiSzRyX09NVlFTYW9cIiwgXCJ4XCI6IFwiR2ZKQ09YaW1HYjNaVzRJSkpJbEtVWmVvajhHQ1c3WVlKUlpnSHVZVXNkc1wiLCBcImNydlwiOiBcIlAtMjU2XCIsIFwia3R5XCI6IFwiRUNcIiwgXCJraWRcIjogXCJhY2Mta2lkLTM4MDJmZDE3LTQ5ZjQtNDhmYy04YWMxLTA5NjI0YTUyYTNhZVwifV0sIFwic2VydmljZV9pZFwiOiBcIjFcIn0i" } - }, - { - "signature": "MOBfIeQ6G4Bg6-4Q9v-Ta6_6Otd7sfXBg3YqVimtT0aL-9apMHl-i2lsuOKRySpe-tXnjQKoawjHpP8rTprqcG677TF0AbhS91LLepUsxt-NwdxnkhjDI8TSew0uVBirjY8-ZHYpLinu0ZMtAGoV-0WLuBPC-RBVqgOUQusJQSAfNyb5lpq2bTo7Xkry41XlrjdbE6tXMuGHmc2Hy9eytNf13597Q0xC0cOOlw92A92WT-6J9PLg4oArLgpBe8Tgc2GZp392DyyKvmTVENxEL1WgS5TlsxdKTH8tCSXwq5pWwkmm3Rnxfk3GUgV8hVaz0r3n1xX7EQKboondOpPeosOnpMu4ZrvoDB5aZz0KGTWuVqE7tHmVsG4lLQlww_e2KpTXfmxzLcpsOm_IfsyE-obI4_Dqi60ArjQ-kcMF6Djb0S-i1-PI-vEbSavYbcKdSjWVB1Z5-pw1rfch3inB2t5uzgjXVdipLH_jLvEUx0RrmRtG7Lq_cyJiV4wRW_YVgZbjVFZqwdsygo9-hg7YO9v-GgZr7d3z7nD6M1z4vJbJfmjXjt--2UtoY71DskxFDHUzajaMuwKiM1uBXt_TIUo3gEIM6xTpB5OEDHqN67aRTmhxK-Hqn1iHAxbnilcNjXIULiEfPQuAIpQWelO6j5drRzmyt04yIgrWQqQ5oFA", - "protected": "eyJhbGciOiAiUlMyNTYifQ", - "header": { - "jwk": { - "n": "v6QswzNJbJj2b9mE3IvPYDZx8K6MiJBDI9RJ1SwEWw0NsblAlxew3YdxvpE0iIfA-G5MHm5sG7DOmNCC9baILosVnG8UGI2QMfhZ8R4Vg-WlKQmGs_jNYaUnD2lr_gs6DTrzmfsYj_UH4NHCCm9CTW-f1s4vMpFaYAPWfTCK2OogBX0BH3f_Q8lFXmdllLN0lT5p18QY9xa9hqWkIbAOPH3Tv66kfJHdSbKeT7HqOeKRj4aBH_kokJWZcMmQAHYPuR2Y46nDQdYKRt822tmEONalupSzNdEErlSzKZ5uPileqIAitHTG0QFzL1ZfiqfI861nrKlFi3LOhXGzk_skXZYZGvLLAZ1TtBIUcM97VyBlJVNRpK9fypLyHN3ezxuazwwZ4gi8-T39E2Xpr0TRj5eVfoflau6LF4MgwQTs6PyKzkwKlcipTcrmMMhoT9MYNih_Sb2E7qlF_gXEfgFzcXO8AkArwGoNlpvYdZdNyu4u6mviH7-ZK6YnkudI6qRCrbG7sYltGXO809NdSnGklMqXDSvghlgHvagLyXJ4C8geRH_9aGzYVjweYmwQxgBMFtpvzotd1KIoeFkKFIXf1p9P02AwgQJSVTdVHltNU9Vkom-TLcO3SZ5FvpC5W1hS67bkD_qStQPWAZ-RtWH0QkjJFGdQVLdK07uZNkSVee8", - "kid": "SRVMGNT-RSA-4096", - "e": "AQAB", - "kty": "RSA" - }, - "kid": "SRVMGNT-RSA-4096" - } - } - ], - "payload": "IntcIm9wZXJhdG9yX2lkXCI6IFwiQUNDLUlELVJBTkRPTVwiLCBcImNyZWF0ZWRcIjogMTQ3MTYwNDQwNSwgXCJzdXJyb2dhdGVfaWRcIjogXCJkMTJjN2UyOC04NzRiLTQwNDAtYmVjNS02NzkzYTYwMzhjMTlfMlwiLCBcInRva2VuX2tleVwiOiB7XCJrZXlcIjoge1wiblwiOiBcInY2UXN3ek5KYkpqMmI5bUUzSXZQWURaeDhLNk1pSkJESTlSSjFTd0VXdzBOc2JsQWx4ZXczWWR4dnBFMGlJZkEtRzVNSG01c0c3RE9tTkNDOWJhSUxvc1ZuRzhVR0kyUU1maFo4UjRWZy1XbEtRbUdzX2pOWWFVbkQybHJfZ3M2RFRyem1mc1lqX1VINE5IQ0NtOUNUVy1mMXM0dk1wRmFZQVBXZlRDSzJPb2dCWDBCSDNmX1E4bEZYbWRsbExOMGxUNXAxOFFZOXhhOWhxV2tJYkFPUEgzVHY2NmtmSkhkU2JLZVQ3SHFPZUtSajRhQkhfa29rSldaY01tUUFIWVB1UjJZNDZuRFFkWUtSdDgyMnRtRU9OYWx1cFN6TmRFRXJsU3pLWjV1UGlsZXFJQWl0SFRHMFFGekwxWmZpcWZJODYxbnJLbEZpM0xPaFhHemtfc2tYWllaR3ZMTEFaMVR0QklVY005N1Z5QmxKVk5ScEs5ZnlwTHlITjNlenh1YXp3d1o0Z2k4LVQzOUUyWHByMFRSajVlVmZvZmxhdTZMRjRNZ3dRVHM2UHlLemt3S2xjaXBUY3JtTU1ob1Q5TVlOaWhfU2IyRTdxbEZfZ1hFZmdGemNYTzhBa0Fyd0dvTmxwdllkWmROeXU0dTZtdmlINy1aSzZZbmt1ZEk2cVJDcmJHN3NZbHRHWE84MDlOZFNuR2tsTXFYRFN2Z2hsZ0h2YWdMeVhKNEM4Z2VSSF85YUd6WVZqd2VZbXdReGdCTUZ0cHZ6b3RkMUtJb2VGa0tGSVhmMXA5UDAyQXdnUUpTVlRkVkhsdE5VOVZrb20tVExjTzNTWjVGdnBDNVcxaFM2N2JrRF9xU3RRUFdBWi1SdFdIMFFrakpGR2RRVkxkSzA3dVpOa1NWZWU4XCIsIFwiZVwiOiBcIkFRQUJcIiwgXCJrdHlcIjogXCJSU0FcIiwgXCJraWRcIjogXCJTUlZNR05ULVJTQS00MDk2XCJ9fSwgXCJsaW5rX2lkXCI6IFwiYTk4ZDg4Y2ItZDA3ZS00YTMyLTk3ODctY2IzODgxZDBiMDZlXCIsIFwib3BlcmF0b3Jfa2V5XCI6IHtcInVzZVwiOiBcInNpZ1wiLCBcImVcIjogXCJBUUFCXCIsIFwia3R5XCI6IFwiUlNBXCIsIFwiblwiOiBcIndITUFwQ2FVSkZpcHlGU2NUNzgxd2VuTm5mbU5jVkQxZTBmSFhfcmVfcWFTNWZvQkJzN1c0aWE1bnVxNjVFQWJKdWFxaGVPR2FEamVIaVU4V1Q5cWdnYks5cTY4SXZUTDN1bjN6R2o5WmQ3N3MySXdzNE1BSW1EeWN3Rml0aDE2M3lxdW9ETXFMX1YySXl5Mm45Uzloa1M5ZkV6cXJsZ01sYklnczJtVkJpNmdWVTJwYnJTN0gxUGFSV194YlFSX1puN19laV9uOFdlWFA1d2NEX3NJYldNa1NCc3VVZ21jam9XM1ktNW1ERDJWYmRFejJFbWtZaTlHZmstcDlBenlVbk56ZkIyTE1jSk1aekpWUWNYaUdCTzdrcG9uRkEwY3VIMV9CR0NsZXJ6Mnh2TWxXdjlPVnZzN3ZDTmRlQV9mano2eloyMUtadVo0RG1nZzBrOTRsd1wifSwgXCJ2ZXJzaW9uXCI6IFwiMS4yXCIsIFwiY3Jfa2V5c1wiOiBbe1wieVwiOiBcIlhJcEdJWjdiejd1YW9qXzlMMDVDUVNPdzZWeWt1RDZiSzRyX09NVlFTYW9cIiwgXCJ4XCI6IFwiR2ZKQ09YaW1HYjNaVzRJSkpJbEtVWmVvajhHQ1c3WVlKUlpnSHVZVXNkc1wiLCBcImNydlwiOiBcIlAtMjU2XCIsIFwia3R5XCI6IFwiRUNcIiwgXCJraWRcIjogXCJhY2Mta2lkLTM4MDJmZDE3LTQ5ZjQtNDhmYy04YWMxLTA5NjI0YTUyYTNhZVwifV0sIFwic2VydmljZV9pZFwiOiBcIjFcIn0i" - } - }, - "type": "ServiceLinkRecord" - } + }, + "type": "ServiceLinkRecord" + } } - } } + } def decrypt_payload(self, payload): payload += '=' * (-len(payload) % 4) # Fix incorrect padding of base64 string. content = decode(payload.encode()) - payload = loads(loads(content.decode("utf-8"))) + payload = loads(content.decode("utf-8")) return payload def get_SLR_payload(self): - base64_payload = self.slr["data"]["sink"]["serviceLinkRecord"]["attributes"]["slr"]["payload"] + debug_log.info(dumps(self.slr, indent=2)) + base64_payload = self.slr["data"]["sink"]["serviceLinkRecord"]["attributes"]["slr"]["attributes"]["slr"][ + "payload"] # TODO: This is a workaround for structure repetition. payload = self.decrypt_payload(base64_payload) return payload def get_CR_payload(self): - base64_payload = self.slr["data"]["source"]["consentRecord"]["attributes"]["cr"]["payload"] + base64_payload = self.slr["data"]["source"]["consentRecord"]["attributes"]["cr"]["attributes"]["cr"][ + "payload"] # TODO: This is a workaround for structure repetition. payload = self.decrypt_payload(base64_payload) return payload @@ -560,6 +771,9 @@ def get_cr_keys(self): def get_rs_id(self): return self.get_CR_payload()["common_part"]["rs_id"] + def get_source_cr_id(self): + return self.get_CR_payload()["common_part"]["cr_id"] + def get_surrogate_id(self): return self.get_CR_payload()["common_part"]["surrogate_id"] @@ -567,4 +781,10 @@ def get_sink_key(self): return self.get_SLR_payload()["token_key"]["key"] def get_dataset(self): - return self.get_CR_payload()["role_specific_part"]["resource_set_description"]["resource_set"]["dataset"] + return self.get_CR_payload()["common_part"]["rs_description"]["resource_set"]["dataset"] + + def get_source_service_id(self): + return self.get_CR_payload()["common_part"]["subject_id"] + + def get_sink_service_id(self): + return self.slr["data"]["sink"]["serviceLinkRecord"]["attributes"]["slr"]["attributes"]["service_id"] diff --git a/Operator_Components/instance/__init__.py b/Operator_Components/instance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Operator_Components/instance/settings.py b/Operator_Components/instance/settings.py new file mode 100644 index 0000000..365f108 --- /dev/null +++ b/Operator_Components/instance/settings.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +from kombu import Exchange, Queue + +TIMEOUT = 8 +KEYSIZE = 512 + +# TODO give these as parameter to init AccountManagerHandler + +ACCOUNT_MANAGEMENT_URL = 'http://account:8080/' + +ACCOUNT_MANAGEMENT_USER = "test_sdk" + +ACCOUNT_MANAGEMENT_PASSWORD = "test_sdk_pw" + +# Setting to /tmp or other ramdisk makes it faster. + +DATABASE_PATH = "./db_Operator.sqlite" + +SELERY_BROKER_URL = 'redis://redis:6379/0' + +SELERY_RESULT_BACKEND = 'redis://redis:6379/0' + +CERT_PATH = "./service_key.jwk" + +CERT_KEY_PATH = "./service_key.jwk" + +CERT_PASSWORD_PATH = "./cert_pw" + +OPERATOR_UID = "41e19fcd-1951-455f-9169-a303f990f52d" + +OPERATOR_ROOT_PATH = "/api/1.2" + +OPERATOR_CR_PATH = "/cr" + +OPERATOR_SLR_PATH = "/slr" + +SERVICE_URL = "http://service_components:7000" + +DEBUG_MODE = False + +CELERY_QUEUES = ( + Queue('op_queue', Exchange('op_queue'), routing_key='op_queue'), +) + +CELERY_DEFAULT_QUEUE = 'op_queue' + +CELERY_ROUTES = { + 'CR_Installer': {'queue': 'op_queue','routing_key': "op_queue"}, +} \ No newline at end of file diff --git a/Operator_Components/instance/settings_template.py.j2 b/Operator_Components/instance/settings_template.py.j2 new file mode 100644 index 0000000..6f4f1fe --- /dev/null +++ b/Operator_Components/instance/settings_template.py.j2 @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +from kombu import Exchange, Queue + +TIMEOUT = 8 +KEYSIZE = 512 + +{% if UID is defined %} +UID = {{ UID }} +{% else %} +UID = 'Operator112' +{% endif %} + + +{% if SERVICE_REGISTRY_SEARCH_DOMAIN is defined %} +SERVICE_REGISTRY_SEARCH_DOMAIN = {{ SERVICE_REGISTRY_SEARCH_DOMAIN }} +{% else %} +SERVICE_REGISTRY_SEARCH_DOMAIN = "http://178.62.229.148:8081" +{% endif %} + +{% if SERVICE_REGISTRY_SEARCH_ENDPOINT is defined %} +SERVICE_REGISTRY_SEARCH_ENDPOINT = {{ SERVICE_REGISTRY_SEARCH_ENDPOINT }} +{% else %} +SERVICE_REGISTRY_SEARCH_ENDPOINT = "/api/v1/services/" +{% endif %} + + +{% if NOT_AFTER_INTERVAL is defined %} +NOT_AFTER_INTERVAL = {{ NOT_AFTER_INTERVAL }} +{% else %} +NOT_AFTER_INTERVAL = 2592000 # 30 days in seconds. +{% endif %} + +# Name of host to connect to. Default: use the local host via a UNIX socket (where applicable) +{% if MYSQL_HOST is defined %} +MYSQL_HOST = {{ MYSQL_HOST }} +{% else %} +MYSQL_HOST = 'localhost' +{% endif %} + + # User to authenticate as. Default: current effective user. +{% if MYSQL_USER is defined %} +MYSQL_USER = {{ MYSQL_USER }} +{% else %} +MYSQL_USER = 'operator' +{% endif %} + +# Password to authenticate with. Default: no password. +{% if MYSQL_PASSWORD is defined %} +MYSQL_PASSWORD = {{ MYSQL_PASSWORD }} +{% else %} +MYSQL_PASSWORD = 'MynorcA' +{% endif %} + +# Database to use. Default: no default database. +{% if MYSQL_DB is defined %} +MYSQL_DB = {{ MYSQL_DB }} +{% else %} +MYSQL_DB = 'MyDataOperator' +{% endif %} + +# TCP port of MySQL server. Default: 3306. +{% if MYSQL_PORT is defined %} +MYSQL_PORT = {{ MYSQL_PORT }} +{% else %} +MYSQL_PORT = 3306 +{% endif %} + + + +# TODO give these as parameter to init AccountManagerHandler +{% if ACCOUNT_MANAGEMENT_URL is defined %} +ACCOUNT_MANAGEMENT_URL = {{ ACCOUNT_MANAGEMENT_URL }} +{% else %} + +ACCOUNT_MANAGEMENT_URL = "http://myaccount.dy.fi/" + +{% endif %} + +{% if ACCOUNT_MANAGEMENT_USER is defined %} +ACCOUNT_MANAGEMENT_USER = {{ ACCOUNT_MANAGEMENT_USER }} +{% else %} +ACCOUNT_MANAGEMENT_USER = "test_sdk" +{% endif %} + +{% if ACCOUNT_MANAGEMENT_PASSWORD is defined %} +ACCOUNT_MANAGEMENT_PASSWORD = {{ ACCOUNT_MANAGEMENT_PASSWORD }} +{% else %} +ACCOUNT_MANAGEMENT_PASSWORD = "test_sdk_pw" +{% endif %} + + +# Setting to /tmp or other ramdisk makes it faster. +{% if DATABASE_PATH is defined %} +DATABASE_PATH = {{ DATABASE_PATH }} +{% else %} + +DATABASE_PATH = "./db_Operator.sqlite" +{% endif %} + + +{% if SELERY_BROKER_URL is defined %} +SELERY_BROKER_URL = {{ SELERY_BROKER_URL }} +{% else %} +SELERY_BROKER_URL = 'redis://localhost:6379/0' +{% endif %} + +{% if SELERY_RESULT_BACKEND is defined %} +SELERY_RESULT_BACKEND = {{ SELERY_RESULT_BACKEND }} +{% else %} +SELERY_RESULT_BACKEND = 'redis://localhost:6379/0' + +{% endif %} + + +{% if CERT_PATH is defined %} +CERT_PATH = {{ CERT_PATH }} +{% else %} +CERT_PATH = "./service_key.jwk" +{% endif %} + +{% if CERT_KEY_PATH is defined %} +CERT_KEY_PATH = {{ CERT_KEY_PATH }} +{% else %} +CERT_KEY_PATH = "./service_key.jwk" +{% endif %} + +{% if CERT_PASSWORD_PATH is defined %} +CERT_PASSWORD_PATH = {{ CERT_PASSWORD_PATH }} +{% else %} +CERT_PASSWORD_PATH = "./cert_pw" +{% endif %} + + +{% if OPERATOR_URL is defined %} +OPERATOR_URL = {{ OPERATOR_URL }} +{% else %} +OPERATOR_URL = "http://localhost:5000" +{% endif %} + +{% if OPERATOR_UID is defined %} +OPERATOR_UID = {{ OPERATOR_UID }} +{% else %} +OPERATOR_UID = "41e19fcd-1951-455f-9169-a303f990f52d" +{% endif %} + + +{% if OPERATOR_ROOT_PATH is defined %} +OPERATOR_ROOT_PATH = {{ OPERATOR_ROOT_PATH }} +{% else %} +OPERATOR_ROOT_PATH = "/api/1.2" +{% endif %} + +{% if OPERATOR_CR_PATH is defined %} +OPERATOR_CR_PATH = {{ OPERATOR_CR_PATH }} +{% else %} +OPERATOR_CR_PATH = "/cr" +{% endif %} + +{% if OPERATOR_SLR_PATH is defined %} +OPERATOR_SLR_PATH = {{ OPERATOR_SLR_PATH }} +{% else %} +OPERATOR_SLR_PATH = "/slr" +{% endif %} + +{% if SERVICE_URL is defined %} +SERVICE_URL = {{ SERVICE_URL }} +{% else %} +SERVICE_URL = "http://localhost:7000" +{% endif %} + +{% if DEBUG_MODE is defined %} +DEBUG_MODE = {{ DEBUG_MODE }} +{% else %} +DEBUG_MODE = True +{% endif %} + + + +CELERY_QUEUES = ( + Queue('op_queue', Exchange('op_queue'), routing_key='op_queue'), +) + +CELERY_DEFAULT_QUEUE = 'op_queue' + +CELERY_ROUTES = { + 'CR_Installer': {'queue': 'op_queue','routing_key': "op_queue"}, +} \ No newline at end of file diff --git a/Operator_Components/op_tasks.py b/Operator_Components/op_tasks.py new file mode 100644 index 0000000..c75e16f --- /dev/null +++ b/Operator_Components/op_tasks.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from requests import post +from factory import create_celery_app + +celery = create_celery_app() + +@celery.task +def CR_installer(crs_csrs_payload, sink_url, source_url): + # Get these as parameter or inside crs_csrs_payload + endpoint = "/api/1.2/cr/add_cr" + print(crs_csrs_payload) + source = post(source_url+endpoint, json=crs_csrs_payload["source"]) + print(source.url, source.reason, source.status_code, source.text) + + sink = post(sink_url+endpoint, json=crs_csrs_payload["sink"]) + print(sink.url, sink.reason, sink.status_code, sink.text) + +# # TODO Possibly remove this on release +# from sqlite3 import OperationalError, IntegrityError +# import db_handler +# from json import dumps, loads +# from requests import get +# @celery.task +# def get_AuthToken(cr_id, operator_url, db_path): +# print(operator_url, db_path, cr_id) +# def storeToken(DictionaryToStore): +# db = db_handler.get_db(db_path) +# try: +# db_handler.init_db(db) +# except OperationalError: +# pass +# for key in DictionaryToStore: +# try: +# db.execute("INSERT INTO token_storage (cr_id,token) \ +# VALUES (?, ?)", [key, dumps(DictionaryToStore[key])]) +# db.commit() +# except IntegrityError as e: # Rewrite incase we get new token. +# db.execute("UPDATE token_storage SET token=? WHERE cr_id=? ;", [dumps(DictionaryToStore[key]), key]) +# db.commit() +# +# print(cr_id) +# token = get("{}/api/1.2/cr/auth_token/{}".format(operator_url, cr_id)) # TODO Get api path from some config? +# print(token.url, token.reason, token.status_code, token.text) +# store_dict = {cr_id: dumps(loads(token.text.encode()))} +# storeToken(store_dict) diff --git a/Operator_Components/requirements.txt b/Operator_Components/requirements.txt index 6a0fe52..d1978bd 100644 --- a/Operator_Components/requirements.txt +++ b/Operator_Components/requirements.txt @@ -20,6 +20,7 @@ jsonschema==2.5.1 jwcrypto==0.3.1 kombu==3.0.35 MarkupSafe==0.23 +mysqlclient==1.3.7 pyasn1==0.1.9 pycparser==2.14 pycryptodome==3.4 @@ -29,5 +30,7 @@ pytz==2016.6.1 redis==2.10.5 requests==2.11.1 six==1.10.0 +uWSGI==2.0.13.1 Werkzeug==0.11.10 wheel==0.24.0 +restapi-logging-handler==0.2.2 \ No newline at end of file diff --git a/Operator_Components/wsgi.py b/Operator_Components/wsgi.py index 12942cd..95978e4 100644 --- a/Operator_Components/wsgi.py +++ b/Operator_Components/wsgi.py @@ -17,7 +17,7 @@ try: from restapi_logging_handler import RestApiHandler - restapihandler = RestApiHandler("http://localhost:9004/") + restapihandler = RestApiHandler("http://172.17.0.1:9004/") logger.addHandler(restapihandler) except Exception as e: diff --git a/Service_Components/Authorization_Management/authorization_management.py b/Service_Components/Authorization_Management/authorization_management.py index 2dabc78..690a52b 100644 --- a/Service_Components/Authorization_Management/authorization_management.py +++ b/Service_Components/Authorization_Management/authorization_management.py @@ -12,7 +12,7 @@ from flask_restful import Resource, Api from helpers import validate_json, SLR_tool, CR_tool, Helpers from jwcrypto import jwk -from tasks import get_AuthToken +from srv_tasks import get_AuthToken api_Service_Mgmnt = Blueprint("api_Service_Mgmnt", __name__) # TODO Rename this @@ -41,16 +41,6 @@ ''' -Service_ID = "SRVMGNT-IDK3Y" -gen = {"generate": "EC", "cvr": "P-256", "kid": Service_ID} -gen2 = {"generate": "EC", "cvr": "P-256", "kid": Service_ID} -service_key = jwk.JWK(**gen) -token_key = jwk.JWK(**gen) - -templ = {Service_ID: {"cr_keys": loads(token_key.export_public())}} -protti = {"alg": "ES256"} -headeri = {"kid": Service_ID, "jwk": loads(service_key.export_public())} - logger = logging.getLogger("sequence") debug_log = logging.getLogger("debug") @@ -74,7 +64,7 @@ def __init__(self): super(Install_CR, self).__init__() self.helpers = Helpers(current_app.config) self.operator_url = current_app.config["OPERATOR_URL"] - + self.db_path = current_app.config["DATABASE_PATH"] @error_handler def post(self): debug_log.info("arrived at Install_CR") @@ -95,6 +85,8 @@ def post(self): sq.task("Verify CR format and mandatory fields") if role == "Source": debug_log.info("Source CR") + debug_log.info(dumps(crt.get_CR_payload(), indent=2)) + debug_log.info(type(crt.get_CR_payload())) errors = validate_json(source_cr_schema, crt.get_CR_payload()) for e in errors: raise DetailedHTTPException(detail={"msg": "Validating Source CR format and fields failed", @@ -131,7 +123,7 @@ def post(self): else: raise DetailedHTTPException(detail={"msg": "Verifying CR failed",}, title="Failure in CR verifying", - status=451) + status=403) sq.task("Verify CSR integrity") # SLR includes CR keys which means we need to get key from stored SLR and use it to verify this @@ -142,7 +134,7 @@ def post(self): else: raise DetailedHTTPException(detail={"msg": "Verifying CSR failed",}, title="Failure in CSR verifying", - status=451) + status=403) sq.task("Verify Status Record") @@ -160,15 +152,16 @@ def post(self): else: raise DetailedHTTPException(detail={"msg": "Verifying CSR cr_id == CR cr_id failed",}, title="Failure in CSR verifying", - status=451) + status=403) # 2) CSR has link to previous CSR prev_csr_id_refers_to_null_as_it_should = crt.get_prev_record_id() == "null" if prev_csr_id_refers_to_null_as_it_should: debug_log.info("prev_csr_id_referred to null as it should.") else: + # TODO: Check here that the csr chain is intact. and then continue. raise DetailedHTTPException(detail={"msg": "Verifying CSR previous_id == 'null' failed",}, title="Failure in CSR verifying", - status=451) + status=403) verify_is_success = crt.verify_cr(slrt.get_cr_keys()) if verify_is_success: @@ -178,24 +171,28 @@ def post(self): raise DetailedHTTPException(detail={"msg": "Verifying CSR failed",}, title="Failure in CSR verifying") # 5) Previous CSR has not been withdrawn - # TODO Implement + # If previous_id is null this step can be ignored. + # Else fetch previous_id from db and check the status. sq.task("Store CR and CSR") store_dict = { "rs_id": crt.get_rs_id(), + "csr_id": crt.get_csr_id(), + "consent_status": crt.get_consent_status(), + "previous_record_id": crt.get_prev_record_id(), "cr_id": crt.get_cr_id_from_cr(), "surrogate_id": surr_id, "slr_id": crt.get_slr_id(), - "json": crt.get_CR_payload() # possibly store the base64 representation + "json": crt.cr["cr"] # possibly store the base64 representation } self.helpers.storeCR_JSON(store_dict) - store_dict["json"] = crt.get_CSR_payload() + store_dict["json"] = crt.cr["csr"] self.helpers.storeCSR_JSON(store_dict) if role == "Sink": debug_log.info("Requesting auth_token") - get_AuthToken.delay(crt.get_cr_id_from_cr(), self.operator_url) + get_AuthToken.delay(crt.get_cr_id_from_cr(), self.operator_url, current_app.config) return {"status": 200, "msg": "OK"}, 200 diff --git a/Service_Components/Service_Mgmnt/service_mgmnt.py b/Service_Components/Service_Mgmnt/service_mgmnt.py index 4033c76..7095141 100644 --- a/Service_Components/Service_Mgmnt/service_mgmnt.py +++ b/Service_Components/Service_Mgmnt/service_mgmnt.py @@ -52,6 +52,7 @@ def __init__(self): @error_handler def get(self): + code_storage = None try: sq.task("Generate code") code = str(guid()) @@ -61,6 +62,8 @@ def get(self): sq.reply_to("Operator_Components Mgmnt", "Returning code") return {'code': code} except Exception as e: + if code_storage is None: + code_storage = "code json structure is broken." raise DetailedHTTPException(exception=e, detail={"msg": "Most likely storing code failed.", "code_json": code_storage}, title="Failure in GenCode endpoint", @@ -72,35 +75,18 @@ def __init__(self): super(UserAuthenticated, self).__init__() keysize = current_app.config["KEYSIZE"] cert_key_path = current_app.config["CERT_KEY_PATH"] - Service_ID = "SRVMGNT-RSA-{}".format(keysize) - gen = {"generate": "EC", "cvr": "P-256", "kid": Service_ID} - gen2 = {"generate": "EC", "cvr": "P-256", "kid": Service_ID} - - gen3 = {"generate": "RSA", "size": keysize, "kid": Service_ID} - self.service_key = jwk.JWK(**gen3) - try: - with open(cert_key_path, "r") as cert_file: - service_key2 = jwk.JWK(**loads(load(cert_file))) - self.service_key = service_key2 - except Exception as e: - debug_log.error(e) - with open(cert_key_path, "w+") as cert_file: - dump(self.service_key.export(), cert_file, indent=2) - service_cert = self.service_key.export_public() - self.token_key = self.service_key - - templ = {Service_ID: {"cr_keys": loads(self.token_key.export_public())}} - protti = {"alg": "RS256"} - headeri = {"kid": Service_ID, "jwk": loads(self.service_key.export_public())} + self.helpers = Helpers(current_app.config) + self.service_key = self.helpers.get_key() self.service_url = current_app.config["SERVICE_URL"] self.operator_url = current_app.config["OPERATOR_URL"] - self.helpers = Helpers(current_app.config) + @timeme @error_handler def post(self): try: + debug_log.info("UserAuthenticated class, method post got json:") debug_log.info(request.json) user_id = request.json["user_id"] code = request.json["code"] @@ -111,9 +97,9 @@ def post(self): sq.task("Link code to generated surrogate_id") self.helpers.add_surrogate_id_to_code(request.json["code"], surrogate_id) data = {"surrogate_id": surrogate_id, "code": request.json["code"], - "token_key": loads(self.service_key.export_public())} + "token_key": self.service_key["pub"]} - sq.send_to("Service_Components", "Send surrogate_id to Service_Components") + sq.send_to("Service_Components", "Send surrogate_id to Service_Mockup") endpoint = "/api/1.2/slr/link" content_json = {"code": code, "surrogate_id": surrogate_id} result_service = post("{}{}".format(self.service_url, endpoint), json=content_json) @@ -152,7 +138,7 @@ def __init__(self): @error_handler def post(self): - + debug_log.info("SignInRedisrector class, method post got json:") debug_log.info(request.json) code = request.json @@ -186,6 +172,7 @@ def verify(jws, header): jws.verify(sign_key) return True except Exception as e: + debug_log.info("JWS verification failed with:") debug_log.info(repr(e)) try: @@ -208,6 +195,7 @@ def verify(jws, header): return True return False except Exception as e: + debug_log.info("JWS verification failed with:") debug_log.info("M:", repr(e)) return False @@ -228,37 +216,25 @@ def header_fix(malformed_dictionary): # We do not check if its malformed, we ex class StoreSLR(Resource): def __init__(self): super(StoreSLR, self).__init__() - keysize = current_app.config["KEYSIZE"] - cert_key_path = current_app.config["CERT_KEY_PATH"] - Service_ID = "SRVMGNT-RSA-{}".format(keysize) - gen = {"generate": "EC", "cvr": "P-256", "kid": Service_ID} - gen2 = {"generate": "EC", "cvr": "P-256", "kid": Service_ID} + config = current_app.config + keysize = config["KEYSIZE"] + cert_key_path = config["CERT_KEY_PATH"] + self.helpers = Helpers(config) + self.service_key = self.helpers.get_key() - gen3 = {"generate": "RSA", "size": keysize, "kid": Service_ID} - self.service_key = jwk.JWK(**gen3) - try: - with open(cert_key_path, "r") as cert_file: - service_key2 = jwk.JWK(**loads(load(cert_file))) - self.service_key = service_key2 - except Exception as e: - debug_log.error(e) - with open(cert_key_path, "w+") as cert_file: - dump(self.service_key.export(), cert_file, indent=2) - service_cert = self.service_key.export_public() - self.token_key = self.service_key # - templ = {Service_ID: {"cr_keys": loads(self.token_key.export_public())}} - self.protti = {"alg": "RS256"} - self.headeri = {"kid": Service_ID, "jwk": loads(self.service_key.export_public())} + self.protti = self.service_key["prot"] + self.headeri = self.service_key["header"] + + self.service_url = config["SERVICE_URL"] + self.operator_url = config["OPERATOR_URL"] - self.service_url = current_app.config["SERVICE_URL"] - self.operator_url = current_app.config["OPERATOR_URL"] - self.helpers = Helpers(current_app.config) @timeme @error_handler def post(self): try: + debug_log.info("StoreSLR class method post got json:") debug_log.info(dumps(request.json, indent=2)) sq.task("Load SLR to object") @@ -268,31 +244,32 @@ def post(self): sq.task("Load slr payload as object") payload = slr["payload"] payload = slr["payload"] - debug_log.info("Before Fix:{}".format(payload)) + debug_log.info("Before padding fix:{}".format(payload)) sq.task("Fix possible incorrect padding in payload") payload += '=' * (-len(payload) % 4) # Fix incorrect padding of base64 string. - debug_log.info("After Fix :{}".format(payload)) + debug_log.info("After padding fix :{}".format(payload)) - sq.task("Decode payload and store it into object") + sq.task("Decode SLR payload and store it into object") debug_log.info(payload.encode()) content = decode(payload.encode()) sq.task("Load decoded payload as python dict") - payload = loads( - loads(content.decode("utf-8"))) # TODO: Figure out why we get str out of loads the first time? - debug_log.info(payload) + payload = loads(content.decode("utf-8")) + debug_log.info("Decoded SLR payload:") debug_log.info(type(payload)) + debug_log.info(dumps(payload, indent=2)) + - sq.task("Fetch surrogate_id from decoded payload") + sq.task("Fetch surrogate_id from decoded SLR payload") surrogate_id = payload["surrogate_id"].encode() - debug_log.info(content) sq.task("Load code from json payload") code = request.json["data"]["code"].encode() + debug_log.info("SLR payload contained code: {}".format(code)) sq.task("Verify surrogate_id and code") - debug_log.info("Surrogate was found: {}".format(self.helpers.verifySurrogate(code, surrogate_id))) + debug_log.info("Surrogate {} has been verified for code {}.".format(self.helpers.verifySurrogate(code, surrogate_id), code)) except Exception as e: raise DetailedHTTPException(title="Verifying Surrogate ID failed", @@ -302,14 +279,13 @@ def post(self): try: sq.task("Create empty JSW object") jwssa = jws.JWS() - debug_log.info("SLR R:\n", loads(dumps(slr))) - debug_log.info(slr["header"]["jwk"]) + debug_log.info("SLR Received:\n"+(dumps(slr, indent=2))) sq.task("Deserialize slr to JWS object created before") jwssa.deserialize(dumps(slr)) - sq.task("Load JWK used to sign JWS from the slr headers into an object") - sign_key = jwk.JWK(**slr["header"]["jwk"]) + sq.task("Load JWK used to sign JWS from the slr payload's cr_keys field into an object") + sign_key = jwk.JWK(**payload["cr_keys"][0]) sq.task("Verify SLR was signed using the key shipped with it") debug_log.info(verifyJWS(slr)) @@ -321,12 +297,12 @@ def post(self): try: sq.task("Fix possible serialization errors in JWS") - faulty_JSON = loads(jwssa.serialize( - compact=False)) # For some reason serialization messes up "header" from "header": {} to "header": "{}" + faulty_JSON = loads(jwssa.serialize(compact=False)) # For some reason serialization messes up "header" from "header": {} to "header": "{}" faulty_JSON["header"] = faulty_JSON["header"] sq.task("Add our signature in the JWS") - jwssa.add_signature(self.service_key, alg="RS256", header=dumps(self.headeri), protected=dumps(self.protti)) + key = jwk.JWK(**self.service_key["key"]) + jwssa.add_signature(key, header=dumps(self.headeri), protected=dumps(self.protti)) sq.task("Fix possible header errors") fixed = header_fix(loads(jwssa.serialize(compact=False))) @@ -344,17 +320,18 @@ def post(self): sq.send_to("Operator_Components Mgmnt", "Verify SLR(JWS)") endpoint = "/api/1.2/slr/verify" result = post("{}{}".format(self.operator_url, endpoint), json=req) - debug_log.info(result.status_code) + debug_log.info("Sent SLR to Operator for verification, results:") + debug_log.info("status code:{}\nreason: {}\ncontent: {}".format(result.status_code, result.reason, result.content)) if result.ok: - sq.task("Store SLR into db") + sq.task("Store following SLR into db") store = loads(loads(result.text)) debug_log.debug(dumps(store, indent=2)) self.helpers.storeJSON({store["data"]["surrogate_id"]: store}) endpoint = "/api/1.2/slr/store_slr" + debug_log.info("Posting SLR for storage in Service Mockup") result = post("{}{}".format(self.service_url, endpoint), json=store) # Send copy to Service_Components else: - debug_log.debug(result.reason) raise DetailedHTTPException(status=result.status_code, detail={"msg": "Something went wrong while verifying SLR with Operator_SLR.", "Error from Operator_SLR": loads(result.text)}, diff --git a/Service_Components/Sink/Sink_DataFlow.py b/Service_Components/Sink/Sink_DataFlow.py new file mode 100644 index 0000000..2914b18 --- /dev/null +++ b/Service_Components/Sink/Sink_DataFlow.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +from signed_requests.signed_request_auth import SignedRequest + +__author__ = 'alpaloma' +from flask import Blueprint, current_app, request +from helpers import Helpers +import requests +from json import dumps, loads +from DetailedHTTPException import error_handler +from flask_restful import Resource, Api +import logging +from jwcrypto import jwk +from Templates import Sequences +debug_log = logging.getLogger("debug") +logger = logging.getLogger("sequence") +api_Sink_blueprint = Blueprint("api_Sink_blueprint", __name__) +api = Api() +api.init_app(api_Sink_blueprint) + +sq = Sequences("Service_Components Mgmnt (Sink)", {}) +# import xmltodict +# @api.representation('application/xml') +# def output_xml(data, code, headers=None): +# if isinstance(data, dict): +# xm = {"response": data} +# resp = make_response(xmltodict.unparse(xm, pretty=True), code) +# resp.headers.extend(headers) +# return resp + +class Status(Resource): + @error_handler + def get(self): + status = {"status": "running", "service_mode": "Sink"} + return status + +class DataFlow(Resource): + def __init__(self): + super(DataFlow, self).__init__() + self.service_url = current_app.config["SERVICE_URL"] + self.operator_url = current_app.config["OPERATOR_URL"] + self.helpers = Helpers(current_app.config) + + @error_handler + def post(self): # TODO Make this a GET + def renew_token(operator_url, record_id): + sq.task("Renewing Auth Token.") + token = requests.get( + "{}/api/1.2/cr/auth_token/{}".format(operator_url, record_id)) # TODO Get api path from some config? + debug_log.info("{}, {}, {}, {}".format(token.url, token.reason, token.status_code, token.text)) + store_dict = {cr_id: dumps(loads(token.text.encode()))} + self.helpers.storeToken(store_dict) + + def step_1(): + params = request.json + debug_log.info(params) + debug_log.info(request.json) + user_id = params["user_id"] + cr_id = params["cr_id"] + rs_id = params["rs_id"] + sq.task("Get data_set_id from POST json") + data_set_id = request.args.get("dataset_id", None) + debug_log.info("data_set_id is ({}), cr_id is ({}), user_id ({}) and rs_id ({})" + .format(data_set_id, cr_id, user_id, rs_id)) + sq.task("Create request") + req = {"we want": "data"} + + sq.task("Validate CR") + cr = self.helpers.validate_cr(cr_id, surrogate_id=user_id) + + sq.task("Validate Request from UI") + distribution_urls = self.helpers.validate_request_from_ui(cr, data_set_id, rs_id) + + # Fetch data request urls + # Data request urls fetched. + debug_log.info("Data request urls fetched.") + return cr_id, cr, distribution_urls + cr_id, cr, distribution_urls = step_1() + + sq.task("Validate Authorisation Token") + surrogate_id = cr["cr"]["common_part"]["surrogate_id"] + our_key = self.helpers.get_key() + our_key_pub = our_key["pub"] + tries = 3 # TODO: Get this from config + while True: + try: + aud = self.helpers.validate_authorization_token(cr_id, surrogate_id, our_key_pub) + break + except ValueError as e: + debug_log.exception(e) + renew_token(self.operator_url, cr_id) + if tries == 0: + raise EnvironmentError("Auth token validation failed and retry counter exceeded.") + tries -= 1 + except TypeError as e: + debug_log.exception(e) + raise EnvironmentError("Token used too soon, halting.") + + # Most verifying and checking below is done in the validate_authorization_token function by jwcrypto + # Fetch Authorisation Token related to CR from data storage by rs_id (cr_id?) + # Check Integrity ( Signed by operator, Operator's public key can be found from SLR) + # Check "Issued" timestamp + # Check "Not Before" timestamp + # Check "Not After" timestamp + + # Check that "sub" contains correct public key(Our key.) + + # OPT: Token expired + # Get new Authorization token, start again from validation. # TODO: Make these steps work as functions that call the next step. + + # Check URL patterns in "aud" field + # Check that fetched distribution urls can be found from "aud" field + + + # Token validated + debug_log.info("Auth Token Validated.") + # With these two steps Sink has verified that it's allowed to make request. + + # Construct request + sq.task("Construct request") + # Select request URL from "aud" field + # Add Authorisation Token to request + # Request constructed. + # Sign request + # Fetch private key pair of public key specified in Authorisation Token's "sub" field. + # Sign with fetched private key + sq.task("Fetch key used to sign request") + our_key_full = jwk.JWK() + our_key_full.import_key(**our_key["key"]) + # Add signature to request + # Request signed. + # Request created. + sq.send_to("Service_Components Mgmnt (Source)", "Data Request (PoP stuff)") + # Make Data Request + for url in distribution_urls: + req = requests.get(url, + auth=SignedRequest(token=aud, sign_method=True, sign_path=True, key=our_key_full, protected=dumps(our_key["prot"]))) + debug_log.info("Made data request and received following data from Source: \n{}" + .format(dumps(loads(req.content), indent=2))) + status = {"status": "ok", "service_mode": "Sink"} + return status + + + +api.add_resource(Status, '/init') +api.add_resource(DataFlow, '/dc') + +#api.add_resource(DataFlow, '/user//consentRecord//resourceSet/') +#"http://service_components:7000/api/1.2/sink_flow/user/95479a08-80cc-4359-ba28-b8ca23ff5572_53af88dc-33de-44be-bc30-e0826db9bd6c/consentRecord/cd431509-777a-4285-8211-95c5ac577537/resourceSet/http%3A%2F%2Fservice_components%3A7000%7C%7C9aebb487-0c83-4139-b12c-d7fcea93a3ad" \ No newline at end of file diff --git a/Service_Components/Sink/__init__.py b/Service_Components/Sink/__init__.py new file mode 100644 index 0000000..51aa6d3 --- /dev/null +++ b/Service_Components/Sink/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from functools import wraps +from flask_restful import Api +import logging + +import factory + +def create_app(settings_override=None, register_security_blueprint=False): + """Returns the Overholt API application instance""" + + app, apis = factory.create_app(__name__, __path__, settings_override, + register_security_blueprint=register_security_blueprint) + debug_log = logging.getLogger("debug") + debug_log.info("Started up Service Components, Service_Sink module successfully.") + return app \ No newline at end of file diff --git a/Service_Components/Source/Source_DataFlow.py b/Service_Components/Source/Source_DataFlow.py new file mode 100644 index 0000000..44fc285 --- /dev/null +++ b/Service_Components/Source/Source_DataFlow.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +__author__ = 'alpaloma' + +from DetailedHTTPException import error_handler +from flask import Blueprint, request, current_app +from flask_restful import Resource, Api +from helpers import Helpers, Token_tool +import logging +from jwcrypto import jwk, jwt, jws +from json import loads, dumps +from Templates import Sequences +from signed_requests.json_builder import pop_handler +debug_log = logging.getLogger("debug") +logger = logging.getLogger("sequence") +api_Source_blueprint = Blueprint("api_Source_blueprint", __name__) +api = Api() +api.init_app(api_Source_blueprint) + +sq = Sequences("Service_Components Mgmnt (Source)", {}) +# import xmltodict +# @api.representation('application/xml') +# def output_xml(data, code, headers=None): +# if isinstance(data, dict): +# xm = {"response": data} +# resp = make_response(xmltodict.unparse(xm, pretty=True), code) +# resp.headers.extend(headers) +# return resp + +class Status(Resource): + @error_handler + def get(self): + status = {"status": "running", "service_mode": "Source"} + return status + +class DataRequest(Resource): + def __init__(self): + super(DataRequest, self).__init__() + self.service_url = current_app.config["SERVICE_URL"] + self.operator_url = current_app.config["OPERATOR_URL"] # TODO: Where do we really get this? + self.helpers = Helpers(current_app.config) + + @error_handler + def get(self): + sq.task("Fetch PoP from authorization header") + authorization = request.headers["Authorization"] + debug_log.info(authorization) + pop_h = pop_handler(token=authorization.split(" ")[1]) # TODO: Logic to pick up PoP + sq.task("Fetch at field from PoP") + decrypted_pop_token = loads(pop_h.get_at()) + debug_log.info("Token verified state should be False here, it is: {}".format(pop_h.verified)) + + debug_log.info(type(decrypted_pop_token)) + debug_log.info(dumps(decrypted_pop_token, indent=2)) + + + sq.task("Decrypt auth_token from PoP and get cr_id.") + token = decrypted_pop_token["at"]["auth_token"] + jws_holder = jwt.JWS() + jws_holder.deserialize(raw_jws=token) + auth_token_payload = loads(jws_holder.__dict__["objects"]["payload"]) + debug_log.info("We got auth_token_payload: {}".format(auth_token_payload)) + + cr_id = auth_token_payload["pi_id"] + debug_log.info("We got cr_id {} from auth_token_payload.".format(cr_id)) + + sq.task("Fetch surrogate_id with cr_id") + surrogate_id = self.helpers.get_surrogate_from_cr_id(cr_id) + + sq.task("Verify CR") + cr = self.helpers.validate_cr(cr_id, surrogate_id) + pop_key = cr["cr"]["role_specific_part"]["pop_key"] + pop_key = jwk.JWK(**pop_key) + + + token_issuer_key = cr["cr"]["role_specific_part"]["token_issuer_key"] + token_issuer_key = jwk.JWK(**token_issuer_key) + + sq.task("Validate auth token") + auth_token = jwt.JWT(jwt=token, key=token_issuer_key) + + debug_log.info("Following auth_token claims successfully verified with token_issuer_key: {}".format(auth_token.claims)) + + sq.task("Validate Request(PoP token)") + pop_h = pop_handler(token=authorization.split(" ")[1], key=pop_key) + decrypted_pop_token = loads(pop_h.get_at()) # This step affects verified state of object. + debug_log.info("Token verified state should be True here, it is: {}".format(pop_h.verified)) + # Validate Request + if pop_h.verified is False: + raise ValueError("Request verification failed.") + + + # Check that related Consent Record exists with the same rs_id # TODO: Bunch of these comments may be outdated, check them all. + # Check that auth_token_issuer_key field of CR matches iss-field in Authorization token + # Check Token's integrity against the signature + # Check Token's validity period includes time of data request + # Check Token's "aud" field includes the URI to which the data request was made + # Token validated. + + # Validate request # TODO: Check that we fill this properly, we should though. + # Check that request was signed with the key in the Token + # Request validated. + + # Validate related CR # TODO: Recheck what this should hold and compare what we do. + # Validate the related Consent Record as defined in MyData Authorisation Specification + # CR Validated. + + # OPT: Introspection # TODO: Implement + # introspect = is_introspection_necessary() + try: + sq.task("Intropection") + self.helpers.introspection(cr_id, self.operator_url) + sq.task("Return requested data.") + return {"Some test data": "like so", "and it continues": "like so!"} + except LookupError as e: + debug_log.exception(e) + return {"error message is": "appropriate."} + # Process request + # Return. + + status = {"status": "running", "service_mode": "Source"} + return status + +api.add_resource(DataRequest, '/datarequest') +api.add_resource(Status, '/init') + diff --git a/Service_Components/Source/__init__.py b/Service_Components/Source/__init__.py new file mode 100644 index 0000000..e3ddf79 --- /dev/null +++ b/Service_Components/Source/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from functools import wraps +from flask_restful import Api +import logging + +import factory + +def create_app(settings_override=None, register_security_blueprint=False): + """Returns the Overholt API application instance""" + + app, apis = factory.create_app(__name__, __path__, settings_override, + register_security_blueprint=register_security_blueprint) + debug_log = logging.getLogger("debug") + debug_log.info("Started up Service Components, Service_Source module successfully.") + return app \ No newline at end of file diff --git a/Service_Components/Templates.py b/Service_Components/Templates.py index c9461f0..b4a8123 100644 --- a/Service_Components/Templates.py +++ b/Service_Components/Templates.py @@ -1,73 +1,95 @@ # -*- coding: utf-8 -*- from time import time -import logging -from json import dumps, loads - -from base64 import urlsafe_b64decode as decode - - -from base64 import urlsafe_b64decode as decode -from json import loads -from jwcrypto import jws, jwk #### Schemas source_cr_schema = { "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", "properties": { - "extensions": { - "type": "object", - "properties": {} - }, - "ki_cr": { - "type": "object", - "properties": {} - }, "common_part": { "type": "object", "properties": { - "issued_at": { - "type": "string" - }, - "surrogate_id": { - "type": "string" - }, - "subject_id": { + "version": { "type": "string" }, "cr_id": { "type": "string" }, - "version_number": { + "surrogate_id": { "type": "string" }, - "not_before": { - "type": "string" + "rs_description": { + "type": "object", + "properties": { + "resource_set": { + "type": "object", + "properties": { + "rs_id": { + "type": "string" + }, + "dataset": { + "type": "array", + "items": { + "type": "object", + "properties": { + "dataset_id": { + "type": "string" + }, + "distribution_id": { + "type": "string" + } + }, + "required": [ + "dataset_id", + "distribution_id" + ] + } + } + }, + "required": [ + "rs_id", + "dataset" + ] + } + }, + "required": [ + "resource_set" + ] }, "slr_id": { "type": "string" }, - "issued": { + "iat": { + "type": "integer" + }, + "nbf": { + "type": "integer" + }, + "exp": { + "type": "integer" + }, + "operator": { "type": "string" }, - "not_after": { + "subject_id": { "type": "string" }, - "rs_id": { + "role": { "type": "string" } }, "required": [ - "issued_at", - "surrogate_id", - "subject_id", + "version", "cr_id", - "version_number", - "not_before", + "surrogate_id", + "rs_description", "slr_id", - "issued", - "not_after", - "rs_id" + "iat", + "nbf", + "exp", + "operator", + "subject_id", + "role" ] }, "role_specific_part": { @@ -76,11 +98,62 @@ "auth_token_issuer_key": { "type": "object", "properties": {} + } + }, + "required": [ + "token_issuer_key" + ] + }, + "consent_receipt_part": { + "type": "object", + "properties": { + "ki_cr": { + "type": "object", + "properties": {} + } + }, + "required": [ + "ki_cr" + ] + }, + "extension_part": { + "type": "object", + "properties": { + "extensions": { + "type": "object", + "properties": {} + } + }, + "required": [ + "extensions" + ] + } + }, + "required": [ + "common_part", + "role_specific_part", + "consent_receipt_part", + "extension_part" + ] +} + +sink_cr_schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "common_part": { + "type": "object", + "properties": { + "version": { + "type": "string" }, - "role": { + "cr_id": { + "type": "string" + }, + "surrogate_id": { "type": "string" }, - "resource_set_description": { + "rs_description": { "type": "object", "properties": { "resource_set": { @@ -94,16 +167,16 @@ "items": { "type": "object", "properties": { - "distribution_id": { + "dataset_id": { "type": "string" }, - "dataset_id": { + "distribution_id": { "type": "string" } }, "required": [ - "distribution_id", - "dataset_id" + "dataset_id", + "distribution_id" ] } } @@ -117,106 +190,91 @@ "required": [ "resource_set" ] - } - }, - "required": [ - "auth_token_issuer_key", - "role", - "resource_set_description" - ] - } - }, - "required": [ - "extensions", - "ki_cr", - "common_part", - "role_specific_part" - ] -} - -sink_cr_schema = { - "$schema": "http://json-schema.org/draft-04/schema#", - "type": "object", - "properties": { - "extensions": { - "type": "object", - "properties": {} - }, - "ki_cr": { - "type": "object", - "properties": {} - }, - "common_part": { - "type": "object", - "properties": { - "issued_at": { - "type": "string" }, - "surrogate_id": { - "type": "string" - }, - "subject_id": { - "type": "string" - }, - "cr_id": { + "slr_id": { "type": "string" }, - "version_number": { - "type": "string" + "iat": { + "type": "integer" }, - "not_before": { - "type": "string" + "nbf": { + "type": "integer" }, - "slr_id": { - "type": "string" + "exp": { + "type": "integer" }, - "issued": { + "operator": { "type": "string" }, - "not_after": { + "subject_id": { "type": "string" }, - "rs_id": { + "role": { "type": "string" } }, "required": [ - "issued_at", - "surrogate_id", - "subject_id", + "version", "cr_id", - "version_number", - "not_before", + "surrogate_id", + "rs_description", "slr_id", - "issued", - "not_after", - "rs_id" + "iat", + "nbf", + "exp", + "operator", + "subject_id", + "role" ] }, "role_specific_part": { "type": "object", "properties": { - "role": { - "type": "string" - }, "usage_rules": { "type": "array", "items": { "type": "string" } + }, + "source_cr_id": { + "type": "string" } }, "required": [ - "role", - "usage_rules" + "usage_rules", + "source_cr_id" + ] + }, + "consent_receipt_part": { + "type": "object", + "properties": { + "ki_cr": { + "type": "object", + "properties": {} + } + }, + "required": [ + "ki_cr" + ] + }, + "extension_part": { + "type": "object", + "properties": { + "extensions": { + "type": "object", + "properties": {} + } + }, + "required": [ + "extensions" ] } }, "required": [ - "extensions", - "ki_cr", "common_part", - "role_specific_part" + "role_specific_part", + "consent_receipt_part", + "extension_part" ] } @@ -233,11 +291,14 @@ #### +import logging +from json import dumps, loads class Sequences: def __init__(self, name, seq=False): ''' :param name: + :param seq: seq should always be dictionary with "seq" field. ''' self.logger = logging.getLogger("sequence") self.name = name @@ -273,4 +334,4 @@ def dump_sequence(self): def seq_form(self, line, seq): self.logger.info(dumps({"seq": line, "time": time()})) - return {"seq": {}} + return {"seq": {}} \ No newline at end of file diff --git a/Service_Components/db_handler.py b/Service_Components/db_handler.py index ba934b7..439d937 100644 --- a/Service_Components/db_handler.py +++ b/Service_Components/db_handler.py @@ -1,44 +1,18 @@ # -*- coding: utf-8 -*- -import sqlite3 +import logging +import MySQLdb -def get_db(db_path): +debug_log = logging.getLogger("debug") +def get_db(host, user, password, database, port): db = None if db is None: - db = sqlite3.connect(db_path) - db.row_factory = sqlite3.Row - try: - init_db(db) - except Exception as e: - pass + db = MySQLdb.connect(host=host, user=user, passwd=password, db=database, port=port) return db + def make_dicts(cursor, row): return dict((cursor.description[idx][0], value) for idx, value in enumerate(row)) -def sqlite_create_table(conn, table_name, table_columns): - conn.cursor.execute("CREATE TABLE {} ({});".format(table_name, ",".join(table_columns))) - conn.commit() -def init_db(conn): - # create db for codes - conn.execute('''CREATE TABLE csr_storage - (cr_id TEXT PRIMARY KEY NOT NULL, - json TEXT NOT NULL, - slr_id TEXT NOT NULL, - surrogate_id TEXT NOT NULL, - rs_id TEXT NOT NULL);''') - conn.execute('''CREATE TABLE cr_storage - (cr_id TEXT PRIMARY KEY NOT NULL, - json TEXT NOT NULL, - slr_id TEXT NOT NULL, - surrogate_id TEXT NOT NULL, - rs_id TEXT NOT NULL);''') - conn.execute('''CREATE TABLE codes - (ID TEXT PRIMARY KEY NOT NULL, - code TEXT NOT NULL);''') - conn.execute('''CREATE TABLE storage - (surrogate_id TEXT PRIMARY KEY NOT NULL, - json TEXT NOT NULL);''') - conn.commit() \ No newline at end of file diff --git a/Service_Components/doc/api/swagger_Authorization_Management.yml b/Service_Components/doc/api/swagger_Authorization_Management.yml index ed495a7..ab85594 100644 --- a/Service_Components/doc/api/swagger_Authorization_Management.yml +++ b/Service_Components/doc/api/swagger_Authorization_Management.yml @@ -27,15 +27,15 @@ paths: 200: description: "Returns 200 OK" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" 400: description: "Bad Request" schema: $ref: "#/definitions/errors" - 451: - description: "Unavailable For Legal Reasons" + 403: + description: "Forbidden" schema: $ref: "#/definitions/errors" definitions: diff --git a/Service_Components/doc/api/swagger_Service_Mgmnt.yml b/Service_Components/doc/api/swagger_Service_Mgmnt.yml index 1a39679..7291952 100644 --- a/Service_Components/doc/api/swagger_Service_Mgmnt.yml +++ b/Service_Components/doc/api/swagger_Service_Mgmnt.yml @@ -6,13 +6,30 @@ info: host: "example.service_mgmnt.example" basePath: "/api/1.2/slr" paths: + /code: + get: + tags: + - "Operator" + - "Service Management" + description: "Generates, store and returns unique code for the SLR flow" + parameters: [] + responses: + 200: + description: "Returns the code used in next steps of SLR flow. This is required\ + \ on most endpoints that follow." + schema: + $ref: "#/definitions/CodeResponse" + 500: + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." + schema: + $ref: "#/definitions/errors" /auth: post: tags: - "Service Management" - "Service" description: "Service calls this after user has authenticated. Causes Service\ - \ Management to generate surrogate_id for the operator." + \ Management to generate surrogate_id for the operator. This endpoint starts a long chain of events." consumes: - "application/json" produces: @@ -29,26 +46,10 @@ paths: description: "Returns 200 OK so Service Mngmt knows SLR was verified\ \ successfully" 500: - description: "Internal server error" - schema: - $ref: "#/definitions/errors" - /code: - get: - tags: - - "Operator" - - "Service Management" - description: "Generates, store and returns unique code for the SLR flow" - parameters: [] - responses: - 200: - description: "Returns the code used in next steps of SLR flow. This is required\ - \ on most endpoints that follow." - schema: - $ref: "#/definitions/CodeResponse" - 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" + /login: post: tags: @@ -69,9 +70,9 @@ paths: $ref: "#/definitions/LoginParams" responses: 200: - description: "Returns 200 OK or Error message" + description: "Returns 200 OK" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" /slr: @@ -79,7 +80,7 @@ paths: tags: - "Service Management" - "Operator" - description: "Takes SLR signed by Operator, signs it with own key and sends\ + description: "Takes SLR signed by account owner at Operator, signs it with own key and sends\ \ to Operator for verification." consumes: - "application/json" @@ -94,9 +95,9 @@ paths: $ref: "#/definitions/SlrParams" responses: 200: - description: "Returns 200 OK or Error message" + description: "Returns 200 OK" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" definitions: diff --git a/Service_Components/doc/api/swagger_Sink_DC.yaml b/Service_Components/doc/api/swagger_Sink_DC.yaml new file mode 100644 index 0000000..42c2e2c --- /dev/null +++ b/Service_Components/doc/api/swagger_Sink_DC.yaml @@ -0,0 +1,71 @@ +--- +swagger: "2.0" +info: + version: "1.2" + title: "Sink DC API" +host: "example.sink.example" +basePath: "/api/1.2/sink_flow" +paths: + /dc: + get: + tags: + - "Sink" + description: "End point that initializes data connection flow " + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "payload detailing data" + required: true + schema: + $ref: "#/definitions/DcPayload" + - in: "query" + name: dataset_id + description: "Dataset we want to request data for" + type: string + responses: + 200: + description: "Returns 200 OK" + + 500: + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." + schema: + $ref: "#/definitions/errors" + + + +definitions: + errors: + type: object + properties: + status: + type: string + description: HTTP status code as string value. + code: + type: integer + description: HTTP status code + title: + type: string + description: Title of error message. + detail: + type: string + description: Detailed error message. + source: + type: string + description: Source URI + + DcPayload: + type: object + properties: + surrogate_id: + type: string + description: string containing surrogate_id + cr_id: + type: string + description: string contaning cr_id + rs_id: + type: string + description: string containing rs_id diff --git a/Service_Components/doc/api/swagger_Source_DC.yaml b/Service_Components/doc/api/swagger_Source_DC.yaml new file mode 100644 index 0000000..1e811ad --- /dev/null +++ b/Service_Components/doc/api/swagger_Source_DC.yaml @@ -0,0 +1,57 @@ +--- +swagger: "2.0" +info: + version: "1.2" + title: "Source DC API" +host: "example.source.example" +basePath: "/api/1.2/source_flow" +paths: + /datarequest: + get: + tags: + - "Source" + description: "End point that receives data request from sink. Contains PoP in Authorization field in Header" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "header" + name: "PoP-token" + description: "Authorization Token" + required: true + type: string + + responses: + 200: + description: "Returns 200" + schema: + $ref: "#/definitions/data" + 500: + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." + schema: + $ref: "#/definitions/errors" + +definitions: + errors: + type: object + properties: + status: + type: string + description: HTTP status code as string value. + code: + type: integer + description: HTTP status code + title: + type: string + description: Title of error message. + detail: + type: string + description: Detailed error message. + source: + type: string + description: Source URI + + data: + type: object + diff --git a/Service_Components/doc/database/Service_Components-DBinit.sql b/Service_Components/doc/database/Service_Components-DBinit.sql new file mode 100644 index 0000000..ba9719e --- /dev/null +++ b/Service_Components/doc/database/Service_Components-DBinit.sql @@ -0,0 +1,103 @@ +-- MySQL Script generated by MySQL Workbench +-- to 15. syyskuuta 2016 14.58.51 +-- Model: New Model Version: 1.0 +-- MySQL Workbench Forward Engineering + +SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; +SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE=''; + +-- ----------------------------------------------------- +-- Schema mydb +-- ----------------------------------------------------- +-- ----------------------------------------------------- +-- Schema db_Srv +-- ----------------------------------------------------- + +-- ----------------------------------------------------- +-- Schema db_Srv +-- ----------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS `db_Srv` DEFAULT CHARACTER SET utf8 ; +USE `db_Srv` ; + +-- ----------------------------------------------------- +-- Table `db_Srv`.`codes` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`codes` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`codes` ( + `ID` LONGTEXT NOT NULL, + `code` LONGTEXT NOT NULL, + PRIMARY KEY (`ID`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Srv`.`cr_storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`cr_storage` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`cr_storage` ( + `cr_id` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + `slr_id` LONGTEXT NOT NULL, + `surrogate_id` LONGTEXT NOT NULL, + `rs_id` LONGTEXT NOT NULL, + PRIMARY KEY (`cr_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Srv`.`csr_storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`csr_storage` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`csr_storage` ( + `cr_id` VARCHAR(255) NOT NULL, + `csr_id` VARCHAR(255) NOT NULL, + `previous_record_id` VARCHAr(255) NOT NULL, + `consent_status` VARCHAR(10) NOT NULL, + `json` LONGTEXT NOT NULL, + `slr_id` LONGTEXT NOT NULL, + `surrogate_id` LONGTEXT NOT NULL, + `rs_id` LONGTEXT NOT NULL, + PRIMARY KEY (`csr_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Srv`.`storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`storage` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`storage` ( + `surrogate_id` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + PRIMARY KEY (`surrogate_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Srv`.`token_storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`token_storage` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`token_storage` ( + `cr_id` LONGTEXT NOT NULL, + `token` LONGTEXT NOT NULL, + PRIMARY KEY (`cr_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + + +SET SQL_MODE=@OLD_SQL_MODE; +SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; +SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; +CREATE USER 'service'@'%' IDENTIFIED BY 'MynorcA'; +GRANT CREATE TEMPORARY TABLES, DELETE, DROP, INSERT, LOCK TABLES, SELECT, UPDATE ON db_Srv.* TO 'service'@'%'; +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/Service_Components/doc/database/Service_Components_db_image-v001.png b/Service_Components/doc/database/Service_Components_db_image-v001.png new file mode 100644 index 0000000000000000000000000000000000000000..19802eacbf565b2fdb029465c2fc6b76a9864593 GIT binary patch literal 47241 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 LR-{ op_celery.log & - nohup python wsgi.py > srv_flask.log & \ No newline at end of file + nohup celery -A srv_tasks worker > srv_celery.log & + nohup python wsgi.py > srv_flask.log & diff --git a/Service_Components/factory.py b/Service_Components/factory.py index 9791bee..8ab9a34 100644 --- a/Service_Components/factory.py +++ b/Service_Components/factory.py @@ -38,7 +38,7 @@ def create_celery_app(app=None): if app is not None: app = app else: - app, apis = create_app('service_component', os.path.dirname(__file__)) + app, apis = create_app('srv_queue', os.path.dirname(__file__)) celery = Celery(__name__, broker=app.config['SELERY_BROKER_URL']) celery.conf.update(app.config) TaskBase = celery.Task diff --git a/Service_Components/helpers.py b/Service_Components/helpers.py index 33b5cfb..7f33415 100644 --- a/Service_Components/helpers.py +++ b/Service_Components/helpers.py @@ -1,19 +1,23 @@ # -*- coding: utf-8 -*- -import pkgutil import importlib - +import logging +import pkgutil +import urllib +from json import dumps, load, dump +import time +from datetime import datetime from flask import Blueprint from flask_restful import Api -import logging - -from json import dumps, loads -debug_log = logging.getLogger("debug") import jsonschema import db_handler -from sqlite3 import OperationalError, IntegrityError -from DetailedHTTPException import DetailedHTTPException +from requests import get, post +from sqlite3 import IntegrityError +from DetailedHTTPException import DetailedHTTPException + +debug_log = logging.getLogger("debug") -def validate_json(schema, json): # "json" here needs to be python dict. + +def validate_json(schema, json): # "json" here needs to be python dict. errors = [] validator = jsonschema.Draft4Validator(schema) validator.check_schema(schema) @@ -23,136 +27,478 @@ def validate_json(schema, json): # "json" here needs to be python dict. return errors - class Helpers: def __init__(self, app_config): - self.db_path = app_config["DATABASE_PATH"] - - def query_db(self, query, args=(), one=False): - db = db_handler.get_db(self.db_path) - cur = db.execute(query, args) - rv = cur.fetchall() - cur.close() - return (rv[0] if rv else None) if one else rv + self.host = app_config["MYSQL_HOST"] + self.cert_key_path = app_config["CERT_KEY_PATH"] + self.keysize = app_config["KEYSIZE"] + self.user = app_config["MYSQL_USER"] + self.passwd = app_config["MYSQL_PASSWORD"] + self.db = app_config["MYSQL_DB"] + self.port = app_config["MYSQL_PORT"] + self.service_id = app_config["SERVICE_ID"] + + def get_key(self): + keysize = self.keysize + cert_key_path = self.cert_key_path + gen3 = {"generate": "RSA", "size": keysize, "kid": self.service_id} + service_key = jwk.JWK(**gen3) + try: + with open(cert_key_path, "r") as cert_file: + service_key2 = jwk.JWK(**loads(load(cert_file))) + service_key = service_key2 + except Exception as e: + debug_log.error(e) + with open(cert_key_path, "w+") as cert_file: + dump(service_key.export(), cert_file, indent=2) + public_key = loads(service_key.export_public()) + full_key = loads(service_key.export()) + protti = {"alg": "RS256"} + headeri = {"kid": self.service_id, "jwk": public_key} + return {"pub": public_key, + "key": full_key, + "prot": protti, + "header": headeri} + + def query_db(self, query, args=()): + ''' + Simple queries to DB + :param query: SQL query + :param args: Arguments to inject into the query + :return: Single hit for the given query + ''' + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() + cur = cursor.execute(query, args) + try: + rv = cursor.fetchone() # Returns tuple + debug_log.info(rv) + if rv is not None: + db.close() + return rv[1] # The second value in the tuple. + else: + return None + except Exception as e: + debug_log.info("query_db failed with error:") + debug_log.exception(e) + debug_log.info(cur) + db.close() + return None def storeJSON(self, DictionaryToStore): - db = db_handler.get_db(self.db_path) - try: - db_handler.init_db(db) - except OperationalError: - pass + """ + Store SLR into database + :param DictionaryToStore: Dictionary in form {"key" : "dict_to_store"} + :return: + """ + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() + debug_log.info("Storing dictionary:") debug_log.info(DictionaryToStore) for key in DictionaryToStore: + debug_log.info("Storing key:") debug_log.info(key) try: - db.execute("INSERT INTO storage (surrogate_id,json) \ - VALUES (?, ?)", [key, dumps(DictionaryToStore[key])]) + cursor.execute("INSERT INTO storage (surrogate_id,json) \ + VALUES (%s, %s)", (key, dumps(DictionaryToStore[key]))) db.commit() except IntegrityError as e: - db.execute("UPDATE storage SET json=? WHERE surrogate_id=? ;", [dumps(DictionaryToStore[key]), key]) + cursor.execute("UPDATE storage SET json=%s WHERE surrogate_id=%s ;", + (dumps(DictionaryToStore[key]), key)) + db.commit() + db.close() + + def storeToken(self, DictionaryToStore): + """ + Store token into database + :param DictionaryToStore: Dictionary in form {"key" : "dict_to_store"} + :return: + """ + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() + for key in DictionaryToStore: + try: + cursor.execute("INSERT INTO token_storage (cr_id,token) \ + VALUES (%s, %s)", (key, dumps(DictionaryToStore[key]))) db.commit() + except IntegrityError as e: # Rewrite incase we get new token. + cursor.execute("UPDATE token_storage SET token=? WHERE cr_id=%s ;", + (dumps(DictionaryToStore[key]), key)) + db.commit() + db.close() def storeCode(self, code): - db = db_handler.get_db(self.db_path) - try: - db_handler.init_db(db) - except OperationalError: - pass + """ + Store generated code into database + :param code: + :return: None + """ + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() code_key = list(code.keys())[0] code_value = code[code_key] - db.execute("INSERT INTO codes (ID,code) \ - VALUES (?, ?)", [code_key, code_value]) + cursor.execute("INSERT INTO codes (ID,code) \ + VALUES (%s, %s)", (code_key, code_value)) db.commit() - - debug_log.info("{} {}".format(code_key, code_value)) - for code in self.query_db("select * from codes where ID = ?;", [code_key]): - debug_log.info(code["code"]) + debug_log.info("Storing code(key,value): {}, {}".format(code_key, code_value)) db.close() def add_surrogate_id_to_code(self, code, surrogate_id): - db = db_handler.get_db(self.db_path) - try: - db_handler.init_db(db) - except OperationalError: - pass - for code in self.query_db("select * from codes where code = ?;", [code]): - code_from_db = code["code"] - code_is_valid_and_unused = "!" in code_from_db - if (code_is_valid_and_unused): - db.execute("UPDATE codes SET code=? WHERE ID=? ;", [surrogate_id, code]) - db.commit() - else: - raise Exception("Invalid code") + """ + Link code with a surrogate_id + :param code: + :param surrogate_id: + :return: None + """ + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() + debug_log.info("Code we look up is {}".format(code)) + code = self.query_db("select * from codes where ID = %s;", (code,)) + debug_log.info("Result for query: {}".format(code)) + code_from_db = code + code_is_valid_and_unused = "!" in code_from_db + if (code_is_valid_and_unused): + cursor.execute("UPDATE codes SET code=%s WHERE ID=%s ;", (surrogate_id, code)) + db.commit() + db.close() + else: + raise Exception("Invalid code") + + def get_cr_json(self, cr_id): + # TODO: query_db is not really optimal when making two separate queries in row. + cr = self.query_db("select * from cr_storage where cr_id = %s;", (cr_id,)) + csr_id = self.get_latest_csr_id(cr_id) + csr = self.query_db("select cr_id, json from csr_storage where csr_id = %s and cr_id = %s;", (csr_id, cr_id,)) + if cr is None or csr is None: + raise IndexError("CR and CSR couldn't be found with given id ({})".format(cr_id)) + debug_log.info("Found CR ({}) and CSR ({})".format(cr, csr)) + cr_from_db = loads(cr) + csr_from_db = loads(csr) + combined = {"cr": cr_from_db, "csr": csr_from_db} + + return combined + + def validate_cr(self, cr_id, surrogate_id): + """ + Lookup and validate ConsentRecord based on given CR_ID + :param cr_id: + :return: CR if found and validated. + """ + combined = self.get_cr_json(cr_id) + debug_log.info("Constructing cr/csr structure for CR_Tool:") + debug_log.info(dumps(combined, indent=2)) + # Using CR tool we get nice helper functions. + tool = CR_tool() + tool.cr = combined + # To fetch key from SLR we need surrogate_id. + # We get this as parameter so as further check we verify its same as in cr. + surrogate_id_from_cr = tool.get_surrogate_id() + debug_log.info("Surrogate_id as parameter was ({}) and from CR ({})".format(surrogate_id, surrogate_id_from_cr)) + if surrogate_id_from_cr != surrogate_id: + raise NameError("User surrogate_id doesn't match surrogate_id in consent record.") + # Now we fetch the SLR and put it to SLR_Tool + slr_tool = SLR_tool() + slr = self.get_slr(surrogate_id) + slr_tool.slr = slr + # Fetch key from SLR. + keys = slr_tool.get_cr_keys() + + # Verify the CR with the keys from SLR + # Check integrity (signature) + + cr_verified = tool.verify_cr(keys) + csr_verified = tool.verify_csr(keys) + if not (cr_verified and csr_verified): + raise ValueError("CR and CSR verification failed.") + debug_log.info("Verified cr/csr ({}) for surrogate_id ({}) ".format(cr_id, surrogate_id)) + + combined_decrypted = dumps({"cr": tool.get_CR_payload(), "csr": tool.get_CSR_payload()}, indent=2) + debug_log.info("Decrypted cr/csr structure is:") + debug_log.info(combined_decrypted) + # Check that state is "Active" + state = tool.get_state() + if state != "Active": + raise ValueError("CR state is not 'Active' but ({})".format(state)) + + # Check "Issued" timestamp + time_now = int(time.time()) + issued = tool.get_issued() + # issued = datetime.strptime(issued_in_cr, "%Y-%m-%dT%H:%M:%SZ") + if time_now < issued: + raise EnvironmentError("This CR is issued in the future!") + debug_log.info("Issued timestamp is valid.") + + # Check "Not Before" timestamp + not_before = tool.get_not_before() + # not_before = datetime.strptime(not_before_in_cr, "%Y-%m-%dT%H:%M:%SZ") + if time_now < not_before: + raise EnvironmentError("This CR will be available in the future, not yet.") + debug_log.info("Not Before timestamp is valid.") + + # Check "Not After" timestamp + not_after = tool.get_not_after() + # not_after = datetime.strptime(not_after_in_cr, "%Y-%m-%dT%H:%M:%SZ") + if time_now > not_after: + raise EnvironmentError("This CR is expired.") + debug_log.info("Not After timestamp is valid.") + # CR validated. + + debug_log.info("CR has been validated.") + return loads(combined_decrypted) def verifyCode(self, code): - db = db_handler.get_db(self.db_path) - for code_row in self.query_db("select * from codes where ID = ?;", [code]): - code_from_db = code_row["code"] + """ + Verify that code is found in database + :param code: + :return: Boolean True if code is found in db. + """ + code = self.query_db("select * from codes where ID = %s;", (code,)) + if code is not None: return True return False def verifySurrogate(self, code, surrogate): - db = db_handler.get_db(self.db_path) - for code_row in self.query_db("select * from codes where ID = ? AND code = ?;", [code, surrogate]): - code_from_db = code_row["code"] + """ + Verify that surrogate id matches code in database + :param code: + :param surrogate: surrogate_id + :return: Boolean True if surrogate_id matches code + """ + code = self.query_db("select * from codes where ID = %s AND code = %s;", (code, surrogate)) + if code is not None: # TODO: Could we remove code and surrogate_id after this check to ensure they wont be abused later. return True return False def get_slr(self, surrogate_id): - db = db_handler.get_db(self.db_path) - for storage_row in self.query_db("select * from storage where surrogate_id = ?;", [surrogate_id]): - slr_from_db = storage_row["json"] - return loads(slr_from_db) + """ + Fetch SLR for given surrogate_id from the database + :param surrogate_id: surrogate_id + :return: Return SLR made for given surrogate_id or None + """ + storage_row = self.query_db("select * from storage where surrogate_id = %s;", (surrogate_id,)) + slr_from_db = loads(storage_row) + return slr_from_db + + def get_surrogate_from_cr_id(self, cr_id): + storage_row = self.query_db("select cr_id,surrogate_id from cr_storage where cr_id = %s;", (cr_id,)) + debug_log.info("Found surrogate_id {}".format(storage_row)) + surrogate_from_db = storage_row + return surrogate_from_db + + def get_token(self, cr_id): + """ + Fetch token for given cr_id from the database + :param cr_id: cr_id + :return: Return Token made for given cr_id or None + """ + storage_row = self.query_db("select * from token_storage where cr_id = %s;", (cr_id,)) + token_from_db = loads(loads(storage_row)) + return token_from_db def storeCR_JSON(self, DictionaryToStore): + """ + Store CR into database + :param DictionaryToStore: Dictionary in form {"key" : "dict_to_store"} + :return: None + """ cr_id = DictionaryToStore["cr_id"] rs_id = DictionaryToStore["rs_id"] surrogate_id = DictionaryToStore["surrogate_id"] slr_id = DictionaryToStore["slr_id"] json = DictionaryToStore["json"] - db = db_handler.get_db(self.db_path) - try: - db_handler.init_db(db) - except OperationalError: - pass + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() + debug_log.info("Storing following CR structure:") debug_log.info(DictionaryToStore) # debug_log.info(key) try: - db.execute("INSERT INTO cr_storage (cr_id, surrogate_id, slr_id, rs_id, json) \ - VALUES (?, ?, ?, ?, ?)", [cr_id, surrogate_id, slr_id, rs_id, dumps(json)]) + cursor.execute("INSERT INTO cr_storage (cr_id, surrogate_id, slr_id, rs_id, json) \ + VALUES (%s, %s, %s, %s, %s)", (cr_id, surrogate_id, slr_id, rs_id, dumps(json))) db.commit() except IntegrityError as e: # db.execute("UPDATE cr_storage SET json=? WHERE cr_id=? ;", [dumps(DictionaryToStore[key]), key]) # db.commit() db.rollback() - raise DetailedHTTPException(detail={"msg": "Adding CR to the database has failed.",}, + raise DetailedHTTPException(detail={"msg": "Adding CR to the database has failed.", }, title="Failure in CR storage", exception=e) def storeCSR_JSON(self, DictionaryToStore): + """ + Store CSR into database + :param DictionaryToStore: Dictionary in form {"key" : "dict_to_store"} + :return: None + """ cr_id = DictionaryToStore["cr_id"] + csr_id = DictionaryToStore["csr_id"] + consent_status = DictionaryToStore["consent_status"] rs_id = DictionaryToStore["rs_id"] surrogate_id = DictionaryToStore["surrogate_id"] + previous_record_id = DictionaryToStore["previous_record_id"] slr_id = DictionaryToStore["slr_id"] json = DictionaryToStore["json"] - db = db_handler.get_db(self.db_path) - try: - db_handler.init_db(db) - except OperationalError: - pass + db = db_handler.get_db(host=self.host, password=self.passwd, user=self.user, port=self.port, database=self.db) + cursor = db.cursor() + debug_log.info("Storing following csr structure:") debug_log.info(DictionaryToStore) # debug_log.info(key) try: - db.execute("INSERT INTO csr_storage (cr_id, surrogate_id, slr_id, rs_id, json) \ - VALUES (?, ?, ?, ?, ?)", [cr_id, surrogate_id, slr_id, rs_id, dumps(json)]) + cursor.execute("INSERT INTO csr_storage (cr_id, csr_id, previous_record_id, consent_status, surrogate_id, slr_id, rs_id, json) \ + VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + [cr_id, csr_id, previous_record_id, consent_status, surrogate_id, slr_id, rs_id, + dumps(json)]) db.commit() except IntegrityError as e: # db.execute("UPDATE csr_storage SET json=? WHERE cr_id=? ;", [dumps(DictionaryToStore[key]), key]) # db.commit() db.rollback() - raise DetailedHTTPException(detail={"msg": "Adding CSR to the database has failed.",}, + raise DetailedHTTPException(detail={"msg": "Adding CSR to the database has failed.", }, title="Failure in CSR storage", exception=e) + def get_active_csr(self, cr_id): + csr = self.query_db("select cr_id, json from csr_storage where cr_id = %s and consent_status = 'Active';", + (cr_id,)) + debug_log.info("Active csr is: {}".format(csr)) + return loads(csr) + + def get_latest_csr_id(self, cr_id): + # Picking first csr_id since its previous record is "null" + csr_id = self.query_db( + "select cr_id, csr_id from csr_storage where cr_id = %s and previous_record_id = 'null';", + (cr_id,)) + debug_log.info("Picked first CSR_ID in search for latest ({})".format(csr_id)) + # If first csr_id is in others csr's previous_record_id field then its not the latest. + newer_csr_id = self.query_db("select cr_id, csr_id from csr_storage where previous_record_id = %s;", + (csr_id,)) + debug_log.info("Later CSR_ID is ({})".format(newer_csr_id)) + # If we don't find newer record but get None, we know we only have one csr in our chain and latest in it is also the first. + if newer_csr_id is None: + return csr_id + # Else we repeat the previous steps in while loop to go trough all records + while True: # TODO: We probably should see to it that this can't get stuck. + try: + newer_csr_id = self.query_db("select cr_id, csr_id from csr_storage where previous_record_id = %s;", + (csr_id,)) + if newer_csr_id is None: + debug_log.info("Latest CSR in our chain seems to be ({})".format(newer_csr_id)) + return csr_id + else: + csr_id = newer_csr_id + except Exception as e: + debug_log.exception(e) + raise e + + def introspection(self, cr_id, operator_url): + # Get our latest csr_id + + # We send cr_id to Operator for inspection. + req = get(operator_url + "/api/1.2/cr" + "/introspection/{}".format(cr_id)) + debug_log.info(req.status_code) + debug_log.info(req.content) + if req.ok: + csr_id = loads(req.content)["csr_id"] + # This is the latest csr we have verifiable chain for. + latest_csr_id = self.get_latest_csr_id(cr_id) + debug_log.info("Comparing our latest csr_id ({}) to ({})".format(latest_csr_id, csr_id)) + if csr_id == latest_csr_id: + debug_log.info("Verified we have latest csr.") + return + else: + debug_log.info("Our csr({}) is outdated!".format(latest_csr_id)) + req = get( + operator_url + "/api/1.2/cr" + "/consent/{}/missing_since/{}".format(cr_id, latest_csr_id)) + if req.ok: + tool = SLR_tool() + content = loads(req.content) + debug_log.info("We got: \n{}".format(content)) + slr_id = self.query_db("select cr_id, slr_id from cr_storage where cr_id = %s;" + , (cr_id,)) + rs_id = self.query_db("select cr_id, rs_id from cr_storage where cr_id = %s;" + , (cr_id,)) + for csr in content["missing_csr"]["data"]: + if not isinstance(csr, dict): + csr = loads(csr) + decoded_payload = tool.decrypt_payload(csr["attributes"]["csr"]["payload"]) + store_dict = { + "rs_id": rs_id, + "csr_id": decoded_payload["record_id"], + "consent_status": decoded_payload["consent_status"], + "previous_record_id": decoded_payload["prev_record_id"], + "cr_id": decoded_payload["cr_id"], + "surrogate_id": decoded_payload["surrogate_id"], + "slr_id": slr_id, + "json": csr # possibly store the base64 representation + } + debug_log.info("Storing CSR: \n{}".format(dumps(store_dict, indent=2))) + self.storeCSR_JSON(store_dict) + debug_log.info("Stored missing csr's to DB") + latest_csr_id = self.get_latest_csr_id(cr_id) + status = self.query_db("select cr_id, consent_status from csr_storage where csr_id = %s;" + , (latest_csr_id,)) + debug_log.info("Our latest csr id now ({}) with status ({})".format(latest_csr_id, status)) + if status == "Active": + debug_log.info("Introspection done successfully.") + else: + debug_log.info("Introspection failed.") + raise LookupError("Introspection failed.") + + + else: + raise ValueError("Request to get missing csr's failed with ({}) and reason ({}), content:\n{} " + .format(req.status_code, req.reason, dumps(loads(req.content), indent=2))) + + else: + raise LookupError("Unable to perform introspect.") + + def validate_request_from_ui(self, cr, data_set_id, rs_id): + debug_log.info("CR passed to validate_request_from_ui:") + debug_log.info(type(cr)) + debug_log.info(cr) + + # The rs_id is urlencoded, do the same to one fetched from cr + rs_id_in_cr = urllib.quote_plus(cr["cr"]["common_part"]["rs_id"]) + + # Check that rs_description field contains rs_id + debug_log.info("rs_id in cr({}) and from ui({})".format(rs_id_in_cr, rs_id)) + if (rs_id != rs_id_in_cr): + raise ValueError("Given rs_id doesn't match CR") + debug_log.info("RS_ID checked successfully") + # Check that rs_description field contains data_set_id (Optional?) + distribution_urls = [] + if data_set_id is not None: + datasets = cr["common_part"]["rs_description"]["resource_set"]["dataset"] + for dataset in datasets: + if dataset["dataset_id"] == data_set_id: + distribution_urls.append(dataset["distribution_url"]) + else: + datasets = cr["cr"]["common_part"]["rs_description"]["resource_set"]["dataset"] + for dataset in datasets: + distribution_urls.append(dataset["distribution_url"]) + debug_log.info("Got following distribution urls") + debug_log.info(distribution_urls) + # Request from UI validated. + debug_log.info("Request from UI validated.") + return distribution_urls + + def validate_authorization_token(self, cr_id, surrogate_id, our_key): + # slr = self.get_slr(surrogate_id) + # slr_tool = SLR_tool() + # slr_tool.slr = slr + # key = slr_tool.get_operator_key() + token = self.get_token(cr_id) + # debug_log.info("Fetched key({}) and token({}).".format(key, token)) + jws_holder = jwt.JWS() + jws_holder.deserialize(raw_jws=token["auth_token"]) + auth_token_payload = loads(jws_holder.__dict__["objects"]["payload"]) + debug_log.info("Decoded Auth Token\n{}".format(dumps(auth_token_payload, indent=2))) + now = time.time() + if auth_token_payload["exp"] < now: + raise ValueError("Token is expired.") + if auth_token_payload["nbf"] > now: + raise TypeError("Token used too soon.") + # debug_log.info(aud) + return token + def register_blueprints(app, package_name, package_path): """Register all Blueprint instances on the specified Flask application found @@ -174,90 +520,98 @@ def register_blueprints(app, package_name, package_path): apis.append(item) return rv, apis + from base64 import urlsafe_b64decode as decode from json import loads + + class SLR_tool: def __init__(self): self.slr = { - "code": "7e4f7cf6-f169-4430-9b23-a4820446fe71", - "data": { - "slr": { - "type": "ServiceLinkRecord", - "attributes": { + "code": "7e4f7cf6-f169-4430-9b23-a4820446fe71", + "data": { + "slr": { + "type": "ServiceLinkRecord", + "attributes": { "slr": { - "payload": "IntcIm9wZXJhdG9yX2lkXCI6IFwiQUNDLUlELVJBTkRPTVwiLCBcImNyZWF0ZWRcIjogMTQ3MTM0NDYyNiwgXCJzdXJyb2dhdGVfaWRcIjogXCI5YjQxNmE5Zi1jYjRmLTRkNWMtYjJiZS01OWQxYjc3ZjJlZmFfMVwiLCBcInRva2VuX2tleVwiOiB7XCJrZXlcIjoge1wieVwiOiBcIkN0NGNHMnpPQzdrano5VWF1WHFqcTRtZ0d0bEdXcDJjcWZneVVlaUU4U2dcIiwgXCJ4XCI6IFwiUnJueHZoZjVsZXppQTZyZms4ZDlRbV96bXd2SDc5X2U5eUhBS2ZJR2dFRVwiLCBcImNydlwiOiBcIlAtMjU2XCIsIFwia3R5XCI6IFwiRUNcIiwgXCJraWRcIjogXCJTUlZNR05ULUlESzNZXCJ9fSwgXCJsaW5rX2lkXCI6IFwiNDJhMzVhN2QtMjkxZS00N2UzLWIyMmYtOTk2NjJmNjgzNDEzXCIsIFwib3BlcmF0b3Jfa2V5XCI6IHtcInVzZVwiOiBcInNpZ1wiLCBcImVcIjogXCJBUUFCXCIsIFwia3R5XCI6IFwiUlNBXCIsIFwiblwiOiBcIndITUFwQ2FVSkZpcHlGU2NUNzgxd2VuTm5mbU5jVkQxZTBmSFhfcmVfcWFTNWZvQkJzN1c0aWE1bnVxNjVFQWJKdWFxaGVPR2FEamVIaVU4V1Q5cWdnYks5cTY4SXZUTDN1bjN6R2o5WmQ3N3MySXdzNE1BSW1EeWN3Rml0aDE2M3lxdW9ETXFMX1YySXl5Mm45Uzloa1M5ZkV6cXJsZ01sYklnczJtVkJpNmdWVTJwYnJTN0gxUGFSV194YlFSX1puN19laV9uOFdlWFA1d2NEX3NJYldNa1NCc3VVZ21jam9XM1ktNW1ERDJWYmRFejJFbWtZaTlHZmstcDlBenlVbk56ZkIyTE1jSk1aekpWUWNYaUdCTzdrcG9uRkEwY3VIMV9CR0NsZXJ6Mnh2TWxXdjlPVnZzN3ZDTmRlQV9mano2eloyMUtadVo0RG1nZzBrOTRsd1wifSwgXCJ2ZXJzaW9uXCI6IFwiMS4yXCIsIFwiY3Jfa2V5c1wiOiBbe1wieVwiOiBcIlhaeWlveV9BME5qQ3Q1ZGt6OW5MOGI3YXdQRl9Cck5iYzVObjFOTTdXS0FcIiwgXCJ4XCI6IFwiR3ZaVEdpMllSb0VCblc2QzB4clpRQ0tNeWwza2lNcjgtRVoySU1ocnpXb1wiLCBcImNydlwiOiBcIlAtMjU2XCIsIFwia3R5XCI6IFwiRUNcIiwgXCJraWRcIjogXCJhY2Mta2lkLTg1MTVhYjQ2LTlkODItNDUzNC1hZDFmLTYzZDFlNDdiZDY2YlwifV0sIFwic2VydmljZV9pZFwiOiBcIjFcIn0i", - "signatures": [ - { - "header": { - "jwk": { - "x": "GvZTGi2YRoEBnW6C0xrZQCKMyl3kiMr8-EZ2IMhrzWo", - "kty": "EC", - "crv": "P-256", - "y": "XZyioy_A0NjCt5dkz9nL8b7awPF_BrNbc5Nn1NM7WKA", - "kid": "acc-kid-8515ab46-9d82-4534-ad1f-63d1e47bd66b" + "payload": "IntcIm9wZXJhdG9yX2lkXCI6IFwiQUNDLUlELVJBTkRPTVwiLCBcImNyZWF0ZWRcIjogMTQ3MTM0NDYyNiwgXCJzdXJyb2dhdGVfaWRcIjogXCI5YjQxNmE5Zi1jYjRmLTRkNWMtYjJiZS01OWQxYjc3ZjJlZmFfMVwiLCBcInRva2VuX2tleVwiOiB7XCJrZXlcIjoge1wieVwiOiBcIkN0NGNHMnpPQzdrano5VWF1WHFqcTRtZ0d0bEdXcDJjcWZneVVlaUU4U2dcIiwgXCJ4XCI6IFwiUnJueHZoZjVsZXppQTZyZms4ZDlRbV96bXd2SDc5X2U5eUhBS2ZJR2dFRVwiLCBcImNydlwiOiBcIlAtMjU2XCIsIFwia3R5XCI6IFwiRUNcIiwgXCJraWRcIjogXCJTUlZNR05ULUlESzNZXCJ9fSwgXCJsaW5rX2lkXCI6IFwiNDJhMzVhN2QtMjkxZS00N2UzLWIyMmYtOTk2NjJmNjgzNDEzXCIsIFwib3BlcmF0b3Jfa2V5XCI6IHtcInVzZVwiOiBcInNpZ1wiLCBcImVcIjogXCJBUUFCXCIsIFwia3R5XCI6IFwiUlNBXCIsIFwiblwiOiBcIndITUFwQ2FVSkZpcHlGU2NUNzgxd2VuTm5mbU5jVkQxZTBmSFhfcmVfcWFTNWZvQkJzN1c0aWE1bnVxNjVFQWJKdWFxaGVPR2FEamVIaVU4V1Q5cWdnYks5cTY4SXZUTDN1bjN6R2o5WmQ3N3MySXdzNE1BSW1EeWN3Rml0aDE2M3lxdW9ETXFMX1YySXl5Mm45Uzloa1M5ZkV6cXJsZ01sYklnczJtVkJpNmdWVTJwYnJTN0gxUGFSV194YlFSX1puN19laV9uOFdlWFA1d2NEX3NJYldNa1NCc3VVZ21jam9XM1ktNW1ERDJWYmRFejJFbWtZaTlHZmstcDlBenlVbk56ZkIyTE1jSk1aekpWUWNYaUdCTzdrcG9uRkEwY3VIMV9CR0NsZXJ6Mnh2TWxXdjlPVnZzN3ZDTmRlQV9mano2eloyMUtadVo0RG1nZzBrOTRsd1wifSwgXCJ2ZXJzaW9uXCI6IFwiMS4yXCIsIFwiY3Jfa2V5c1wiOiBbe1wieVwiOiBcIlhaeWlveV9BME5qQ3Q1ZGt6OW5MOGI3YXdQRl9Cck5iYzVObjFOTTdXS0FcIiwgXCJ4XCI6IFwiR3ZaVEdpMllSb0VCblc2QzB4clpRQ0tNeWwza2lNcjgtRVoySU1ocnpXb1wiLCBcImNydlwiOiBcIlAtMjU2XCIsIFwia3R5XCI6IFwiRUNcIiwgXCJraWRcIjogXCJhY2Mta2lkLTg1MTVhYjQ2LTlkODItNDUzNC1hZDFmLTYzZDFlNDdiZDY2YlwifV0sIFwic2VydmljZV9pZFwiOiBcIjFcIn0i", + "signatures": [ + { + "header": { + "jwk": { + "x": "GvZTGi2YRoEBnW6C0xrZQCKMyl3kiMr8-EZ2IMhrzWo", + "kty": "EC", + "crv": "P-256", + "y": "XZyioy_A0NjCt5dkz9nL8b7awPF_BrNbc5Nn1NM7WKA", + "kid": "acc-kid-8515ab46-9d82-4534-ad1f-63d1e47bd66b" + }, + "kid": "acc-kid-8515ab46-9d82-4534-ad1f-63d1e47bd66b" + }, + "protected": "eyJhbGciOiAiRVMyNTYifQ", + "signature": "fsSuhqLp6suUuT8waseMlpYcFx4vqIviIteBLUNWPUOubHPDY64sbpfx_flpPFymxG_t8r3Ptb96kv-ZDyjb7g" }, - "kid": "acc-kid-8515ab46-9d82-4534-ad1f-63d1e47bd66b" - }, - "protected": "eyJhbGciOiAiRVMyNTYifQ", - "signature": "fsSuhqLp6suUuT8waseMlpYcFx4vqIviIteBLUNWPUOubHPDY64sbpfx_flpPFymxG_t8r3Ptb96kv-ZDyjb7g" - }, - { - "header": { - "jwk": { - "x": "Rrnxvhf5leziA6rfk8d9Qm_zmwvH79_e9yHAKfIGgEE", - "kty": "EC", - "crv": "P-256", - "y": "Ct4cG2zOC7kjz9UauXqjq4mgGtlGWp2cqfgyUeiE8Sg", - "kid": "SRVMGNT-IDK3Y" - }, - "kid": "SRVMGNT-IDK3Y" - }, - "protected": "eyJhbGciOiAiRVMyNTYifQ", - "signature": "3rZCfJxvpD7covQjH_lhkJwId8ynVIMLZ6t1obiCrlwJOJe_Yc7dmImi10w8tc9_7c7u35_ysiD72wIlbJ4oFQ" - } - ] + { + "header": { + "jwk": { + "x": "Rrnxvhf5leziA6rfk8d9Qm_zmwvH79_e9yHAKfIGgEE", + "kty": "EC", + "crv": "P-256", + "y": "Ct4cG2zOC7kjz9UauXqjq4mgGtlGWp2cqfgyUeiE8Sg", + "kid": "SRVMGNT-IDK3Y" + }, + "kid": "SRVMGNT-IDK3Y" + }, + "protected": "eyJhbGciOiAiRVMyNTYifQ", + "signature": "3rZCfJxvpD7covQjH_lhkJwId8ynVIMLZ6t1obiCrlwJOJe_Yc7dmImi10w8tc9_7c7u35_ysiD72wIlbJ4oFQ" + } + ] } - } - }, - "meta": { - "slsr_id": "374707b7-a60b-4596-9f3a-6a5affa414c3", - "slr_id": "42a35a7d-291e-47e3-b22f-99662f683413" - }, - "slsr": { - "type": "ServiceLinkStatusRecord", - "attributes": { + } + }, + "meta": { + "slsr_id": "374707b7-a60b-4596-9f3a-6a5affa414c3", + "slr_id": "42a35a7d-291e-47e3-b22f-99662f683413" + }, + "slsr": { + "type": "ServiceLinkStatusRecord", + "attributes": { "slsr": { - "header": { - "jwk": { - "x": "GvZTGi2YRoEBnW6C0xrZQCKMyl3kiMr8-EZ2IMhrzWo", - "kty": "EC", - "crv": "P-256", - "y": "XZyioy_A0NjCt5dkz9nL8b7awPF_BrNbc5Nn1NM7WKA", - "kid": "acc-kid-8515ab46-9d82-4534-ad1f-63d1e47bd66b" + "header": { + "jwk": { + "x": "GvZTGi2YRoEBnW6C0xrZQCKMyl3kiMr8-EZ2IMhrzWo", + "kty": "EC", + "crv": "P-256", + "y": "XZyioy_A0NjCt5dkz9nL8b7awPF_BrNbc5Nn1NM7WKA", + "kid": "acc-kid-8515ab46-9d82-4534-ad1f-63d1e47bd66b" + }, + "kid": "acc-kid-8515ab46-9d82-4534-ad1f-63d1e47bd66b" }, - "kid": "acc-kid-8515ab46-9d82-4534-ad1f-63d1e47bd66b" - }, - "protected": "eyJhbGciOiAiRVMyNTYifQ", - "payload": "IntcInNscl9pZFwiOiBcIjQyYTM1YTdkLTI5MWUtNDdlMy1iMjJmLTk5NjYyZjY4MzQxM1wiLCBcImFjY291bnRfaWRcIjogXCIxXCIsIFwic2xfc3RhdHVzXCI6IFwiQWN0aXZlXCIsIFwicmVjb3JkX2lkXCI6IFwiMzc0NzA3YjctYTYwYi00NTk2LTlmM2EtNmE1YWZmYTQxNGMzXCIsIFwiaWF0XCI6IDE0NzEzNDQ2MjYsIFwicHJldl9yZWNvcmRfaWRcIjogXCJOVUxMXCJ9Ig", - "signature": "cfj3Zm5ICVtTdUJigKGTxJX4V8vzs1e9qVj83hPmiD-XJonrBRW60zQN-3lRTuJithFbrGgBJShGj1InuNGMsw" + "protected": "eyJhbGciOiAiRVMyNTYifQ", + "payload": "IntcInNscl9pZFwiOiBcIjQyYTM1YTdkLTI5MWUtNDdlMy1iMjJmLTk5NjYyZjY4MzQxM1wiLCBcImFjY291bnRfaWRcIjogXCIxXCIsIFwic2xfc3RhdHVzXCI6IFwiQWN0aXZlXCIsIFwicmVjb3JkX2lkXCI6IFwiMzc0NzA3YjctYTYwYi00NTk2LTlmM2EtNmE1YWZmYTQxNGMzXCIsIFwiaWF0XCI6IDE0NzEzNDQ2MjYsIFwicHJldl9yZWNvcmRfaWRcIjogXCJOVUxMXCJ9Ig", + "signature": "cfj3Zm5ICVtTdUJigKGTxJX4V8vzs1e9qVj83hPmiD-XJonrBRW60zQN-3lRTuJithFbrGgBJShGj1InuNGMsw" } - } - }, - "surrogate_id": "9b416a9f-cb4f-4d5c-b2be-59d1b77f2efa_1" - }} + } + }, + "surrogate_id": "9b416a9f-cb4f-4d5c-b2be-59d1b77f2efa_1" + }} + def decrypt_payload(self, payload): payload += '=' * (-len(payload) % 4) # Fix incorrect padding of base64 string. content = decode(payload.encode()) - payload = loads(loads(content.decode("utf-8"))) + payload = loads(content.decode("utf-8")) + debug_log.info("Decrypted payload is:") + debug_log.info(payload) return payload def get_SLR_payload(self): base64_payload = self.slr["data"]["slr"]["attributes"]["slr"]["payload"] + debug_log.info("Decrypting SLR payload:") payload = self.decrypt_payload(base64_payload) return payload def get_SLSR_payload(self): - base64_payload = self.slr["data"]["slsr"]["attributes"]["slsr"]["payload"] + base64_payload = self.slr["data"]["ssr"]["attributes"]["ssr"]["payload"] + debug_log.info("Decrypting SSR payload:") payload = self.decrypt_payload(base64_payload) return payload @@ -283,62 +637,73 @@ def get_cr_keys(self): # print(sl.get_source_surrogate_id()) from jwcrypto import jwk, jws + + class CR_tool: def __init__(self): self.cr = { - "csr": { - "signature": "e4tiFSvnqUb8k1U6BXC5WhbkQWVJZqMsDqc3efPRkBcL1cM21mSJXYOS4dSiCx4ak8S8S1IKN4wcyuAxXfrGeQ", - "payload": "IntcImNvbW1vbl9wYXJ0XCI6IHtcInNscl9pZFwiOiBcImJhYmY5Mjc3LWEyZmItNGI4MS1iMTYyLTE4ZTI5MzUyNzYxN1wiLCBcInZlcnNpb25fbnVtYmVyXCI6IFwiU3RyaW5nXCIsIFwicnNfaWRcIjogXCIyXzYyNmE3YmZiLTk0MmEtNDI2ZC1hNDc2LWE0Mzk5NmYyMDAwNVwiLCBcImNyX2lkXCI6IFwiMjlmZmRkZmMtNjBhMS00YmYwLTkzMWMtNGQ1ZWYwMmQ2N2YyXCIsIFwiaXNzdWVkXCI6IDE0NzE1OTMwMjYsIFwic3ViamVjdF9pZFwiOiBcIjFcIiwgXCJub3RfYmVmb3JlXCI6IFwiU3RyaW5nXCIsIFwibm90X2FmdGVyXCI6IFwiU3RyaW5nXCIsIFwiaXNzdWVkX2F0XCI6IFwiU3RyaW5nXCIsIFwic3Vycm9nYXRlX2lkXCI6IFwiZTZlMjdlNzUtNjUxZi00Y2I0LTg5ZTItYTUxZWI5NDllYjYwXzJcIn0sIFwicm9sZV9zcGVjaWZpY19wYXJ0XCI6IHtcInJvbGVcIjogXCJTaW5rXCIsIFwidXNhZ2VfcnVsZXNcIjogW1wiQWxsIHlvdXIgY2F0cyBhcmUgYmVsb25nIHRvIHVzXCIsIFwiU29tZXRoaW5nIHJhbmRvbVwiXX0sIFwiZXh0ZW5zaW9uc1wiOiB7fSwgXCJtdmNyXCI6IHt9fSI", - "protected": "eyJhbGciOiAiRVMyNTYifQ", - "header": { - "jwk": { - "kty": "EC", - "crv": "P-256", - "y": "XIpGIZ7bz7uaoj_9L05CQSOw6VykuD6bK4r_OMVQSao", - "x": "GfJCOXimGb3ZW4IJJIlKUZeoj8GCW7YYJRZgHuYUsds", - "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" - }, - "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" - } - }, - "cr": { - "signature": "fiiVhAPxzYGgkV3D43FvgKSdIvDrsyMm_Vz4WWhBoLaXbTcZKNEvKL5Tx1O6YRwShOc9plK7YRxgWyY9OYd7zA", - "payload": "IntcImFjY291bnRfaWRcIjogXCJlNmUyN2U3NS02NTFmLTRjYjQtODllMi1hNTFlYjk0OWViNjBfMlwiLCBcImNyX2lkXCI6IFwiMjlmZmRkZmMtNjBhMS00YmYwLTkzMWMtNGQ1ZWYwMmQ2N2YyXCIsIFwicHJldl9yZWNvcmRfaWRcIjogXCJudWxsXCIsIFwicmVjb3JkX2lkXCI6IFwiZTBiZDk1MTUtNjA5Zi00YzMxLThiMmQtZDliMTY5NjdiZmQzXCIsIFwiaWF0XCI6IDE0NzE1OTMwMjYsIFwiY29uc2VudF9zdGF0dXNcIjogXCJBY3RpdmVcIn0i", - "protected": "eyJhbGciOiAiRVMyNTYifQ", - "header": { - "jwk": { - "kty": "EC", - "crv": "P-256", - "y": "XIpGIZ7bz7uaoj_9L05CQSOw6VykuD6bK4r_OMVQSao", - "x": "GfJCOXimGb3ZW4IJJIlKUZeoj8GCW7YYJRZgHuYUsds", - "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" - }, - "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" - } - } -} + "csr": { + "signature": "e4tiFSvnqUb8k1U6BXC5WhbkQWVJZqMsDqc3efPRkBcL1cM21mSJXYOS4dSiCx4ak8S8S1IKN4wcyuAxXfrGeQ", + "payload": "IntcImNvbW1vbl9wYXJ0XCI6IHtcInNscl9pZFwiOiBcImJhYmY5Mjc3LWEyZmItNGI4MS1iMTYyLTE4ZTI5MzUyNzYxN1wiLCBcInZlcnNpb25fbnVtYmVyXCI6IFwiU3RyaW5nXCIsIFwicnNfaWRcIjogXCIyXzYyNmE3YmZiLTk0MmEtNDI2ZC1hNDc2LWE0Mzk5NmYyMDAwNVwiLCBcImNyX2lkXCI6IFwiMjlmZmRkZmMtNjBhMS00YmYwLTkzMWMtNGQ1ZWYwMmQ2N2YyXCIsIFwiaXNzdWVkXCI6IDE0NzE1OTMwMjYsIFwic3ViamVjdF9pZFwiOiBcIjFcIiwgXCJub3RfYmVmb3JlXCI6IFwiU3RyaW5nXCIsIFwibm90X2FmdGVyXCI6IFwiU3RyaW5nXCIsIFwiaXNzdWVkX2F0XCI6IFwiU3RyaW5nXCIsIFwic3Vycm9nYXRlX2lkXCI6IFwiZTZlMjdlNzUtNjUxZi00Y2I0LTg5ZTItYTUxZWI5NDllYjYwXzJcIn0sIFwicm9sZV9zcGVjaWZpY19wYXJ0XCI6IHtcInJvbGVcIjogXCJTaW5rXCIsIFwidXNhZ2VfcnVsZXNcIjogW1wiQWxsIHlvdXIgY2F0cyBhcmUgYmVsb25nIHRvIHVzXCIsIFwiU29tZXRoaW5nIHJhbmRvbVwiXX0sIFwiZXh0ZW5zaW9uc1wiOiB7fSwgXCJtdmNyXCI6IHt9fSI", + "protected": "eyJhbGciOiAiRVMyNTYifQ", + "header": { + "jwk": { + "kty": "EC", + "crv": "P-256", + "y": "XIpGIZ7bz7uaoj_9L05CQSOw6VykuD6bK4r_OMVQSao", + "x": "GfJCOXimGb3ZW4IJJIlKUZeoj8GCW7YYJRZgHuYUsds", + "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" + }, + "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" + } + }, + "cr": { + "signature": "fiiVhAPxzYGgkV3D43FvgKSdIvDrsyMm_Vz4WWhBoLaXbTcZKNEvKL5Tx1O6YRwShOc9plK7YRxgWyY9OYd7zA", + "payload": "IntcImFjY291bnRfaWRcIjogXCJlNmUyN2U3NS02NTFmLTRjYjQtODllMi1hNTFlYjk0OWViNjBfMlwiLCBcImNyX2lkXCI6IFwiMjlmZmRkZmMtNjBhMS00YmYwLTkzMWMtNGQ1ZWYwMmQ2N2YyXCIsIFwicHJldl9yZWNvcmRfaWRcIjogXCJudWxsXCIsIFwicmVjb3JkX2lkXCI6IFwiZTBiZDk1MTUtNjA5Zi00YzMxLThiMmQtZDliMTY5NjdiZmQzXCIsIFwiaWF0XCI6IDE0NzE1OTMwMjYsIFwiY29uc2VudF9zdGF0dXNcIjogXCJBY3RpdmVcIn0i", + "protected": "eyJhbGciOiAiRVMyNTYifQ", + "header": { + "jwk": { + "kty": "EC", + "crv": "P-256", + "y": "XIpGIZ7bz7uaoj_9L05CQSOw6VykuD6bK4r_OMVQSao", + "x": "GfJCOXimGb3ZW4IJJIlKUZeoj8GCW7YYJRZgHuYUsds", + "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" + }, + "kid": "acc-kid-3802fd17-49f4-48fc-8ac1-09624a52a3ae" + } + } + } + def decrypt_payload(self, payload): - #print("payload :\n", slr) - #print("Before Fix:", payload) + # print("payload :\n", slr) + # print("Before Fix:", payload) payload += '=' * (-len(payload) % 4) # Fix incorrect padding of base64 string. - #print("After Fix :", payload) + # print("After Fix :", payload) content = decode(payload.encode()) - payload = loads(loads(content.decode("utf-8"))) + payload = loads(content.decode("utf-8")) + debug_log.info("Decrypted payload is:") + debug_log.info(payload) return payload def get_CR_payload(self): - base64_payload = self.cr["cr"]["payload"] + base64_payload = self.cr["cr"]["attributes"]["cr"]["payload"] payload = self.decrypt_payload(base64_payload) return payload def get_CSR_payload(self): - base64_payload = self.cr["csr"]["payload"] + base64_payload = self.cr["csr"]["attributes"]["csr"]["payload"] payload = self.decrypt_payload(base64_payload) return payload def get_cr_id_from_csr(self): return self.get_CSR_payload()["cr_id"] + def get_csr_id(self): + return self.get_CSR_payload()["record_id"] # Perhaps this could just be csr_id + + def get_consent_status(self): + return self.get_CSR_payload()["consent_status"] + def get_prev_record_id(self): return self.get_CSR_payload()["prev_record_id"] @@ -351,12 +716,27 @@ def cr_id_matches_in_csr_and_cr(self): def get_usage_rules(self): return self.get_CR_payload()["role_specific_part"]["usage_rules"] + def get_pop_key(self): + return self.get_CR_payload()["role_specific_part"]["pop_key"] + def get_slr_id(self): return self.get_CR_payload()["common_part"]["slr_id"] + def get_issued(self): + return self.get_CR_payload()["common_part"]["iat"] + + def get_not_before(self): + return self.get_CR_payload()["common_part"]["nbf"] + + def get_not_after(self): + return self.get_CR_payload()["common_part"]["exp"] + def get_rs_id(self): return self.get_CR_payload()["common_part"]["rs_id"] + def get_state(self): + return self.get_CSR_payload()["consent_status"] + def get_subject_id(self): return self.get_CR_payload()["common_part"]["subject_id"] @@ -364,42 +744,110 @@ def get_surrogate_id(self): return self.get_CR_payload()["common_part"]["surrogate_id"] def get_role(self): - return self.get_CR_payload()["role_specific_part"]["role"] + return self.get_CR_payload()["common_part"]["role"] def verify_cr(self, keys): + debug_log.info("CR in object:\n{}".format(dumps(self.cr, indent=2))) for key in keys: cr_jwk = jwk.JWK(**key) cr_jws = jws.JWS() - cr_jws.deserialize(dumps(self.cr["cr"])) + cr = self.cr["cr"]["attributes"]["cr"] + cr_jws.deserialize(dumps(cr)) try: cr_jws.verify(cr_jwk) return True except Exception as e: - pass - #print(repr(e)) - #return False + debug_log.info( + "FAILED key verification for CR: \n({})\n WITH KEY: \n({})".format(cr, cr_jwk.export_public())) + debug_log.exception(e) + # print(repr(e)) + # return False return False - def verify_csr(self, keys): for key in keys: cr_jwk = jwk.JWK(**key) csr_jws = jws.JWS() - csr_jws.deserialize(dumps(self.cr["csr"])) + csr = self.cr["csr"]["attributes"]["csr"] + csr_jws.deserialize(dumps(csr)) try: csr_jws.verify(cr_jwk) return True except Exception as e: + debug_log.info("FAILED key verification for CSR: \n({})\n WITH KEY: \n({})".format(csr, cr_jwk.export_public())) + debug_log.exception(e) pass - #print(repr(e)) - #return False + # print(repr(e)) + # return False return False -#crt = CR_tool() -#print (dumps(crt.get_CR_payload(), indent=2)) -#print (dumps(crt.get_CSR_payload(), indent=2)) -#print(crt.get_role()) + +# crt = CR_tool() +# print (dumps(crt.get_CR_payload(), indent=2)) +# print (dumps(crt.get_CSR_payload(), indent=2)) +# print(crt.get_role()) # print(crt.get_cr_id()) # print(crt.get_usage_rules()) -# print(crt.get_surrogate_id()) \ No newline at end of file +# print(crt.get_surrogate_id()) +from jwcrypto import jwt +from jwcrypto.jwt import JWTExpired + + +class Token_tool: + def __init__(self): + # Replace token. + self.token = { + "auth_token": "eyJhbGciOiJSUzI1NiJ9.eyJhdWQiOlt7ImRhdGFzZXRfaWQiOiJTdHJpbmciLCJkaXN0cmlidXRpb25faWQiOiJTdHJpbmcifV0sImV4cCI6IjIwMTYtMTEtMDhUMTM6MzA6MjUgIiwiaWF0IjoiMjAxNi0xMC0wOVQxMzozMDoyNSAiLCJpc3MiOnsiZSI6IkFRQUIiLCJraWQiOiJBQ0MtSUQtUkFORE9NIiwia3R5IjoiUlNBIiwibiI6InRtaGxhUFV3SmdvNHlTVE1yVEdGRnliVnhLMjh1REd0SlNGRGRHazNiYXhUV21nZkswQzZETXF3NWxxcC1FWFRNVFJmSXFNYmRNY0RtVU5ueUpwUTF3In0sImp0aSI6Ijc5ZmI3NDg0LTE2YjYtNDEzYy04ZGI0LWZlMjcwYjg4Y2UxNiIsIm5iZiI6IjIwMTYtMTAtMDlUMTM6MzA6MjUgIiwicnNfaWQiOiJodHRwOi8vc2VydmljZV9jb21wb25lbnRzOjcwMDB8fDljMWYxNTdkLWM4MWEtNGY1Ni1hZmYxLTc2MWZjNTVhNDBkOSIsInN1YiI6eyJlIjoiQVFBQiIsImtpZCI6IlNSVk1HTlQtUlNBLTUxMiIsImt0eSI6IlJTQSIsIm4iOiJ5R2dzUDljV01pUFBtZ09RMEp0WVN3Nnp3dURvdThBR0F5RHV0djVwTHc1aXZ6NnhvTGhaTS1pUVdGN0VzckVHdFNyUU55WUxzMlZzLUpxbW50UGpIUSJ9fQ.s1KOu1Q_ifNEnmBQ6QcmNxd0Oy1Fxp-z_4hsCI5fNfOa5vtWai68_OKN_NoUjtqUCy-CJcLHnGGoxTh_vHcjtg"} + # Replace key. + self.key = None + + def decrypt_payload(self, payload): + key = jwk.JWK() + key.import_key(**self.key) + token = jwt.JWT() + # This step actually verifies the signature, the format and timestamps. + try: + token.deserialize(self.token["auth_token"], key) + except JWTExpired as e: + debug_log.exception(e) + # TODO: get new auth token and start again. + raise e + claims = loads(token.claims) + # payload += '=' * (-len(payload) % 4) # Fix incorrect padding of base64 string. + # content = decode(payload.encode('utf-8')) + debug_log.info("Decrypted following claims from token:") + debug_log.info(dumps(claims, indent=2)) + # payload = loads(loads(content.decode('utf-8'))) + return claims + + def get_token(self): + debug_log.info("Fetching token..") + decrypted_token = self.decrypt_payload(self.token["auth_token"]) + debug_log.info("Got following token:") + debug_log.info(dumps(decrypted_token, indent=2)) + return decrypted_token + + def verify_token(self, + our_key): # TODO: Get some clarification what we want to verify now that sub field doesn't contain key. + debug_log.info("Verifying token..\nOur key is:") + debug_log.info(our_key) + debug_log.info(type(our_key)) + + if self.key is None: + raise UnboundLocalError("Set Token_tool objects key variable to Operator key before use.") + token = self.get_token() + kid = token["cnf"]["kid"] + source_cr_id = token["pi_id"] + debug_log.info("Source CR id is:") + debug_log.info(type(source_cr_id)) + debug_log.info(source_cr_id) + # debug_log.info(our_key) + if cmp(source_cr_id, kid) != 0: + raise ValueError("JWK's didn't match.") + + # TODO: Figure out beter way to return aud + return token + +# tt = Token_tool() +# print(tt.decrypt_payload(tt.token["auth_token"])) diff --git a/Service_Components/instance/__init__.py b/Service_Components/instance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Service_Components/instance/settings.py b/Service_Components/instance/settings.py new file mode 100644 index 0000000..1d34c50 --- /dev/null +++ b/Service_Components/instance/settings.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from kombu import Exchange, Queue + +TIMEOUT = 8 + +KEYSIZE = 512 + +# Setting to /tmp or other ramdisk makes it faster. + +DATABASE_PATH = "./db_Srv.sqlite" + +SELERY_BROKER_URL = 'redis://redis:6379/0' + +SELERY_RESULT_BACKEND = 'redis://redis:6379/0' + +CERT_PATH = "./service_key.jwk" +CERT_KEY_PATH = "./service_key.jwk" +CERT_PASSWORD_PATH = "./cert_pw" + +SERVICE_URL = "http://service_mockup:2000" + + + +OPERATOR_URL = "http://operator_components:5000" + + + + +SERVICE_ROOT_PATH = "/api/1.2" + + + +SERVICE_CR_PATH = "/cr" + + + +SERVICE_SLR_PATH = "/slr" + + + +DEBUG_MODE = False + + +CELERY_QUEUES = ( + Queue('srv_queue', Exchange('srv_queue'), routing_key='srv_queue'), +) + +CELERY_DEFAULT_QUEUE = 'srv_queue' + +CELERY_ROUTES = { + 'get_AuthToken': {'queue': 'srv_queue', 'routing_key': "srv_queue"} +} \ No newline at end of file diff --git a/Service_Components/instance/settings_template.py.j2 b/Service_Components/instance/settings_template.py.j2 new file mode 100644 index 0000000..a08e581 --- /dev/null +++ b/Service_Components/instance/settings_template.py.j2 @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +from kombu import Exchange, Queue + +TIMEOUT = 8 + +KEYSIZE = 512 + +{% if IS_SINK is defined %} +IS_SINK = {{ IS_SINK }} +{% else %} +IS_SINK = True +{% endif %} + +{% if IS_SOURCE is defined %} +IS_SOURCE = {{ IS_SOURCE }} +{% else %} +IS_SOURCE = True +{% endif %} + +# Name of host to connect to. Default: use the local host via a UNIX socket (where applicable) +{% if MYSQL_HOST is defined %} +MYSQL_HOST = {{ MYSQL_HOST }} +{% else %} +MYSQL_HOST = 'localhost' +{% endif %} + + # User to authenticate as. Default: current effective user. +{% if MYSQL_USER is defined %} +MYSQL_USER = {{ MYSQL_USER }} +{% else %} +MYSQL_USER = 'service' +{% endif %} + +# Password to authenticate with. Default: no password. +{% if MYSQL_PASSWORD is defined %} +MYSQL_PASSWORD = {{ MYSQL_PASSWORD }} +{% else %} +MYSQL_PASSWORD = 'MynorcA' +{% endif %} + +# Database to use. Default: no default database. +{% if MYSQL_DB is defined %} +MYSQL_DB = {{ MYSQL_DB }} +{% else %} +MYSQL_DB = 'db_Srv' +{% endif %} + +# TCP port of MySQL server. Default: 3306. +{% if MYSQL_PORT is defined %} +MYSQL_PORT = {{ MYSQL_PORT }} +{% else %} +MYSQL_PORT = 3306 +{% endif %} + + + + + +# Setting to /tmp or other ramdisk makes it faster. +{% if DATABASE_PATH is defined %} +DATABASE_PATH = {{ DATABASE_PATH }} +{% else %} + +DATABASE_PATH = "./db_Operator.sqlite" + +{% endif %} + + +{% if SELERY_BROKER_URL is defined %} +SELERY_BROKER_URL = {{ SELERY_BROKER_URL }} +{% else %} +SELERY_BROKER_URL = 'redis://localhost:6379/1' +{% endif %} + +{% if SELERY_RESULT_BACKEND is defined %} +SELERY_RESULT_BACKEND = {{ SELERY_RESULT_BACKEND }} +{% else %} +SELERY_RESULT_BACKEND = 'redis://localhost:6379/1' +{% endif %} + + +{% if CERT_PATH is defined %} +CERT_PATH = {{ CERT_PATH }} +{% else %} +CERT_PATH = "./service_key.jwk" +{% endif %} + +{% if CERT_KEY_PATH is defined %} +CERT_KEY_PATH = {{ CERT_KEY_PATH }} +{% else %} +CERT_KEY_PATH = "./service_key.jwk" +{% endif %} + +{% if CERT_PASSWORD_PATH is defined %} +CERT_PASSWORD_PATH = {{ CERT_PASSWORD_PATH }} +{% else %} +CERT_PASSWORD_PATH = "./cert_pw" +{% endif %} + + + +{% if SERVICE_URL is defined %} +SERVICE_URL = {{ SERVICE_URL }} +{% else %} +SERVICE_URL = "http://localhost:2000" +{% endif %} + +{% if OPERATOR_URL is defined %} +OPERATOR_URL = {{ OPERATOR_URL }} +{% else %} +OPERATOR_URL = "http://localhost:5000" +{% endif %} + +{% if SERVICE_ID is defined %} +SERVICE_ID = {{ SERVICE_ID }} +{% else %} +SERVICE_ID = "SRVMGMNT-CHANGE_ME" +{% endif %} + +{% if SERVICE_ROOT_PATH is defined %} +SERVICE_ROOT_PATH = {{ SERVICE_ROOT_PATH }} +{% else %} +SERVICE_ROOT_PATH = "/api/1.2" +{% endif %} + +{% if SERVICE_CR_PATH is defined %} +SERVICE_CR_PATH = {{ SERVICE_CR_PATH }} +{% else %} +SERVICE_CR_PATH = "/cr" +{% endif %} + +{% if SERVICE_SLR_PATH is defined %} +SERVICE_SLR_PATH = {{ SERVICE_SLR_PATH }} +{% else %} +SERVICE_SLR_PATH = "/slr" +{% endif %} + +{% if DEBUG_MODE is defined %} +DEBUG_MODE = {{ DEBUG_MODE }} +{% else %} +DEBUG_MODE = True +{% endif %} + +CELERY_QUEUES = ( + Queue('srv_queue', Exchange('srv_queue'), routing_key='srv_queue'), +) + +CELERY_DEFAULT_QUEUE = 'srv_queue' + +CELERY_ROUTES = { + 'get_AuthToken': {'queue': 'srv_queue', 'routing_key': "srv_queue"} +} \ No newline at end of file diff --git a/Service_Components/requirements.txt b/Service_Components/requirements.txt index 6a0fe52..d1978bd 100644 --- a/Service_Components/requirements.txt +++ b/Service_Components/requirements.txt @@ -20,6 +20,7 @@ jsonschema==2.5.1 jwcrypto==0.3.1 kombu==3.0.35 MarkupSafe==0.23 +mysqlclient==1.3.7 pyasn1==0.1.9 pycparser==2.14 pycryptodome==3.4 @@ -29,5 +30,7 @@ pytz==2016.6.1 redis==2.10.5 requests==2.11.1 six==1.10.0 +uWSGI==2.0.13.1 Werkzeug==0.11.10 wheel==0.24.0 +restapi-logging-handler==0.2.2 \ No newline at end of file diff --git a/Service_Components/signed_requests/__init__.py b/Service_Components/signed_requests/__init__.py new file mode 100644 index 0000000..0e1d8a9 --- /dev/null +++ b/Service_Components/signed_requests/__init__.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# __init__.py +# +# MIT License +# +# Copyright (c) 2016 Aleksi Palomäki +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +__author__ = "Aleksi Palomäki" diff --git a/Service_Components/signed_requests/doc_tests b/Service_Components/signed_requests/doc_tests new file mode 100644 index 0000000..93acc97 --- /dev/null +++ b/Service_Components/signed_requests/doc_tests @@ -0,0 +1,93 @@ +# MIT License +# +# Copyright (c) 2016 Aleksi Palomäki +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +The ``json_builder` module +====================== + +Using ``hash`` +------------------- + +First import +``hash_params`` from the ``json_builder`` module: + + >>> from json_builder import hash_params + +Create hash_params object: + >>> jsb = hash_params() + +Now use it with string: + + >>> jsb.hash("b=bar&a=foo&c=duck") + 'u4LgkGUWhP9MsKrEjA4dizIllDXluDku6ZqCeyuR-JY' + +Now use it with list: + + >>> jsb.hash([["b","bar"],["a","foo"],["c","duck"]]) + 'u4LgkGUWhP9MsKrEjA4dizIllDXluDku6ZqCeyuR-JY' + +Now use it with list and dict: + + >>> jsb.hash(["b", "a", "c"], {"a":"foo","c":"duck", "b":"bar"}) + 'u4LgkGUWhP9MsKrEjA4dizIllDXluDku6ZqCeyuR-JY' + +The ``signed_request_auth`` module +====================== + +Using ``SignedRequest`` +------------------- +Imports and make a key + >>> import requests + >>> from signed_request_auth import SignedRequest + >>> from jwcrypto import jwk + >>> key = jwk.JWK.generate(kty='oct', size=256) + +Form most request that gets PoP Authorization header and check that it can be generated + >>> req = requests.Request("GET", "http://localhost/", auth=SignedRequest(token="blaa", key=key, sign_query=True), params={"a": "foo", "b": "bar", "c": "duck"}) + >>> req.prepare() # doctest: +ELLIPSIS + + >>> header = req.__dict__["auth"].generate_authorization_header() + >>> header # doctest: +ELLIPSIS + 'PoP eyJ...' + +Import pop_handler and test generated pop with it. + >>> from json_builder import pop_handler + >>> token = header.split(" ")[1] + >>> poppi = pop_handler(token=token, key=key) + >>> poppi.get_at() # doctest: +ELLIPSIS + '{"q": [["a", "c", "b"], "A7zM9tEc3J__xtM6rPf7veMqpehXtSoD3tJMS2OUDTs"], "at": "blaa", "ts": ...}' + + + + +This part is just to ease up seeing actual test results via copy&paste + +import requests +from signed_request_auth import SignedRequest +from jwcrypto import jwk +key = jwk.JWK.generate(kty='oct', size=256) +req = requests.Request("GET", "http://requestb.in/19w0mpv1", auth=SignedRequest(token="blaa", key=key, sign_query=True, sign_method=True, sign_path=True), params={"a": "foo", "b": "bar", "c": "duck"}) +req.prepare() # doctest: +ELLIPSIS +header = req.__dict__["auth"].generate_authorization_header() +from json_builder import pop_handler +token = header.split(" ")[1] +poppi = pop_handler(token=token, key=key) +poppi.get_at() diff --git a/Service_Components/signed_requests/json_builder.py b/Service_Components/signed_requests/json_builder.py new file mode 100644 index 0000000..7d76429 --- /dev/null +++ b/Service_Components/signed_requests/json_builder.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# json_builder.py +# +# MIT License +# +# Copyright (c) 2016 Aleksi Palomäki +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +__author__ = "Aleksi Palomäki" +import base64 +import hashlib +from urllib import quote_plus as precent_encode +from jwcrypto import jws + +class base_hash: + def base64_encode(self, sha_digest): + """ + + :param sha_digest: byte array(octet stream) representation of hashed string, we base 64 encode it here + :return: base64url_safe encode of the hash with padding removed. + """ + return base64.urlsafe_b64encode(sha_digest).replace("=", "") # Note how we remove padding here, apparently everyone does. + + def _hash_string(self, string): + """ + + :param string: string in query string format to be hashed, for example "a=1&b=2&c=3" + :return: byte array(octet stream) representation of hashed string + """ + sha = hashlib.sha256() + sha.update(string) + return sha.digest() + +class hash_params(base_hash): + + def _hash_list(self, param_list): + """ + + :param list: list as [["key", "value"], ["key2", "value2"]] or [("key", "value"), ("key2", "value2")] + :return: byte array(octet stream) representation of hashed string + """ + string = "" + for pair in param_list: + string += "{}={}&".format(precent_encode(pair[0]), precent_encode(pair[1])) + string = string.rstrip("&") + return self._hash_string(string) + + def _hash_list_and_dict(self, list, dict): + """ + + :param list: list of keys as ["key1", "key2"] + :param dict: dict as {"key", "value} + :return: byte array(octet stream) representation of hashed string + """ + string = "" + for key in list: + string += "{}={}&".format(precent_encode(key), precent_encode(dict[key])) + string = string.rstrip("&") + return self._hash_string(string) + + def hash(self, hashable, dictionary=None): + """ + + :return: base64 representation of hash + :param hashable: + """ + if isinstance(hashable, list) and dictionary is None: + hash_value = self.base64_encode(self._hash_list(hashable)) + + elif isinstance(hashable, str): + hash_value = self.base64_encode(self._hash_string(hashable)) + elif isinstance(dictionary, dict): + hash_value = self.base64_encode(self._hash_list_and_dict(hashable, dictionary)) + else: + raise TypeError("Invalid type, hash(hashable) supports only string('a=1&b=2'), " + "list of [key, value] or (key, value) pairs, " + "dict and list ([key1,key2], {key2: value, key1: value}") + return hash_value + +class hash_headers(base_hash): + def __init__(self): + pass + + + def _hash_list_and_dict(self, list, dict): + """ + + :param list: list of keys as ["key1", "key2"] + :param dict: dict as {"key", "value} + :return: byte array(octet stream) representation of hashed string + """ + string = "" + for key in list: + string += "{}={}&".format(precent_encode(key), precent_encode(dict[key])) + string = string.rstrip("&") + return self._hash_string(string) + + +class pop_handler: + def __init__(self, token, key=None, alg=None): + #if alg is None: + # alg = "HS256" + self.verified = False + self.key = key + self.jws_token = jws.JWS() + self.jws_token.deserialize(token, key=key, alg=alg) + try: + self.decrypted = self.jws_token.payload + self.verified = True + except Exception as e: + self.decrypted = self.jws_token.__dict__["objects"]["payload"] + self.verified = False + + def get_at(self): + return self.decrypted + diff --git a/Service_Components/signed_requests/run_tests.py b/Service_Components/signed_requests/run_tests.py new file mode 100644 index 0000000..0ecf30a --- /dev/null +++ b/Service_Components/signed_requests/run_tests.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# run_tests.py +# +# MIT License +# +# Copyright (c) 2016 Aleksi Palomäki +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +__author__ = "Aleksi Palomäki" +import doctest + +doctest.testfile("doc_tests", verbose=True) diff --git a/Service_Components/signed_requests/signed_request_auth.py b/Service_Components/signed_requests/signed_request_auth.py new file mode 100644 index 0000000..5c98da0 --- /dev/null +++ b/Service_Components/signed_requests/signed_request_auth.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# signed_request_auth.py +# +# MIT License +# +# Copyright (c) 2016 Aleksi Palomäki +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +__author__ = "Aleksi Palomäki" +import time +import urlparse +from json import dumps +from requests.auth import AuthBase + +from jwcrypto import jws + +from json_builder import hash_params + + +class SignedRequest(AuthBase): + def generate_authorization_header(self): + # print(dumps(self.json_structure, indent=2)) + """ + Generates the actual PoP token and the string for Authorization header + :return: + """ + token = jws.JWS(dumps(self.json_structure).encode("utf-8")) + token.add_signature(key=self.sign_key, alg=self.alg, header=self.header, protected=self.protected) + authorization_header = "PoP {}".format(token.serialize(compact=True)) + return authorization_header + + def __init__(self, + token=None, # Required + sign_method=False, + sign_url=False, + sign_path=False, + sign_query=False, + sign_header=False, + sign_body=False, + key=None, # Required + alg=None, + protected=None, + header=None): + + """ + + :param token: Token for the "at" field (Required) + :param sign_method: Do we add method to the signed part? (Optional) + :param sign_url: Do we add url to the signed part? (Optional) + :param sign_path: Do we add path to the signed part? (Optional) + :param sign_query: Do we add query parameters to the signed part? (Optional) + :param sign_header: Do we add headers to the signed part? (Optional) + :param sign_body: Do we add content of body to the signed part? (Optional) + :param key: JWK used to sign the signed part (Required) + :param alg: Algorithm used in key (Defaults to HS256) (Optional) + :param protected: Protected field for the signing (Optional) + :param header: Header part for the signing (Optional) + """ + if alg is None: + if protected is None and header is None: + protected = dumps({"typ": "JWS", + "alg": "HS256"}) + + self.sign_method = sign_method + self.sign_url = sign_url + self.sign_path = sign_path + self.sign_query = sign_query + self.sign_header = sign_header + self.sign_body = sign_body + + self.sign_key = key + self.alg = alg + self.header = header + self.protected = protected + + if self.sign_key is None: + raise TypeError("Key can't be type None.") + + self.json_structure = { + "at": token, # Required + "ts": time.time() # Optional but Recommended. + } + + + def __call__(self, r): + """ + + :param r: PreparedRequest object + :return: PreparedRequest object + """ + hasher = hash_params() + # print(r.__dict__) + + if self.sign_query: + params = urlparse.parse_qsl(urlparse.urlparse(r.url).query) + # print(params) + keys = [] + for item in params: + keys.append(item[0]) + hash = hasher.hash(params) + self.json_structure["q"] = [keys, hash] # 'q' for query + if self.sign_method: + self.json_structure["m"] = r.method + if self.sign_path: + self.json_structure["p"] = urlparse.urlparse(r.url).path + auth_header_has_content = r.headers.get("Authorization", False) + if auth_header_has_content: # TODO: Naive attempt to consider existing stuff in Authorization, I need to read more about requests to know if this could work. + r.headers['Authorization'] = "{},{}".format(self.generate_authorization_header(), + r.headers['Authorization']).rstrip(",") + else: + r.headers['Authorization'] = self.generate_authorization_header() + return r diff --git a/Service_Components/srv_tasks.py b/Service_Components/srv_tasks.py new file mode 100644 index 0000000..0e741fd --- /dev/null +++ b/Service_Components/srv_tasks.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +from requests import post +from factory import create_celery_app +import urllib +celery = create_celery_app() + +# TODO Possibly remove this on release +# @celery.task +# def CR_installer(crs_csrs_payload, sink_url, source_url): +# # Get these as parameter or inside crs_csrs_payload +# endpoint = "/api/1.2/cr/add_cr" +# print(crs_csrs_payload) +# source = post(source_url+endpoint, json=crs_csrs_payload["source"]) +# print(source.url, source.reason, source.status_code, source.text) +# +# sink = post(sink_url+endpoint, json=crs_csrs_payload["sink"]) +# print(sink.url, sink.reason, sink.status_code, sink.text) + + +from sqlite3 import OperationalError, IntegrityError +import db_handler +from json import dumps, loads +from requests import get +from instance.settings import MYSQL_HOST, MYSQL_PASSWORD, MYSQL_USER, MYSQL_PORT, MYSQL_DB +from helpers import Helpers, CR_tool +@celery.task +def get_AuthToken(cr_id, operator_url, app_config): + print(operator_url, cr_id) + helpers = Helpers(app_config) + print(cr_id) + token = get("{}/api/1.2/cr/auth_token/{}".format(operator_url, cr_id)) # TODO Get api path from some config? + print(token.url, token.reason, token.status_code, token.text) + store_dict = {cr_id: dumps(loads(token.text.encode()))} + helpers.storeToken(store_dict) + + cr_csr = helpers.get_cr_json(cr_id) + cr_tool = CR_tool() + cr_tool.cr = cr_csr + + user_id = cr_tool.get_surrogate_id() + rs_id = cr_tool.get_rs_id() + + #req = get("http://service_components:7000/api/1.2/sink_flow/init") + #print(req.url, req.status_code, req.content) + + data = {"cr_id": cr_id, + "user_id": user_id, + "rs_id": urllib.quote_plus(rs_id)} + print(dumps(data, indent=2)) + + req = post("http://service_components:7000/api/1.2/sink_flow/dc", json=data) + # req = get("http://service_components:7000/api/1.2/sink_flow/" + # "user/"+"95479a08-80cc-4359-ba28-b8ca23ff5572_53af88dc-33de-44be-bc30-e0826db9bd6c"+"/" + # "consentRecord/"+"cd431509-777a-4285-8211-95c5ac577537"+"/" + # "resourceSet/"+urllib.quote_plus("http://service_components:7000||9aebb487-0c83-4139-b12c-d7fcea93a3ad")) + print(req.url, req.status_code, req.content) diff --git a/Service_Components/wsgi.py b/Service_Components/wsgi.py index 9622e58..fa5d457 100644 --- a/Service_Components/wsgi.py +++ b/Service_Components/wsgi.py @@ -11,6 +11,8 @@ import Service_Mgmnt import Service_Root import Authorization_Management +import Sink +import Source @@ -18,7 +20,7 @@ logger = logging.getLogger("sequence") try: from restapi_logging_handler import RestApiHandler - restapihandler = RestApiHandler("http://localhost:9004/") + restapihandler = RestApiHandler("http://172.18.0.1:9004/") logger.addHandler(restapihandler) except Exception as e: @@ -29,14 +31,24 @@ logging.basicConfig() debug_log.setLevel(logging.INFO) -from instance.settings import SERVICE_ROOT_PATH, SERVICE_CR_PATH, SERVICE_SLR_PATH +from instance.settings import SERVICE_ROOT_PATH, SERVICE_CR_PATH, SERVICE_SLR_PATH, IS_SINK, IS_SOURCE + +# Common parts. +paths = { + SERVICE_ROOT_PATH+SERVICE_SLR_PATH: Service_Mgmnt.create_app(), + SERVICE_ROOT_PATH+SERVICE_CR_PATH: Authorization_Management.create_app() + } + +if IS_SINK: + debug_log.info(SERVICE_ROOT_PATH+"/sink_flow") + paths[SERVICE_ROOT_PATH+"/sink_flow"] = Sink.create_app() +if IS_SOURCE: + paths[SERVICE_ROOT_PATH+"/source_flow"] = Source.create_app() + +application = DispatcherMiddleware(Service_Root.create_app(), paths) + + -application = DispatcherMiddleware(Service_Root.create_app(), - { - SERVICE_ROOT_PATH+SERVICE_SLR_PATH: Service_Mgmnt.create_app(), - SERVICE_ROOT_PATH+SERVICE_CR_PATH: Authorization_Management.create_app() - } - ) if __name__ == "__main__": run_simple('0.0.0.0', 7000, application, use_reloader=False, use_debugger=False, threaded=True) \ No newline at end of file diff --git a/Service_Mockup/Service/service.py b/Service_Mockup/Service/service.py index ed9e7df..fb6244f 100644 --- a/Service_Mockup/Service/service.py +++ b/Service_Mockup/Service/service.py @@ -3,15 +3,14 @@ import logging import time from json import loads -from requests import post -from sqlite3 import OperationalError, IntegrityError -import db_handler as db_handler from DetailedHTTPException import DetailedHTTPException, error_handler from flask import request, Blueprint, current_app from flask_cors import CORS from flask_restful import Resource, Api +from helpers import Helpers from jwcrypto import jwk +from requests import post debug_log = logging.getLogger("debug") @@ -63,80 +62,18 @@ def wrapper(*args, **kw): return wrapper -def storeJSON(DictionaryToStore): - db = db_handler.get_db() - try: - db_handler.init_db(db) - except OperationalError: - pass - - debug_log.info(DictionaryToStore) - - for key in DictionaryToStore: - debug_log.info(key) - # codes = {"jsons": {}} - # codes = {"jsons": {}} - try: - db.execute("INSERT INTO storage (ID,json) \ - VALUES (?, ?)", [key, dumps(DictionaryToStore[key])]) - db.commit() - except IntegrityError as e: - db.execute("UPDATE storage SET json=? WHERE ID=? ;", [dumps(DictionaryToStore[key]), key]) - db.commit() - - -def storeCodeUser(DictionaryToStore): - # {"code": "user_id"} - db = db_handler.get_db() - try: - db_handler.init_db(db) - except OperationalError: - pass - - debug_log.info(DictionaryToStore) - - for key in DictionaryToStore: - debug_log.info(key) - db.execute("INSERT INTO code_and_user_mapping (code, user_id) \ - VALUES (?, ?)", [key, dumps(DictionaryToStore[key])]) - db.commit() - - -def get_user_id_with_code(code): - db = db_handler.get_db() - for code_row in db_handler.query_db("select * from code_and_user_mapping where code = ?;", [code]): - user_from_db = code_row["user_id"] - return user_from_db - raise DetailedHTTPException(status=500, - detail={"msg": "Unable to link code to user_id in database", "detail": {"code": code}}, - title="Failed to link code to user_id") - # Letting world burn if user was not in db. Fail fast, fail hard. - - -def storeSurrogateJSON(DictionaryToStore): - db = db_handler.get_db() - try: - db_handler.init_db(db) - except OperationalError: - pass - - debug_log.info(DictionaryToStore) - - for key in DictionaryToStore: - debug_log.info(key) - db.execute("INSERT INTO surrogate_and_user_mapping (user_id, surrogate_id) \ - VALUES (?, ?)", [key, dumps(DictionaryToStore[key])]) - db.commit() - - class UserLogin(Resource): + def __init__(self): + super(UserLogin, self).__init__() + self.helpers = Helpers(current_app.config) + @timeme @error_handler def post(self): debug_log.info(dumps(request.json, indent=2)) user_id = request.json["user_id"] code = request.json["code"] - storeCodeUser({code: user_id}) + self.helpers.storeCodeUser({code: user_id}) debug_log.info("User logged in with id ({})".format(format(user_id))) endpoint = "/api/1.2/slr/auth" @@ -156,25 +93,34 @@ def post(self): class RegisterSur(Resource): + def __init__(self): + super(RegisterSur, self).__init__() + self.db_path = current_app.config["DATABASE_PATH"] + self.helpers = Helpers(current_app.config) @timeme @error_handler def post(self): try: # Remove this check once debugging is done. TODO - user_id = get_user_id_with_code(request.json["code"]) + user_id = self.helpers.get_user_id_with_code(request.json["code"]) debug_log.info("We got surrogate_id {} for user_id {}".format(request.json["surrogate_id"], user_id)) debug_log.info(dumps(request.json, indent=2)) - storeSurrogateJSON({user_id: request.json}) + self.helpers.storeSurrogateJSON({user_id: request.json}) except Exception as e: pass class StoreSlr(Resource): + def __init__(self): + super(StoreSlr, self).__init__() + self.db_path = current_app.config["DATABASE_PATH"] + self.helpers = Helpers(current_app.config) + @timeme @error_handler def post(self): debug_log.info(dumps(request.json, indent=2)) store = request.json - storeJSON({store["data"]["surrogate_id"]: store}) + self.helpers.storeJSON({store["data"]["surrogate_id"]: store}) api.add_resource(UserLogin, '/login') diff --git a/Service_Mockup/Service_Root/root.py b/Service_Mockup/Service_Root/root.py index 90af604..9544d06 100644 --- a/Service_Mockup/Service_Root/root.py +++ b/Service_Mockup/Service_Root/root.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __author__ = 'alpaloma' -from flask import Blueprint, make_response +from flask import Blueprint, make_response, current_app from flask_restful import Resource, Api from DetailedHTTPException import DetailedHTTPException, error_handler @@ -31,9 +31,10 @@ def output_json(data, code, headers=None): class Root(Resource): #@error_handler def get(self): - - status = '{"status": "running"}' - return json.loads(status) + app = current_app + config = app.config + status = {"id": config[""]} + return status api.add_resource(Root, '/') diff --git a/Service_Mockup/db_handler.py b/Service_Mockup/db_handler.py index 69e4f47..439d937 100644 --- a/Service_Mockup/db_handler.py +++ b/Service_Mockup/db_handler.py @@ -1,49 +1,18 @@ # -*- coding: utf-8 -*- -import sqlite3 +import logging +import MySQLdb -DATABASE = '/tmp/db_Service.sqlite' - -def get_db(): - db = None#getattr(g, '_database', None) +debug_log = logging.getLogger("debug") +def get_db(host, user, password, database, port): + db = None if db is None: - db = sqlite3.connect(DATABASE)#g._database = sqlite3.connect(DATABASE) - db.row_factory = sqlite3.Row - try: - init_db(db) - except Exception as e: - pass + db = MySQLdb.connect(host=host, user=user, passwd=password, db=database, port=port) return db - def make_dicts(cursor, row): return dict((cursor.description[idx][0], value) for idx, value in enumerate(row)) -def query_db(query, args=(), one=False): - cur = get_db().execute(query, args) - rv = cur.fetchall() - cur.close() - return (rv[0] if rv else None) if one else rv - -def sqlite_create_table(conn, table_name, table_columns): - conn.cursor.execute("CREATE TABLE {} ({});".format(table_name, ",".join(table_columns))) - conn.commit() - -def init_db(conn): - # create db for codes - # conn.execute('''CREATE TABLE codes (ID TEXT PRIMARY KEY NOT NULL, code TEXT NOT NULL);''') - conn.execute('''CREATE TABLE code_and_user_mapping - (code TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL);''') - conn.execute('''CREATE TABLE surrogate_and_user_mapping - (user_id TEXT PRIMARY KEY NOT NULL, - surrogate_id TEXT NOT NULL);''') - conn.execute('''CREATE TABLE storage - (ID TEXT PRIMARY KEY NOT NULL, - json TEXT NOT NULL);''') - #sqlite_create_table(conn, "codes", ["id", "text", "code": "text"}) # Create table for codes - #sqlite_create_table(conn, "") - conn.commit() \ No newline at end of file diff --git a/Service_Mockup/doc/api/swagger_Service.yml b/Service_Mockup/doc/api/swagger_Service.yml index 8a83547..049ebd6 100644 --- a/Service_Mockup/doc/api/swagger_Service.yml +++ b/Service_Mockup/doc/api/swagger_Service.yml @@ -27,7 +27,7 @@ paths: 200: description: "Returns 200 OK or Error Message" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" /login: @@ -52,7 +52,7 @@ paths: 200: description: "Returns 200 OK or Error message" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" /store_slr: @@ -76,7 +76,7 @@ paths: 200: description: "Returns 200 OK or Error message" 500: - description: "Internal server error" + description: "Internal server error. The actual status code and content of the error message may vary depending on error occurred." schema: $ref: "#/definitions/errors" definitions: diff --git a/Service_Mockup/doc/database/Service_Mockup-DBinit.sql b/Service_Mockup/doc/database/Service_Mockup-DBinit.sql new file mode 100644 index 0000000..5a61a56 --- /dev/null +++ b/Service_Mockup/doc/database/Service_Mockup-DBinit.sql @@ -0,0 +1,68 @@ +-- MySQL Script generated by MySQL Workbench +-- to 15. syyskuuta 2016 15.37.01 +-- Model: New Model Version: 1.0 +-- MySQL Workbench Forward Engineering + +SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; +SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL,ALLOW_INVALID_DATES'; + +-- ----------------------------------------------------- +-- Schema mydb +-- ----------------------------------------------------- +-- ----------------------------------------------------- +-- Schema db_Service_Mockup +-- ----------------------------------------------------- + +-- ----------------------------------------------------- +-- Schema db_Service_Mockup +-- ----------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS `db_Service_Mockup` DEFAULT CHARACTER SET latin1 ; +USE `db_Service_Mockup` ; + +-- ----------------------------------------------------- +-- Table `db_Service_Mockup`.`code_and_user_mapping` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Service_Mockup`.`code_and_user_mapping` ; + +CREATE TABLE IF NOT EXISTS `db_Service_Mockup`.`code_and_user_mapping` ( + `code` LONGTEXT NOT NULL, + `user_id` LONGTEXT NOT NULL, + PRIMARY KEY (`code`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Service_Mockup`.`storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Service_Mockup`.`storage` ; + +CREATE TABLE IF NOT EXISTS `db_Service_Mockup`.`storage` ( + `ID` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + PRIMARY KEY (`ID`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Service_Mockup`.`surrogate_and_user_mapping` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Service_Mockup`.`surrogate_and_user_mapping` ; + +CREATE TABLE IF NOT EXISTS `db_Service_Mockup`.`surrogate_and_user_mapping` ( + `user_id` LONGTEXT NOT NULL, + `surrogate_id` LONGTEXT NOT NULL, + PRIMARY KEY (`user_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +SET SQL_MODE=@OLD_SQL_MODE; +SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; +SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; + +CREATE USER 'serviceMockup'@'%' IDENTIFIED BY 'MynorcA'; +GRANT CREATE TEMPORARY TABLES, DELETE, DROP, INSERT, LOCK TABLES, SELECT, UPDATE ON db_Service_Mockup.* TO 'serviceMockup'@'%'; +FLUSH PRIVILEGES; diff --git a/Service_Mockup/doc/database/Service_Mockup_db_image-v001.png b/Service_Mockup/doc/database/Service_Mockup_db_image-v001.png new file mode 100644 index 0000000000000000000000000000000000000000..bb9337cc533764e6efde8e5ceeecbc19d51f5027 GIT binary patch literal 18994 zcmeFZWmsIzx~@A22@pI$f;SK>1b6q~?k>UIy|DyI@C0{vhsHH{pm7ZZ3Bes2_ucvC zk~!yEbFaP6xz4{+{21L_WAv!58ddeY?{im&DJx20pc0`10DvJQ{Z17Cp1@xt(4ahl z|0j}~c@lm=GLe&d2R#1y%Wf}<0|0VB=AEdzSNi^vr7zybEO5*Y@tfjGGn*oQPC@Zh znlktecK(~}?sL_5FE+v_&)#Z^D8ECnR*f&q4SeQ@pdy-284MCDrVCb|+_QkR&hu^D zS7VE_&8wq4-+KJ8a@aG>Zb<{qgs6Fh8tc5m7DLHt{hC1vz>ml;5|M+k#Yn0J$b+$e z2W}ux1Y;vJV4+Y1^B8-JpiuN8KoP}IC@{4_@T;7^!2+?xJ|lKQ5VxiIGMMP-WJ*-y zo_6(ge>%iZMF=$~AtgaV|C}gWkU@$mMjEuu#XTb}FVDb8&y99qbB$?{8B8M$XC}xjkGA3{wW{M$N%c=7>5O^x$>JP z1Ch{O&1m$qoQqw|PsXEJ!d2|aZ|&}CxUxshY5HDrJ?I9pva;4dOht>^TYSC8cihZ3 zUdj@ah5mB)U_e0-KQ1d+E->HV%c%Twib2a=S3z)aFsh&)^+oy0)sl%Tgnei!I7T4D zlG*cx%(4FKU1P-?F?>2+v+rLPvlWz9L-Iu6xAXNTf6j8+i$O#?sxB~-h6SM8 zHg98Fg%%KIYiCrAiT8J4od4N(H{eX zoyCJ&vK3V{zqI;|TW8v&hr+@OuD2RKUu&YKD=IUBd(l99d(yXxBcJ zruo`>>}s}V@{$nAD(*CS1$QkKx4rHf;d9+6Ph;uzYF6Kl>^7F8-zWEe$QY-nD4O*2 zijVd5#VbH_+3j@?SSknWKzh;c%b*x8tIHGZ(onq>PI* zqt;_$ss&j(ibi(-sC~JAY|R^0!QTdLColR1$F-7kz*h77mqZFP|z#j`m%!-fQAdu(YHRK7}BU552R^mR@;-@F$xo=5j)c%-sY1z~yvPA!@u?G*8`Zi;|LF zT{0-;S>g-SXEQ{Q3Hh)-v4RCNE@FG?k0u2t{TrR_PsB;qna;N=80hZSg_V|DQc5dI zgz6ooWDGUc@3zyYp~4jp--PqC;N9Y_^3X`uxw^l~n7H7ecVcL5c=0tuukDHWQ!$k# zJ*>ytp);lVufg6LtJ$!sUQ#mn9>NekS4nm0p7 z!8jtlmtGU%)ml_pUG~DYwk;(_w$|}kqS>8j4G4NToveCjvJr)yAnMG;PR1%ylLIq*U@WxffgsKTvl z5cNd7ivY8$+cXcxn+v7pvaJ#NFyMIdqXvMDCv<#5SCx6SMyp{ypJv->bbRi(8YD$J zUsIr2VMFrVQux*K2a2*ws?i%WTwt-u?Qk(?Mefu!Gv#_2M*194D$4O-b7S$H=105g zFrq#D%O9!j_$HFkYFb)~>9<|*HzWp&E|ZnII&{t5etn~Hjuk5oY`a*Gwat}fg$ulm zh#V*Q*5Pph#p;K;of5mnPO!bD?t@U}xk*7xCeB`Eyb%n zp-b#(Vad(?o2^-lPmf1GzkC;^r0JB8o%L6FA!rvF(9YbK*r+u|f*AOjA`c*^a`__t znsHbbIq)v61Q3@f9>W# za+IjjaPySfkBguj3@j_{9a<_E>@4Qv{TJv!{9tlc3Mv3^t0y9lQc`#Ax3`IQ8hdG< z*`-GnrnO_noj#JAE207@=n+&hnHhNyU7h#l1vmYIk}xm-F292frbG!n$E`)o@m`~R znWw9SLL8?*qFa#0j&sI)KL`c4v^eO3tcK*_&jh;ygh3fE*(%$r{~asdh%$$5p-xK} z?AP}1hO;8dxzT&S^RpUgQH(x6*mve=i{VjXiUUZFyt$c*C}@aULG8l<#%I;_3b~1+ z#A*ltF98rhU{eod&n*ju`%fScSfuLn2f%56?g)x@iuqWZ@_zf*cToTv`_*$G;OL=)oc{iMv?C*K68sRnUU$Dhf4s;Y*b_cjf*49#@_rJ_&2_{6(SDgGBi$I+^Sj zfsa&zHy92FyX4a$Ze#7p;kk6-uTHEI2LOcl`CeMKfRUQ4j==)vLi1iB{Y(WGB zV-g7bs-a~jxjh})$#YLSdDj=y;beA%e>CRga(eb0b)+*z*D$&07={*267PR9d{G~f z<#(DF-y0`fo{j~)4m)f1Cevfuu?~cgjUIQKteHv*WO$j4^{w3^idEdhdYGFW*IsTd zzH>IEX`uQ(YhzQe=<8FSeC`MwqhS(X??<-m5Z7tF!q+~^I%PJ=_^!D*2@%E){xMoJ zvKKNQR0Um8H(lxQlW1rs;($fwzJF0!?-SQ$Tu6{;A;XS{C!xk53VAvLh@z^hxx^gY z`~E6EfzLe{7M27pZ?*~*)z*HmGJ3ZAwPJ>|ZCU337+fC(2`Lz8Ed)wxXe4IQyE?nN zoYoD)B@ukSo8+KQ{ITu6Q12)pe0|<0J|JwQreUa|yG}~*8vFhfS2#51)UlB7y8+3| zLk4If{?eqnmiGlpE!)1!MNgRde0Z*&WY98wHomC#&#-lUXZV=zCf6(Lk~I+ayn{Zg zQUp6nR2>u8E7WU=pbW0*ST|OdbbdZ>eiMfPT#4Bd&#R@edhGwuET~Ow2%In-lrP0)n~u*6&ogFw%l~8 z;bnHbNBm^S=PhlsHSVb_0KnHql61VNo@2B_TW_w z#mGjU+tqj2!QIfR!-@p>+L!K%JA+k=K!49oY@*Dl^+kJvh~-u~PiuHHy33(&?{Ql`1uQdh{EM!(xU+>67S+ zZBSQ`Tyfc6CK&5=7~WA&IJVfqgdnq>1PaBp;@^i4;+{O0ze+~7_p!0Xid%PJ8x*{} z*F9Lm`}=ROZL9`ksl7IEb|;U2;rw&lA)X|BIgS^%%hHwVg_$Q^i|9Zfg)N4acBliA z*Z4_rE=n#R$rSQ+1Y3qg1_nB!t>GB?B4F6g-^|8=Apr881IJ2nb)XFuo9v!xF&)o%XOo#>d7YI4}YjVcQBq8i5=SzeNU(!;TzOzka@*oy3n zNhF3<<>2KMHc11y@e?q79y5lMU{n?nFtK+&GBzuenumKYc~o{?)D{0=eU|M$zu9t0 zm=$uXAZ!4h)8CBEBa696qU#RrJ&)%{QquNRltBats+ci<(`WwF>(VZqq8-~4G`3G9 z1d4wLMPwaA+@BM3y&i&gSx*1X+vwL@iGsyKH(bCUM98b%1>kz1S*DeD&f3q2D=QjJ@wau8KA)&*&k)V}C-$5v7qmgQ^y!rgR z)v_J@&fD`xX#7jltXum1vh>u+^2odJeMg@IFOLO>)w8?;oB9?Qtd?bkFd(U9W;Yxy zqWc|S{J710y~|(9#2n*6)?o58c<23fdat+K%lW)};@=2!hgfdM$tlqh z0>6;=(dBQwZZ+oEbP@jal_XzM>-JJjt;y0nBp0kUn`Vy86vKW1%}b9H*MF$+7V z4CF9JC$eXEw%BzK^gn`zzRO?r23*z=b|$3EaAVnPCkolD#WaI4IOvd^*;Y^1Lb7N)_IqX|XQF{~XTxALmC#px+KiATq22aIb zKHl%_&6=1Ircz&HC_cbK1QJ^y%->vz>RrUu*CUk*)6@MgOVNa`_7BVKYk&HOlpL_SeB6pQY1Q%B(Tu zCjdb;VZQR4-rt3w`|qkWsm~^N2D2Qg+E!ZK#*iryZ{+WH5>r@pp!VmWS^hwtNtbc%a+6S zSZhcXb$W`??P{F@q1OW;MQ;gwYDatWrbe3q50&_k51Xo>gtZ2^8D5)HM<Q!Ea7~=&hz*Fo_spEVu>%+PWFgTzv_NgHKvxhc6x&Dpk23U4 zd)Rb|$~?wKc^8F6FC*R`VwA>u6}Hs=hI(!DHIQSOFW3F!N0;912_*Jyzdx?6Qj1Xg z?g*I_#^;BeP9FC8vtedO=wO`%KK=Ij!q!@-!mhBDaW+Mk$GKhgnAl5yvZJ=CT33qw z^Vu+0GDp4^kLCETWqNS1w+gRTgkX`wLL-$bs`4YT;EF9Q3EKFqQxypykJ7HQ>PMpRj$J+^#JZn00D{PopbRyJs|HGj-Tzp-{Qk);w1eyXj>+Hbq~AIxZT~ z@^w<&?1kTroXTAAp&$@AzQKu1oFPJv zjmkUP4%-L6>s_3A{RLSP9ks1243GZf>zAHmRzpiCJ6lDihb^({BrsSk@4fw@8`bF% z{!FU$wTEF2Vw)~IGR2qc^N0@I0_Dg81d8dC7JF~(uy?Oj1|BQrwuP++LuE*p8O2Zp zt&SsT|9Rm?{J+mJiZ?Q1`Q)9rSXfjTj=qUhWfQ&%EFk8rB_W-%ixVUdZb^o#1UICf^Siqz ziGoQ91?nYMsfWGB+J2)oh+3t3`uQAEv^P=9TBk}uboW!+qI>cjL{B!I=xh@@9bpt14y1?+tA)CWsD%^V3Rz54Z=_Rh|r zJ2dJu%;lJ{{>rNPWY#mUZQZ4L(U2S)#g25tr5U_mL?^j%=#?Y#I&Ni^sQ%bw=Y$vG zaLu177Xi?oIhjYu#Pv+#ni2omNG4Y7>ed4@F2R@)%V8k@m8k3`Kwnm>M*lSL?l#Y} zK`+2f-><>hD06{txRNu%xPEJynC-;)EOEFrw>%v-R0e&y5(%n*JHqznH27u-vMiX} z)+$1%su}5bSXEu+Vj~M-gVEP^?$oc%TepTx#Uz%_><&Sp-M*H028Ehs^h^w>xCnvw zd7bNJN5w5|0tCS2eFtq2{xA)wHv&-e+Na0AMhg8+JOj2Yhon zfLI?rRC~C*+b?_cdsrfyRgC_u=iB#9owhQ--#9zTQ+aaD$baCmb)r5=zKE3Q-77a~`Ncm_=Vdx7!(+QOJ{;o?Dsn!y`jlfHL-#K7!x?7ro3#|Yl|C5@E> zgpQ@rku|SPHrGDQ;+`AeLq!>|cEq}0X9r}~hoD>PjjD?G&Nhy!5(j{|!E=`7#pW`$ z?SfRbR!@YrNi8>L#}Mo)o?HZg%ZBlGTBvir7O?$VcqEqiP_3-y8bdyi^iKxN-5f0G`F)o<&W>2zuz zCJqh|Nc%Nxn8?5$gSrOy1pxRGgI^#hgT1d!5m=c6oO2hwzfmrq56JarbHxT1mgmFE zFdm=fgxO=^C$)YnD*Jtgn9NNXQfkddMUzhLtjV4txn0rNs{Nyd{NOSQ%;u(LC_u<#ALTApT_nyOEHLP%>fyv)z*C|(l% z7v-ZB^>@nX9Ppk^I%}U5g$4fQ>xJmk8HPZL z+h3x4v<)(ItfvF(jH#{HEs4*7>;y~Aj@rzO3&nK5_7bndN8Qdv@E)=7OJyL~_ezR= zyPkcHdWHpV58g~BIXaFyoLsvCfZFC9Gt-;2=JSl?&vOmK>Is9gek?_(vN<7QPI!O`?KaD(p=BJiG1;RR{+WO$E(jH?R>C zTYtmt8CjzhN|?}6R)YfLE z7?1|WrfXTL7RauwY$eq%?O8j?SKVnh45u>L*_l!z;Y+c0y4hb(eWT<|AO2-LU@{gX z?$E#vp2z_$H3%#+2TyZ5+)X!)Vj@xm^AUh7CI8tc-iBMH2R|FDAmSpQfBvX3<>!AI zyim!Xo|*{V$RK?phC!DwG>VCk;{i_@{R^vkJ98r^#8dk<%5Tt8#B+*2S#xdBx{J4+ zT{#5e3Q@bcx#{okU;gw(nfps(Vq$D;H(VtWaf=z~a>MQGB{Z5iI$mDyu}nd4Z|@9J zG_gx@&#NVIH4;+NppPHn+9o3-Lnd|@3Zs2=ZEXUO`TeyuL;$V^q0qA2+}!Hw z@#SR@faj(^y87=d0BXpmPf`vJ4*vcfMMXtvX~aN$d^|yvB)o3$0-4v>*I)Evv;0%G z($mx7_m`5A3K5$aq>nsI3J(uQM8>3|qOz)A_+y!a3;2_eBf7i0!^2SvROzr$fNv8M zU(M(B8XXdnlFDXmfG>k)910PACSmWyrs@hTsu|J}5~#mQ@9*!|LLkxnxlX&`*w*Qv8nEe@ znNb1Lv$MLDawqVtRZUH^^BR(bir>xI$@w|sn>SW0Davoo&dwsk;VGK%T&KC!_r{Kn zWFXMt*4Eto2XePD_fNz=jjPcWswMHcZ=i#S06B`->#sndViAN*Yi&?EHXc5?h{9{! zW>*51Q&O_j;o{**rqZh$t5AP?61rS02j0E_F%ZSW^T!qVp@lT21ceO^4M9QTSovSR z!0`iwoZHAHG`s1Ifo?(IHcx`L> z_)&uw3ZP{ACOVoTd;IWshp)5G4emUiqPX9~?WbSH;xld@cqmJKp`~A;`+rV=pPuvy z0WtTQCMuBC&4V0ei6$GvH;#*jrVxVoho|uM{vum&Sc%$P8E94lf9NR4b!01`2j0v**{3K^A-HY!=YLO6p5p{s8PzQzLQM$~DjIWau4tlK%WUO`14l8R3+l7L4Zu3{d>a*S9^dui zQ7cW^_L2Nz$|nbzPa{Pu>8oExgklAh!{;h2v|)v5T}HB!)Rh;40S|4X#--IK17lh4 z7OML1*Qz)PaB*?@;SiJZx5an%N?Y?#gLko?4(%%9I>ser zF$vl7;RA4qaFfQS$U>^rqEcVAbh@6)5Q8Y_1}cq%@(}>4@$jDnw%;@N)H0wouo>D+ z?u>6s=?nP%Y(zDPQGBCTiV7d$(FcLW&-t=DIG z#?dp0uFRm!p^R>u`^zizu&BNf-S4$7L9wn*7d}dX2PR|sgQx{nAEws^O3T2Zo`Y^t zDZ@|Lpx)P$Jh;JBAkMdc_RKvTZn+=WzUqs5@Erc`d}*IE60JTGj|`F-kBYX#VJMqW zx!&MoAAx!02SsJ)FBlhU(;~}+sXB5%K4f0hrPyd>9|_^&3wceK$#FHj{!C9YWJ@~f1OB|V{I?DI5t~n%v&{ipMP|$5+eO!xy4j0xA)^~DYbBfEstGX@e96=(ww)3%&&tMY44|MB%l zxSD2J0w4CjV|5 zUSC5-=c{OS{1x~@2~`3oND+KfWdMg>Zfa`qqaO&T9Yu^+-T>pp@nKRI#cyR1ZzV4y z!21{ep7ddMBtwpdTzecC3(s)$wlY^o99V-7qcBGm;#2}2#>qB9yd63|z(I|*(`Udq z4;g|;Q3)^PQ7carfBGm3x}}$CiviOrQ_U1^gX0a#)}jv1ijq~Ie+aQQVtsaHDz1Tb zw?zzGUXpSQ)01mgl=IxrF+owrz3QwKERc;M-o0S@9nQV`E2&QHri;sIi{LEh0-i#YBZk|)^h~&s z!rXhSREsN%a8^}@9#tA$D-MVxyLrBLUKned0#e@R!X~A!8i{!RFqoQej`Zff&;Fdf z)+hcaLekc z-S0#exf9i=Iewck(34X1zc#As_!+7}9G{i7kRm+(uNGkA^{hK{sDt zw)%zC%Z^1tZXYponfH%Up`D%9bGeR>@vT0~ZLwZE>p6GB)2z(H1|5_XF5BBJeHs4( zTdSrU+Eo32~OsXS3f89TT((BO3CxLMC z>2+8g3;hzzXrib8>hh2|ap5f5Cm)W9be0>j9SusG>%0)3y5?3x$X=n0K@gd)p1|c* z^S~mXL%z+=F6#dc0Tq*-7x8m4D_Wob@^WTsfGJDJ0DSSjtaClLcGZGMjt7GZAC#X0eD)Z?w0c6)%XOd2 z{gITIzel7N{;hGAA7UiM3AR3#!-H|0q7B9^t0^8u=wNcoYG3$`rP^+%gG&_PX7dsl z=P;k1<7;WswYICyzd1@JT1lN$q14d30KH+iw-9m6m6y!xvYI4DhjUV9Y`f=3ar4qC zbnGLO3j5y<|64xlPl)js+N9Mh0`OeyUBXv`PXY78EN)Kg*+?1j{{TWEkdrgu01wOe zexjN;UrASH73LvE9MIYYq69brI$7ff?zm59wwfz>0*BO|4pK@_=B@m?I13tYAJ1H@ zBY_H?Z$(eur5`1Z>Rnz$p!Ya!nmU;=03`D#_M#RGCoBHd!^<#z+w@L_Refw)DCfz4 zqFZ`Iyi#fX&af-{>%_%w&K57&;u0@f1|* zK1)FakWjLRX@q1DV+!DhLF^~77J&uL!XH^FGlc@SV;=oje!@Jqo3_#geF1VF7v-Bi z_MdZ1nKz?FN*!l69ri{HnLgkcmmlm?E>o4$M^tb9&q64{on8TiAe$ z2r+`*t3R@|6v-+AEa)7Dd;bxblC#@is2*~E*T9yPdH;dD*CYC12RR3vBiKxcL&)v6 z=t}$W!+&+#5R~-qXNi$Clha}beSqa8KfzDv&xD{x*M{M0i0Std$3_J-)ys%{xKA-YBQd+S6Fb`ceh zJ{%6Yadhl_a!%^YAIoFN(L_h21e3-dp*ayQ4!azjOrT{Kt+AGVI68^xf-e=q4_K*d zm|r)u;d9e+D~&?eY)Xg9WFy)T#h<=r1&R}1CQyfPD1=dS`DAz;5RWPO`<;xRj3ENh zS~vmBF{6{~A$m6K?J;(X5~nI1Es>Z?z0Px`wz6 z)o>`1FfBEM$~#IpiE8i^?NIM>R&mpe*7ai{?_c1RyJNyeLD?g}#Oe+)m(wuzHnBDyjxSk{NLBM#P*7HA4SZ@?6AAnV)O*Kd<}BToQqs;q(ftnP7+F9NkEq&hlz zwc3MfO}r?dZ*U=0F`h*^4s?wZ^)Dqf$`{4oLmTB4SUp#mg2}xFa1MjdlqjF0PgR>H zuAgWhf{ss|w!3EZ9U5@Nr_No=!{|W6ct}=WJ>;KwlAQOzayJa4sr%T@m=rMYF!#^&|yfxLV3@M;QY!HwuH+}R8@ z_)eexy@;Tz@U06?mc*U2|E~;U+eUn`OS#kLdA`Ckr99*cD3@N!?+*&A&yD_h?3^EcNuyZP*2aCK2%pyxbh?hOqbXzylwT}@ z#c4p1%dt8d@d;{%-tNu6(92q83nD_nIdO*E>TU5wFPucnU zHqDSfGowL|=iaJBx1zGGjbYfcf5jZmg8lB!WqASo5WbP?xQQ->Li3hBRs7&uy=j~C zXUv&TjU#Kjv)tE8%XG%S9=OY=w9B z(G&U1rO&yDqGla$QGtApUq;Yb;mpgK7(&fmZar_O!I%cL2F6IOV+7z`$C{Qyie|mD zy9px@_{QrZ^#!d3BTr6gBge@6%d|7s2LHRJR=C;vi_2vP9#~~;Ypy^27Z6u~aqwZ< zrfXb@P^W{eJluvpT6NqX=3!KIh@# z{Rwk9k7oaUz73lEszbrd7RZasPl4KB?8F@&eQ2+huEk(S^rd6D*#=gl`A@go9Wf%F zS(gSAyWp}J^q0e4K1`Y6ay`6&MdjnTq61%|PoUfeBv-qiEMw;+bRSmK-40W|wiiWM z0hZMLh$OUghBrPu5InRequJV|r=hh`FJAoJ z@KmURrn}v;>(9H+&T3AMqky%6j<%@%8k4+(BwANV?15zxwwdx(D-{vwKAU z_BPsFm!G`|KQUDPjZ-TaVC$ETON>tC$diJR;ToiUfAQz*G8Pqj$4Uosb6>Qq0qI_#B>oG8i9+%K%Z93~v`i)Nt8Pn+d>6 z;~=ywD>7=TW6?$-@v6-(PA1TU3OwD}V;e}HA-Je*eg_45v@IiU+48G1Rnw@ddgvrZ40Z=j!@9fVkYJ!5F0>5O; zUPJo7Ro6{OFZ_7^8~`$>Rm7f{Dih0yscEWqBL!aTJxwP=DhpKaE zdt=9m8p1P?@1FuVf(-dx4OB4@fp5jke%BY9c51e>z435t2yMv1!H<^acRk}Nx?EAE z9A}+{e=vwaVZD&bsn~NgGXn0MK1|+KOI=>Vm=|43BayP9iVudrg!4ZIS0CXX`PvU$ z+r0Yr#qZcS^LcPb%*6{fim?tM$7Nz>3H3}FYWvg&)iAonin;>$EOC>DK_s|Jf&DqP z1cQ_IVS^??_MJ&%4s-^Bxt~Gmbjj>q+|kq; z+rjASj}J*KaQfwl^BTHQ6AAC-FOm!kkeoTJ6T^oWL7yeh7Wh8We3gV*g1+OoZQboFb6#BN8#rp3$r$@d^fnq zf4Dq%hnze1^+u5rF^ZL;?yUo8dxqFGwc1DWKuXnzZNjm}TelU{8?ar5R4qE6fXOfnYZ%ZuVU}H=V zV%69(Y<40{)>r(a(?bh6=PISWP1jz z*Kqr_+f^7v(CLpf=)!@6-Jk#8X41#|VcFb7%$63jyXPcWe3pc%4x1BGjNisNdgjso z6CE?6f{uF%7AIC#d>><;Q*~EU!x%?L`!*Rxq!S%&-7pBk-u0bzIW;ui3B6t>zh_Bm zEr&<;8C_0-!kvNUW9H^`8gEkz_Z}S&Qj&rs_e*V7UU8ZGzB=T-cRMhM`X88Y=UdZ! zrOY|-w*>}vK)DYYUwGAW4~NU{U3^l|SaF{eG^(+Kc!N3#x^eO3gNX6on$dZtgBGQ=R0wy10b^U4oH$ZZKaz(?jdGuYE1kOWGlUr3Nv4 z0xe~`hdjx(IU0CX=n0%d9*Sz#>Oq}_aFB6hkRDlBDp)kTM#rN zcUUN$ioXNu*f?!crYaloU!XFANG&wjq@ zr@sP#*DELVjq+s+O?)-401-R^@W^Le$g6banLTk=k3O3F^a?WJLnZ*gZi>=xah)=w zZF$+c7;N{w_K4OX!1b`f0L@|_e3pix)C|+D@562p){RYT$;YB$lAhKvWJ=|EY>T!} zFZw6hAgKd8`&1uC`TLcT_1$O1f5EXA5^TC8O|-zA=-45uZ|crZCV*lf{wV?if((^6 z4W)R|lulvJOv)hLMq|U6j=lepnW6u0wit0E5=P4(sllE!_H_nsY_$CEYwr7HjxyEN zb|b}&9HxhFD0vUIhhdy#naZ5)X5tQFh0tV`j&qQc$zOuRMKz2N2y}}y>aafkpd~Dz zre!8Cu~ixNDH;c`cJIAO~3tgE9;uC+!s$jxRB26R%&{_dng=(i-az?O}v zQ2o^%o8QgD_bR3>{du1oL)w!0&h^{)-B?g_4E*i%2#%w6c?9|=C6jNYZ@)H zWuyHdH!&u0G;4P74PZ&HkX(h!^3Lohbf&raj*g4*|>-{06ZW2^Sc0bZ|T+O zaoFc|W~NcD+r4-|Vh3}Ri{&xZ*2zWx14?hDl6JRNuzcD$!22Afi0j?^~5_GHd}NCfbOe|9^S{l#Ln>!&TbZs>EQ&V{}&3k=u*oYxXzin-6EuC z-qZe@&cTV^OaVj)&W0Mnmq(BGGq{?WPMA+(u4I_y+wEaa!mL2DYvbM4QuiFr=y27& z(?TJ-fMp0so^ezGEs<@4;frDvZ2F%mv;YKPrE6(<;VZ#kEE-(>07|=WYEq|?p$3wB zLqX~cpkGH-#U*q@!_&nxoAL1=bMsX=SmEH{86e1NSsq~RFWae87QpN63|W~tVz#CJ zXEbaw?Tq*U_Z1w<%hb6Z^6^;UcfL3;{N4O$d+%~o;drMmf`AQM zB^$|}8`V!z?*RaoT{)wUcHW=UsV}+A{qKK&tWD8d2@CVGhYIvemh7ok9CA}m%jm|Z zJ(aPxk!J`LI=$k2iP&Ek>irf5u8aBd-239(`*hihE*B>M*1887AW!{K(b<0dkHEf; z0^9HgQ=a?A2PzpU4Hku> zr>1aUoNI$K=hnw8_g2Egi21pgEHXVVw$$1xvIuffJ7YW(b-#Dx8ze+8y17*ha0kIR z{$TpoWmT;-`K_j1Fowl13*!F;&zlk=V*HJr7q24w-F`=jghe{ee^>tzUzpf*gZ|V1 zB$944?b(A5KP9dga+vA=g{dWTJPcksHZ`M&Y|pbO$;=gLwLeEgdKW`NiqE?9<*^fK zNRLGl#i#w^x=AkX2FvGn&5y8827<)Cr#dYA;Y_Hqp#Q2Tb6R_u9pE? z%V1hM+U~Nt%a{;muCq|gUGKNb7>eJJ`)4^CtYy%M%kX`0F^p=i453uA_S5O_b4_x! z)pb`WFX7vgRkfjyZM9u@xwP=hx7jGM27q5DUZZUDzn0tS!f{YTUVr`E1lf~GxNo=a zS>fw*6?^g&_U*Ii2hTCA! zZyi$ah7BN2>vVLQPbJ@r~z-d<1z z0f7T+G6j?U2T#W;=OXyWu%b5G6rWJIRdFVy9Z;QR)tU9=V;;Q{{Jx+KO6_)y_P3EA z@CbvGC%C_s3I7Y)-&H&7(BAI2(sBDrmYW^EF%k}|xLd-rLE_qkC8@%kj7d;_j^s}t zRZI5wv6U}BHHR$>-bjZaC;i>;iTu6)Kb{B^(e7+^Y#-F+p%yC^H-_h>rgvfFNl;T$ zQ#iDhmN7Xu$IZpnpr9h?G&wm5Cy59OU&b^@Yzu*T;F}@mYA9cV^le_m4aTRZw^@#5 zwzakWp;3aH+~M})g&vHTj}PhDGdT9d&(9x6l>&b_PK9A_(v()^NtQMo31UC(T060`vxC84!xoU37%brX zh6^)u6&%{??iPb@nF5sG;DJCOIM8~1eVv(^37@I}WmQ!Z4kEw5%7_IVS9D$*Ooul| zMuw}4OKmNDp9ASjR0(#3CFYkeUm|0YNJ~qP+tmNzZb(22i|@|s1M+Q-x(fb}5wH!{ z!CE^_&BS-FYakHl=xCvJ%ZV9H+;afloObs1YHDh7va$<=HpnJAT+GZzRhpIl)_*Uq_d$jeHh$Al;cj{HTI-zhX zC`vNX+&goNCwi|%>@ztE1GJ^11B8y$V6PMn8El8X3I9mG?Z#JVEo3-rQ@?O|_o`>2ZPy_?epKX_jSVWy#UO;X-b1?%Vr^AoB#-f+Yts5)u@O{CLOJv-}CG{euI0 zd;1Lolw-TTLj`%LjcS48w%|bJml+!kS>9$*x?mRc9%mAYUM~Uv(s(JT{FEEX088YX z|Fy19wWg2Wyz$Z8x_58xEaTXG-galRFUPtUnSYGfHd*WO>h@0d_E|x%UN*n4KRro> z^RU6=N6&z(`6ichIbK6yz8fq>eB%I5bx8NanC^f7tj7W}RdBNpP{E3@)vx32%#YUwpY+MrRtr9v zb|ruP`kUM4mg{O*R0$W)E(}__|HiA||GNGjAl|u6wh}ci-KUeK2T& zxncWF_0Uso_x{hB^JO1DTlv$eD<1tbdwXO \ + $APP_INSTALL_PATH$OVERHOLT_APPLICATION_PATH/instance/settings.py + +# Try to start whatever was given as a parameter to "docker run" -command +exec "$@" + diff --git a/init-db/MyDataAccount-DBinit.sql b/init-db/MyDataAccount-DBinit.sql new file mode 100755 index 0000000..db3dfbb --- /dev/null +++ b/init-db/MyDataAccount-DBinit.sql @@ -0,0 +1,405 @@ +-- MySQL Script generated by MySQL Workbench +-- 11/17/16 16:42:54 +-- Model: New Model Version: 1.0 +-- MySQL Workbench Forward Engineering + +SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; +SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL'; + +-- ----------------------------------------------------- +-- Schema MyDataAccount +-- ----------------------------------------------------- + +-- ----------------------------------------------------- +-- Schema MyDataAccount +-- ----------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS `MyDataAccount` DEFAULT CHARACTER SET utf8 ; +USE `MyDataAccount` ; + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`Accounts` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`Accounts` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Accounts` ( + `id` INT NOT NULL AUTO_INCREMENT, + `globalIdentifier` VARCHAR(255) NOT NULL, + `activated` TINYINT(1) NOT NULL DEFAULT 0, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE INDEX `globalIdenttifyer_UNIQUE` (`globalIdentifier` ASC)) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`Particulars` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`Particulars` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Particulars` ( + `id` INT NOT NULL AUTO_INCREMENT, + `firstname` VARCHAR(255) NOT NULL, + `lastname` VARCHAR(255) NOT NULL, + `dateOfBirth` DATE NULL DEFAULT NULL, + `img_url` VARCHAR(255) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `Accounts_id` INT NOT NULL, + PRIMARY KEY (`id`), + INDEX `fk_Particulars_Accounts1_idx` (`Accounts_id` ASC), + CONSTRAINT `fk_Particulars_Accounts1` + FOREIGN KEY (`Accounts_id`) + REFERENCES `MyDataAccount`.`Accounts` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`ServiceLinkRecords` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`ServiceLinkRecords` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`ServiceLinkRecords` ( + `id` INT NOT NULL AUTO_INCREMENT, + `serviceLinkRecord` BLOB NOT NULL, + `Accounts_id` INT NOT NULL, + `serviceLinkRecordId` VARCHAR(255) NOT NULL, + `serviceId` VARCHAR(255) NOT NULL, + `surrogateId` VARCHAR(255) NOT NULL, + `operatorId` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `fk_ServiceLinkRecords_Accounts1_idx` (`Accounts_id` ASC), + UNIQUE INDEX `serviceLinkRecordId_UNIQUE` (`serviceLinkRecordId` ASC), + CONSTRAINT `fk_ServiceLinkRecords_Accounts1` + FOREIGN KEY (`Accounts_id`) + REFERENCES `MyDataAccount`.`Accounts` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`ConsentRecords` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`ConsentRecords` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`ConsentRecords` ( + `id` INT NOT NULL AUTO_INCREMENT, + `consentRecord` BLOB NOT NULL, + `ServiceLinkRecords_id` INT NOT NULL, + `surrogateId` VARCHAR(255) NOT NULL, + `consentRecordId` VARCHAR(255) NOT NULL, + `ResourceSetId` VARCHAR(255) NOT NULL, + `serviceLinkRecordId` VARCHAR(255) NOT NULL, + `subjectId` VARCHAR(255) NOT NULL, + `role` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `fk_ConsentRecords_ServiceLinkRecords1_idx` (`ServiceLinkRecords_id` ASC), + UNIQUE INDEX `consentRecordId_UNIQUE` (`consentRecordId` ASC), + CONSTRAINT `fk_ConsentRecords_ServiceLinkRecords1` + FOREIGN KEY (`ServiceLinkRecords_id`) + REFERENCES `MyDataAccount`.`ServiceLinkRecords` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`LocalIdentityPWDs` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`LocalIdentityPWDs` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`LocalIdentityPWDs` ( + `id` INT NOT NULL AUTO_INCREMENT, + `password` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`)) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`LocalIdentities` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`LocalIdentities` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`LocalIdentities` ( + `id` INT NOT NULL AUTO_INCREMENT, + `username` VARCHAR(255) NOT NULL, + `LocalIdentityPWDs_id` INT NOT NULL, + `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE INDEX `username_UNIQUE` (`username` ASC), + INDEX `fk_LocalIdentities_LocalIdentityPWDs1_idx` (`LocalIdentityPWDs_id` ASC), + INDEX `fk_LocalIdentities_Accounts1_idx` (`Accounts_id` ASC), + CONSTRAINT `fk_LocalIdentities_LocalIdentityPWDs1` + FOREIGN KEY (`LocalIdentityPWDs_id`) + REFERENCES `MyDataAccount`.`LocalIdentityPWDs` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION, + CONSTRAINT `fk_LocalIdentities_Accounts1` + FOREIGN KEY (`Accounts_id`) + REFERENCES `MyDataAccount`.`Accounts` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`RemoteIdentityProviders` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`RemoteIdentityProviders` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`RemoteIdentityProviders` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`)) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`RemoteIdentities` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`RemoteIdentities` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`RemoteIdentities` ( + `id` INT NOT NULL AUTO_INCREMENT, + `remoteUniqueId` VARCHAR(255) NOT NULL, + `Accounts_id` INT NOT NULL, + `RemoteIdentityProviders_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE INDEX `opdenIdIdentifyer_UNIQUE` (`remoteUniqueId` ASC), + INDEX `fk_RemoteIdentities_Accounts1_idx` (`Accounts_id` ASC), + INDEX `fk_RemoteIdentities_RemoteIdentityProviders1_idx` (`RemoteIdentityProviders_id` ASC), + CONSTRAINT `fk_RemoteIdentities_Accounts1` + FOREIGN KEY (`Accounts_id`) + REFERENCES `MyDataAccount`.`Accounts` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION, + CONSTRAINT `fk_RemoteIdentities_RemoteIdentityProviders1` + FOREIGN KEY (`RemoteIdentityProviders_id`) + REFERENCES `MyDataAccount`.`RemoteIdentityProviders` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`Salts` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`Salts` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Salts` ( + `id` INT NOT NULL AUTO_INCREMENT, + `salt` VARCHAR(255) NOT NULL, + `LocalIdentities_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE INDEX `hash_UNIQUE` (`salt` ASC), + INDEX `fk_Salts_LocalIdentities1_idx` (`LocalIdentities_id` ASC), + CONSTRAINT `fk_Salts_LocalIdentities1` + FOREIGN KEY (`LocalIdentities_id`) + REFERENCES `MyDataAccount`.`LocalIdentities` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`Settings` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`Settings` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Settings` ( + `id` INT NOT NULL AUTO_INCREMENT, + `setting_key` VARCHAR(255) NOT NULL, + `setting_value` VARCHAR(255) NOT NULL, + `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `fk_Settings_Accounts1_idx` (`Accounts_id` ASC), + CONSTRAINT `fk_Settings_Accounts1` + FOREIGN KEY (`Accounts_id`) + REFERENCES `MyDataAccount`.`Accounts` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`Contacts` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`Contacts` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Contacts` ( + `id` INT NOT NULL AUTO_INCREMENT, + `address1` VARCHAR(255) NULL DEFAULT NULL, + `address2` VARCHAR(255) NULL, + `postalCode` VARCHAR(255) NULL DEFAULT NULL, + `city` VARCHAR(255) NULL DEFAULT NULL, + `state` VARCHAR(255) NULL DEFAULT NULL, + `country` VARCHAR(255) NULL DEFAULT NULL, + `entryType` VARCHAR(255) NOT NULL, + `prime` TINYINT(1) NOT NULL DEFAULT 0, + `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `fk_Contacts_Accounts1_idx` (`Accounts_id` ASC), + CONSTRAINT `fk_Contacts_Accounts1` + FOREIGN KEY (`Accounts_id`) + REFERENCES `MyDataAccount`.`Accounts` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`ConsentStatusRecords` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`ConsentStatusRecords` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`ConsentStatusRecords` ( + `id` INT NOT NULL AUTO_INCREMENT, + `consentStatusRecordId` VARCHAR(255) NOT NULL, + `consentStatus` VARCHAR(255) NOT NULL, + `consentStatusRecord` BLOB NOT NULL, + `ConsentRecords_id` INT NOT NULL, + `consentRecordId` VARCHAR(255) NOT NULL, + `issued_at` BIGINT NOT NULL, + `prevRecordId` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `fk_ConsentStatusRecords_ConsentRecords1_idx` (`ConsentRecords_id` ASC), + UNIQUE INDEX `consentStatusRecordId_UNIQUE` (`consentStatusRecordId` ASC), + CONSTRAINT `fk_ConsentStatusRecords_ConsentRecords1` + FOREIGN KEY (`ConsentRecords_id`) + REFERENCES `MyDataAccount`.`ConsentRecords` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`ServiceLinkStatusRecords` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`ServiceLinkStatusRecords` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`ServiceLinkStatusRecords` ( + `id` INT NOT NULL AUTO_INCREMENT, + `serviceLinkStatus` VARCHAR(255) NOT NULL, + `serviceLinkStatusRecord` BLOB NOT NULL, + `ServiceLinkRecords_id` INT NOT NULL, + `serviceLinkRecordId` VARCHAR(255) NOT NULL, + `issued_at` BIGINT NOT NULL, + `prevRecordId` VARCHAR(255) NOT NULL, + `serviceLinkStatusRecordId` VARCHAR(255) NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `fk_ServiceLinkStatusRecords_ServiceLinkRecords1_idx` (`ServiceLinkRecords_id` ASC), + UNIQUE INDEX `serviceLinkStatusRecordId_UNIQUE` (`serviceLinkStatusRecordId` ASC), + CONSTRAINT `fk_ServiceLinkStatusRecords_ServiceLinkRecords1` + FOREIGN KEY (`ServiceLinkRecords_id`) + REFERENCES `MyDataAccount`.`ServiceLinkRecords` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`EventLogs` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`EventLogs` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`EventLogs` ( + `id` INT NOT NULL AUTO_INCREMENT, + `actor` VARCHAR(255) NOT NULL, + `event` BLOB NOT NULL, + `created` BIGINT NOT NULL, + `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `fk_EventLogs_Accounts1_idx` (`Accounts_id` ASC), + CONSTRAINT `fk_EventLogs_Accounts1` + FOREIGN KEY (`Accounts_id`) + REFERENCES `MyDataAccount`.`Accounts` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`Emails` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`Emails` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Emails` ( + `id` INT NOT NULL AUTO_INCREMENT, + `email` VARCHAR(255) NULL DEFAULT NULL, + `entryType` VARCHAR(255) NOT NULL, + `prime` TINYINT(1) NOT NULL DEFAULT 0, + `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `fk_Emails_Accounts1_idx` (`Accounts_id` ASC), + CONSTRAINT `fk_Emails_Accounts1` + FOREIGN KEY (`Accounts_id`) + REFERENCES `MyDataAccount`.`Accounts` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`Telephones` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`Telephones` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`Telephones` ( + `id` INT NOT NULL AUTO_INCREMENT, + `tel` VARCHAR(255) NULL DEFAULT NULL, + `entryType` VARCHAR(255) NOT NULL, + `prime` TINYINT(1) NOT NULL DEFAULT 0, + `Accounts_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `fk_Telephones_Accounts1_idx` (`Accounts_id` ASC), + CONSTRAINT `fk_Telephones_Accounts1` + FOREIGN KEY (`Accounts_id`) + REFERENCES `MyDataAccount`.`Accounts` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +-- ----------------------------------------------------- +-- Table `MyDataAccount`.`OneTimeCookies` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `MyDataAccount`.`OneTimeCookies` ; + +CREATE TABLE IF NOT EXISTS `MyDataAccount`.`OneTimeCookies` ( + `id` INT NOT NULL AUTO_INCREMENT, + `oneTimeCookie` VARCHAR(255) NOT NULL, + `used` TINYINT(1) NOT NULL DEFAULT 0, + `created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `LocalIdentities_id` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + UNIQUE INDEX `oneTimeCookie_UNIQUE` (`oneTimeCookie` ASC), + PRIMARY KEY (`id`), + INDEX `fk_OneTimeCookies_LocalIdentities1_idx` (`LocalIdentities_id` ASC), + CONSTRAINT `fk_OneTimeCookies_LocalIdentities1` + FOREIGN KEY (`LocalIdentities_id`) + REFERENCES `MyDataAccount`.`LocalIdentities` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION) +ENGINE = InnoDB; + + +SET SQL_MODE=@OLD_SQL_MODE; +SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; +SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; diff --git a/init-db/MyDataAccount-UserInit.sql b/init-db/MyDataAccount-UserInit.sql new file mode 100644 index 0000000..042a923 --- /dev/null +++ b/init-db/MyDataAccount-UserInit.sql @@ -0,0 +1,11 @@ +-- MySQL Script +-- 09/14/16 10:31:08 + +REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'mydataaccount'@'%'; +DROP USER 'mydataaccount'@'%'; +DELETE FROM mysql.user WHERE user='mydataaccount'; +FLUSH PRIVILEGES; + +CREATE USER 'mydataaccount'@'%' IDENTIFIED BY 'wr8gabrA'; +GRANT CREATE TEMPORARY TABLES, DELETE, DROP, INSERT, LOCK TABLES, SELECT, UPDATE ON MyDataAccount.* TO 'mydataaccount'@'%'; +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/init-db/Operator_Components-DBinit.sql b/init-db/Operator_Components-DBinit.sql new file mode 100644 index 0000000..2025b6f --- /dev/null +++ b/init-db/Operator_Components-DBinit.sql @@ -0,0 +1,83 @@ +-- MySQL Script generated by MySQL Workbench +-- to 15. syyskuuta 2016 15.32.11 +-- Model: New Model Version: 1.0 +-- MySQL Workbench Forward Engineering + +SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; +SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL,ALLOW_INVALID_DATES'; + +-- ----------------------------------------------------- +-- Schema mydb +-- ----------------------------------------------------- +-- ----------------------------------------------------- +-- Schema db_Operator +-- ----------------------------------------------------- + +-- ----------------------------------------------------- +-- Schema db_Operator +-- ----------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS `db_Operator` DEFAULT CHARACTER SET utf8 ; +USE `db_Operator` ; + +-- ----------------------------------------------------- +-- Table `db_Operator`.`cr_tbl` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Operator`.`cr_tbl` ; + +CREATE TABLE IF NOT EXISTS `db_Operator`.`cr_tbl` ( + `rs_id` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + PRIMARY KEY (`rs_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Operator`.`rs_id_tbl` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Operator`.`rs_id_tbl` ; + +CREATE TABLE IF NOT EXISTS `db_Operator`.`rs_id_tbl` ( + `rs_id` LONGTEXT NOT NULL, + `used` TINYINT(1) NOT NULL, + PRIMARY KEY (`rs_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Operator`.`session_store` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Operator`.`session_store` ; + +CREATE TABLE IF NOT EXISTS `db_Operator`.`session_store` ( + `code` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + PRIMARY KEY (`code`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + +-- ----------------------------------------------------- +-- Table `db_Operator`.`keys_tbl` TODO: Check this, used to have kid as PK but would cause fails since service gives same key for all surrogates atm. +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Operator`.`service_keys_tbl` ; + +CREATE TABLE IF NOT EXISTS `db_Operator`.`service_keys_tbl` ( + `kid` LONGTEXT NOT NULL, + `surrogate_id` LONGTEXT NOT NULL, + `key_json` LONGTEXT NOT NULL, + PRIMARY KEY (`surrogate_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + + + +SET SQL_MODE=@OLD_SQL_MODE; +SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; +SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; + +CREATE USER 'operator'@'%' IDENTIFIED BY 'MynorcA'; +GRANT CREATE TEMPORARY TABLES, DELETE, DROP, INSERT, LOCK TABLES, SELECT, UPDATE ON db_Operator.* TO 'operator'@'%'; +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/init-db/Service_Components-DBinit.sql b/init-db/Service_Components-DBinit.sql new file mode 100644 index 0000000..ba9719e --- /dev/null +++ b/init-db/Service_Components-DBinit.sql @@ -0,0 +1,103 @@ +-- MySQL Script generated by MySQL Workbench +-- to 15. syyskuuta 2016 14.58.51 +-- Model: New Model Version: 1.0 +-- MySQL Workbench Forward Engineering + +SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; +SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE=''; + +-- ----------------------------------------------------- +-- Schema mydb +-- ----------------------------------------------------- +-- ----------------------------------------------------- +-- Schema db_Srv +-- ----------------------------------------------------- + +-- ----------------------------------------------------- +-- Schema db_Srv +-- ----------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS `db_Srv` DEFAULT CHARACTER SET utf8 ; +USE `db_Srv` ; + +-- ----------------------------------------------------- +-- Table `db_Srv`.`codes` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`codes` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`codes` ( + `ID` LONGTEXT NOT NULL, + `code` LONGTEXT NOT NULL, + PRIMARY KEY (`ID`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Srv`.`cr_storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`cr_storage` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`cr_storage` ( + `cr_id` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + `slr_id` LONGTEXT NOT NULL, + `surrogate_id` LONGTEXT NOT NULL, + `rs_id` LONGTEXT NOT NULL, + PRIMARY KEY (`cr_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Srv`.`csr_storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`csr_storage` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`csr_storage` ( + `cr_id` VARCHAR(255) NOT NULL, + `csr_id` VARCHAR(255) NOT NULL, + `previous_record_id` VARCHAr(255) NOT NULL, + `consent_status` VARCHAR(10) NOT NULL, + `json` LONGTEXT NOT NULL, + `slr_id` LONGTEXT NOT NULL, + `surrogate_id` LONGTEXT NOT NULL, + `rs_id` LONGTEXT NOT NULL, + PRIMARY KEY (`csr_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Srv`.`storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`storage` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`storage` ( + `surrogate_id` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + PRIMARY KEY (`surrogate_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Srv`.`token_storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Srv`.`token_storage` ; + +CREATE TABLE IF NOT EXISTS `db_Srv`.`token_storage` ( + `cr_id` LONGTEXT NOT NULL, + `token` LONGTEXT NOT NULL, + PRIMARY KEY (`cr_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + + +SET SQL_MODE=@OLD_SQL_MODE; +SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; +SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; +CREATE USER 'service'@'%' IDENTIFIED BY 'MynorcA'; +GRANT CREATE TEMPORARY TABLES, DELETE, DROP, INSERT, LOCK TABLES, SELECT, UPDATE ON db_Srv.* TO 'service'@'%'; +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/init-db/Service_Mockup-DBinit.sql b/init-db/Service_Mockup-DBinit.sql new file mode 100644 index 0000000..5a61a56 --- /dev/null +++ b/init-db/Service_Mockup-DBinit.sql @@ -0,0 +1,68 @@ +-- MySQL Script generated by MySQL Workbench +-- to 15. syyskuuta 2016 15.37.01 +-- Model: New Model Version: 1.0 +-- MySQL Workbench Forward Engineering + +SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; +SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL,ALLOW_INVALID_DATES'; + +-- ----------------------------------------------------- +-- Schema mydb +-- ----------------------------------------------------- +-- ----------------------------------------------------- +-- Schema db_Service_Mockup +-- ----------------------------------------------------- + +-- ----------------------------------------------------- +-- Schema db_Service_Mockup +-- ----------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS `db_Service_Mockup` DEFAULT CHARACTER SET latin1 ; +USE `db_Service_Mockup` ; + +-- ----------------------------------------------------- +-- Table `db_Service_Mockup`.`code_and_user_mapping` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Service_Mockup`.`code_and_user_mapping` ; + +CREATE TABLE IF NOT EXISTS `db_Service_Mockup`.`code_and_user_mapping` ( + `code` LONGTEXT NOT NULL, + `user_id` LONGTEXT NOT NULL, + PRIMARY KEY (`code`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Service_Mockup`.`storage` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Service_Mockup`.`storage` ; + +CREATE TABLE IF NOT EXISTS `db_Service_Mockup`.`storage` ( + `ID` LONGTEXT NOT NULL, + `json` LONGTEXT NOT NULL, + PRIMARY KEY (`ID`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +-- ----------------------------------------------------- +-- Table `db_Service_Mockup`.`surrogate_and_user_mapping` +-- ----------------------------------------------------- +DROP TABLE IF EXISTS `db_Service_Mockup`.`surrogate_and_user_mapping` ; + +CREATE TABLE IF NOT EXISTS `db_Service_Mockup`.`surrogate_and_user_mapping` ( + `user_id` LONGTEXT NOT NULL, + `surrogate_id` LONGTEXT NOT NULL, + PRIMARY KEY (`user_id`(255))) +ENGINE = InnoDB +DEFAULT CHARACTER SET = utf8; + + +SET SQL_MODE=@OLD_SQL_MODE; +SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; +SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; + +CREATE USER 'serviceMockup'@'%' IDENTIFIED BY 'MynorcA'; +GRANT CREATE TEMPORARY TABLES, DELETE, DROP, INSERT, LOCK TABLES, SELECT, UPDATE ON db_Service_Mockup.* TO 'serviceMockup'@'%'; +FLUSH PRIVILEGES; diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..5200283 --- /dev/null +++ b/start.sh @@ -0,0 +1,13 @@ +mkdir -p ./init-db +cp ./Account/doc/database/MyDataAccount-DBinit.sql ./init-db/ +cp ./Account/doc/database/MyDataAccount-UserInit.sql ./init-db/ +cp ./Operator_Components/doc/database/Operator_Components-DBinit.sql ./init-db/ +cp ./Service_Components/doc/database/Service_Components-DBinit.sql ./init-db/ +cp ./Service_Mockup/doc/database/Service_Mockup-DBinit.sql ./init-db/ + +docker-compose rm --force mysql-db # Clean db +docker volume rm mydatasdkbleedingedge_mysql-data # Clean db +reset # Reset terminal +docker-compose down -v --remove-orphans # Clean out trash. +docker-compose up --build # Put the thing up and running + diff --git a/ui_flow.py b/ui_flow.py index fdf133c..812b276 100755 --- a/ui_flow.py +++ b/ui_flow.py @@ -1,58 +1,123 @@ # -*- coding: utf-8 -*- + import json import argparse from requests import get, post +from uuid import uuid4 # TODO: Maybe these should be given as parameters -Service_ID_A = 10 -Service_ID_B = 100 - +#Service_ID_Source = "57f3a57b0cf2fcf22eea33a2" # MyLocation +#Service_ID_Sink = "57f3a57b0cf2fcf22eea33a3" # PHR +#Service_ID_Source = "582b7df00cf2727145535753" # MyLocation +#Service_ID_Sink = "582b7df00cf2727145535754" # PHR +Service_ID_Source = "582f2bf50cf2f4663ec4f01f" # MyLocation +Service_ID_Sink = "582f2bf50cf2f4663ec4f020" # PHR # TODO: Add more printing. Now user barely knows if initialization happened and did it succeed or not. # Sends JSON-payloads to Account that create three new accounts. # Needed in order to start_ui_flow() -function to work. -def initialize(operator_url): +def initialize(account_url): + username = "example_username-" + str(uuid4()) + password = "example_password" + print ("\n##### CREATE USER ACCOUNTS #####") - print("NOTE: Throws an error if run for second time as you cannot " \ - "create more accounts with same unique usernames. " \ + print("NOTE: Throws an error if run for second time as you cannot " + "create more accounts with same unique usernames. " "(Will be fixed in later releases.)\n\n" ) - resp = post(operator_url + 'api/accounts/', - json={"firstName": "Erkki", "lastName": "Esimerkki", "dateOfBirth": "31-05-2016", - "email": "erkki.esimerkki@examlpe.org", "username": "testUffser", "password": "Hello", - "acceptTermsOfService": "True"}) + user_data = {"data": { + "type": "Account", + "attributes": { + 'firstName': 'ExampleFirstName', + 'lastName': 'ExampleLastName', + 'dateOfBirth': '2010-05-14', + 'email': username + '@examlpe.org', + 'username': username, + 'password': password, + 'acceptTermsOfService': 'True' + } + } + } + resp = post(account_url + 'api/accounts/', + json=user_data) print(resp.status_code, resp.reason, resp.text, resp.url) print(json.dumps(json.loads(resp.text), indent=2)) - post(operator_url + 'api/accounts/', - json={"firstName": "Iso", "lastName": "Pasi", "dateOfBirth": "31-05-2016", "email": "iso.pasi@examlpe.org", - "username": "pasi", "password": "0nk0va", "acceptTermsOfService": "True"}) - post(operator_url + 'api/accounts/', json={"firstName": "Dude", "lastName": "Dudeson", "dateOfBirth": "31-05-2016", - "email": "dude.dudeson@examlpe.org", "username": "mydata", - "password": "Hello", "acceptTermsOfService": "True"}) - return + user_data["data"]["attributes"]["firstName"] = "Iso" + user_data["data"]["attributes"]["lastName"] = "Pasi" + user_data["data"]["attributes"]["email"] = "iso.pasi@example.org" + user_data["data"]["attributes"]["username"] = "pasi" + user_data["data"]["attributes"]["password"] = "0nk0va" + resp = post(account_url + 'api/accounts/', + json=user_data) + print(resp.status_code, resp.reason, resp.text, resp.url) + print(json.dumps(json.loads(resp.text), indent=2)) + + user_data["data"]["attributes"]["firstName"] = "Dude" + user_data["data"]["attributes"]["lastName"] = "Dudeson" + user_data["data"]["attributes"]["email"] = "dude.dudeson@example.org" + user_data["data"]["attributes"]["username"] = "mydata" + user_data["data"]["attributes"]["password"] = "Hello" + resp = post(account_url + 'api/accounts/', + json=user_data) + print(resp.status_code, resp.reason, resp.text, resp.url) + print(json.dumps(json.loads(resp.text), indent=2)) + # post(account_url + 'api/accounts/', + # json={"firstName": "Iso", "lastName": "Pasi", "dateOfBirth": "31-05-2016", "email": "iso.pasi@examlpe.org", + # "username": "pasi", "password": "0nk0va", "acceptTermsOfService": "True"}) + # post(operator_url + 'api/accounts/', json={"firstName": "Dude", "lastName": "Dudeson", "dateOfBirth": "31-05-2016", + # "email": "dude.dudeson@examlpe.org", "username": "mydata", + # "password": "Hello", "acceptTermsOfService": "True"}) + return # TODO: Refactor and return something. -# First creates two Service Links by making a GET-request to Operator backend. -# Then gives a Consent for these Services by sending a Consent form as JSON-payload to Operator backend. -# Should print "201 Created" if the flow was excuted succesfully. -def start_ui_flow(operator_url): - print("\n##### MAKE TWO SERVICE LINKS #####") - slr_flow1 = get(operator_url + "api/1.2/slr/account/2/service/1") - print(slr_flow1.url, slr_flow1.reason, slr_flow1.status_code, slr_flow1.text) - slr_flow2 = get(operator_url + "api/1.2/slr/account/2/service/2") - print(slr_flow2.url, slr_flow2.reason, slr_flow2.status_code, slr_flow2.text) +# Creates two Service Links by making a GET-request to Operator backend. +def create_service_link(operator_url, service_id): + print("\n##### CREATE A SERVICE LINK #####") + slr_flow = get(operator_url + "api/1.2/slr/account/2/service/"+service_id) + if not slr_flow.ok: + print("Creation of first SLR failed with status ({}) reason ({}) and the following content:\n{}".format( + slr_flow.status_code, + slr_flow.reason, + json.dumps(json.loads(slr_flow.content), indent=2) + )) + raise Exception("SLR flow failed.") + print(slr_flow.url, slr_flow.reason, slr_flow.status_code, slr_flow.text) - # This format needs to be specified, even if done with url params instead. - ids = {"sink": Service_ID_B, "source": Service_ID_A} + return + + +# TODO: Refactor and return something. +# Gives a Consent for these Services by sending a Consent form as JSON-payload to Operator backend. +# Should print "201 Created" if the Consent was executed succesfully. +def give_consent(operator_url, sink_id, source_id): print("\n##### GIVE CONSENT #####") - req = get(operator_url + "api/1.2/cr/consent_form/account/2?sink={}&source={}".format(Service_ID_B, Service_ID_A)) + # This format needs to be specified, even if done with url params instead. + ids = {"sink": sink_id, "source": source_id} + + print("\n###### 1.FETCH CONSENT FORM ######") + req = get(operator_url + "api/1.2/cr/consent_form/account/2?sink={}&source={}".format(sink_id, source_id)) + if not req.ok: + print("Fetching consent form consent failed with status ({}) reason ({}) and the following content:\n{}".format( + req.status_code, + req.reason, + json.dumps(json.loads(req.content), indent=2) + )) + raise Exception("Consent flow failed.") + + print("\n###### 2.SEND CONSENT FORM ######") print(req.url, req.reason, req.status_code, req.text) js = json.loads(req.text) - req = post(operator_url + "api/1.2/cr/consent_form/account/2", json=js) + if not req.ok: + print("Granting consent failed with status ({}) reason ({}) and the following content:\n{}".format( + req.status_code, + req.reason, + json.dumps(json.loads(req.content), indent=2) + )) + raise Exception("Consent flow failed.") print(req.url, req.reason, req.status_code) print("\n") @@ -67,7 +132,7 @@ def start_ui_flow(operator_url): # Parse command line arguments parser = argparse.ArgumentParser() - # TODO: Use boolean value instead of int. + # Urls help_string_account_url = \ "URL to Account. Defaults to 'http://localhost:8080'. \ NOTE: Throws an error if run for second time as you cannot\ @@ -87,8 +152,66 @@ def start_ui_flow(operator_url): default="http://localhost:5000/", required=False) + # Skips + help_string_skip_init = \ + "Should account init be skipped. Init is done by default. Specify this flag to skip init." + parser.add_argument("--skip_init", + help=help_string_skip_init, + action="store_true", + required=False) + + help_string_skip_slr = \ + "Should account init be skipped. Init is done by default. Specify this flag to skip init." + parser.add_argument("--skip_slr", + help=help_string_skip_slr, + action="store_true", + required=False) + + # IDs + help_string_sink_id = \ + "ID of the Sink. \ + Check that this matches to what is specified in Service Registry. \ + Defaults to '{}'.".format(Service_ID_Sink) + parser.add_argument("--sink_id", + help=help_string_sink_id, + type=str, + default=Service_ID_Sink, + required=False) + + help_string_source_id = \ + "ID of the Source. \ + Check that this matches to what is specified in Service Registry. \ + Defaults to '{}'.".format(Service_ID_Source) + parser.add_argument("--source_id", + help=help_string_source_id, + type=str, + default=Service_ID_Source, + required=False) + +# exclusive_grp = parser.add_mutually_exclusive_group() +# exclusive_grp.add_argument('--skip_init', action='store_true', dest='foo', help='skip init') +# exclusive_grp.add_argument('--no-foo', action='store_false', dest='foo', help='do not do foo') + args = parser.parse_args() - initialize(args.account_url) +# print 'Starting program', 'with' if args.foo else 'without', 'foo' +# print 'Starting program', 'with' if args.no_foo else 'without', 'no_foo' + + # Just for user to see the given input + print(args.account_url) + print(args.operator_url) + print(args.skip_init) + print(args.sink_id) + print(args.source_id) + + if not args.skip_init: + # Do not skip init + initialize(args.account_url) + + # SLR + if not args.skip_slr: + create_service_link(args.operator_url, args.sink_id) + create_service_link(args.operator_url, args.source_id) - start_ui_flow(args.operator_url) + # Consent + give_consent(args.operator_url, args.sink_id, args.source_id)

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 H@1%eG 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�