diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 06f50330a..92aa8784f 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,23 +1,68 @@ +# Change Log + +All notable changes to this project will be documented in this file. + +## December 11, 2023 + +- **Task** Merge `gdx-sso`, `gdx-dev`, `gdx-main` into `main` [🎟️DESENG-442](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-442) + ## December 5, 2023 + - **Task** Remove unused project metadata [🎟️DESENG-441](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-441) +## December 4, 2023 + +- **Feature**: .env var audit and cleanup [🎟️DESENG-414](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-414) (work finished) + - Full rewrite of met_api/config.py + - Sample .env files updated to capture all current settings + - Changed many configs to use a nested dict structure + - Changed all configs to use get_named_config() to access settings + - SQLAlchemy now generates its url based on db settings + - Default settings are handled more gracefully + - Enable file-watching reloader and debugger for development environments + - Inline documentation added in config.py + - Removed unused settings + ## November 29, 2023 -- **Feature** Superusers can publish engagements without attached surveys [🎟️DESENG-438](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-438) -## November 11, 2023 +- **Feature**: Superusers can publish engagements without attached surveys [🎟️DESENG-438](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-438) + +## November 21, 2023 + - **Feature**: Started logging source url path with feedback submission. Viewable in dashboard. [🎟️DESENG-415](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-415) -- **Bug Fix**: Removed a duplicate service class. [🎟️DESENG-429](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-429) + +## November 11, 2023 + +- **Bug Fix**: Removed a duplicate service class [🎟️DESENG-429](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-429) + +## November 6, 2023 + +- **Feature**: Switch MET to use Keycloak SSO service [🎟️DESENG-408](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-408) + - Switch all role-based checks on the API to use a single callback function (`current_app.config['JWT_ROLE_CALLBACK']`) + - Added a configurable path `JWT_ROLE_CLAIM` to indicate where your SSO instance places role information in the JWT token. If your access token looks like: + `{ ..., "realm_access": { "roles": [ "role1", "role2"]}}` you would set `JWT_ROLE_CLAIM=realm_access.roles` + - Explicitly disable single tenant mode by default to ensure correct multi-tenancy behaviour + - Remove local Keycloak instances and configuration + - Default to the "standard" realm for Keycloak + - Use tenancy information from DB rather than Keycloak + +- **Feature**: .env var audit and cleanup [🎟️DESENG-414](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-414) ## October 26, 2023 -- **Bug Fix**: Upgraded BC-Sans font to newest version. [🎟️DESENG-413](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-413) -- **Bug Fix**: Engagements will now open in the same browser window/tab, not a new one. [🎟️DESENG-421](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-421) -- **Bug Fix**: Update sample .env files - [🎟️DESENG-414](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-414) -- Sample .env files have been updated to reflect the current state of the project. -- *Breaking*: Keycloak URLs and resources now point to the BC Government's SSO service when using `sample.env` as a baseline -- *Breaking*: The `met_api` module has been updated slightly to consume Pathfinder SSO's API schema. -- Changes to `DEVELOPMENT.md` to reflect the current state of the project -- Remove one old production .env file with obsolete settings - -## October 1, 2023 + +- **Bug Fix**: Upgraded BC-Sans font to the newest version [🎟️DESENG-413](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-413) + +## October 19, 2023 + +- **Feature**: Update sample .env files [🎟️DESENG-414](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-414) + - Sample .env files have been updated to reflect the current state of the project. + - Keycloak URLs and resources now point to the BC Government's SSO service when using `sample.env` as a baseline + - The `met_api` module has been updated slightly to consume Pathfinder SSO's API schema. + - Remove one old production .env file with obsolete settings +- Changes to DEVELOPMENT.md to reflect the current state of the project + + +## v1.0.0 - 2023-10-01 + - App handoff from EAO to GDX -- Added changelog \ No newline at end of file +- Added changelog diff --git a/analytics-api/src/analytics_api/config.py b/analytics-api/src/analytics_api/config.py index 8f39edb9a..115518b40 100644 --- a/analytics-api/src/analytics_api/config.py +++ b/analytics-api/src/analytics_api/config.py @@ -28,20 +28,26 @@ load_dotenv(find_dotenv()) -def get_named_config(config_name: str = 'development'): - """Return the configuration object based on the name. +def get_named_config(environment: str | None) -> '_Config': + """ + Retrieve a configuration object by name. Used by the Flask app factory. - :raise: KeyError: if an unknown configuration is requested + :param config_name: The name of the configuration. + :return: The requested configuration object. + :raises: KeyError if the requested configuration is not found. """ - if config_name in ['production', 'staging', 'default']: - config = ProdConfig() - elif config_name == 'testing': - config = TestConfig() - elif config_name == 'development': - config = DevConfig() - else: - raise KeyError("Unknown configuration '{config_name}'") - return config + config_mapping = { + 'development': DevConfig, + 'default': ProdConfig, + 'staging': ProdConfig, + 'production': ProdConfig, + 'testing': TestConfig, + } + try: + print(f'Loading configuration: {environment}...') + return config_mapping[environment]() + except KeyError: + raise KeyError(f'Configuration "{environment}" not found.') class _Config(): # pylint: disable=too-few-public-methods diff --git a/met-api/docker-compose.yml b/met-api/docker-compose.yml deleted file mode 100644 index e0f595b8f..000000000 --- a/met-api/docker-compose.yml +++ /dev/null @@ -1,65 +0,0 @@ -version: "3.9" - -services: - # keycloak: - # image: quay.io/keycloak/keycloak:12.0.2 - # ports: - # - "8081:8081" - # environment: - # - KEYCLOAK_USER=admin - # - KEYCLOAK_PASSWORD=admin - # command: -b 0.0.0.0 -Djboss.http.port=8081 -Dkeycloak.migration.action=import -Dkeycloak.migration.provider=dir -Dkeycloak.migration.dir=/tmp/keycloak/test -Dkeycloak.migration.strategy=OVERWRITE_EXISTING - # healthcheck: - # test: - # [ - # "CMD", - # "curl", - # "--fail", - # "http://localhost:8081/auth/realms/demo || exit 1", - # ] - # interval: 30s - # timeout: 10s - # retries: 10 - # volumes: - # - ./setup:/tmp/keycloak/test/ - met-db: - image: postgres - volumes: - - db-data:/var/lib/postgresql/data2 - environment: - - POSTGRES_USER=admin - - POSTGRES_PASSWORD:admin - - POSTGRES_HOST_AUTH_METHOD=trust - ports: - - 54332:5432/tcp - restart: unless-stopped - - met-db-test: - image: postgres - volumes: - - db-data:/var/lib/postgresql/data3 - environment: - - POSTGRES_USER=admin - - POSTGRES_PASSWORD:admin - - POSTGRES_HOST_AUTH_METHOD=trust - ports: - - 54333:5432/tcp - restart: unless-stopped - - met-analytics-db: - image: postgres - volumes: - - db-data:/var/lib/postgresql/data4 - environment: - - POSTGRES_USER=admin - - POSTGRES_PASSWORD:admin - - POSTGRES_HOST_AUTH_METHOD=trust - ports: - - 54334:5432/tcp - restart: unless-stopped - - -volumes: - db-data: - driver: local - \ No newline at end of file diff --git a/met-api/sample.env b/met-api/sample.env index c4379332f..15e938527 100644 --- a/met-api/sample.env +++ b/met-api/sample.env @@ -1,29 +1,55 @@ +# GDX MET API Configuration +# For more information on these values, please see the documentation +# or met-api/src/met_api/config.py + # Changes Flask's run mode and the set of env vars are used to configure the app. You should not need to change this here. FLASK_ENV=development -# Database configuration. -# See DEVELOPMENT.md for instructions on how to set up the local database. -DATABASE_HOST="localhost" -DATABASE_PORT="5432" -DATABASE_USERNAME="postgres" -DATABASE_PASSWORD="postgres" -DATABASE_NAME="met" +USE_DEBUG=True # Enable a dev-friendly debug mode +TESTING= # Handle errors normally (False) or raise exceptions (True) -# Email API endpoint -NOTIFICATIONS_EMAIL_ENDPOINT=https://met-notify-api-dev.apps.gold.devops.gov.bc.ca/api/v1/notifications/email +# CORS Settings +CORS_ORIGINS=http://localhost:3000,http://localhost:5000 + +# Miscellaneous Settings +SECRET_KEY="" # For Flask sessions. If unset, this value is randomized +SHAPEFILE_UPLOAD_FOLDER="/tmp/uploads" +SLUG_MAX_CHARACTERS=100 +# disables certain checks for user permissions and tenant access. Buggy. +IS_SINGLE_TENANT_ENVIRONMENT=false +USE_TEST_KEYCLOAK_DOCKER=false +USE_DOCKER_MOCK=false +LEGISLATIVE_TIMEZONE="America/Vancouver" +ENGAGEMENT_END_TIME="5 PM" +# Default name for the tenant. Used to initially populate the database. +DEFAULT_TENANT_SHORT_NAME="GDX" +DEFAULT_TENANT_NAME="Government Digital Experience Division" +DEFAULT_TENANT_DESCRIPTION="The Government Digital Experience (GDX) Division + is responsible for setting standards in delivering government information and + services digitally. Their work includes creating web content guides, ensuring + accessibility and inclusion, and overseeing forms management and visual design + for a better digital user experience." -# Keycloak configuration. Keycloak is now hosted, and local keycloak instances are no longer needed. -KEYCLOAK_BASE_URL=https://dev.loginproxy.gov.bc.ca/auth -KEYCLOAK_REALMNAME=standard -JWT_OIDC_AUDIENCE=modern-engagement-tools-4787 -JWT_OIDC_WELL_KNOWN_CONFIG=${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALMNAME}/.well-known/openid-configuration -JWT_OIDC_JWKS_URI=${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALMNAME}/protocol/openid-connect/certs -JWT_OIDC_ISSUER=${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALMNAME} +# Keycloak configuration. +# Populate from 'GDX Modern Engagement Tools-installation-*.json' +# https://bcgov.github.io/sso-requests +KEYCLOAK_BASE_URL="" # auth-server-url +KEYCLOAK_REALMNAME="" # realm +MET_ADMIN_CLIENT_ID="" # resource +MET_ADMIN_CLIENT_SECRET="" # credentials.secret +KEYCLOAK_CONNECT_TIMEOUT= 60 # seconds -# Authenticates the MET API with Keycloak for running tests. -# Currently unused since the hosted Keycloak instance does not support API usage. -MET_ADMIN_CLIENT_ID= -MET_ADMIN_CLIENT_SECRET= +# JWT OIDC configuration for authentication +# Populate from 'GDX MET web (public)-installation-*.json' +JWT_OIDC_AUDIENCE="" # resource +JWT_OIDC_ISSUER="" # default: constructed from base url and realm name +JWT_OIDC_WELL_KNOWN_CONFIG="" # default: constructed from issuer +JWT_OIDC_JWKS_URI="" # default: constructed from issuer +# Object path to access roles from JWT token +JWT_OIDC_ROLE_CLAIM=realm_access.roles # SSO schema +# JWT_OIDC_ROLE_CLAIM=client_roles # Keycloak schema +JWT_OIDC_CACHING_ENABLED=true # Enable caching of JWKS. +JWT_OIDC_JWKS_CACHE_TIMEOUT=300 # Timeout for JWKS cache in seconds. # S3 configuration. Used for uploading custom header images, etc. S3_ACCESS_KEY_ID= @@ -33,13 +59,83 @@ S3_REGION='us-east-1' S3_SECRET_ACCESS_KEY= S3_SERVICE='execute-api' -# EPIC integration configuration -EPIC_URL=https://eagle-dev.apps.silver.devops.gov.bc.ca/api/commentperiod -EPIC_JWT_OIDC_ISSUER=${KEYCLOAK_BASE_URL}/auth/realms/eao-epic -EPIC_KC_CLIENT_ID=eagle-admin-console -EPIC_MILESTONE=5cf00c03a266b7e1877504e9 -EPIC_KEYCLOAK_SERVICE_ACCOUNT_ID= -EPIC_KEYCLOAK_SERVICE_ACCOUNT_SECRET= +# Database Configuration +DATABASE_HOST="localhost" +DATABASE_PORT="5432" +DATABASE_USERNAME="postgres" +DATABASE_PASSWORD="postgres" +DATABASE_NAME="met" +#Default: set from above settings (this overrides them) +SQLALCHEMY_DATABASE_URI= +SQLALCHEMY_ECHO= +SQLALCHEMY_TRACK_MODIFICATIONS= + +# Email API Configuration +NOTIFICATIONS_EMAIL_ENDPOINT=https://met-notify-api-dev.apps.gold.devops.gov.bc.ca/api/v1/notifications/email +EMAIL_SECRET_KEY="notASecureKey" # If unset, this value is randomized +EMAIL_ENVIRONMENT= +EMAIL_FROM_ADDRESS="met-example@gov.bc.ca" +# Email Template Configuration +# Subject lines have a reasonable default value +SUBSCRIBE_EMAIL_TEMPLATE_ID= +SUBSCRIBE_EMAIL_SUBJECT= +REJECTED_EMAIL_TEMPLATE_ID= +REJECTED_EMAIL_SUBJECT= +VERIFICATION_EMAIL_TEMPLATE_ID= +VERIFICATION_EMAIL_SUBJECT= +SUBMISSION_RESPONSE_EMAIL_TEMPLATE_ID= +SUBMISSION_RESPONSE_EMAIL_SUBJECT= +CLOSEOUT_EMAIL_TEMPLATE_ID= +CLOSEOUT_EMAIL_SUBJECT= +ACCESS_REQUEST_EMAIL_TEMPLATE_ID= +ACCESS_REQUEST_EMAIL_SUBJECT= +ACCESS_REQUEST_EMAIL_ADDRESS="accessRequestHandler.fakeName@gov.bc.ca" + +# Site paths for creating emails from templates +SITE_URL=localhost:3000 +SURVEY_PATH=/surveys/submit/{survey_id}/{token} +USER_MANAGEMENT_PATH=/usermanagement +SUBMISSION_PATH=/engagements/{engagement_id}/edit/{token} +SUBSCRIBE_PATH=/engagements/{engagement_id}/subscribe/{token} +UNSUBSCRIBE_PATH=/engagements/{engagement_id}/unsubscribe/{participant_id} +ENGAGEMENT_PATH=/engagements/{engagement_id}/view +ENGAGEMENT_PATH_SLUG=/{slug} +ENGAGEMENT_DASHBOARD_PATH=/engagements/{engagement_id}/comments/public +ENGAGEMENT_DASHBOARD_PATH_SLUG=/{slug}/comments/public + +#CDogs API settings +CDOGS_ACCESS_TOKEN= +CDOGS_BASE_URL= +CDOGS_SERVICE_CLIENT= +CDOGS_SERVICE_CLIENT_SECRET= +CDOGS_TOKEN_URL= + +JWT_OIDC_TEST_AUDIENCE= +JWT_OIDC_TEST_CLIENT_SECRET= +JWT_OIDC_TEST_ISSUER= +JWT_OIDC_TEST_ALGORITHMS= + +# Test database settings +# If unset, uses the same settings as the main database +DATABASE_TEST_USERNAME= +DATABASE_TEST_PASSWORD= +DATABASE_TEST_NAME= +DATABASE_TEST_HOST= +DATABASE_TEST_PORT= + +# Docker database settings +# If unset, uses the same settings as the main database +DATABASE_DOCKER_USERNAME= +DATABASE_DOCKER_PASSWORD= +DATABASE_DOCKER_NAME= +DATABASE_DOCKER_HOST= +DATABASE_DOCKER_PORT= -# Allowed CORS origins -CORS_ORIGIN=http://localhost:3000,http://localhost:5000 \ No newline at end of file +# EPIC Integration Configuration +EPIC_INTEGRATION_ENABLED=false +EPIC_URL= +EPIC_JWT_OIDC_ISSUER= +EPIC_KC_CLIENT_ID= +EPIC_MILESTONE= +EPIC_KEYCLOAK_SERVICE_ACCOUNT_ID= +EPIC_KEYCLOAK_SERVICE_ACCOUNT_SECRET= \ No newline at end of file diff --git a/met-api/src/met_api/__init__.py b/met-api/src/met_api/__init__.py index 2694f90ed..d672b4bc2 100644 --- a/met-api/src/met_api/__init__.py +++ b/met-api/src/met_api/__init__.py @@ -15,7 +15,6 @@ from met_api.models.tenant import Tenant as TenantModel from met_api.utils import constants from met_api.utils.cache import cache -from met_api.utils.util import allowedorigins # Security Response headers csp = ( @@ -48,10 +47,10 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'development')): # Flask app initialize app = Flask(__name__) - # All configuration are in config file + # Configure app from config.py app.config.from_object(get_named_config(run_mode)) - CORS(app, origins=allowedorigins(), supports_credentials=True) + CORS(app, origins=app.config['CORS_ORIGINS'], supports_credentials=True) # Register blueprints app.register_blueprint(API_BLUEPRINT) @@ -128,8 +127,15 @@ def build_cache(app): def setup_jwt_manager(app_context, jwt_manager): """Use flask app to configure the JWTManager to work for a particular Realm.""" - def get_roles(a_dict): - return a_dict['realm_access']['roles'] # pragma: no cover - + def get_roles(token_info): + """ + Consumes a token_info dictionary and returns a list of roles. + Uses a configurable path to the roles in the token_info dictionary. + """ + role_access_path = app_context.config['JWT_CONFIG']['ROLE_CLAIM'] + for key in role_access_path.split('.'): + token_info = token_info.get(key, {}) + return token_info + app_context.config['JWT_ROLE_CALLBACK'] = get_roles jwt_manager.init_app(app_context) diff --git a/met-api/src/met_api/config.py b/met-api/src/met_api/config.py index b1ed7a0db..1c1d26e7d 100644 --- a/met-api/src/met_api/config.py +++ b/met-api/src/met_api/config.py @@ -11,345 +11,358 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""All of the configuration for the service is captured here. -All items are loaded, -or have Constants defined here that are loaded into the Flask configuration. -All modules and lookups get their configuration from the Flask config, -rather than reading environment variables directly or by accessing this configuration directly. +""" +All the configuration for MET's API. + +Wherever possible, the configuration is loaded from the environment. The aim is +to have this be the "single source of truth" for configuration in the API, +wherever feasible. If you are adding a setting or config option that cannot be +configured in a user-facing GUI, please make sure it loads its value from here, +and create an entry for it in the sample .env file. """ import os -import sys from dotenv import find_dotenv, load_dotenv -from flask import g -# this will load all the envars from a .env file located in the project root (api) -load_dotenv(find_dotenv()) +from met_api.utils.constants import TestKeyConfig +from met_api.utils.util import is_truthy +# Search in increasingly higher folders for a .env file, then load it, +# appending any variables we find to the current environment. +load_dotenv(find_dotenv()) +# remove all env variables with no text (allows for entries to be unset easily) +os.environ = {k: v for k, v in os.environ.items() if v} -def get_named_config(config_name: str = 'development'): - """Return the configuration object based on the name. - :raise: KeyError: if an unknown configuration is requested - """ - if config_name in ['production', 'staging', 'default']: - config = ProdConfig() - elif config_name == 'testing': - config = TestConfig() - elif config_name == 'development': - config = DevConfig() - elif config_name == 'docker': - config = DockerConfig() - else: - raise KeyError("Unknown configuration '{config_name}'") - return config - - -def get_s3_config(key: str): - """Return the s3 configuration object based on the tenant short name. - - :raise: KeyError: if an unknown configuration is requested +def get_named_config(environment: str | None) -> 'Config': """ - tenant_short_name = g.get('tenant_name', None) - if not tenant_short_name: - return _Config.S3_CONFIG['DEFAULT'][key] - - tenant_short_name = tenant_short_name.upper() - - if tenant_short_name in _Config.S3_CONFIG: - return _Config.S3_CONFIG[tenant_short_name][key] - - config_key = f'{tenant_short_name}_{key}' - config_value = os.getenv(config_key) - - if not config_value: - return _Config.S3_CONFIG['DEFAULT'][key] - - return config_value - + Retrieve a configuration object by name. Used by the Flask app factory. -def get_gc_notify_config(key: str): - """Return the gc notify configuration object based on the tenant short name. - - :raise: KeyError: if an unknown configuration is requested + :param config_name: The name of the configuration. + :return: The requested configuration object. + :raises: KeyError if the requested configuration is not found. """ - tenant_short_name = g.get('tenant_name', None) - if not tenant_short_name: - return _Config.GC_NOTIFY_CONFIG['DEFAULT'][key] + config_mapping = { + 'development': DevConfig, + 'default': ProdConfig, + 'staging': ProdConfig, + 'production': ProdConfig, + 'testing': TestConfig, + 'docker': DockerConfig, + } + try: + print(f'Loading configuration: {environment}...') + return config_mapping[environment]() + except KeyError: + raise KeyError(f'Configuration "{environment}" not found.') - tenant_short_name = tenant_short_name.upper() +def env_truthy(env_var, default: bool = False): + """ + Return True if the environment variable is set to a truthy value. + Accepts a default value, which is returned if the environment variable is + not set. + """ + return is_truthy(os.getenv(env_var, str(default))) - if tenant_short_name in _Config.GC_NOTIFY_CONFIG: - return _Config.GC_NOTIFY_CONFIG[tenant_short_name][key] +class Config: # pylint: disable=too-few-public-methods + """ + Base configuration that sets reasonable defaults for all other configs. + New configurations should inherit from this one where possible. + Reference: https://flask.palletsprojects.com/en/3.0.x/config/ + """ - config_key = f'{tenant_short_name}_{key}' - config_value = os.getenv(config_key) + def __init__(self) -> None: + """ + Initializes the configuration object. Performs more advanced + configuration logic that is not possible in the normal class definition. + """ + # If extending this class, call super().__init__() in your constructor. + print(f'SQLAlchemy URL: {self.SQLALCHEMY_DATABASE_URI}') + + # apply configs to _Config in the format that flask_jwt_oidc expects + # this flattens the JWT_CONFIG dict into individual attributes + for key, value in self.JWT_CONFIG.items(): + setattr(self, f'JWT_OIDC_{key}', value) + + # Enable live reload and interactive API debugger for developers + os.environ["FLASK_DEBUG"] = str(self.USE_DEBUG) + + @property + def SQLALCHEMY_DATABASE_URI(self) -> str: + """ + Dynamically fetch the SQLAlchemy Database URI based on the DB config. + This avoids having to redefine the URI after setting the DB access + credentials in subclasses. Can be overridden by env variables.""" + return os.environ.get( + 'SQLALCHEMY_DATABASE_URI', + f'postgresql://' + f'{self.DB_CONFIG.get("USER")}:{self.DB_CONFIG.get("PASSWORD")}@' + f'{self.DB_CONFIG.get("HOST")}:{self.DB_CONFIG.get("PORT")}/' + f'{self.DB_CONFIG.get("NAME")}' + ) + + + # If enabled, Exceptions are propagated up, instead of being handled + # by the the app’s error handlers. Enable this for tests. + TESTING = env_truthy('FLASK_TESTING', default=False) + + # If enabled, the interactive debugger will be shown for any + # unhandled Exceptions, and the server will be reloaded when code changes. + USE_DEBUG = env_truthy('FLASK_DEBUG', default=False) + + # SQLAlchemy settings + # Echoes the SQL queries generated - useful for debugging + SQLALCHEMY_ECHO = env_truthy('SQLALCHEMY_ECHO') + # Disable modification tracking for performance + SQLALCHEMY_TRACK_MODIFICATIONS = env_truthy('SQLALCHEMY_TRACK_MODIFICATIONS') + + # Used for session management. Randomized by default for security, but + # should be set to a fixed value in production to avoid invalidating sessions. + SECRET_KEY = os.getenv('SECRET_KEY', os.urandom(24)) + + # A temporary writable location to unzip shapefile uploads. + # This folder will be REMOVED after every shapefile conversion. + SHAPEFILE_UPLOAD_FOLDER = os.getenv('SHAPEFILE_UPLOAD_FOLDER', '/tmp/uploads') - if not config_value: - return _Config.GC_NOTIFY_CONFIG['DEFAULT'][key] + # The maximum number of characters allowed in a slug. + SLUG_MAX_CHARACTERS = int(os.getenv('SLUG_MAX_CHARACTERS', '100')) - return config_value + # Single tenant environment mode - disables certain checks for user + # permissions and tenant access. When enabled, all users are assumed to + # have access to all tenants. Will probably cause bugs if enabled. + IS_SINGLE_TENANT_ENVIRONMENT = env_truthy('IS_SINGLE_TENANT_ENVIRONMENT') + # Whether to attempt to start a Keycloak Docker container for testing. + USE_TEST_KEYCLOAK_DOCKER = env_truthy('USE_TEST_KEYCLOAK_DOCKER') + # TODO: What does this do? Why is it required? + USE_DOCKER_MOCK = env_truthy('USE_DOCKER_MOCK') -class _Config(): # pylint: disable=too-few-public-methods - """Base class configuration that should set reasonable defaults for all the other configurations.""" + # Timezone in Victoria, BC + LEGISLATIVE_TIMEZONE = os.getenv('LEGISLATIVE_TIMEZONE', 'America/Vancouver') - PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) + # Used to create the default tenant when setting up the database. + # Also used for some test cases. + DEFAULT_TENANT_SHORT_NAME = os.getenv('DEFAULT_TENANT_SHORT_NAME', 'DEFAULT') + DEFAULT_TENANT_NAME = os.getenv('DEFAULT_TENANT_NAME', 'Default Tenant') + DEFAULT_TENANT_DESCRIPTION = os.getenv( + 'DEFAULT_TENANT_DESCRIPTION', + 'The default tenant for MET. Used for testing and development.' + ) + + # CORS settings + CORS_ORIGINS = os.getenv('CORS_ORIGINS', '').split(',') + + EPIC_CONFIG = { + 'ENABLED': env_truthy('EPIC_INTEGRATION_ENABLED'), + 'JWT_OIDC_ISSUER': os.getenv('EPIC_JWT_OIDC_ISSUER'), + 'URL': os.getenv('EPIC_URL'), + 'MILESTONE': os.getenv('EPIC_MILESTONE'), + 'KEYCLOAK_SERVICE_ACCOUNT_ID': os.getenv('EPIC_SERVICE_ACCOUNT_ID'), + 'KEYCLOAK_SERVICE_ACCOUNT_SECRET': os.getenv('EPIC_SERVICE_ACCOUNT_SECRET'), + 'KEYCLOAK_CLIENT_ID': os.getenv('EPIC_KC_CLIENT_ID'), + } - SECRET_KEY = 'a secret' - TESTING = False - DEBUG = False + # Keycloak configuration + KEYCLOAK_CONFIG = KC = { + 'BASE_URL': os.getenv('KEYCLOAK_BASE_URL', ''), + 'REALMNAME': os.getenv('KEYCLOAK_REALMNAME', 'standard'), + 'SERVICE_ACCOUNT_ID': os.getenv('MET_ADMIN_CLIENT_ID'), + 'SERVICE_ACCOUNT_SECRET': os.getenv('MET_ADMIN_CLIENT_SECRET'), + 'ADMIN_USERNAME': os.getenv('MET_ADMIN_CLIENT_ID'), + 'ADMIN_SECRET': os.getenv('MET_ADMIN_CLIENT_SECRET'), + 'CONNECT_TIMEOUT': int(os.getenv('KEYCLOAK_CONNECT_TIMEOUT', 60)), + } - # POSTGRESQL - DB_USER = os.getenv('DATABASE_USERNAME', '') - DB_PASSWORD = os.getenv('DATABASE_PASSWORD', '') - DB_NAME = os.getenv('DATABASE_NAME', '') - DB_HOST = os.getenv('DATABASE_HOST', '') - DB_PORT = os.getenv('DATABASE_PORT', '5432') - SQLALCHEMY_DATABASE_URI = f'postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}' - SQLALCHEMY_ECHO = False - SQLALCHEMY_TRACK_MODIFICATIONS = False + # JWT OIDC Settings (for Keycloak) + JWT_CONFIG = JWT = { + 'ISSUER': ( + _issuer := os.getenv( + 'JWT_OIDC_ISSUER', + f'{KC["BASE_URL"]}/realms/{KC["REALMNAME"]}' + )), + 'WELL_KNOWN_CONFIG': os.getenv( + 'JWT_OIDC_WELL_KNOWN_CONFIG', + f'{_issuer}/.well-known/openid-configuration', + ), + 'JWKS_URI': os.getenv('JWT_OIDC_JWKS_URI', f'{_issuer}/protocol/openid-connect/certs'), + 'ALGORITHMS': os.getenv('JWT_OIDC_ALGORITHMS', 'RS256'), + 'AUDIENCE': os.getenv('JWT_OIDC_AUDIENCE', 'account'), + 'CACHING_ENABLED': str(env_truthy('JWT_OIDC_CACHING_ENABLED', True)), + 'JWKS_CACHE_TIMEOUT': int(os.getenv('JWT_OIDC_JWKS_CACHE_TIMEOUT', 300)), + 'ROLE_CLAIM': os.getenv('JWT_OIDC_ROLE_CLAIM', 'realm_access.roles'), + } - # JWT_OIDC Settings - JWT_OIDC_WELL_KNOWN_CONFIG = os.getenv('JWT_OIDC_WELL_KNOWN_CONFIG') - JWT_OIDC_ALGORITHMS = os.getenv('JWT_OIDC_ALGORITHMS', 'RS256') - JWT_OIDC_JWKS_URI = os.getenv('JWT_OIDC_JWKS_URI') - JWT_OIDC_ISSUER = os.getenv('JWT_OIDC_ISSUER') - JWT_OIDC_AUDIENCE = os.getenv('JWT_OIDC_AUDIENCE', 'account') - JWT_OIDC_CACHING_ENABLED = os.getenv('JWT_OIDC_CACHING_ENABLED', 'True') - JWT_OIDC_JWKS_CACHE_TIMEOUT = 300 + # PostgreSQL configuration + DB_CONFIG = DB = { + 'USER': os.getenv('DATABASE_USERNAME', ''), + 'PASSWORD': os.getenv('DATABASE_PASSWORD', ''), + 'NAME': os.getenv('DATABASE_NAME', ''), + 'HOST': os.getenv('DATABASE_HOST', ''), + 'PORT': os.getenv('DATABASE_PORT', '5432'), + } - S3_CONFIG = { - 'DEFAULT': { - 'S3_BUCKET': os.getenv('S3_BUCKET'), - 'S3_ACCESS_KEY_ID': os.getenv('S3_ACCESS_KEY_ID'), - 'S3_SECRET_ACCESS_KEY': os.getenv('S3_SECRET_ACCESS_KEY'), - 'S3_HOST': os.getenv('S3_HOST'), - 'S3_REGION': os.getenv('S3_REGION'), - 'S3_SERVICE': os.getenv('S3_SERVICE') - } + # Configuration for AWS S3, used for file storage + S3_CONFIG = S3 = { + 'BUCKET': os.getenv('S3_BUCKET'), + 'ACCESS_KEY_ID': os.getenv('S3_ACCESS_KEY_ID'), + 'SECRET_ACCESS_KEY': os.getenv('S3_SECRET_ACCESS_KEY'), + 'HOST': os.getenv('S3_HOST'), + 'REGION': os.getenv('S3_REGION'), + 'SERVICE': os.getenv('S3_SERVICE'), } - # Service account details - KEYCLOAK_BASE_URL = os.getenv('KEYCLOAK_BASE_URL') - KEYCLOAK_REALMNAME = os.getenv('KEYCLOAK_REALMNAME', 'met') - KEYCLOAK_SERVICE_ACCOUNT_ID = os.getenv('MET_ADMIN_CLIENT_ID') - KEYCLOAK_SERVICE_ACCOUNT_SECRET = os.getenv('MET_ADMIN_CLIENT_SECRET') - # TODO separate out clients for APIs and user management. - # TODO API client wont need user management roles in keycloak. - KEYCLOAK_ADMIN_USERNAME = os.getenv('MET_ADMIN_CLIENT_ID') - KEYCLOAK_ADMIN_SECRET = os.getenv('MET_ADMIN_CLIENT_SECRET') - - # front end urls - SUBMISSION_PATH = os.getenv('SUBMISSION_PATH', '/engagements/{engagement_id}/edit/{token}') - SURVEY_PATH = os.getenv('SURVEY_PATH', '/surveys/submit/{survey_id}/{token}') - SUBSCRIBE_PATH = os.getenv('SUBSCRIBE_PATH', '/engagements/{engagement_id}/subscribe/{token}') - UNSUBSCRIBE_PATH = os.getenv('UNSUBSCRIBE_PATH', '/engagements/{engagement_id}/unsubscribe/{participant_id}') - ENGAGEMENT_PATH = os.getenv('ENGAGEMENT_PATH', '/engagements/{engagement_id}/view') - ENGAGEMENT_PATH_SLUG = os.getenv('ENGAGEMENT_PATH_SLUG', '/{slug}') - # engagement dashboard path is used to pass the survey result to the public user. - # The link is changed such that public user can access the comments page from the email and not the dashboard. - ENGAGEMENT_DASHBOARD_PATH = os.getenv('ENGAGEMENT_DASHBOARD_PATH', '/engagements/{engagement_id}/comments/public') - ENGAGEMENT_DASHBOARD_PATH_SLUG = os.getenv('ENGAGEMENT_DASHBOARD_PATH_SLUG', '/{slug}/comments/public') - USER_MANAGEMENT_PATH = os.getenv('USER_MANAGEMENT_PATH', '/usermanagement') - SITE_URL = os.getenv('SITE_URL') - - # The GC notify email variables - GC_NOTIFY_CONFIG = { - 'DEFAULT': { - 'VERIFICATION_EMAIL_TEMPLATE_ID': os.getenv('VERIFICATION_EMAIL_TEMPLATE_ID'), - 'VERIFICATION_EMAIL_SUBJECT': os.getenv('VERIFICATION_EMAIL_SUBJECT', '{engagement_name} - Access link'), - 'SUBSCRIBE_EMAIL_TEMPLATE_ID': os.getenv('SUBSCRIBE_EMAIL_TEMPLATE_ID'), - 'SUBSCRIBE_EMAIL_SUBJECT': os.getenv('SUBSCRIBE_EMAIL_SUBJECT', 'Confirm your subscription'), - 'REJECTED_EMAIL_TEMPLATE_ID': os.getenv('REJECTED_EMAIL_TEMPLATE_ID'), - 'REJECTED_EMAIL_SUBJECT': os.getenv('REJECTED_EMAIL_SUBJECT', '{engagement_name} - About your Comments'), - 'SUBMISSION_RESPONSE_EMAIL_TEMPLATE_ID': os.getenv('SUBMISSION_RESPONSE_EMAIL_TEMPLATE_ID'), - 'SUBMISSION_RESPONSE_EMAIL_SUBJECT': os.getenv('SUBMISSION_RESPONSE_EMAIL_SUBJECT', - 'Your feedback was successfully submitted'), - # End time should match to the time met close-out CRON job runs - 'ENGAGEMENT_END_TIME': os.getenv('ENGAGEMENT_END_TIME', '8 AM'), - 'EMAIL_ENVIRONMENT': os.getenv('EMAIL_ENVIRONMENT', ''), - 'ACCESS_REQUEST_EMAIL_TEMPLATE_ID': os.getenv('ACCESS_REQUEST_EMAIL_TEMPLATE_ID'), - 'ACCESS_REQUEST_EMAIL_SUBJECT': os.getenv('ACCESS_REQUEST_EMAIL_SUBJECT', 'MET - New User Access Request'), - 'ACCESS_REQUEST_EMAIL_ADDRESS': os.getenv('ACCESS_REQUEST_EMAIL_ADDRESS') + # The following are the paths used in the email templates. They do not + # determine the actual paths used in the application. They are used to + # construct the links in the emails sent to users. + PATH_CONFIG = PATHS = { + 'SITE': os.getenv('SITE_URL'), + 'SURVEY': os.getenv('SURVEY_PATH', '/surveys/submit/{survey_id}/{token}'), + 'USER_MANAGEMENT': os.getenv('USER_MANAGEMENT_PATH', '/usermanagement'), + 'SUBMISSION': os.getenv( + 'SUBMISSION_PATH', '/engagements/{engagement_id}/edit/{token}' + ), + 'SUBSCRIBE': os.getenv( + 'SUBSCRIBE_PATH', '/engagements/{engagement_id}/subscribe/{token}' + ), + 'UNSUBSCRIBE': os.getenv( + 'UNSUBSCRIBE_PATH', '/engagements/{engagement_id}/unsubscribe/{participant_id}' + ), + "ENGAGEMENT": { + 'VIEW': os.getenv('ENGAGEMENT_PATH', '/engagements/{engagement_id}/view'), + 'SLUG': os.getenv('ENGAGEMENT_PATH_SLUG', '/{slug}'), + 'DASHBOARD': os.getenv( + 'ENGAGEMENT_DASHBOARD_PATH','/engagements/{engagement_id}/comments/public' + ), + 'DASHBOARD_SLUG': os.getenv( + 'ENGAGEMENT_DASHBOARD_PATH_SLUG', '/{slug}/comments/public' + ), } } + # The API endpoint used to send emails to participants. NOTIFICATIONS_EMAIL_ENDPOINT = os.getenv('NOTIFICATIONS_EMAIL_ENDPOINT') - # CDOGS - CDOGS_ACCESS_TOKEN = os.getenv('CDOGS_ACCESS_TOKEN') - CDOGS_BASE_URL = os.getenv('CDOGS_BASE_URL') - CDOGS_SERVICE_CLIENT = os.getenv('CDOGS_SERVICE_CLIENT') - CDOGS_SERVICE_CLIENT_SECRET = os.getenv('CDOGS_SERVICE_CLIENT_SECRET') - CDOGS_TOKEN_URL = os.getenv('CDOGS_TOKEN_URL') - - # just a temporary writable location to unzip the files. - # This gets cleared after every shapefile conversion. - SHAPEFILE_UPLOAD_FOLDER = os.getenv('SHAPEFILE_UPLOAD_FOLDER', '/tmp/uploads') - - # default tenant configs ; Set to EAO for now.Overwrite using openshift variables - DEFAULT_TENANT_SHORT_NAME = os.getenv('DEFAULT_TENANT_SHORT_NAME', 'EAO') - DEFAULT_TENANT_NAME = os.getenv('DEFAULT_TENANT_NAME', 'Environment Assessment Office') - DEFAULT_TENANT_DESCRIPTION = os.getenv('DEFAULT_TENANT_DESCRIPTION', 'Environment Assessment Office') - - EMAIL_SECRET_KEY = os.getenv('EMAIL_SECRET_KEY', 'secret') - - # Slug generation - SLUG_MAX_CHARACTERS = int(os.getenv('SLUG_MAX_CHARACTERS', '100')) + # The secret key used for encryption when sending emails to participants. + EMAIL_SECRET_KEY = os.getenv('EMAIL_SECRET_KEY', os.urandom(24)) + # Templates for sending users various notifications by email. + EMAIL_TEMPLATES = { + # The time of day when engagements get closed. This should match the + # value in met-cron/cron/crontab + 'CLOSING_TIME': os.getenv('ENGAGEMENT_END_TIME', '5 PM'), + 'FROM_ADDRESS': os.getenv('EMAIL_FROM_ADDRESS'), + 'ENVIRONMENT' : os.getenv('EMAIL_ENVIRONMENT'), + 'SUBSCRIBE': { + 'ID': os.getenv('SUBSCRIBE_EMAIL_TEMPLATE_ID'), + 'SUBJECT': os.getenv('SUBSCRIBE_EMAIL_SUBJECT', + 'Confirm your subscription'), + }, + 'REJECTED': { + 'ID': os.getenv('REJECTED_EMAIL_TEMPLATE_ID'), + 'SUBJECT': os.getenv('REJECTED_EMAIL_SUBJECT', + '{engagement_name} - About your Comments'), + }, + 'VERIFICATION': { + 'ID': os.getenv('VERIFICATION_EMAIL_TEMPLATE_ID'), + 'SUBJECT': os.getenv('VERIFICATION_EMAIL_SUBJECT', + '{engagement_name} - Access link'), + }, + 'SUBMISSION_RESPONSE': { + 'ID': os.getenv('SUBMISSION_RESPONSE_EMAIL_TEMPLATE_ID'), + 'SUBJECT': os.getenv('SUBMISSION_RESPONSE_EMAIL_SUBJECT', + 'MET - Your feedback was successfully submitted'), + }, + "CLOSEOUT":{ + 'ID': os.getenv('CLOSEOUT_EMAIL_TEMPLATE_ID'), + 'SUBJECT': os.getenv('CLOSEOUT_EMAIL_SUBJECT', + 'MET - Engagement Closed'), + }, + 'ACCESS_REQUEST': { + 'ID': os.getenv('ACCESS_REQUEST_EMAIL_TEMPLATE_ID'), + 'SUBJECT': os.getenv('ACCESS_REQUEST_EMAIL_SUBJECT', + 'MET - New User Access Request'), + 'DEST_EMAIL_ADDRESS': os.getenv('ACCESS_REQUEST_EMAIL_ADDRESS'), + } + } - # EAO is a single Tenant Environment where EAO is the only env and should be set to True - # This flag decides if additonal tenant based checks has to be carried or not - IS_SINGLE_TENANT_ENVIRONMENT = os.getenv('IS_SINGLE_TENANT_ENVIRONMENT', 'False').lower() == 'true' + # Configuration for the CDOGS API + CDOGS_CONFIG = { + 'ACCESS_TOKEN': os.getenv('CDOGS_ACCESS_TOKEN'), + 'BASE_URL': os.getenv('CDOGS_BASE_URL'), + 'SERVICE_CLIENT': os.getenv('CDOGS_SERVICE_CLIENT'), + 'SERVICE_CLIENT_SECRET': os.getenv('CDOGS_SERVICE_CLIENT_SECRET'), + 'TOKEN_URL': os.getenv('CDOGS_TOKEN_URL'), + } - # EAO EPIC configs - IS_EAO_ENVIRONMENT = os.getenv('IS_EAO_ENVIRONMENT', 'False').lower() == 'true' - EPIC_KEYCLOAK_SERVICE_ACCOUNT_ID = os.getenv('EPIC_KEYCLOAK_SERVICE_ACCOUNT_ID') - EPIC_KEYCLOAK_SERVICE_ACCOUNT_SECRET = os.getenv('EPIC_KEYCLOAK_SERVICE_ACCOUNT_SECRET') - EPIC_JWT_OIDC_ISSUER = os.getenv('EPIC_JWT_OIDC_ISSUER') - EPIC_URL = os.getenv('EPIC_URL') - EPIC_MILESTONE = os.getenv('EPIC_MILESTONE') - EPIC_KC_CLIENT_ID = os.getenv('EPIC_KC_CLIENT_ID') +class DevConfig(Config): # pylint: disable=too-few-public-methods + """Dev Config.""" - # Timezone in BC - LEGISLATIVE_TIMEZONE = os.getenv('LEGISLATIVE_TIMEZONE', 'America/Vancouver') + # Default to using the debugger for development + USE_DEBUG = env_truthy('USE_DEBUG', True) -class DevConfig(_Config): # pylint: disable=too-few-public-methods - """Dev Config.""" +class TestConfig(TestKeyConfig, Config): # pylint: disable=too-few-public-methods + """ + The configuration used when running the test suite. + Extends TestKeyConfig, which contains some large constant keys that are used + in the tests. It is stored in a separate file to avoid clutter. + TestKeyConfig, in turn, extends the default Config class from above. + """ - TESTING = False - DEBUG = True - print(f'SQLAlchemy URL (DevConfig): {_Config.SQLALCHEMY_DATABASE_URI}') - - -class TestConfig(_Config): # pylint: disable=too-few-public-methods - """In support of testing only.used by the py.test suite.""" - - DEBUG = True - TESTING = True - DEBUG = True - TESTING = True - # POSTGRESQL - DB_USER = os.getenv('DATABASE_TEST_USERNAME', 'postgres') - DB_PASSWORD = os.getenv('DATABASE_TEST_PASSWORD', 'postgres') - DB_NAME = os.getenv('DATABASE_TEST_NAME', 'postgres') - DB_HOST = os.getenv('DATABASE_TEST_HOST', 'localhost') - DB_PORT = os.getenv('DATABASE_TEST_PORT', '5432') - SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_TEST_URL', - f'postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}') - - # JWT OIDC settings - # JWT_OIDC_TEST_MODE will set jwt_manager to use - JWT_OIDC_TEST_MODE = True + def __init__(self) -> None: + super().__init__() + + # Override Keycloak variables here + self.KC['ADMIN_USERNAME'] = os.getenv( + 'KEYCLOAK_TEST_ADMIN_CLIENTID', + self.KC['ADMIN_USERNAME'] + ) + self.KC['ADMIN_SECRET'] = os.getenv( + 'KEYCLOAK_TEST_ADMIN_SECRET', + self.KC['ADMIN_SECRET'] + ) + self.KC['BASE_URL'] = os.getenv('KEYCLOAK_TEST_BASE_URL', self.KC['BASE_URL']) + self.KC['REALMNAME'] = os.getenv('KEYCLOAK_TEST_REALMNAME', self.KC['REALMNAME']) + + # Propagate exceptions up to the test runner + TESTING = env_truthy('TESTING', default=True) + + # explicitly disable the debugger; we want the tests to fail if an + # unhandled exception occurs + USE_DEBUG = False + + # JWT OIDC Settings for the test environment + JWT_OIDC_TEST_MODE = True # enables the test mode for flask_jwt_oidc JWT_OIDC_TEST_AUDIENCE = os.getenv('JWT_OIDC_TEST_AUDIENCE') JWT_OIDC_TEST_CLIENT_SECRET = os.getenv('JWT_OIDC_TEST_CLIENT_SECRET') JWT_OIDC_TEST_ISSUER = os.getenv('JWT_OIDC_TEST_ISSUER') JWT_OIDC_TEST_ALGORITHMS = os.getenv('JWT_OIDC_TEST_ALGORITHMS') - JWT_OIDC_TEST_KEYS = { - 'keys': [ - { - 'kid': 'met-web', - 'kty': 'RSA', - 'alg': 'RS256', - 'use': 'sig', - 'n': 'AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-' - 'TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR', - 'e': 'AQAB' - } - ] - } - JWT_OIDC_TEST_PRIVATE_KEY_JWKS = { - 'keys': [ - { - 'kid': 'met-web', - 'kty': 'RSA', - 'alg': 'RS256', - 'use': 'sig', - 'n': 'AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-' - 'TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR', - 'e': 'AQAB', - 'd': 'C0G3QGI6OQ6tvbCNYGCqq043YI_8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhskURaDwk4-' - '8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh_' - 'xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0', - 'p': 'APXcusFMQNHjh6KVD_hOUIw87lvK13WkDEeeuqAydai9Ig9JKEAAfV94W6Aftka7tGgE7ulg1vo3eJoLWJ1zvKM', - 'q': 'AOjX3OnPJnk0ZFUQBwhduCweRi37I6DAdLTnhDvcPTrrNWuKPg9uGwHjzFCJgKd8KBaDQ0X1rZTZLTqi3peT43s', - 'dp': 'AN9kBoA5o6_Rl9zeqdsIdWFmv4DB5lEqlEnC7HlAP-3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhc', - 'dq': 'ANtbSY6njfpPploQsF9sU26U0s7MsuLljM1E8uml8bVJE1mNsiu9MgpUvg39jEu9BtM2tDD7Y51AAIEmIQex1nM', - 'qi': 'XLE5O360x-MhsdFXx8Vwz4304-MJg-oGSJXCK_ZWYOB_FGXFRTfebxCsSYi0YwJo-oNu96bvZCuMplzRI1liZw' - } - ] + # Override the DB config to use the test database, if one is configured + DB_CONFIG = { + 'USER': os.getenv('DATABASE_TEST_USERNAME', Config.DB.get('USER')), + 'PASSWORD': os.getenv('DATABASE_TEST_PASSWORD', Config.DB.get('PASSWORD')), + 'NAME': os.getenv('DATABASE_TEST_NAME', Config.DB.get('NAME')), + 'HOST': os.getenv('DATABASE_TEST_HOST', Config.DB.get('HOST')), + 'PORT': os.getenv('DATABASE_TEST_PORT', Config.DB.get('PORT')), } - JWT_OIDC_TEST_PRIVATE_KEY_PEM = """ - -----BEGIN RSA PRIVATE KEY----- - MIICXQIBAAKBgQDfn1nKQshOSj8xw44oC2klFWSNLmK3BnHONCJ1bZfq0EQ5gIfg - tlvB+Px8Ya+VS3OnK7Cdi4iU1fxO9ktN6c6TjmmmFevk8wIwqLthmCSF3r+3+h4e - ddj7hucMsXWv05QUrCPoL6YUUz7Cgpz7ra24rpAmK5z7lsV+f3BEvXkrUQIDAQAB - AoGAC0G3QGI6OQ6tvbCNYGCqq043YI/8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhs - kURaDwk4+8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh/ - xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0CQQD13LrBTEDR44ei - lQ/4TlCMPO5bytd1pAxHnrqgMnWovSIPSShAAH1feFugH7ZGu7RoBO7pYNb6N3ia - C1idc7yjAkEA6Nfc6c8meTRkVRAHCF24LB5GLfsjoMB0tOeEO9w9Ous1a4o+D24b - AePMUImAp3woFoNDRfWtlNktOqLel5PjewJBAN9kBoA5o6/Rl9zeqdsIdWFmv4DB - 5lEqlEnC7HlAP+3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhcCQQDb - W0mOp436T6ZaELBfbFNulNLOzLLi5YzNRPLppfG1SRNZjbIrvTIKVL4N/YxLvQbT - NrQw+2OdQACBJiEHsdZzAkBcsTk7frTH4yGx0VfHxXDPjfTj4wmD6gZIlcIr9lZg - 4H8UZcVFN95vEKxJiLRjAmj6g273pu9kK4ymXNEjWWJn - -----END RSA PRIVATE KEY-----""" - - KEYCLOAK_ADMIN_USERNAME = os.getenv('KEYCLOAK_TEST_ADMIN_CLIENTID', 'met-admin') - KEYCLOAK_ADMIN_SECRET = os.getenv('KEYCLOAK_TEST_ADMIN_SECRET', '2222222222') - KEYCLOAK_BASE_URL = os.getenv('KEYCLOAK_TEST_BASE_URL', 'http://localhost:8088') - KEYCLOAK_REALMNAME = os.getenv('KEYCLOAK_TEST_REALMNAME', 'demo') - - JWT_OIDC_AUDIENCE = os.getenv('JWT_OIDC_TEST_AUDIENCE') - JWT_OIDC_CLIENT_SECRET = os.getenv('JWT_OIDC_TEST_CLIENT_SECRET') - JWT_OIDC_ISSUER = os.getenv('JWT_OIDC_TEST_ISSUER') - # Service account details - KEYCLOAK_SERVICE_ACCOUNT_ID = os.getenv('KEYCLOAK_TEST_ADMIN_CLIENTID') - KEYCLOAK_SERVICE_ACCOUNT_SECRET = os.getenv('KEYCLOAK_TEST_ADMIN_SECRET') - - # Legal-API URL - LEGAL_API_URL = 'https://mock-auth-tools.pathfinder.gov.bc.ca/rest/legal-api/2.7/api/v1' - - NOTIFY_API_URL = 'http://localhost:8080/notify-api/api/v1' - BCOL_API_URL = 'http://localhost:8080/bcol-api/api/v1' - PAY_API_URL = 'http://localhost:8080/pay-api/api/v1' - PAY_API_SANDBOX_URL = 'http://localhost:8080/pay-api/api/v1' - - # If any value is present in this flag, starts up a keycloak docker - USE_TEST_KEYCLOAK_DOCKER = os.getenv('USE_TEST_KEYCLOAK_DOCKER', None) - USE_DOCKER_MOCK = os.getenv('USE_DOCKER_MOCK', None) - PROPAGATE_EXCEPTIONS = True - - -class DockerConfig(_Config): # pylint: disable=too-few-public-methods - """In support of testing only.used by the py.test suite.""" - - # POSTGRESQL - DB_USER = os.getenv('DATABASE_DOCKER_USERNAME') - DB_PASSWORD = os.getenv('DATABASE_DOCKER_PASSWORD') - DB_NAME = os.getenv('DATABASE_DOCKER_NAME') - DB_HOST = os.getenv('DATABASE_DOCKER_HOST') - DB_PORT = os.getenv('DATABASE_DOCKER_PORT', '5432') - SQLALCHEMY_DATABASE_URI = f'postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}' - - print(f'SQLAlchemy URL (Docker): {SQLALCHEMY_DATABASE_URI}') - - -class ProdConfig(_Config): # pylint: disable=too-few-public-methods - """Production Config.""" - SECRET_KEY = os.getenv('SECRET_KEY', None) +class DockerConfig(Config): # pylint: disable=too-few-public-methods + """Configuration for deployment using Docker.""" + + # Override DB config to use the docker database, if one is configured + DB_CONFIG = { + 'USER': os.getenv('DATABASE_DOCKER_USERNAME', Config.DB.get('USER')), + 'PASSWORD': os.getenv('DATABASE_DOCKER_PASSWORD', Config.DB.get('PASSWORD')), + 'NAME': os.getenv('DATABASE_DOCKER_NAME', Config.DB.get('NAME')), + 'HOST': os.getenv('DATABASE_DOCKER_HOST', Config.DB.get('HOST')), + 'PORT': os.getenv('DATABASE_DOCKER_PORT', Config.DB.get('PORT')), + } - if not SECRET_KEY: - SECRET_KEY = os.urandom(24) - print('WARNING: SECRET_KEY being set as a one-shot', file=sys.stderr) - TESTING = False - DEBUG = False +class ProdConfig(Config): # pylint: disable=too-few-public-methods + """Production Config.""" + diff --git a/met-api/src/met_api/models/base_model.py b/met-api/src/met_api/models/base_model.py index 066ae46d3..bbdb779b4 100644 --- a/met-api/src/met_api/models/base_model.py +++ b/met-api/src/met_api/models/base_model.py @@ -67,16 +67,10 @@ def flush(self): db.session.flush() return self - def add_to_session(self): - """Save and flush.""" - return self.flush() - def save(self): """Save and commit.""" - self._set_tenant_id() - db.session.add(self) - db.session.flush() - db.session.commit() + self.flush() + self.commit() def _set_tenant_id(self): # add tenant id to the model if the child model has tenant id column diff --git a/met-api/src/met_api/models/new_engagement_metadata.py b/met-api/src/met_api/models/new_engagement_metadata.py new file mode 100644 index 000000000..751596b8e --- /dev/null +++ b/met-api/src/met_api/models/new_engagement_metadata.py @@ -0,0 +1,53 @@ +""" +The Engagement Metadata models. +""" + +from sqlalchemy.dialects import postgresql +from sqlalchemy.sql.schema import ForeignKey + +from .base_model import BaseModel +from .db import db + +class MetadataModel(BaseModel): + """ + Metadata for an Engagement. Can be used to store any arbitrary data. + """ + __tablename__ = 'metadata_relationship' + tenant_id = db.Column( + db.Integer, + ForeignKey('tenant.id', ondelete='CASCADE'), + primary_key=True + ) + engagement_id = db.Column( + db.Integer, + ForeignKey('engagement.id', ondelete='CASCADE'), + primary_key=True + ) + category_id = db.Column( + db.Integer, + ForeignKey('metadata_category.category_id', ondelete='CASCADE'), + primary_key=True + ) + value = db.Column(db.String(512), unique=False, nullable=True) + + category = db.relationship('MetadataTaxonomy', backref='metadata') + engagements = db.relationship('Engagement', backref='metadata') + +class MetadataTaxonomyModel(BaseModel): + """ + Defines a category of metadata fields. + """ + __tablename__ = 'metadata_category' + category_id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column( + db.Integer, + ForeignKey('tenant.id', ondelete='CASCADE'), + primary_key=True + ) + freeform = db.Column(db.Boolean, unique=False, nullable=True) + category_type = db.Column(db.String(100), unique=False, nullable=True) + one_per_engagement = db.Column(db.Boolean, unique=False, nullable=True) + name = db.Column(db.String(100), unique=False, nullable=True) + data_type = db.Column(db.String(100), unique=False, nullable=True) + description = db.Column(db.String(100), unique=False, nullable=True) + metadata = db.relationship(MetadataModel, backref='metadata_category') \ No newline at end of file diff --git a/met-api/src/met_api/services/authorization.py b/met-api/src/met_api/services/authorization.py index 4d58266ba..b30cdb0de 100644 --- a/met-api/src/met_api/services/authorization.py +++ b/met-api/src/met_api/services/authorization.py @@ -21,12 +21,13 @@ def check_auth(**kwargs): """Check if user is authorized to perform action on the service.""" skip_tenant_check = current_app.config.get('IS_SINGLE_TENANT_ENVIRONMENT') user_from_context: UserContext = kwargs['user_context'] + user_from_db = StaffUserModel.get_user_by_external_id(user_from_context.sub) token_roles = set(user_from_context.roles) permitted_roles = set(kwargs.get('one_of_roles', [])) has_valid_roles = token_roles & permitted_roles if has_valid_roles: if not skip_tenant_check: - user_tenant_id = user_from_context.tenant_id + user_tenant_id = user_from_db.tenant_id _validate_tenant(kwargs.get('engagement_id'), user_tenant_id) return @@ -47,8 +48,8 @@ def _validate_tenant(eng_id, tenant_id): return engagement_tenant_id = EngagementModel.find_tenant_id_by_id(eng_id) if engagement_tenant_id and str(tenant_id) != str(engagement_tenant_id): - current_app.logger.debug(f'Aborting . Tenant Id on Engagement and user context Mismatch' - f'engagement_tenant_id:{engagement_tenant_id} ' + current_app.logger.debug(f'Aborting . Tenant Id on Engagement and user context Mismatch\n' + f'engagement_tenant_id:{engagement_tenant_id}\n' f'tenant_id: {tenant_id}') abort(HTTPStatus.FORBIDDEN) diff --git a/met-api/src/met_api/services/cdogs_api_service.py b/met-api/src/met_api/services/cdogs_api_service.py index 08bf7f190..d4ad9850d 100644 --- a/met-api/src/met_api/services/cdogs_api_service.py +++ b/met-api/src/met_api/services/cdogs_api_service.py @@ -23,7 +23,7 @@ import requests from flask import current_app -from met_api.config import _Config +from met_api.config import Config class CdogsApiService: @@ -31,10 +31,12 @@ class CdogsApiService: def __init__(self): """Initiate class.""" + # we can't use current_app.config here because it isn't initialized yet + config = Config().CDOGS_CONFIG + self.base_url = config['BASE_URL'] + self.access_token = self._get_access_token() - file_dir = os.path.dirname(os.path.realpath('__file__')) - def generate_document(self, template_hash_code: str, data, options): """Generate document based on template and data.""" request_body = { @@ -48,7 +50,7 @@ def generate_document(self, template_hash_code: str, data, options): 'Authorization': f'Bearer {self.access_token}' } - url = f'{_Config.CDOGS_BASE_URL}/api/v2/template/{template_hash_code}/render' + url = f'{self.base_url}/api/v2/template/{template_hash_code}/render' return self._post_generate_document(json_request_body, headers, url) @staticmethod @@ -62,7 +64,7 @@ def upload_template(self, template_file_path): 'Authorization': f'Bearer {self.access_token}' } - url = f'{_Config.CDOGS_BASE_URL}/api/v2/template' + url = f'{self.base_url}/api/v2/template' with open(template_file_path, 'rb') as file_handle: template = {'template': ('template', file_handle, 'multipart/form-data')} @@ -102,16 +104,16 @@ def check_template_cached(self, template_hash_code: str): 'Authorization': f'Bearer {self.access_token}' } - url = f'{_Config.CDOGS_BASE_URL}/api/v2/template/{template_hash_code}' + url = f'{self.base_url}/api/v2/template/{template_hash_code}' response = requests.get(url, headers=headers) return response.status_code == HTTPStatus.OK @staticmethod def _get_access_token(): - token_url = _Config.CDOGS_TOKEN_URL - service_client = _Config.CDOGS_SERVICE_CLIENT - service_client_secret = _Config.CDOGS_SERVICE_CLIENT_SECRET + token_url = CdogsApiService.config['TOKEN_URL'] + service_client = CdogsApiService.config['SERVICE_CLIENT'] + service_client_secret = CdogsApiService.config['SERVICE_CLIENT_SECRET'] basic_auth_encoded = base64.b64encode( bytes(f'{service_client}:{service_client_secret}', 'utf-8')).decode('utf-8') diff --git a/met-api/src/met_api/services/email_verification_service.py b/met-api/src/met_api/services/email_verification_service.py index d958cf4fa..0405b2c47 100644 --- a/met-api/src/met_api/services/email_verification_service.py +++ b/met-api/src/met_api/services/email_verification_service.py @@ -18,7 +18,6 @@ from met_api.services.participant_service import ParticipantService from met_api.utils import notification from met_api.utils.template import Template -from met_api.config import get_gc_notify_config class EmailVerificationService: @@ -127,16 +126,18 @@ def _send_verification_email(email_verification: dict, subscription_type) -> Non status_code=HTTPStatus.INTERNAL_SERVER_ERROR) from exc @staticmethod - def _render_email_template(survey: SurveyModel, - token, - email_type: EmailVerificationType, - subscription_type, - participant_id): + def _render_email_template( + survey: SurveyModel, + token, + email_type: EmailVerificationType, + subscription_type, + participant_id, + ): if email_type == EmailVerificationType.Subscribe: - return EmailVerificationService._render_subscribe_email_template(survey, token, - subscription_type, participant_id) + return EmailVerificationService._render_subscribe_email_template( + survey, token, subscription_type, participant_id) # if email_type == EmailVerificationType.RejectedComment: - # TODO: move reject comment email verification logic here + # TODO: move reject comment email verification logic here # return return EmailVerificationService._render_survey_email_template(survey, token) @@ -144,25 +145,26 @@ def _render_email_template(survey: SurveyModel, # pylint: disable-msg=too-many-locals def _render_subscribe_email_template(survey: SurveyModel, token, subscription_type, participant_id): # url is origin url excluding context path - engagement: EngagementModel = EngagementModel.find_by_id( - survey.engagement_id) - tenant_name = EmailVerificationService._get_tenant_name( - engagement.tenant_id) + engagement: EngagementModel = EngagementModel.find_by_id(survey.engagement_id) + tenant_name = EmailVerificationService._get_tenant_name(engagement.tenant_id) project_name = EmailVerificationService._get_project_name( subscription_type, tenant_name, engagement) is_subscribing_to_tenant = subscription_type == SubscriptionTypes.TENANT.value is_subscribing_to_project = subscription_type != SubscriptionTypes.TENANT.value - template_id = get_gc_notify_config('SUBSCRIBE_EMAIL_TEMPLATE_ID') template = Template.get_template('subscribe_email.html') - confirm_path = current_app.config.get('SUBSCRIBE_PATH'). \ - format(engagement_id=engagement.id, token=token) - unsubscribe_path = current_app.config.get('UNSUBSCRIBE_PATH'). \ - format(engagement_id=engagement.id, participant_id=participant_id) - confirm_url = notification.get_tenant_site_url( - engagement.tenant_id, confirm_path) + templates = current_app.config['EMAIL_TEMPLATES'] + paths = current_app.config['PATH_CONFIG'] + template_id = templates['SUBSCRIBE']['ID'] + confirm_path = paths.get('SUBSCRIBE').format( + engagement_id=engagement.id, token=token + ) + unsubscribe_path = paths.get('UNSUBSCRIBE').format( + engagement_id=engagement.id, participant_id=participant_id + ) + confirm_url = notification.get_tenant_site_url(engagement.tenant_id, confirm_path) unsubscribe_url = notification.get_tenant_site_url( engagement.tenant_id, unsubscribe_path) - email_environment = get_gc_notify_config('EMAIL_ENVIRONMENT') + email_environment = templates['ENVIRONMENT'] args = { 'project_name': project_name, 'confirm_url': confirm_url, @@ -172,7 +174,7 @@ def _render_subscribe_email_template(survey: SurveyModel, token, subscription_ty 'is_subscribing_to_tenant': is_subscribing_to_tenant, 'is_subscribing_to_project': is_subscribing_to_project, } - subject = get_gc_notify_config('SUBSCRIBE_EMAIL_SUBJECT') + subject = templates['SUBSCRIBE']['SUBJECT'] body = template.render( project_name=args.get('project_name'), confirm_url=args.get('confirm_url'), @@ -187,20 +189,18 @@ def _render_subscribe_email_template(survey: SurveyModel, token, subscription_ty @staticmethod def _render_survey_email_template(survey: SurveyModel, token): # url is origin url excluding context path - engagement: EngagementModel = EngagementModel.find_by_id( - survey.engagement_id) + engagement: EngagementModel = EngagementModel.find_by_id(survey.engagement_id) engagement_name = engagement.name - template_id = get_gc_notify_config('VERIFICATION_EMAIL_TEMPLATE_ID') - email_environment = get_gc_notify_config('EMAIL_ENVIRONMENT') + paths = current_app.config['PATH_CONFIG'] + templates = current_app.config['EMAIL_TEMPLATES'] + template_id = templates['VERIFICATION']['ID'] + subject_template = templates['VERIFICATION']['SUBJECT'] + email_environment = templates['ENVIRONMENT'] template = Template.get_template('email_verification.html') - subject_template = get_gc_notify_config('VERIFICATION_EMAIL_SUBJECT') - survey_path = current_app.config.get('SURVEY_PATH'). \ - format(survey_id=survey.id, token=token) - engagement_path = EmailVerificationService.get_engagement_path( - engagement) + survey_path = paths['SURVEY'].format(survey_id=survey.id, token=token) + engagement_path = EmailVerificationService.get_engagement_path(engagement) site_url = notification.get_tenant_site_url(engagement.tenant_id) - tenant_name = EmailVerificationService._get_tenant_name( - engagement.tenant_id) + tenant_name = EmailVerificationService._get_tenant_name(engagement.tenant_id) args = { 'engagement_name': engagement_name, 'survey_url': f'{site_url}{survey_path}', @@ -223,14 +223,12 @@ def _render_survey_email_template(survey: SurveyModel, token): @staticmethod def get_engagement_path(engagement: EngagementModel, is_public_url=True): """Get an engagement path.""" + paths = current_app.config['PATH_CONFIG'] if is_public_url: - engagement_slug = EngagementSlugModel.find_by_engagement_id( - engagement.id) + engagement_slug = EngagementSlugModel.find_by_engagement_id(engagement.id) if engagement_slug: - return current_app.config.get('ENGAGEMENT_PATH_SLUG'). \ - format(slug=engagement_slug.slug) - return current_app.config.get('ENGAGEMENT_PATH'). \ - format(engagement_id=engagement.id) + return paths['ENGAGEMENT']['SLUG'].format(slug=engagement_slug.slug) + return paths['ENGAGEMENT']['VIEW'].format(engagement_id=engagement.id) @staticmethod def _get_tenant_name(tenant_id): diff --git a/met-api/src/met_api/services/engagement_service.py b/met-api/src/met_api/services/engagement_service.py index 5e4388ff5..7f1ef3d6a 100644 --- a/met-api/src/met_api/services/engagement_service.py +++ b/met-api/src/met_api/services/engagement_service.py @@ -26,8 +26,6 @@ from met_api.utils.template import Template from met_api.utils.token_info import TokenInfo from met_api.models import Tenant as TenantModel -from met_api.config import get_gc_notify_config - class EngagementService: """Engagement management service.""" @@ -99,7 +97,7 @@ def _get_scope_options(user_roles, has_team_access): return EngagementScopeOptions(restricted=False) if has_team_access: # return those engagements where user has access for edit members.. - # either he has edit_member role or check if he is a team member + # either they have edit_member role or check if they are a team member has_edit_role = Role.EDIT_MEMBERS.value in user_roles if has_edit_role: return EngagementScopeOptions(restricted=False) @@ -269,7 +267,7 @@ def _send_closeout_emails(engagement: EngagementModel) -> None: """Send the engagement closeout emails.Throws error if fails.""" subject, body, args = EngagementService._render_email_template(engagement) participants = SubmissionModel.get_engaged_participants(engagement.id) - template_id = current_app.config.get('ENGAGEMENT_CLOSEOUT_EMAIL_TEMPLATE_ID', None) + template_id = current_app.config['EMAIL_TEMPLATES']['CLOSEOUT']['ID'] emails = [participant.decode_email(participant.email_address) for participant in participants] # Removes duplicated records emails = list(set(emails)) @@ -287,9 +285,9 @@ def _render_email_template(engagement: EngagementModel): template = Template.get_template('email_engagement_closeout.html') dashboard_path = EngagementService._get_dashboard_path(engagement) engagement_url = notification.get_tenant_site_url(engagement.tenant_id, dashboard_path) - subject = current_app.config.get('ENGAGEMENT_CLOSEOUT_EMAIL_SUBJECT'). \ - format(engagement_name=engagement.name) - email_environment = get_gc_notify_config('EMAIL_ENVIRONMENT') + templates = current_app.config['EMAIL_TEMPLATES'] + subject = templates['CLOSEOUT']['SUBJECT'].format(engagement_name=engagement.name) + email_environment = templates['ENVIROMENT'] tenant_name = EngagementService._get_tenant_name( engagement.tenant_id) args = { @@ -314,8 +312,7 @@ def _get_tenant_name(tenant_id): @staticmethod def _get_dashboard_path(engagement: EngagementModel): engagement_slug = EngagementSlugModel.find_by_engagement_id(engagement.id) + paths = current_app.config['PATH_CONFIG'] if engagement_slug: - return current_app.config.get('ENGAGEMENT_DASHBOARD_PATH_SLUG'). \ - format(slug=engagement_slug.slug) - return current_app.config.get('ENGAGEMENT_DASHBOARD_PATH'). \ - format(engagement_id=engagement.id) + return paths['ENGAGEMENT']['DASHBOARD_SLUG'].format(slug=engagement_slug.slug) + return paths['ENGAGEMENT']['DASHBOARD'].format(engagement_id=engagement.id) diff --git a/met-api/src/met_api/services/keycloak.py b/met-api/src/met_api/services/keycloak.py index 9170f783c..815976ad9 100644 --- a/met-api/src/met_api/services/keycloak.py +++ b/met-api/src/met_api/services/keycloak.py @@ -28,9 +28,10 @@ class KeycloakService: # pylint: disable=too-few-public-methods @staticmethod def get_user_groups(user_id): """Get user group from Keycloak by userid.""" - base_url = current_app.config.get('KEYCLOAK_BASE_URL') - realm = current_app.config.get('KEYCLOAK_REALMNAME') - timeout = current_app.config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + timeout = keycloak['CONNECT_TIMEOUT'] + base_url = keycloak['BASE_URL'] + realm = keycloak['REALMNAME'] admin_token = KeycloakService._get_admin_token() headers = { 'Content-Type': ContentType.JSON.value, @@ -51,8 +52,9 @@ def get_users_groups(user_ids: List): # TODO fix this during tests and remove below if not base_url: return {} - realm = current_app.config.get('KEYCLOAK_REALMNAME') - timeout = current_app.config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + realm = keycloak['REALMNAME'] + timeout = keycloak['CONNECT_TIMEOUT'] admin_token = KeycloakService._get_admin_token() headers = { 'Content-Type': ContentType.JSON.value, @@ -75,10 +77,10 @@ def get_users_groups(user_ids: List): @staticmethod def _get_group_id(admin_token: str, group_name: str): """Get a group id for the group name.""" - config = current_app.config - base_url = config.get('KEYCLOAK_BASE_URL') - realm = config.get('KEYCLOAK_REALMNAME') - timeout = config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + base_url = keycloak['BASE_URL'] + realm = keycloak['REALMNAME'] + timeout = keycloak['CONNECT_TIMEOUT'] get_group_url = f'{base_url}/auth/admin/realms/{realm}/groups?search={group_name}' headers = { 'Content-Type': ContentType.JSON.value, @@ -100,31 +102,32 @@ def _find_group_or_subgroup_id(groups: list, group_name: str): @staticmethod def _get_admin_token(): """Create an admin token.""" - config = current_app.config - base_url = config.get('KEYCLOAK_BASE_URL') - realm = config.get('KEYCLOAK_REALMNAME') - admin_client_id = config.get( - 'KEYCLOAK_ADMIN_USERNAME') - admin_secret = config.get('KEYCLOAK_ADMIN_SECRET') - timeout = config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + admin_client_id = keycloak['ADMIN_USERNAME'] + admin_secret = keycloak['ADMIN_SECRET'] + timeout = keycloak['CONNECT_TIMEOUT'] headers = { 'Content-Type': 'application/x-www-form-urlencoded' } - token_url = f'{base_url}/auth/realms/{realm}/protocol/openid-connect/token' - - response = requests.post(token_url, - data=f'client_id={admin_client_id}&grant_type=client_credentials' - f'&client_secret={admin_secret}', headers=headers, - timeout=timeout) + TOKEN_ISSUER = current_app.config['JWT_CONFIG']['ISSUER'] + token_url = f'{TOKEN_ISSUER}/protocol/openid-connect/token' + + response = requests.post( + token_url, + headers=headers, + timeout=timeout, + data=f'client_id={admin_client_id}&grant_type=client_credentials' + f'&client_secret={admin_secret}' + ) return response.json().get('access_token') @staticmethod def _remove_user_from_group(user_id: str, group_name: str): """Remove user from the keycloak group.""" - config = current_app.config - base_url = config.get('KEYCLOAK_BASE_URL') - realm = config.get('KEYCLOAK_REALMNAME') - timeout = config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + base_url = keycloak['BASE_URL'] + realm = keycloak['REALMNAME'] + timeout = keycloak['CONNECT_TIMEOUT'] # Create an admin token admin_token = KeycloakService._get_admin_token() # Get the '$group_name' group @@ -143,10 +146,10 @@ def _remove_user_from_group(user_id: str, group_name: str): @staticmethod def add_user_to_group(user_id: str, group_name: str): """Add user to the keycloak group.""" - config = current_app.config - base_url = config.get('KEYCLOAK_BASE_URL') - realm = config.get('KEYCLOAK_REALMNAME') - timeout = config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + base_url = keycloak['BASE_URL'] + realm = keycloak['REALMNAME'] + timeout = keycloak['CONNECT_TIMEOUT'] # Create an admin token admin_token = KeycloakService._get_admin_token() # Get the '$group_name' group @@ -185,10 +188,10 @@ def add_attribute_to_user(user_id: str, attribute_value: str, attribute_id: str @staticmethod def remove_user_from_group(user_id: str, group_name: str): """Remove user from the keycloak group.""" - config = current_app.config - base_url = config.get('KEYCLOAK_BASE_URL') - realm = config.get('KEYCLOAK_REALMNAME') - timeout = config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + base_url = keycloak['BASE_URL'] + realm = keycloak['REALMNAME'] + timeout = keycloak['CONNECT_TIMEOUT'] # Create an admin token admin_token = KeycloakService._get_admin_token() # Get the '$group_name' group @@ -206,13 +209,12 @@ def remove_user_from_group(user_id: str, group_name: str): @staticmethod def add_user(user: dict): """Add user to Keycloak.Mainly used for Tests;Dont use it for actual user creation in application.""" - config = current_app.config # Add user and set password admin_token = KeycloakService._get_admin_token() - - base_url = config.get('KEYCLOAK_BASE_URL') - realm = config.get('KEYCLOAK_REALMNAME') - timeout = config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + base_url = keycloak['BASE_URL'] + realm = keycloak['REALMNAME'] + timeout = keycloak['CONNECT_TIMEOUT'] # Add user to the keycloak group '$group_name' headers = { @@ -230,9 +232,10 @@ def add_user(user: dict): @staticmethod def get_user_by_username(username, admin_token=None): """Get user from Keycloak by username.""" - base_url = current_app.config.get('KEYCLOAK_BASE_URL') - realm = current_app.config.get('KEYCLOAK_REALMNAME') - timeout = current_app.config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + base_url = keycloak['BASE_URL'] + realm = keycloak['REALMNAME'] + timeout = keycloak['CONNECT_TIMEOUT'] if not admin_token: admin_token = KeycloakService._get_admin_token() @@ -249,9 +252,10 @@ def get_user_by_username(username, admin_token=None): @staticmethod def toggle_user_enabled_status(user_id, enabled): """Toggle the enabled status of a user in Keycloak.""" - base_url = current_app.config.get('KEYCLOAK_BASE_URL') - realm = current_app.config.get('KEYCLOAK_REALMNAME') - timeout = current_app.config.get('CONNECT_TIMEOUT', 60) + keycloak = current_app.config['KEYCLOAK_CONFIG'] + base_url = keycloak['BASE_URL'] + realm = keycloak['REALMNAME'] + timeout = keycloak['CONNECT_TIMEOUT'] admin_token = KeycloakService._get_admin_token() headers = { 'Content-Type': ContentType.JSON.value, diff --git a/met-api/src/met_api/services/object_storage_service.py b/met-api/src/met_api/services/object_storage_service.py index 9a309089f..3d6b88e75 100644 --- a/met-api/src/met_api/services/object_storage_service.py +++ b/met-api/src/met_api/services/object_storage_service.py @@ -5,10 +5,10 @@ from typing import List import requests +from flask import current_app from aws_requests_auth.aws_auth import AWSRequestsAuth from markupsafe import string -from met_api.config import get_s3_config from met_api.schemas.document import Document @@ -18,50 +18,55 @@ class ObjectStorageService: def __init__(self): """Initialize the service.""" # initialize s3 config from environment variables - self.s3_access_key_id = get_s3_config('S3_ACCESS_KEY_ID') - self.s3_secret_access_key = get_s3_config('S3_SECRET_ACCESS_KEY') - self.s3_host = get_s3_config('S3_HOST') - self.s3_bucket = get_s3_config('S3_BUCKET') - self.s3_region = get_s3_config('S3_REGION') - self.s3_service = get_s3_config('S3_SERVICE') + s3 = current_app.config['S3_CONFIG'] + self.s3_auth = AWSRequestsAuth( + aws_access_key=s3["ACCESS_KEY_ID"], + aws_secret_access_key=s3["SECRET_ACCESS_KEY"], + aws_host=s3["HOST"], + aws_region=s3["REGION"], + aws_service=s3["SERVICE"] + ) + self.s3_bucket = s3["BUCKET"] def get_url(self, filename: string): """Get the object url.""" - if(not self.s3_host or + if(not self.s3_auth.aws_host or not self.s3_bucket or not filename ): return '' - return f'https://{self.s3_host}/{self.s3_bucket}/{filename}' + + return f'https://{self.s3_auth.aws_host}/{self.s3_bucket}/{filename}' def get_auth_headers(self, documents: List[Document]): - """Get the s3 auth headers or the provided documents.""" - if(self.s3_access_key_id is None or - self.s3_secret_access_key is None or - self.s3_host is None or - self.s3_bucket is None - ): - return {'status': 'Configuration Issue', - 'message': 'accesskey is None or secretkey is None or S3 host is None or formsbucket is None'}, 500 + """Get the S3 auth headers for the provided documents.""" + if ( + self.s3_auth.aws_access_key is None + or self.s3_auth.aws_secret_access_key is None + or self.s3_auth.aws_host is None + or self.s3_bucket is None + ): + return { + 'status': 'Configuration Issue', + 'message': 'accesskey is None or secretkey is None or S3 host is None or formsbucket is None' + }, 500 for file in documents: s3sourceuri = file.get('s3sourceuri', None) filenamesplittext = os.path.splitext(file.get('filename')) uniquefilename = f'{uuid.uuid4()}{filenamesplittext[1]}' - auth = AWSRequestsAuth( - aws_access_key=self.s3_access_key_id, - aws_secret_access_key=self.s3_secret_access_key, - aws_host=self.s3_host, - aws_region=self.s3_region, - aws_service=self.s3_service) s3uri = s3sourceuri if s3sourceuri is not None else self.get_url(uniquefilename) - response = requests.put( - s3uri, data=None, auth=auth) if s3sourceuri is None else requests.get(s3uri, auth=auth) + + if s3sourceuri is None: + response = requests.put(s3uri, data=None, auth=self.s3_auth) + else: + response = requests.get(s3uri, auth=self.s3_auth) file['filepath'] = s3uri file['authheader'] = response.request.headers['Authorization'] file['amzdate'] = response.request.headers['x-amz-date'] file['uniquefilename'] = uniquefilename if s3sourceuri is None else '' + return documents diff --git a/met-api/src/met_api/services/project_service.py b/met-api/src/met_api/services/project_service.py index 64286027f..a9aca2644 100644 --- a/met-api/src/met_api/services/project_service.py +++ b/met-api/src/met_api/services/project_service.py @@ -21,8 +21,8 @@ def update_project_info(eng_id: str) -> EngagementModel: logger = logging.getLogger(__name__) try: - is_eao_environment = current_app.config.get('IS_EAO_ENVIRONMENT') - if not is_eao_environment: + epic_integration = current_app.config.get('EPIC_CONFIG') + if not epic_integration['ENABLED']: return engagement_metadata: EngagementMetadataModel @@ -33,14 +33,14 @@ def update_project_info(eng_id: str) -> EngagementModel: eao_service_account_token = ProjectService._get_eao_service_account_token() if engagement_metadata and engagement_metadata.project_tracking_id: - update_url = f'{current_app.config.get("EPIC_URL")}/{engagement_metadata.project_tracking_id}' + update_url = f'{epic_integration["URL"]}/{engagement_metadata.project_tracking_id}' api_response = RestService.put(endpoint=update_url, token=eao_service_account_token, data=epic_comment_period_payload, raise_for_status=False) # no handling of return so far since epic doesnt return anything else: - create_url = f'{current_app.config.get("EPIC_URL")}' + create_url = f'{epic_integration["URL"]}' api_response = RestService.post(endpoint=create_url, token=eao_service_account_token, data=epic_comment_period_payload, raise_for_status=False) response_data = api_response.json() @@ -76,7 +76,7 @@ def _construct_epic_payload(engagement): 'dateStarted': start_date_utc, 'instructions': '', 'commentTip': '', - 'milestone': current_app.config.get('EPIC_MILESTONE'), + 'milestone': current_app.config['EPIC_CONFIG']['MILESTONE_ID'], 'openHouse': '', 'relatedDocuments': '', 'isPublished': 'true' @@ -85,8 +85,9 @@ def _construct_epic_payload(engagement): @staticmethod def _get_eao_service_account_token(): - kc_service_id = current_app.config.get('EPIC_KEYCLOAK_SERVICE_ACCOUNT_ID') - kc_secret = current_app.config.get('EPIC_KEYCLOAK_SERVICE_ACCOUNT_SECRET') - issuer_url = current_app.config.get('EPIC_JWT_OIDC_ISSUER') - client_id = current_app.config.get('EPIC_KC_CLIENT_ID') + epic = current_app.config['EPIC_CONFIG'] + kc_service_id = epic.get('KEYCLOAK_SERVICE_ACCOUNT_ID') + kc_secret = epic.get('KEYCLOAK_SERVICE_ACCOUNT_SECRET') + client_id = epic.get('KEYCLOAK_CLIENT_ID') + issuer_url = epic.get('JWT_OIDC_ISSUER') return RestService.get_access_token_with_password(kc_service_id, kc_secret, client_id, issuer_url) diff --git a/met-api/src/met_api/services/rest_service.py b/met-api/src/met_api/services/rest_service.py index 2551979d4..017e9b505 100644 --- a/met-api/src/met_api/services/rest_service.py +++ b/met-api/src/met_api/services/rest_service.py @@ -57,8 +57,10 @@ def _invoke(rest_method, endpoint, token=None, # pylint: disable=too-many-argum response = None try: invoke_rest_method = getattr(requests, rest_method) - response = invoke_rest_method(endpoint, data=data, headers=headers, - timeout=current_app.config.get('CONNECT_TIMEOUT', 60)) + response = invoke_rest_method( + endpoint, data=data, headers=headers, + timeout=current_app.config['KEYCLOAK_CONFIG']['CONNECT_TIMEOUT'] + ) if raise_for_status: response.raise_for_status() except (ReqConnectionError, ConnectTimeout) as exc: @@ -106,9 +108,10 @@ def put(endpoint, token=None, # pylint: disable=too-many-arguments @staticmethod def get_service_account_token(kc_service_id: str = None, kc_secret: str = None, issuer_url: str = None) -> str: """Generate a service account token.""" - kc_service_id = kc_service_id or current_app.config.get('KEYCLOAK_SERVICE_ACCOUNT_ID') - kc_secret = kc_secret or current_app.config.get('KEYCLOAK_SERVICE_ACCOUNT_SECRET') - issuer_url = issuer_url or current_app.config.get('JWT_OIDC_ISSUER') + keycloak = current_app.config['KEYCLOAK_CONFIG'] + kc_service_id = kc_service_id or keycloak.get('SERVICE_ACCOUNT_ID') + kc_secret = kc_secret or keycloak.get('SERVICE_ACCOUNT_SECRET') + issuer_url = issuer_url or current_app.config['JWT_OIDC_ISSUER'] if kc_service_id is None or kc_secret is None or issuer_url is None: raise ValueError('Missing required parameters') diff --git a/met-api/src/met_api/services/slug_generation_service.py b/met-api/src/met_api/services/slug_generation_service.py index 6152b8cac..c91df42f2 100644 --- a/met-api/src/met_api/services/slug_generation_service.py +++ b/met-api/src/met_api/services/slug_generation_service.py @@ -1,7 +1,6 @@ """Service for generating slugs.""" from slugify import UniqueSlugify -from met_api.config import _Config - +from met_api.config import Config class SlugGenerationService: """Service for generating slugs.""" @@ -19,6 +18,6 @@ def create_custom_unique_slugify(): """Create and return a unique slugify.""" slugify = UniqueSlugify( to_lower=True, # NOSONAR # to_lower is a valid paramter for awesome-slugify - max_length=_Config.SLUG_MAX_CHARACTERS + max_length=Config.SLUG_MAX_CHARACTERS, ) return slugify diff --git a/met-api/src/met_api/services/staff_user_service.py b/met-api/src/met_api/services/staff_user_service.py index bcd3725fc..4c0faa080 100644 --- a/met-api/src/met_api/services/staff_user_service.py +++ b/met-api/src/met_api/services/staff_user_service.py @@ -12,8 +12,6 @@ from met_api.utils.constants import GROUP_NAME_MAPPING, Groups from met_api.utils.enums import KeycloakGroupName from met_api.utils.template import Template -from met_api.config import get_gc_notify_config - KEYCLOAK_SERVICE = KeycloakService() @@ -56,11 +54,11 @@ def create_or_update_user(self, user: dict): @staticmethod def _send_access_request_email(user: StaffUserModel) -> None: """Send a new user email.Throws error if fails.""" - to_email_address = get_gc_notify_config('ACCESS_REQUEST_EMAIL_ADDRESS') + templates = current_app.config['EMAIL_TEMPLATES'] + to_email_address = templates['ACCESS_REQUEST']['DEST_EMAIL_ADDRESS'] if to_email_address is None: return - - template_id = get_gc_notify_config('ACCESS_REQUEST_EMAIL_TEMPLATE_ID') + template_id = templates['ACCESS_REQUEST']['ID'] subject, body, args = StaffUserService._render_email_template(user) try: notification.send_email(subject=subject, @@ -77,10 +75,13 @@ def _send_access_request_email(user: StaffUserModel) -> None: @staticmethod def _render_email_template(user: StaffUserModel): template = Template.get_template('email_access_request.html') - subject = get_gc_notify_config('ACCESS_REQUEST_EMAIL_SUBJECT') - grant_access_url = \ - notification.get_tenant_site_url(user.tenant_id, current_app.config.get('USER_MANAGEMENT_PATH')) - email_environment = get_gc_notify_config('EMAIL_ENVIRONMENT') + templates = current_app.config['EMAIL_TEMPLATES'] + paths = current_app.config['PATHS'] + subject = templates['ACCESS_REQUEST']['SUBJECT'] + grant_access_url = notification.get_tenant_site_url( + user.tenant_id, paths['USER_MANAGEMENT'] + ) + email_environment = templates['ENVIRONMENT'] args = { 'first_name': user.first_name, 'last_name': user.last_name, diff --git a/met-api/src/met_api/services/submission_service.py b/met-api/src/met_api/services/submission_service.py index f13fd17da..41e28352b 100644 --- a/met-api/src/met_api/services/submission_service.py +++ b/met-api/src/met_api/services/submission_service.py @@ -32,7 +32,6 @@ from met_api.utils import notification from met_api.utils.roles import Role from met_api.utils.template import Template -from met_api.config import get_gc_notify_config class SubmissionService: @@ -329,7 +328,8 @@ def _send_rejected_email(staff_review_details: dict, submission: SubmissionModel """Send an verification email.Throws error if fails.""" participant_id = submission.participant_id participant = ParticipantModel.find_by_id(participant_id) - template_id = get_gc_notify_config('REJECTED_EMAIL_TEMPLATE_ID') + templates = current_app.config.get('EMAIL_TEMPLATES') + template_id = templates['REJECTED']['ID'] subject, body, args = SubmissionService._render_email_template( staff_review_details, submission, review_note, token) try: @@ -353,18 +353,20 @@ def _render_email_template(staff_review_details: dict, submission: SubmissionMod engagement: EngagementModel = EngagementModel.find_by_id( submission.engagement_id) survey: SurveyModel = SurveyModel.find_by_id(submission.survey_id) + templates = current_app.config['EMAIL_TEMPLATES'] + paths = current_app.config['PATH_CONFIG'] engagement_name = engagement.name survey_name = survey.name tenant_name = SubmissionService._get_tenant_name( engagement.tenant_id) - submission_path = current_app.config.get('SUBMISSION_PATH'). \ - format(engagement_id=submission.engagement_id, - submission_id=submission.id, token=token) + submission_path = paths['SUBMISSION'].format( + engagement_id=submission.engagement_id, + submission_id=submission.id, token=token + ) submission_url = notification.get_tenant_site_url( engagement.tenant_id, submission_path) - subject = get_gc_notify_config('REJECTED_EMAIL_SUBJECT'). \ - format(engagement_name=engagement_name) - email_environment = get_gc_notify_config('EMAIL_ENVIRONMENT') + subject = templates['REJECTED']['SUBJECT'] + email_environment = templates['ENVIRONMENT'] args = { 'engagement_name': engagement_name, 'survey_name': survey_name, @@ -395,7 +397,8 @@ def _render_email_template(staff_review_details: dict, submission: SubmissionMod def _send_submission_response_email(participant_id, engagement_id) -> None: """Send response to survey submission.""" participant = ParticipantModel.find_by_id(participant_id) - template_id = get_gc_notify_config('SUBMISSION_RESPONSE_EMAIL_TEMPLATE_ID') + templates = current_app.config['EMAIL_TEMPLATES'] + template_id = templates['SUBMISSION_RESPONSE']['ID'] subject, body, args = SubmissionService._render_submission_response_email_template(engagement_id) try: notification.send_email(subject=subject, @@ -414,16 +417,17 @@ def _send_submission_response_email(participant_id, engagement_id) -> None: @staticmethod def _render_submission_response_email_template(engagement_id): engagement: EngagementModel = EngagementModel.find_by_id(engagement_id) + templates = current_app.config['EMAIL_TEMPLATES'] template = Template.get_template('submission_response.html') - subject = get_gc_notify_config('SUBMISSION_RESPONSE_EMAIL_SUBJECT') + subject = templates['SUBMISSION_RESPONSE']['SUBJECT'] dashboard_path = SubmissionService._get_dashboard_path(engagement) engagement_url = notification.get_tenant_site_url(engagement.tenant_id, dashboard_path) - email_environment = get_gc_notify_config('EMAIL_ENVIRONMENT') + email_environment = templates['ENVIRONMENT'] tenant_name = SubmissionService._get_tenant_name( engagement.tenant_id) args = { 'engagement_url': engagement_url, - 'engagement_time': get_gc_notify_config('ENGAGEMENT_END_TIME'), + 'engagement_time': templates['CLOSING_TIME'], 'engagement_end_date': datetime.strftime(engagement.end_date, EmailVerificationService.full_date_format), 'tenant_name': tenant_name, 'email_environment': email_environment, @@ -440,11 +444,14 @@ def _render_submission_response_email_template(engagement_id): @staticmethod def _get_dashboard_path(engagement: EngagementModel): engagement_slug = EngagementSlugModel.find_by_engagement_id(engagement.id) + paths = current_app.config['PATH_CONFIG'] if engagement_slug: - return current_app.config.get('ENGAGEMENT_DASHBOARD_PATH_SLUG'). \ - format(slug=engagement_slug.slug) - return current_app.config.get('ENGAGEMENT_DASHBOARD_PATH'). \ - format(engagement_id=engagement.id) + return paths['ENGAGEMENT']['DASHBOARD_SLUG'].format( + slug=engagement_slug.slug + ) + return paths['ENGAGEMENT']['DASHBOARD'].format( + engagement_id=engagement.id + ) @staticmethod def is_unpublished(engagement_id): diff --git a/met-api/src/met_api/utils/constants.py b/met-api/src/met_api/utils/constants.py index cd34fcc90..d0a81c7de 100644 --- a/met-api/src/met_api/utils/constants.py +++ b/met-api/src/met_api/utils/constants.py @@ -38,3 +38,62 @@ def get_name_by_value(value): GROUP_NAME_MAPPING = {group.name: group.value for group in Groups} TENANT_ID_JWT_CLAIM = 'tenant_id' + +class TestKeyConfig: # pylint: disable=too-few-public-methods + """This config is used to hold bulky test keys so they don't clutter up the + main configuration file for the app. + These keys are publicly available and can be used for testing purposes. + """ + + JWT_OIDC_TEST_KEYS = { + 'keys': [ + { + 'kid': 'met-web', + 'kty': 'RSA', + 'alg': 'RS256', + 'use': 'sig', + 'n': 'AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-' + 'TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR', + 'e': 'AQAB' + } + ] + } + + JWT_OIDC_TEST_PRIVATE_KEY_JWKS = { + 'keys': [ + { + 'kid': 'met-web', + 'kty': 'RSA', + 'alg': 'RS256', + 'use': 'sig', + 'n': 'AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-' + 'TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR', + 'e': 'AQAB', + 'd': 'C0G3QGI6OQ6tvbCNYGCqq043YI_8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhskURaDwk4-' + '8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh_' + 'xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0', + 'p': 'APXcusFMQNHjh6KVD_hOUIw87lvK13WkDEeeuqAydai9Ig9JKEAAfV94W6Aftka7tGgE7ulg1vo3eJoLWJ1zvKM', + 'q': 'AOjX3OnPJnk0ZFUQBwhduCweRi37I6DAdLTnhDvcPTrrNWuKPg9uGwHjzFCJgKd8KBaDQ0X1rZTZLTqi3peT43s', + 'dp': 'AN9kBoA5o6_Rl9zeqdsIdWFmv4DB5lEqlEnC7HlAP-3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhc', + 'dq': 'ANtbSY6njfpPploQsF9sU26U0s7MsuLljM1E8uml8bVJE1mNsiu9MgpUvg39jEu9BtM2tDD7Y51AAIEmIQex1nM', + 'qi': 'XLE5O360x-MhsdFXx8Vwz4304-MJg-oGSJXCK_ZWYOB_FGXFRTfebxCsSYi0YwJo-oNu96bvZCuMplzRI1liZw' + } + ] + } + + JWT_OIDC_TEST_PRIVATE_KEY_PEM = """ + -----BEGIN RSA PRIVATE KEY----- + MIICXQIBAAKBgQDfn1nKQshOSj8xw44oC2klFWSNLmK3BnHONCJ1bZfq0EQ5gIfg + tlvB+Px8Ya+VS3OnK7Cdi4iU1fxO9ktN6c6TjmmmFevk8wIwqLthmCSF3r+3+h4e + ddj7hucMsXWv05QUrCPoL6YUUz7Cgpz7ra24rpAmK5z7lsV+f3BEvXkrUQIDAQAB + AoGAC0G3QGI6OQ6tvbCNYGCqq043YI/8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhs + kURaDwk4+8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh/ + xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0CQQD13LrBTEDR44ei + lQ/4TlCMPO5bytd1pAxHnrqgMnWovSIPSShAAH1feFugH7ZGu7RoBO7pYNb6N3ia + C1idc7yjAkEA6Nfc6c8meTRkVRAHCF24LB5GLfsjoMB0tOeEO9w9Ous1a4o+D24b + AePMUImAp3woFoNDRfWtlNktOqLel5PjewJBAN9kBoA5o6/Rl9zeqdsIdWFmv4DB + 5lEqlEnC7HlAP+3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhcCQQDb + W0mOp436T6ZaELBfbFNulNLOzLLi5YzNRPLppfG1SRNZjbIrvTIKVL4N/YxLvQbT + NrQw+2OdQACBJiEHsdZzAkBcsTk7frTH4yGx0VfHxXDPjfTj4wmD6gZIlcIr9lZg + 4H8UZcVFN95vEKxJiLRjAmj6g273pu9kK4ymXNEjWWJn + -----END RSA PRIVATE KEY-----""" diff --git a/met-api/src/met_api/utils/notification.py b/met-api/src/met_api/utils/notification.py index 9f0a7288a..472ced067 100644 --- a/met-api/src/met_api/utils/notification.py +++ b/met-api/src/met_api/utils/notification.py @@ -27,7 +27,7 @@ def send_email(subject, email, html_body, args, template_id): if not email or not is_valid_email(email): return - sender = current_app.config.get('MAIL_FROM_ID') + sender = current_app.config['EMAIL_TEMPLATES']['FROM_ADDRESS'] service_account_token = RestService.get_service_account_token() send_email_endpoint = current_app.config.get('NOTIFICATIONS_EMAIL_ENDPOINT') payload = { diff --git a/met-api/src/met_api/utils/tenant_validator.py b/met-api/src/met_api/utils/tenant_validator.py index caa0bd720..2696b6433 100644 --- a/met-api/src/met_api/utils/tenant_validator.py +++ b/met-api/src/met_api/utils/tenant_validator.py @@ -25,6 +25,7 @@ from met_api.auth import jwt as _jwt from met_api.utils.constants import TENANT_ID_JWT_CLAIM from met_api.utils.roles import Role +from met_api.models.staff_user import StaffUser def require_role(role, skip_tenant_check_for_admin=False): @@ -54,14 +55,14 @@ def wrapper(*args, **kwargs): if skip_tenant_check_for_admin and is_met_global_admin(token_info): return func(*args, **kwargs) - tenant_id = token_info.get(TENANT_ID_JWT_CLAIM, None) - current_app.logger.debug(f'Tenant Id From JWT Claim {tenant_id}') - current_app.logger.debug(f'Tenant Id From g {g.tenant_id}') - if g.tenant_id and str(g.tenant_id) == str(tenant_id): + user_id = token_info.get('sub', None) + # fetch user from the db + user = StaffUser.get_user_by_external_id(user_id) + if user and user.tenant_id == g.tenant_id: return func(*args, **kwargs) else: abort(HTTPStatus.FORBIDDEN, - description='The user has no access to this tenant') + description='The user does not exist or has no access to this tenant') return wrapper @@ -74,7 +75,5 @@ def _get_token_info() -> Dict: def is_met_global_admin(token_info) -> bool: """Return True if the user is MET Admin ie who can manage all tenants.""" - roles: list = token_info.get('realm_access', None).get('roles', []) if 'realm_access' in token_info \ - else [] - + roles = current_app.config['JWT_ROLE_CALLBACK'](token_info) return Role.CREATE_TENANT.value in roles diff --git a/met-api/src/met_api/utils/user_context.py b/met-api/src/met_api/utils/user_context.py index 4aa0cbde4..f09bcf38a 100644 --- a/met-api/src/met_api/utils/user_context.py +++ b/met-api/src/met_api/utils/user_context.py @@ -16,7 +16,7 @@ import functools from typing import Dict -from flask import g, request +from flask import g, request, current_app from met_api.utils.constants import TENANT_ID_JWT_CLAIM from met_api.utils.roles import Role @@ -39,8 +39,7 @@ def __init__(self): self._last_name: str = token_info.get('lastname', None) self._tenant_id: str = token_info.get(TENANT_ID_JWT_CLAIM, None) self._bearer_token: str = _get_token() - self._roles: list = token_info.get('realm_access', None).get('roles', []) if 'realm_access' in token_info \ - else [] + self._roles: list = current_app.config['JWT_ROLE_CALLBACK'](token_info) self._sub: str = token_info.get('sub', None) self._name: str = f"{token_info.get('firstname', None)} {token_info.get('lastname', None)}" diff --git a/met-api/src/met_api/utils/util.py b/met-api/src/met_api/utils/util.py index feb7127dc..91a0d1e5d 100644 --- a/met-api/src/met_api/utils/util.py +++ b/met-api/src/met_api/utils/util.py @@ -19,12 +19,10 @@ import base64 import os -import re import urllib from humps.main import camelize, decamelize - def cors_preflight(methods): """Render an option method on the class.""" @@ -42,6 +40,16 @@ def options(self, *args, **kwargs): # pylint: disable=unused-argument return wrapper +def is_truthy(value: str) -> bool: + """ + Check if a value is truthy or not. + + :param value: The value to check. + :return: True if the value seems affirmative, False otherwise. + """ + return str(value).lower() in ('1', 'true', 'yes', 'y', 'on') + + def camelback2snake(camel_dict: dict): """Convert the passed dictionary's keys from camelBack case to snake_case.""" @@ -52,15 +60,9 @@ def snake2camelback(snake_dict: dict): """Convert the passed dictionary's keys from snake_case to camelBack case.""" return camelize(snake_dict) - def allowedorigins(): - """Return allowed origin.""" - _allowedcors = os.getenv('CORS_ORIGIN') - allowedcors = [] - if _allowedcors and ',' in _allowedcors: - for entry in re.split(',', _allowedcors): - allowedcors.append(entry) - return allowedcors + """Return the allowed origins for CORS.""" + return os.getenv('CORS_ORIGINS', '').split(',') class Singleton(type): @@ -77,7 +79,8 @@ def __call__(cls, *args, **kwargs): def digitify(payload: str) -> int: """Return the digits from the string.""" - return int(re.sub(r'\D', '', payload)) + digits = ''.join(filter(str.isdigit, payload)) + return int(digits) def escape_wam_friendly_url(param): diff --git a/met-cron/config.py b/met-cron/config.py index 635561079..47cdda6bb 100644 --- a/met-cron/config.py +++ b/met-cron/config.py @@ -37,25 +37,28 @@ } -def get_named_config(config_name: str = 'development'): - """Return the configuration object based on the name. +def get_named_config(environment: str | None) -> '_Config': + """ + Retrieve a configuration object by name. Used by the Flask app factory. - :raise: KeyError: if an unknown configuration is requested + :param config_name: The name of the configuration. + :return: The requested configuration object. + :raises: KeyError if the requested configuration is not found. """ - print(f'CONFIGURATION: {config_name}') - if config_name in ['production', 'staging', 'default']: - config = ProdConfig() - elif config_name == 'testing': - config = TestConfig() - elif config_name == 'development': - config = DevConfig() - elif config_name == 'docker': - config = DockerConfig() - elif config_name == 'migration': - config = MigrationConfig() - else: - raise KeyError("Unknown configuration '{config_name}'") - return config + config_mapping = { + 'development': DevConfig, + 'default': ProdConfig, + 'staging': ProdConfig, + 'production': ProdConfig, + 'testing': TestConfig, + 'docker': DockerConfig, + 'migration': MigrationConfig, + } + try: + print(f'Loading configuration: {environment}...') + return config_mapping[environment]() + except KeyError: + raise KeyError(f'Configuration "{environment}" not found.') class _Config(): # pylint: disable=too-few-public-methods diff --git a/met-cron/invoke_jobs.py b/met-cron/invoke_jobs.py index 9713848a2..316667e42 100644 --- a/met-cron/invoke_jobs.py +++ b/met-cron/invoke_jobs.py @@ -21,7 +21,7 @@ from flask import Flask from utils.logger import setup_logging -import config +from config import get_named_config setup_logging(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'logging.conf')) # important to do this first @@ -32,9 +32,10 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): app = Flask(__name__) print(f'>>>>> Creating app in run_mode: {run_mode}') - print(f'>>>>> Creating app in run_mode: {config.CONFIGURATION[run_mode]}') - app.config.from_object(config.CONFIGURATION[run_mode]) + # Configure app from config.py + app.config.from_object(get_named_config(run_mode)) + # Configure Sentry app.logger.info(f'<<<< Starting Jobs >>>>') db.init_app(app) diff --git a/met-cron/src/met_cron/services/mail_service.py b/met-cron/src/met_cron/services/mail_service.py index 9b7856056..fda052eb0 100644 --- a/met-cron/src/met_cron/services/mail_service.py +++ b/met-cron/src/met_cron/services/mail_service.py @@ -57,11 +57,11 @@ def _render_email_template(engagement, participant, template): project_name = None if metadata_model and 'project_name' in metadata_model.project_metadata: project_name = metadata_model.project_metadata.get('project_name') - view_path = current_app.config.get('ENGAGEMENT_VIEW_PATH'). \ - format(engagement_id=engagement.id) - unsubscribe_url = current_app.config.get('UNSUBSCRIBE_PATH'). \ - format(engagement_id=engagement.id, participant_id=participant.id) - email_environment = current_app.config.get('EMAIL_ENVIRONMENT', '') + paths = current_app.config['PATH_CONFIG'] + view_path = paths['ENGAGEMENT']['VIEW'].format(engagement_id=engagement.id) + unsubscribe_url = paths['UNSUBSCRIBE'].format( + engagement_id=engagement.id, participant_id=participant.id) + email_environment = current_app.config['EMAIL_TEMPLATES']['ENVIRONMENT'] args = { 'project_name': project_name if project_name else engagement.name, 'survey_url': f'{site_url}{view_path}', diff --git a/met-web/sample.env b/met-web/sample.env index c8f37cb1f..30b6433ba 100644 --- a/met-web/sample.env +++ b/met-web/sample.env @@ -1,9 +1,19 @@ -# Keycloak auth endpoint -REACT_APP_KEYCLOAK_URL=https://dev.loginproxy.gov.bc.ca/auth -REACT_APP_KEYCLOAK_REALM=standard +# Keycloak auth +# Copy from 'GDX MET web (public)-installation-*.json' +# https://bcgov.github.io/sso-requests +REACT_APP_KEYCLOAK_URL= # auth-server-url +REACT_APP_KEYCLOAK_REALM= # realm +REACT_APP_KEYCLOAK_CLIENT= # resource -# Resource identifier for the Keycloak client -REACT_APP_KEYCLOAK_CLIENT=modern-engagement-tools-4787 +# Form.io settings +REACT_APP_FORMIO_PROJECT_URL= +REACT_APP_FORM_ID= +REACT_APP_FORMIO_JWT_SECRET= +REACT_APP_USER_RESOURCE_FORM_ID= +REACT_APP_FORMIO_ANONYMOUS_USER="anonymous" +REACT_APP_FORMIO_ANONYMOUS_ID= + +REACT_APP_PUBLIC_URL=http://localhost:3000 # The role needed to be considered an admin # TODO: Allocate a dedicated role for this on SSO @@ -14,5 +24,10 @@ REACT_APP_API_URL=http://localhost:5000/api # `analytics-api` endpoint REACT_APP_ANALYTICS_API_URL=http://localhost:5001/api -# Default tenant to assign when signing in for the first time -REACT_APP_DEFAULT_TENANT=eao \ No newline at end of file + +# Users visiting the root URL will be redirected to this tenant +REACT_APP_DEFAULT_TENANT=gdx + +# Whether to skip certain auth checks. Should be false in production. +# Must match the value set for IS_SINGLE_TENANT_ENVIRONMENT in the API. +REACT_APP_IS_SINGLE_TENANT_ENVIRONMENT=false \ No newline at end of file diff --git a/met-web/src/config.ts b/met-web/src/config.ts index 1520da771..aa7c5b24f 100644 --- a/met-web/src/config.ts +++ b/met-web/src/config.ts @@ -40,20 +40,18 @@ const getEnv = (key: string, defaultValue = '') => { // adding localStorage to access the MET API from external sources(eg: web-components) const API_URL = localStorage.getItem('met-api-url') || getEnv('REACT_APP_API_URL'); const PUBLIC_URL = localStorage.getItem('met-public-url') || getEnv('REACT_APP_PUBLIC_URL'); -const REDASH_DASHBOARD_URL = getEnv('REACT_APP_REDASH_PUBLIC_URL'); -const REDASH_CMNTS_DASHBOARD_URL = getEnv('REACT_APP_REDASH_COMMENTS_PUBLIC_URL'); // adding localStorage to access the MET Analytics API from external sources(eg: web-components) const REACT_APP_ANALYTICS_API_URL = localStorage.getItem('analytics-api-url') || getEnv('REACT_APP_ANALYTICS_API_URL'); // Formio Environment Variables -const FORMIO_PROJECT_URL = getEnv('REACT_APP_API_PROJECT_URL'); -const FORMIO_API_URL = getEnv('REACT_APP_API_PROJECT_URL'); +const FORMIO_PROJECT_URL = getEnv('REACT_APP_FORMIO_PROJECT_URL'); +const FORMIO_API_URL = getEnv('REACT_APP_FORMIO_PROJECT_URL'); const FORMIO_FORM_ID = getEnv('REACT_APP_FORM_ID'); const FORMIO_JWT_SECRET = getEnv('REACT_APP_FORMIO_JWT_SECRET'); const FORMIO_USER_RESOURCE_FORM_ID = getEnv('REACT_APP_USER_RESOURCE_FORM_ID'); const FORMIO_ANONYMOUS_USER = getEnv('REACT_APP_FORMIO_ANONYMOUS_USER'); -const FORMIO_ANONYMOUS_ID = getEnv('REACT_APP_ANONYMOUS_ID'); +const FORMIO_ANONYMOUS_ID = getEnv('REACT_APP_FORMIO_ANONYMOUS_ID'); // Keycloak Environment Variables const KC_URL = getEnv('REACT_APP_KEYCLOAK_URL'); @@ -69,8 +67,6 @@ export const AppConfig = { apiUrl: API_URL, analyticsApiUrl: REACT_APP_ANALYTICS_API_URL, publicUrl: PUBLIC_URL, - redashDashboardUrl: REDASH_DASHBOARD_URL, - redashCmntsDashboardUrl: REDASH_CMNTS_DASHBOARD_URL, formio: { projectUrl: FORMIO_PROJECT_URL, apiUrl: FORMIO_API_URL, diff --git a/tools/keycloak/docker-compose.yml b/tools/keycloak/docker-compose.yml deleted file mode 100644 index 213071286..000000000 --- a/tools/keycloak/docker-compose.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: "3.5" -services: - keycloak: - container_name: keycloak - image: quay.io/keycloak/keycloak:14.0.0 - volumes: - - ./init:/opt/jboss/keycloak/init - - keycloak:/opt/jboss/keycloak - ports: - - 8080:8080 - networks: - - metnetwork - env_file: - - ./keycloak.env - restart: unless-stopped -volumes: - keycloak: - name: keycloak.local -networks: - metnetwork: - external: - name: metnetwork diff --git a/tools/keycloak/init/realm-export.json b/tools/keycloak/init/realm-export.json deleted file mode 100644 index 06f32146c..000000000 --- a/tools/keycloak/init/realm-export.json +++ /dev/null @@ -1,2671 +0,0 @@ -{ - "id": "met", - "realm": "met", - "displayNameHtml": "