Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tycho sessionid #353

Merged
merged 4 commits into from
Jul 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"])
frostyfan109 marked this conversation as resolved.
Show resolved Hide resolved
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()

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looked odd due to multiple runs through of 256, so I asked cGPT to optimize.

Characters to use for the token

characters = string.ascii_letters + string.digits

# Generate a token and check for uniqueness
while True:
    token = ''.join(secrets.choice(characters) for _ in range(256))
    if not UserIdentityToken.objects.filter(token=token).exists():
        return token

Copy link
Contributor Author

@frostyfan109 frostyfan109 May 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well we could just use the := operator but I'm not sure what versions of python we need to support in Appstore

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
Loading