diff --git a/.travis.yml b/.travis.yml index fba238f6..6e6dbf6c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,16 +13,16 @@ addons: organization: "sartography" before_install: - - cp config/travis-testing.py config/testing.py - psql -c 'create database crc_test;' -U postgres install: - - pip install pipenv pytest coverage awscli - - export PATH=$PATH:$HOME/.local/bin; - - pipenv install + - pipenv install --dev env: - - PB_BASE_URL='http://workflow.sartography.com:5001/pb/' + global: + - TESTING=true + - PB_ENABLED=false + - SQLALCHEMY_DATABASE_URI="postgresql://postgres:@localhost:5432/crc_test" script: - pipenv run coverage run -m pytest @@ -33,7 +33,7 @@ after_success: deploy: provider: script - script: bash deploy.sh + script: bash deploy.sh sartography/cr-connect-workflow skip_cleanup: true on: all_branches: true diff --git a/Dockerfile b/Dockerfile index 8e6885a8..bc602d09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,14 @@ -FROM python:3.7-slim +FROM sartography/cr-connect-python-base WORKDIR /app COPY Pipfile Pipfile.lock /app/ RUN set -xe \ - && pip install pipenv \ - && apt-get update -q \ - && apt-get install -y -q \ - gcc python3-dev libssl-dev \ - curl postgresql-client git-core \ - gunicorn3 postgresql-client \ && pipenv install --dev \ && apt-get remove -y gcc python3-dev libssl-dev \ && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /app \ && useradd _gunicorn --no-create-home --user-group COPY . /app/ diff --git a/Pipfile b/Pipfile index 3cf80ffc..34cc883d 100644 --- a/Pipfile +++ b/Pipfile @@ -6,6 +6,7 @@ verify_ssl = true [dev-packages] pytest = "*" pbr = "*" +coverage = "*" [packages] connexion = {extras = ["swagger-ui"],version = "*"} diff --git a/Pipfile.lock b/Pipfile.lock index 19fcdf9d..fc4fba3e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -930,6 +930,43 @@ ], "version": "==19.3.0" }, + "coverage": { + "hashes": [ + "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", + "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", + "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", + "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", + "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", + "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", + "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", + "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", + "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", + "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", + "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", + "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", + "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", + "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", + "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", + "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", + "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", + "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", + "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", + "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", + "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", + "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", + "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", + "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", + "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", + "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", + "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", + "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", + "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", + "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", + "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" + ], + "index": "pypi", + "version": "==5.1" + }, "importlib-metadata": { "hashes": [ "sha256:0505dd08068cfec00f53a74a0ad927676d7757da81b7436a6eefe4c7cf75c545", diff --git a/config/default.py b/config/default.py index 3faaef7b..ed44e6fe 100644 --- a/config/default.py +++ b/config/default.py @@ -9,9 +9,10 @@ JSON_SORT_KEYS = False # CRITICAL. Do not sort the data when returning values NAME = "CR Connect Workflow" FLASK_PORT = environ.get('PORT0') or environ.get('FLASK_PORT', default="5000") CORS_ALLOW_ORIGINS = re.split(r',\s*', environ.get('CORS_ALLOW_ORIGINS', default="localhost:4200, localhost:5002")) -DEVELOPMENT = environ.get('DEVELOPMENT', default="true") == "true" TESTING = environ.get('TESTING', default="false") == "true" -PRODUCTION = (environ.get('PRODUCTION', default="false") == "true") or (not DEVELOPMENT and not TESTING) +PRODUCTION = (environ.get('PRODUCTION', default="false") == "true") +TEST_UID = environ.get('TEST_UID', default="dhf8r") +ADMIN_UIDS = re.split(r',\s*', environ.get('ADMIN_UIDS', default="dhf8r,ajl2j,cah3us,cl3wf")) # Sentry flag ENABLE_SENTRY = environ.get('ENABLE_SENTRY', default="false") == "true" @@ -28,14 +29,14 @@ SQLALCHEMY_DATABASE_URI = environ.get( 'SQLALCHEMY_DATABASE_URI', default="postgresql://%s:%s@%s:%s/%s" % (DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME) ) -TOKEN_AUTH_TTL_HOURS = int(environ.get('TOKEN_AUTH_TTL_HOURS', default=4)) +TOKEN_AUTH_TTL_HOURS = float(environ.get('TOKEN_AUTH_TTL_HOURS', default=24)) TOKEN_AUTH_SECRET_KEY = environ.get('TOKEN_AUTH_SECRET_KEY', default="Shhhh!!! This is secret! And better darn well not show up in prod.") FRONTEND_AUTH_CALLBACK = environ.get('FRONTEND_AUTH_CALLBACK', default="http://localhost:4200/session") SWAGGER_AUTH_KEY = environ.get('SWAGGER_AUTH_KEY', default="SWAGGER") # %s/%i placeholders expected for uva_id and study_id in various calls. PB_ENABLED = environ.get('PB_ENABLED', default="false") == "true" -PB_BASE_URL = environ.get('PB_BASE_URL', default="http://localhost:5001/pb/").strip('/') + '/' # Trailing slash required +PB_BASE_URL = environ.get('PB_BASE_URL', default="http://localhost:5001/v2.0/").strip('/') + '/' # Trailing slash required PB_USER_STUDIES_URL = environ.get('PB_USER_STUDIES_URL', default=PB_BASE_URL + "user_studies?uva_id=%s") PB_INVESTIGATORS_URL = environ.get('PB_INVESTIGATORS_URL', default=PB_BASE_URL + "investigators?studyid=%i") PB_REQUIRED_DOCS_URL = environ.get('PB_REQUIRED_DOCS_URL', default=PB_BASE_URL + "required_docs?studyid=%i") @@ -51,6 +52,6 @@ MAIL_DEBUG = environ.get('MAIL_DEBUG', default=True) MAIL_SERVER = environ.get('MAIL_SERVER', default='smtp.mailtrap.io') MAIL_PORT = environ.get('MAIL_PORT', default=2525) MAIL_USE_SSL = environ.get('MAIL_USE_SSL', default=False) -MAIL_USE_TLS = environ.get('MAIL_USE_TLS', default=True) +MAIL_USE_TLS = environ.get('MAIL_USE_TLS', default=False) MAIL_USERNAME = environ.get('MAIL_USERNAME', default='') MAIL_PASSWORD = environ.get('MAIL_PASSWORD', default='') diff --git a/config/testing.py b/config/testing.py index a7c6a893..c7a777ad 100644 --- a/config/testing.py +++ b/config/testing.py @@ -4,16 +4,15 @@ from os import environ basedir = os.path.abspath(os.path.dirname(__file__)) NAME = "CR Connect Workflow" -DEVELOPMENT = True TESTING = True TOKEN_AUTH_SECRET_KEY = "Shhhh!!! This is secret! And better darn well not show up in prod." -PB_ENABLED = False # This is here, for when we are running the E2E Tests in the frontend code bases. # which will set the TESTING envronment to true, causing this to execute, but we need # to respect the environment variables in that case. # when running locally the defaults apply, meaning we use crc_test for doing the tests # locally, and we don't over-write the database. Did you read this far? Have a cookie! +PB_ENABLED = environ.get('PB_ENABLED', default="false") == "true" DB_HOST = environ.get('DB_HOST', default="localhost") DB_PORT = environ.get('DB_PORT', default="5432") DB_NAME = environ.get('DB_NAME', default="crc_test") @@ -23,8 +22,8 @@ SQLALCHEMY_DATABASE_URI = environ.get( 'SQLALCHEMY_DATABASE_URI', default="postgresql://%s:%s@%s:%s/%s" % (DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME) ) +ADMIN_UIDS = ['dhf8r'] print('### USING TESTING CONFIG: ###') print('SQLALCHEMY_DATABASE_URI = ', SQLALCHEMY_DATABASE_URI) -print('DEVELOPMENT = ', DEVELOPMENT) print('TESTING = ', TESTING) diff --git a/config/travis-testing.py b/config/travis-testing.py deleted file mode 100644 index 17a4b914..00000000 --- a/config/travis-testing.py +++ /dev/null @@ -1,17 +0,0 @@ -import os -basedir = os.path.abspath(os.path.dirname(__file__)) - -NAME = "CR Connect Workflow" -DEVELOPMENT = True -TESTING = True -SQLALCHEMY_DATABASE_URI = "postgresql://postgres:@localhost:5432/crc_test" -TOKEN_AUTH_TTL_HOURS = 2 -TOKEN_AUTH_SECRET_KEY = "Shhhh!!! This is secret! And better darn well not show up in prod." -FRONTEND_AUTH_CALLBACK = "http://localhost:4200/session" # Not Required -PB_ENABLED = False - -print('+++ USING TRAVIS TESTING CONFIG: +++') -print('SQLALCHEMY_DATABASE_URI = ', SQLALCHEMY_DATABASE_URI) -print('DEVELOPMENT = ', DEVELOPMENT) -print('TESTING = ', TESTING) -print('FRONTEND_AUTH_CALLBACK = ', FRONTEND_AUTH_CALLBACK) diff --git a/crc/__init__.py b/crc/__init__.py index 86865e31..1ac2678f 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -57,15 +57,16 @@ env = Environment(loader=FileSystemLoader(template_dir)) mail = Mail(app) print('=== USING THESE CONFIG SETTINGS: ===') -print('DB_HOST = ', ) -print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS']) -print('DEVELOPMENT = ', app.config['DEVELOPMENT']) -print('TESTING = ', app.config['TESTING']) -print('PRODUCTION = ', app.config['PRODUCTION']) -print('PB_BASE_URL = ', app.config['PB_BASE_URL']) -print('LDAP_URL = ', app.config['LDAP_URL']) print('APPLICATION_ROOT = ', app.config['APPLICATION_ROOT']) +print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS']) +print('DB_HOST = ', app.config['DB_HOST']) +print('LDAP_URL = ', app.config['LDAP_URL']) +print('PB_BASE_URL = ', app.config['PB_BASE_URL']) print('PB_ENABLED = ', app.config['PB_ENABLED']) +print('PRODUCTION = ', app.config['PRODUCTION']) +print('TESTING = ', app.config['TESTING']) +print('TEST_UID = ', app.config['TEST_UID']) +print('ADMIN_UIDS = ', app.config['ADMIN_UIDS']) @app.cli.command() def load_example_data(): diff --git a/crc/api.yml b/crc/api.yml index 8ae9d9c6..64f6086a 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -9,54 +9,18 @@ servers: security: - jwt: ['secret'] paths: - /sso_backdoor: + /login: get: - operationId: crc.api.user.backdoor - summary: A backdoor that allows someone to log in as a specific user, if they - are in a staging environment. + operationId: crc.api.user.login + summary: In production, logs the user in via SSO. If not in production, logs in as a specific user for testing. security: [] # Disable security for this endpoint only. parameters: - name: uid - in: query - required: true - schema: - type: string - - name: email_address in: query required: false schema: type: string - - name: display_name - in: query - required: false - schema: - type: string - - name: affiliation - in: query - required: false - schema: - type: string - - name: eppn - in: query - required: false - schema: - type: string - - name: first_name - in: query - required: false - schema: - type: string - - name: last_name - in: query - required: false - schema: - type: string - - name: title - in: query - required: false - schema: - type: string - - name: redirect + - name: redirect_url in: query required: false schema: @@ -150,6 +114,8 @@ paths: $ref: "#/components/schemas/Study" delete: operationId: crc.api.study.delete_study + security: + - auth_admin: ['secret'] summary: Removes the given study completely. tags: - Studies @@ -251,6 +217,8 @@ paths: $ref: "#/components/schemas/WorkflowSpec" put: operationId: crc.api.workflow.update_workflow_specification + security: + - auth_admin: ['secret'] summary: Modifies an existing workflow specification with the given parameters. tags: - Workflow Specifications @@ -268,6 +236,8 @@ paths: $ref: "#/components/schemas/WorkflowSpec" delete: operationId: crc.api.workflow.delete_workflow_specification + security: + - auth_admin: ['secret'] summary: Removes an existing workflow specification tags: - Workflow Specifications @@ -313,6 +283,8 @@ paths: $ref: "#/components/schemas/WorkflowSpecCategory" post: operationId: crc.api.workflow.add_workflow_spec_category + security: + - auth_admin: ['secret'] summary: Creates a new workflow spec category with the given parameters. tags: - Workflow Specification Category @@ -350,6 +322,8 @@ paths: $ref: "#/components/schemas/WorkflowSpecCategory" put: operationId: crc.api.workflow.update_workflow_spec_category + security: + - auth_admin: ['secret'] summary: Modifies an existing workflow spec category with the given parameters. tags: - Workflow Specification Category @@ -367,6 +341,8 @@ paths: $ref: "#/components/schemas/WorkflowSpecCategory" delete: operationId: crc.api.workflow.delete_workflow_spec_category + security: + - auth_admin: ['secret'] summary: Removes an existing workflow spec category tags: - Workflow Specification Category @@ -566,6 +542,8 @@ paths: example: '' put: operationId: crc.api.file.set_reference_file + security: + - auth_admin: ['secret'] summary: Update the contents of a named reference file. tags: - Files @@ -624,6 +602,8 @@ paths: $ref: "#/components/schemas/Workflow" delete: operationId: crc.api.workflow.delete_workflow + security: + - auth_admin: ['secret'] summary: Removes an existing workflow tags: - Workflows and Tasks @@ -944,6 +924,11 @@ components: scheme: bearer bearerFormat: JWT x-bearerInfoFunc: crc.api.user.verify_token + auth_admin: + type: http + scheme: bearer + bearerFormat: JWT + x-bearerInfoFunc: crc.api.user.verify_token_admin schemas: User: properties: diff --git a/crc/api/approval.py b/crc/api/approval.py index c9866f88..b3ee0fed 100644 --- a/crc/api/approval.py +++ b/crc/api/approval.py @@ -24,7 +24,6 @@ def get_approval_counts(as_user=None): .all() study_ids = [a.study_id for a in db_user_approvals] - print('study_ids', study_ids) db_other_approvals = db.session.query(ApprovalModel)\ .filter(ApprovalModel.study_id.in_(study_ids))\ @@ -39,8 +38,8 @@ def get_approval_counts(as_user=None): other_approvals[approval.study_id] = approval counts = {} - for status in ApprovalStatus: - counts[status.name] = 0 + for name, value in ApprovalStatus.__members__.items(): + counts[name] = 0 for approval in db_user_approvals: # Check if another approval has the same study id @@ -57,6 +56,8 @@ def get_approval_counts(as_user=None): counts[ApprovalStatus.CANCELED.name] += 1 elif other_approval.status == ApprovalStatus.APPROVED.name: counts[approval.status] += 1 + else: + counts[approval.status] += 1 else: counts[approval.status] += 1 diff --git a/crc/api/user.py b/crc/api/user.py index 36e9926e..a298808d 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -10,29 +10,113 @@ from crc.services.ldap_service import LdapService, LdapModel .. module:: crc.api.user :synopsis: Single Sign On (SSO) user login and session handlers """ -def verify_token(token): - failure_error = ApiError("invalid_token", "Unable to decode the token you provided. Please re-authenticate", status_code=403) - if (not 'PRODUCTION' in app.config or not app.config['PRODUCTION']) and token == app.config["SWAGGER_AUTH_KEY"]: + + +def verify_token(token=None): + """ + Verifies the token for the user (if provided). If in production environment and token is not provided, + gets user from the SSO headers and returns their token. + + Args: + token: Optional[str] + + Returns: + token: str + + Raises: + ApiError. If not on production and token is not valid, returns an 'invalid_token' 403 error. + If on production and user is not authenticated, returns a 'no_user' 403 error. + """ + + failure_error = ApiError("invalid_token", "Unable to decode the token you provided. Please re-authenticate", + status_code=403) + + if not _is_production() and (token is None or 'user' not in g): g.user = UserModel.query.first() token = g.user.encode_auth_token() - try: - token_info = UserModel.decode_auth_token(token) - g.user = UserModel.query.filter_by(uid=token_info['sub']).first() - except: - raise failure_error - if g.user is not None: - return token_info + if token: + try: + token_info = UserModel.decode_auth_token(token) + g.user = UserModel.query.filter_by(uid=token_info['sub']).first() + except: + raise failure_error + if g.user is not None: + return token_info + else: + raise failure_error + + # If there's no token and we're in production, get the user from the SSO headers and return their token + if not token and _is_production(): + uid = _get_request_uid(request) + + if uid is not None: + db_user = UserModel.query.filter_by(uid=uid).first() + + if db_user is not None: + g.user = db_user + token = g.user.encode_auth_token().decode() + token_info = UserModel.decode_auth_token(token) + return token_info + + else: + raise ApiError("no_user", "User not found. Please login via the frontend app before accessing this feature.", + status_code=403) + + +def verify_token_admin(token=None): + """ + Verifies the token for the user (if provided) in non-production environment. If in production environment, + checks that the user is in the list of authorized admins + + Args: + token: Optional[str] + + Returns: + token: str + """ + + # If this is production, check that the user is in the list of admins + if _is_production(): + uid = _get_request_uid(request) + + if uid is not None and uid in app.config['ADMIN_UIDS']: + return verify_token() + + # If we're not in production, just use the normal verify_token method else: - raise failure_error + return verify_token(token) def get_current_user(): return UserModelSchema().dump(g.user) -@app.route('/v1.0/login') -def sso_login(): - # This what I see coming back: + +def login( + uid=None, + redirect_url=None, +): + """ + In non-production environment, provides an endpoint for end-to-end system testing that allows the system + to simulate logging in as a specific user. In production environment, simply logs user in via single-sign-on + (SSO) Shibboleth authentication headers. + + Args: + uid: Optional[str] + redirect_url: Optional[str] + + Returns: + str. If not on production, returns the frontend auth callback URL, with auth token appended. + If on production and user is authenticated via SSO, returns the frontend auth callback URL, + with auth token appended. + + Raises: + ApiError. If on production and user is not authenticated, returns a 404 error. + """ + + # ---------------------------------------- + # Shibboleth Authentication Headers + # ---------------------------------------- # X-Remote-Cn: Daniel Harold Funk (dhf8r) # X-Remote-Sn: Funk # X-Remote-Givenname: Daniel @@ -47,59 +131,52 @@ def sso_login(): # X-Forwarded-Host: dev.crconnect.uvadcos.io # X-Forwarded-Server: dev.crconnect.uvadcos.io # Connection: Keep-Alive - uid = request.headers.get("Uid") - if not uid: - uid = request.headers.get("X-Remote-Uid") - if not uid: - raise ApiError("invalid_sso_credentials", "'Uid' nor 'X-Remote-Uid' were present in the headers: %s" - % str(request.headers)) - redirect = request.args.get('redirect') - app.logger.info("SSO_LOGIN: Full URL: " + request.url) - app.logger.info("SSO_LOGIN: User Id: " + uid) - app.logger.info("SSO_LOGIN: Will try to redirect to : " + str(redirect)) - info = LdapService.user_info(uid) - return _handle_login(info, redirect) + # If we're in production, override any uid with the uid from the SSO request headers + if _is_production(): + uid = _get_request_uid(request) + + if uid: + app.logger.info("SSO_LOGIN: Full URL: " + request.url) + app.logger.info("SSO_LOGIN: User Id: " + uid) + app.logger.info("SSO_LOGIN: Will try to redirect to : " + str(redirect_url)) + + ldap_info = LdapService().user_info(uid) + + if ldap_info: + return _handle_login(ldap_info, redirect_url) + + raise ApiError('404', 'unknown') + @app.route('/sso') def sso(): response = "" response += "

Headers

" response += "