Skip to content

Commit

Permalink
Merge pull request #353 from helxplatform/tycho-sessionid
Browse files Browse the repository at this point in the history
Tycho sessionid
  • Loading branch information
Hoid authored Jul 17, 2024
2 parents 05add18 + 2b3fc55 commit 7137802
Show file tree
Hide file tree
Showing 11 changed files with 122 additions and 20 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ LOG_LEVEL := "info"
endif

ifeq "${DEBUG}" "true"
LOG_LEVEL := "debug"
LOG_LEVEL := DEBUG
endif

ifeq "${ENVS_FROM_FILE}" "true"
Expand Down
44 changes: 32 additions & 12 deletions appstore/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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()
)
Expand 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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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?
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions appstore/appstore/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(':')
Expand Down
3 changes: 3 additions & 0 deletions appstore/core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@

class AppsCoreServicesConfig(AppConfig):
name = 'core'

def ready(self):
import core.signals
36 changes: 35 additions & 1 deletion appstore/core/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -30,4 +44,24 @@ class IrodAuthorizedUser(models.Model):
uid = models.IntegerField()

def __str__(self):
return f"{self.user}, {self.uid}"
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 }"

Empty file added appstore/core/signals.py
Empty file.
5 changes: 4 additions & 1 deletion appstore/core/urls.py
Original file line number Diff line number Diff line change
@@ -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"),
]
23 changes: 23 additions & 0 deletions appstore/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions appstore/tycho/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand All @@ -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 {}
Expand Down
2 changes: 2 additions & 0 deletions appstore/tycho/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions appstore/tycho/template/pod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down Expand Up @@ -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 %}
Expand Down

0 comments on commit 7137802

Please sign in to comment.