diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..3ab49813 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Describe your changes + +## Secure Software Development Lifecycle +- [ ] High Level Data Flow Diagrams Exist for Feature/Function? +- [ ] Initial Threat Modeling table has been completed against diagram? +- [ ] Have code changes been validated against [OWASP Top 10?](https://owasp.org/www-project-top-ten/) + - [A01:2021 - Broken Access Control](https://owasp.org/Top10/A01_2021-Broken_Access_Control/) + - [A02:2021 - Cryptographic Failures](https://owasp.org/Top10/A02_2021-Cryptographic_Failures/) + - [A03:2021 - Injection](https://owasp.org/Top10/A03_2021-Injection/) + - [A04:2021 - Insecure Design](https://owasp.org/Top10/A04_2021-Insecure_Design/) + - [A05:2021 - Security Misconfiguration](https://owasp.org/Top10/A05_2021-Security_Misconfiguration/) + - [A06:2021 - Vulnerable and Outdated Components](https://owasp.org/Top10/A06_2021-Vulnerable_and_Outdated_Components/) + - [A07:2021 - Identification and Authentication Failures](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/) + - [A08:2021 - Software and Data Integrity Failures](https://owasp.org/Top10/A08_2021-Software_and_Data_Integrity_Failures/) + - [A09:2021 - Security Logging and Monitoring Failures](https://owasp.org/Top10/A09_2021-Security_Logging_and_Monitoring_Failures/) + - [A10:2021 - Server-Side Request Forgery](https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/) diff --git a/Makefile b/Makefile index 4174a428..e1890c79 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ DOCKER_TAG := ${VERSION} DOCKER_IMAGE := ${DOCKER_OWNER}/${DOCKER_APP}:$(DOCKER_TAG) SECRET_KEY := $(shell openssl rand -base64 12) APP_LIST ?= api appstore core frontend middleware product -BRANDS := braini bdc heal restartr scidas eduhelx argus tracs eduhelx-sandbox eduhelx-dev +BRANDS := braini bdc heal restartr scidas eduhelx argus tracs eduhelx-sandbox eduhelx-dev eduhelx-dev-student eduhelx-dev-professor eduhelx-student eduhelx-professor MANAGE := ${PYTHON} appstore/manage.py SETTINGS_MODULE := ${DJANGO_SETTINGS_MODULE} @@ -55,7 +55,7 @@ LOG_LEVEL := "info" endif ifeq "${DEBUG}" "true" -LOG_LEVEL := "debug" +LOG_LEVEL := DEBUG endif ifeq "${ENVS_FROM_FILE}" "true" diff --git a/appstore/api/v1/views.py b/appstore/api/v1/views.py index 428ccb9d..66d46423 100644 --- a/appstore/api/v1/views.py +++ b/appstore/api/v1/views.py @@ -19,7 +19,7 @@ from allauth import socialaccount from tycho.context import ContextFactory, Principal -from core.models import IrodAuthorizedUser +from core.models import IrodAuthorizedUser, UserIdentityToken from .models import Instance, InstanceSpec, App, LoginProvider, Resources, User from .serializers import ( @@ -225,7 +225,8 @@ def to_bytes(memory): # TODO fetch by user instead of iterating all? # sanitize input to avoid injection. -def get_social_tokens(username): +def get_social_tokens(request): + username = request.user social_token_model_objects = ( ContentType.objects.get(model="socialtoken").model_class().objects.all() ) @@ -248,6 +249,10 @@ def get_social_tokens(username): return str(username), access_token, refresh_token +def get_tokens(request): + username = request.user.get_username() + return username, None, None + class AppViewSet(viewsets.GenericViewSet): """ AppViewSet - ViewSet for managing Tycho apps. @@ -454,12 +459,12 @@ def get_serializer_class(self): return InstanceSerializer @functools.lru_cache(maxsize=16, typed=False) - def get_principal(self, user): + def get_principal(self, request): """ Retrieve principal information from Tycho based on the request user. """ - tokens = get_social_tokens(user) + tokens = get_tokens(request) principal = Principal(*tokens) return principal @@ -498,7 +503,7 @@ def list(self, request): """ active = self.get_queryset() - principal = self.get_principal(request.user) + principal = self.get_principal(request) username = principal.username host = get_host(request) instances = [] @@ -542,6 +547,8 @@ def create(self, request): Given an app id and resources pass the information to Tycho to start a instance of an app. """ + + username = request.user.get_username() serializer = self.get_serializer(data=request.data) logging.debug("checking if request is valid") @@ -551,13 +558,15 @@ def create(self, request): logging.debug(f"resource_request: {resource_request}") irods_enabled = os.environ.get("IROD_HOST",'').strip() # TODO update social query to fetch user. - tokens = get_social_tokens(request.user) + #Need to set an environment variable for the IRODS UID if irods_enabled != '': - nfs_id = get_nfs_uid(request.user) + nfs_id = get_nfs_uid(username) os.environ["NFSRODS_UID"] = str(nfs_id) - principal = Principal(*tokens) + # We will update this later once a system id for the app exists + identity_token = UserIdentityToken.objects.create(user=request.user) + principal = Principal(username, identity_token.token, None) app_id = serializer.data["app_id"] app_data = tycho.apps.get(app_id) @@ -588,9 +597,15 @@ def create(self, request): if validation_response is not None: return validation_response + env = {} + if settings.GRADER_API_URL is not None: + env["GRADER_API_URL"] = settings.GRADER_API_URL + host = get_host(request) - system = tycho.start(principal, app_id, resource_request.resources, host) + system = tycho.start(principal, app_id, resource_request.resources, host, env) + identity_token.consumer_id = identity_token.compute_app_consumer_id(app_id, system.identifier) + identity_token.save() s = InstanceSpec( principal.username, @@ -620,6 +635,7 @@ def create(self, request): # Failed to construct a tracked instance, attempt to remove # potentially created instance rather than leaving it hanging. tycho.delete({"name": system.services[0].identifier}) + identity_token.delete() return Response( {"message": "failed to submit app start."}, status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -629,7 +645,7 @@ def retrieve(self, request, sid=None): """ Provide active instance details. """ - principal = self.get_principal(request.user) + principal = self.get_principal(request) username = principal.username host = get_host(request) instance = None @@ -646,7 +662,7 @@ def retrieve(self, request, sid=None): @action(detail=True, methods=['get']) def is_ready(self, request, sid=None): - principal = self.get_principal(request.user) + principal = self.get_principal(request) username = principal.username host = get_host(request) instance = None @@ -673,6 +689,10 @@ def destroy(self, request, sid=None): logger.info("request username: " + str(request.user.username)) if status.services[0].username == request.user.username: response = tycho.delete({"name": serializer.validated_data["sid"]}) + # Delete all the tokens the user had associated with that app + consumer_id = UserIdentityToken.compute_app_consumer_id(serializer.validated_data["aid"], serializer.validated_data["sid"]) + tokens = UserIdentityToken.objects.filter(user=request.user, consumer_id=consumer_id) + tokens.delete() # TODO How can we avoid this sleep? Do we need an immediate response beyond # a successful submission? Can we do a follow up with Web Sockets or SSE # to the front end? @@ -691,7 +711,7 @@ def partial_update(self, request, sid=None): data = serializer.validated_data data.update({"tycho-guid": sid}) - principal = self.get_principal(request.user) + principal = self.get_principal(request) username = principal.username host = get_host(request) instance = self.get_instance(sid,username,host) diff --git a/appstore/appstore/settings/base.py b/appstore/appstore/settings/base.py index 53d4e4d3..48931e2b 100644 --- a/appstore/appstore/settings/base.py +++ b/appstore/appstore/settings/base.py @@ -131,6 +131,8 @@ "allauth.account.middleware.AccountMiddleware" ] +GRADER_API_URL = os.environ.get("GRADER_API_URL", None) + SESSION_IDLE_TIMEOUT = int(os.environ.get("DJANGO_SESSION_IDLE_TIMEOUT", 300)) EXPORTABLE_ENV = os.environ.get("EXPORTABLE_ENV",None) if EXPORTABLE_ENV != None: EXPORTABLE_ENV = EXPORTABLE_ENV.split(':') diff --git a/appstore/appstore/settings/eduhelx-dev-professor_settings.py b/appstore/appstore/settings/eduhelx-dev-professor_settings.py new file mode 100644 index 00000000..baf276b3 --- /dev/null +++ b/appstore/appstore/settings/eduhelx-dev-professor_settings.py @@ -0,0 +1,14 @@ +from .base import * +from product.configuration import ProductSettings, ProductColorScheme, ProductLink + +# TODO remove Application brand once the new frontend is complete and +# the django templates in core are removed. +APPLICATION_BRAND = "eduhelx-dev-professor" + +PRODUCT_SETTINGS = ProductSettings( + brand="eduhelx-dev-professor", + title="EduHeLx Dev Professor", + logo_url="/static/images/eduhelx-dev-professor/logo.png", + color_scheme=ProductColorScheme("#666666", "#e6e6e6"), #TBD + links=[], +) diff --git a/appstore/appstore/settings/eduhelx-dev-student_settings.py b/appstore/appstore/settings/eduhelx-dev-student_settings.py new file mode 100644 index 00000000..6743fd31 --- /dev/null +++ b/appstore/appstore/settings/eduhelx-dev-student_settings.py @@ -0,0 +1,14 @@ +from .base import * +from product.configuration import ProductSettings, ProductColorScheme, ProductLink + +# TODO remove Application brand once the new frontend is complete and +# the django templates in core are removed. +APPLICATION_BRAND = "eduhelx-dev-student" + +PRODUCT_SETTINGS = ProductSettings( + brand="eduhelx-dev-student", + title="EduHeLx Dev Student", + logo_url="/static/images/eduhelx-dev-student/logo.png", + color_scheme=ProductColorScheme("#666666", "#e6e6e6"), #TBD + links=[], +) diff --git a/appstore/appstore/settings/eduhelx-professor_settings.py b/appstore/appstore/settings/eduhelx-professor_settings.py new file mode 100644 index 00000000..47c8ed5d --- /dev/null +++ b/appstore/appstore/settings/eduhelx-professor_settings.py @@ -0,0 +1,14 @@ +from .base import * +from product.configuration import ProductSettings, ProductColorScheme, ProductLink + +# TODO remove Application brand once the new frontend is complete and +# the django templates in core are removed. +APPLICATION_BRAND = "eduhelx-professor" + +PRODUCT_SETTINGS = ProductSettings( + brand="eduhelx-professor", + title="EduHeLx Professor", + logo_url="/static/images/eduhelx-professor/logo.png", + color_scheme=ProductColorScheme("#666666", "#e6e6e6"), #TBD + links=[], +) diff --git a/appstore/appstore/settings/eduhelx-student_settings.py b/appstore/appstore/settings/eduhelx-student_settings.py new file mode 100644 index 00000000..52c4c354 --- /dev/null +++ b/appstore/appstore/settings/eduhelx-student_settings.py @@ -0,0 +1,14 @@ +from .base import * +from product.configuration import ProductSettings, ProductColorScheme, ProductLink + +# TODO remove Application brand once the new frontend is complete and +# the django templates in core are removed. +APPLICATION_BRAND = "eduhelx-student" + +PRODUCT_SETTINGS = ProductSettings( + brand="eduhelx-student", + title="EduHeLx", + logo_url="/static/images/eduhelx-student/logo.png", + color_scheme=ProductColorScheme("#666666", "#e6e6e6"), #TBD + links=[], +) diff --git a/appstore/core/apps.py b/appstore/core/apps.py index 21a8cc23..296eed9d 100644 --- a/appstore/core/apps.py +++ b/appstore/core/apps.py @@ -3,3 +3,6 @@ class AppsCoreServicesConfig(AppConfig): name = 'core' + + def ready(self): + import core.signals \ No newline at end of file diff --git a/appstore/core/models.py b/appstore/core/models.py index df42b770..44d56fdc 100644 --- a/appstore/core/models.py +++ b/appstore/core/models.py @@ -1,7 +1,21 @@ +import secrets from django.db import models +from django.utils import timezone from django.contrib.auth import get_user_model +from django.contrib.sessions.models import Session as SessionModel from django.core.exceptions import ValidationError from django_saml2_auth.user import get_user +from datetime import timedelta +from string import ascii_letters, digits, punctuation + +UserModel = get_user_model() + +def generate_token(): + token = "".join(secrets.choice(ascii_letters + digits) for i in range(256)) + # Should realistically never occur, but it's possible. + while UserIdentityToken.objects.filter(token=token).exists(): + token = "".join(secrets.choice(ascii_letters + digits) for i in range(256)) + return token def update_user(user): # as of Django_saml2_auth v3.12.0 does not add email address by default @@ -30,4 +44,24 @@ class IrodAuthorizedUser(models.Model): uid = models.IntegerField() def __str__(self): - return f"{self.user}, {self.uid}" \ No newline at end of file + return f"{self.user}, {self.uid}" + +class UserIdentityToken(models.Model): + user = models.ForeignKey(UserModel, on_delete=models.CASCADE) + + token = models.CharField(max_length=256, unique=True, default=generate_token) + # Optionally, identify the consumer (probably an app) whom the token was generated for. + consumer_id = models.CharField(max_length=256, default=None, null=True) + expires = models.DateTimeField(default=timezone.now() + timedelta(days=31)) + + @property + def valid(self): + return timezone.now() <= self.expires + + @staticmethod + def compute_app_consumer_id(app_id, system_id): + return f"{ app_id }-{ system_id }" + + def __str__(self): + return f"{ self.user.get_username() }-token-{ self.pk }" + \ No newline at end of file diff --git a/appstore/core/signals.py b/appstore/core/signals.py new file mode 100644 index 00000000..e69de29b diff --git a/appstore/core/static/images/eduhelx-dev-professor/favicon.ico b/appstore/core/static/images/eduhelx-dev-professor/favicon.ico new file mode 100644 index 00000000..491d22e5 Binary files /dev/null and b/appstore/core/static/images/eduhelx-dev-professor/favicon.ico differ diff --git a/appstore/core/static/images/eduhelx-dev-professor/logo.png b/appstore/core/static/images/eduhelx-dev-professor/logo.png new file mode 100644 index 00000000..87e64ab0 Binary files /dev/null and b/appstore/core/static/images/eduhelx-dev-professor/logo.png differ diff --git a/appstore/core/static/images/eduhelx-dev-student/favicon.ico b/appstore/core/static/images/eduhelx-dev-student/favicon.ico new file mode 100644 index 00000000..491d22e5 Binary files /dev/null and b/appstore/core/static/images/eduhelx-dev-student/favicon.ico differ diff --git a/appstore/core/static/images/eduhelx-dev-student/logo.png b/appstore/core/static/images/eduhelx-dev-student/logo.png new file mode 100644 index 00000000..87e64ab0 Binary files /dev/null and b/appstore/core/static/images/eduhelx-dev-student/logo.png differ diff --git a/appstore/core/static/images/eduhelx-professor/favicon.ico b/appstore/core/static/images/eduhelx-professor/favicon.ico new file mode 100644 index 00000000..491d22e5 Binary files /dev/null and b/appstore/core/static/images/eduhelx-professor/favicon.ico differ diff --git a/appstore/core/static/images/eduhelx-professor/logo.png b/appstore/core/static/images/eduhelx-professor/logo.png new file mode 100644 index 00000000..87e64ab0 Binary files /dev/null and b/appstore/core/static/images/eduhelx-professor/logo.png differ diff --git a/appstore/core/static/images/eduhelx-student/favicon.ico b/appstore/core/static/images/eduhelx-student/favicon.ico new file mode 100644 index 00000000..491d22e5 Binary files /dev/null and b/appstore/core/static/images/eduhelx-student/favicon.ico differ diff --git a/appstore/core/static/images/eduhelx-student/logo.png b/appstore/core/static/images/eduhelx-student/logo.png new file mode 100644 index 00000000..87e64ab0 Binary files /dev/null and b/appstore/core/static/images/eduhelx-student/logo.png differ diff --git a/appstore/core/urls.py b/appstore/core/urls.py index 0a1e1823..4562e075 100644 --- a/appstore/core/urls.py +++ b/appstore/core/urls.py @@ -1,8 +1,11 @@ from django.urls import path, re_path -from .views import auth, index, HandlePrivateURL404s +from .views import auth, auth_identity, index, HandlePrivateURL404s urlpatterns = [ path('default/', index, name='index'), + # Auth based on sessionid cookie path("auth/", auth, name="auth"), + # Auth based on identity access token + path("auth/identity/", auth_identity, name="auth-identity"), re_path(r"^private/*", HandlePrivateURL404s, name="private"), ] diff --git a/appstore/core/views.py b/appstore/core/views.py index 91228290..8e9d7dcb 100644 --- a/appstore/core/views.py +++ b/appstore/core/views.py @@ -8,6 +8,8 @@ from django.http import HttpResponse, JsonResponse from django.shortcuts import render, redirect +from core.models import UserIdentityToken + from tycho.context import ContextFactory from urllib.parse import urljoin @@ -57,6 +59,11 @@ def get_brand_details(brand): "heal": {"name": "NIH Heal Initiative", "logo": "logo.png"}, "argus": {"name": "Argus Array", "logo": "logo.png"}, "eduhelx": {"name": "EduHelx", "logo": "logo.png"}, + "eduhelx-dev": {"name": "EduHelx", "logo": "logo.png"}, + "eduhelx-dev-student": {"name": "EduHelx", "logo": "logo.png"}, + "eduhelx-dev-professor": {"name": "EduHelx", "logo": "logo.png"}, + "eduhelx-student": {"name": "EduHelx", "logo": "logo.png"}, + "eduhelx-professor": {"name": "EduHelx", "logo": "logo.png"}, "testing": {"name": "Testing", "logo": "logo.png"}, "ordrd": {"name": "Ordr D", "logo": "logo.png"}, }[brand] @@ -74,6 +81,27 @@ def get_access_token(request): return access_token +def auth_identity(request): + auth_header = request.headers.get("Authorization") + try: + bearer, raw_token = auth_header.split(" ") + if bearer != "Bearer": raise ValueError() + except: + return HttpResponse("Authorization header must be structured as 'Bearer {token}'", status=400) + + try: + token = UserIdentityToken.objects.get(token=raw_token) + if not token.valid: + return HttpResponse("The token is expired. Try restarting the app.", status=401) + remote_user = token.user.get_username() + response = HttpResponse(remote_user, status=200) + response["REMOTE_USER"] = remote_user + response["ACCESS_TOKEN"] = token.token + return response + except UserIdentityToken.DoesNotExist: + return HttpResponse("The token does not exist. Try restarting the app.", status=401) + + @login_required def auth(request): """Provide an endpoint for getting the user identity. diff --git a/appstore/tycho/context.py b/appstore/tycho/context.py index 41c863a8..bfb132ea 100644 --- a/appstore/tycho/context.py +++ b/appstore/tycho/context.py @@ -5,6 +5,7 @@ import uuid import yaml import copy +from typing import TypedDict, Optional from deepmerge import Merger from requests_cache import CachedSession from string import Template @@ -283,7 +284,7 @@ def delete (self, request): def update(self, request): return self.client.patch(request) - def start (self, principal, app_id, resource_request, host): + def start (self, principal, app_id, resource_request, host, extra_container_env={}): """ Get application metadata, docker-compose structure, settings, and compose API request. """ logger.info(f"\nprincipal: {principal}\napp_id: {app_id}\n" f"resource_request: {resource_request}\nhost: {host}") @@ -301,7 +302,13 @@ def start (self, principal, app_id, resource_request, host): """ Use a pre-existing k8s service account """ service_account = self.apps[app_id]['serviceAccount'] if 'serviceAccount' in self.apps[app_id].keys() else None """ Add entity's auth information """ - principal_params = {"username": principal.username, "access_token": principal.access_token, "refresh_token": principal.refresh_token, "host": host} + principal_params = { + "username": principal.username, + "access_token": principal.access_token, + "refresh_token": principal.refresh_token, + "host": host, + "extra_container_env": extra_container_env + } principal_params_json = json.dumps(principal_params, indent=4) """ Security Context that are set for the app """ spec["security_context"] = self.apps[app_id]["securityContext"] if 'securityContext' in self.apps[app_id].keys() else {} diff --git a/appstore/tycho/model.py b/appstore/tycho/model.py index dc1df562..bdd80f79 100644 --- a/appstore/tycho/model.py +++ b/appstore/tycho/model.py @@ -207,7 +207,9 @@ def __init__(self, config, name, principal, service_account, conn_string, proxy_ username_remove_us = self.username.replace("_", "-") username_remove_dot = username_remove_us.replace(".", "-") self.username_all_hyphens = username_remove_dot + self.access_token = principal.get("access_token") self.host = principal.get("host") + self.extra_container_env = principal.get("extra_container_env", {}) self.annotations = {} self.namespace = "default" self.serviceaccount = service_account diff --git a/appstore/tycho/template/pod.yaml b/appstore/tycho/template/pod.yaml index d54e8f3f..408a1ded 100644 --- a/appstore/tycho/template/pod.yaml +++ b/appstore/tycho/template/pod.yaml @@ -109,6 +109,12 @@ spec: value: {{ system.username }} - name: USER value: {{ system.username }} + - name: ACCESS_TOKEN + value: {{ system.access_token }} + {% for key, value in system.extra_container_env.items() %} + - name: {{ key }} + value: {{ value }} + {%endfor %} {% if system.amb %} - name: NB_PREFIX value: /private/{{system.system_name}}/{{system.username}}/{{system.identifier}} @@ -143,9 +149,11 @@ spec: {% endif %} {% endif %} {% if system.system_env %} - {% for env in system.system_env %} - - name: {{ env }} - value: {{ system.system_env[env]}} + {% for key, value in system.system_env.items() %} + {% if value is string or value is number or value is boolean %} + - name: {{ key }} + value: {{ value }} + {% endif %} {% endfor %} {% endif %} {% if container.expose|length > 0 %}