diff --git a/Makefile b/Makefile index 4174a428..7b7d901a 100644 --- a/Makefile +++ b/Makefile @@ -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/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/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..8f41040e 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 @@ -74,6 +76,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 %}