From c79fe06ad0fce79467df59fb9cca712277f8fd8f Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Wed, 10 Jun 2026 16:59:31 -0400 Subject: [PATCH 01/14] remove persistent authorized user entries --- appstore/middleware/filter_whitelist_middleware.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/appstore/middleware/filter_whitelist_middleware.py b/appstore/middleware/filter_whitelist_middleware.py index aa6d1fa2..d042104d 100644 --- a/appstore/middleware/filter_whitelist_middleware.py +++ b/appstore/middleware/filter_whitelist_middleware.py @@ -158,9 +158,9 @@ def is_authorized(user): if AllowWhiteListedUserOnly.is_auto_whitelisted_email(user): logger.debug("[AUTHZ] AUTO-pattern match → persisting email") - AuthorizedUser.objects.get_or_create( - email=user.email, defaults={"username": user.username} - ) + # AuthorizedUser.objects.get_or_create( + # email=user.email, defaults={"username": user.username} + # ) return True if AuthorizedUser.objects.filter(username=user.username).exists(): @@ -169,9 +169,9 @@ def is_authorized(user): if AllowWhiteListedUserOnly._ldap_group_member(user): logger.debug("[AUTHZ] LDAP group match → persisting email") - AuthorizedUser.objects.get_or_create( - email=user.email, defaults={"username": user.username} - ) + # AuthorizedUser.objects.get_or_create( + # email=user.email, defaults={"username": user.username} + # ) return True logger.debug("[AUTHZ] No rule matched; user is NOT authorised") From 6f22b8a2dab7c1bbe4cae684ce3d5d43755a807b Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Thu, 11 Jun 2026 12:13:12 -0400 Subject: [PATCH 02/14] Prefer the local part of a user's email for their username if assertion lacks an explicit username --- appstore/appstore/adapter.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/appstore/appstore/adapter.py b/appstore/appstore/adapter.py index 21bac816..26b1fd9a 100644 --- a/appstore/appstore/adapter.py +++ b/appstore/appstore/adapter.py @@ -62,12 +62,13 @@ def get_logout_redirect_url(self, request): return url class SocialAccountAdapter(DefaultSocialAccountAdapter): - - # debug commenting out for now. - # def populate_user(self, request, sociallogin, data): - # user = super().populate_user(request, sociallogin, data) - # print('sociallogin.account.extra_data:', sociallogin.account.extra_data) - # return user + def generate_unique_username(self, txts, regex=None): + # Split the email to use as generated username (jdoe@gmail.com -> jdoe) + # rather than the first name. allauth still sanitizes and suffixes on collision. + email = next((t for t in txts if t and "@" in t), None) + if email: + txts = [email] + [t for t in txts if t != email] + return super().generate_unique_username(txts, regex) def on_authentication_error(self, request, provider, error=None, exception=None, extra_context=None): provider_id = provider.id if provider else "unknown" From 749341a13f85604e7c91c907e776eb1d2ab4ab05 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Thu, 11 Jun 2026 12:39:29 -0400 Subject: [PATCH 03/14] move code to correct account adapter --- appstore/appstore/adapter.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/appstore/appstore/adapter.py b/appstore/appstore/adapter.py index 26b1fd9a..8641532f 100644 --- a/appstore/appstore/adapter.py +++ b/appstore/appstore/adapter.py @@ -24,6 +24,14 @@ class LoginRedirectAdapter(DefaultAccountAdapter, DefaultSocialAccountAdapter): https://django-allauth.readthedocs.io/en/latest/advanced.html#custom-redirects """ + + def generate_unique_username(self, txts, regex=None): + # Split the email to use as generated username (jdoe@gmail.com -> jdoe) + # rather than the first name. allauth still sanitizes and suffixes on collision. + email = next((t for t in txts if t and "@" in t), None) + if email: + txts = [email] + [t for t in txts if t != email] + return super().generate_unique_username(txts, regex) def _login_url(self, request): if request.session.get("helx_frontend") == "django": @@ -62,14 +70,6 @@ def get_logout_redirect_url(self, request): return url class SocialAccountAdapter(DefaultSocialAccountAdapter): - def generate_unique_username(self, txts, regex=None): - # Split the email to use as generated username (jdoe@gmail.com -> jdoe) - # rather than the first name. allauth still sanitizes and suffixes on collision. - email = next((t for t in txts if t and "@" in t), None) - if email: - txts = [email] + [t for t in txts if t != email] - return super().generate_unique_username(txts, regex) - def on_authentication_error(self, request, provider, error=None, exception=None, extra_context=None): provider_id = provider.id if provider else "unknown" error_code = error.name if error else "unknown" From 7ed5352087077d59f075db9b78ae6d2ff7f1f1fe Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Thu, 11 Jun 2026 14:37:16 -0400 Subject: [PATCH 04/14] fix another bug with missing mail assertions --- appstore/core/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/appstore/core/models.py b/appstore/core/models.py index 77ebcbc8..65b7fd60 100644 --- a/appstore/core/models.py +++ b/appstore/core/models.py @@ -27,8 +27,9 @@ def update_user(user): # https://github.com/grafana/django-saml2-auth/blob/11b97beaa2a431209e2c54103cb49c033c42ff54/django_saml2_auth/user.py#L165 # This trigger gets and set the email field in the django user db _user = get_user(user) - _user.email = user['email'] - _user.save() + if user['email']: + _user.email = user['email'] + _user.save() return _user class AuthorizedUser(models.Model): From 929cb0aa03df8f2b79ebb0714cd4107190ed76d0 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Thu, 11 Jun 2026 16:05:00 -0400 Subject: [PATCH 05/14] Verified SAML merging + username conflict resolution --- appstore/appstore/settings/base.py | 6 +++ appstore/core/models.py | 82 +++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/appstore/appstore/settings/base.py b/appstore/appstore/settings/base.py index 6478cc13..8c86ef8c 100644 --- a/appstore/appstore/settings/base.py +++ b/appstore/appstore/settings/base.py @@ -454,6 +454,11 @@ # middleware that would disrupt in the process MIDDLEWARE[1:1] = DEBUG_MIDDLEWARE +# Whether the SAML IdP is trusted to have verified the email address it releases. +# When True, a SAML login whose email matches an existing account's verified email +# is merged into that account (see core.models.saml_get_user) +SAML_TRUST_VERIFIED_EMAIL = os.environ.get("SAML_TRUST_VERIFIED_EMAIL", "False").lower() == "true" + SAML2_AUTH = { # Optional settings below "DEFAULT_NEXT_URL": "/helx/workspaces/login/success", # Custom target redirect URL after the user get logged in. Default to /admin if not set. This setting will be overwritten if you have parameter ?next= specificed in the login URL. @@ -472,6 +477,7 @@ }, "TRIGGER": { "CREATE_USER": "core.models.update_user", + "GET_USER": "core.models.saml_get_user", }, "ASSERTION_URL": os.environ.get("SAML2_AUTH_ASSERTION_URL"), "ENTITY_ID": os.environ.get( diff --git a/appstore/core/models.py b/appstore/core/models.py index 65b7fd60..60f12c80 100644 --- a/appstore/core/models.py +++ b/appstore/core/models.py @@ -4,12 +4,83 @@ 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 django_saml2_auth.user import get_user, get_user_id from datetime import timedelta from string import ascii_letters, digits, punctuation UserModel = get_user_model() + +# Provider key used for the allauth SocialAccount row that links a SAML +# identity (the assertion uid / onyen) to a Django user. This gives SAML users +# a stable identity independent of their username, so resolution never depends +# on the (shared, collidable) username namespace. +SAML_PROVIDER = "saml" + + +def saml_get_user(user): + # TRIGGER.GET_USER hook for django_saml2_auth. Decides which existing + # account, if any, a SAML login resolves to. The library's default is to log + # into ANY user whose username equals the assertion uid, with no identity + # check -- a takeover risk once accounts are also created via allauth/Google, + # and it cannot give a stable account to a user whose uid collides with a + # different person's username. + # + # We resolve by a stable SocialAccount(provider="saml", uid) link instead of + # by username. Order: + # 1. Linked SAML identity exists -> return its user (username-independent). + # 2. Verified-email merge (only if this IdP is trusted): assertion email + # matches an account's verified allauth EmailAddress -> link this SAML + # identity to that account and return it. + # 3. Legacy SAML user predating the link: a user whose username equals the + # uid and has no SocialAccount -> adopt it and backfill the link. + # 4. Otherwise return None: the library creates a fresh user (suffixing the + # username for uniqueness if needed); update_user then creates the link. + from django.conf import settings + from allauth.socialaccount.models import SocialAccount + from allauth.account.models import EmailAddress + + user_id = get_user_id(user) + if not user_id: + return None + + # 1. Stable SAML identity link. + link = ( + SocialAccount.objects.filter(provider=SAML_PROVIDER, uid=user_id) + .select_related("user") + .first() + ) + if link: + return link.user + + # 2. Verified-email merge, only when this deployment's IdP is trusted. + email = (user.get("email") or "").lower() if isinstance(user, dict) else "" + if email and settings.SAML_TRUST_VERIFIED_EMAIL: + verified = EmailAddress.objects.filter( + email__iexact=email, verified=True + ).select_related("user").first() + if verified: + SocialAccount.objects.get_or_create( + provider=SAML_PROVIDER, uid=user_id, defaults={"user": verified.user} + ) + return verified.user + + # 3. Legacy SAML account created before this link existed (username == uid, + # no social account). Adopt it and backfill the link. + try: + existing = UserModel.objects.get(username__iexact=user_id) + except UserModel.DoesNotExist: + existing = None + if existing and not SocialAccount.objects.filter(user=existing).exists(): + SocialAccount.objects.get_or_create( + provider=SAML_PROVIDER, uid=user_id, defaults={"user": existing} + ) + return existing + + # 4. New SAML identity: let the library create a fresh user. update_user + # (the CREATE_USER trigger) creates the SocialAccount link afterward. + return None + def generate_token(): token = "".join(secrets.choice(ascii_letters + digits) for i in range(256)) # Should realistically never occur, but it's possible. @@ -26,10 +97,19 @@ def update_user(user): # https://github.com/grafana/django-saml2-auth/blob/11b97beaa2a431209e2c54103cb49c033c42ff54/django_saml2_auth/user.py#L93 # https://github.com/grafana/django-saml2-auth/blob/11b97beaa2a431209e2c54103cb49c033c42ff54/django_saml2_auth/user.py#L165 # This trigger gets and set the email field in the django user db + from allauth.socialaccount.models import SocialAccount + _user = get_user(user) if user['email']: _user.email = user['email'] _user.save() + # Ensure the stable SAML identity link exists (created here for fresh users; + # saml_get_user backfills legacy ones). Keyed on the assertion uid. + user_id = get_user_id(user) + if user_id: + SocialAccount.objects.get_or_create( + provider=SAML_PROVIDER, uid=user_id, defaults={"user": _user} + ) return _user class AuthorizedUser(models.Model): From deb7cf39b2b5bb89802b20286e63ad4c42663b39 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Thu, 11 Jun 2026 16:50:30 -0400 Subject: [PATCH 06/14] Revert "Verified SAML merging + username conflict resolution" This reverts commit 929cb0aa03df8f2b79ebb0714cd4107190ed76d0. --- appstore/appstore/settings/base.py | 6 --- appstore/core/models.py | 82 +----------------------------- 2 files changed, 1 insertion(+), 87 deletions(-) diff --git a/appstore/appstore/settings/base.py b/appstore/appstore/settings/base.py index 8c86ef8c..6478cc13 100644 --- a/appstore/appstore/settings/base.py +++ b/appstore/appstore/settings/base.py @@ -454,11 +454,6 @@ # middleware that would disrupt in the process MIDDLEWARE[1:1] = DEBUG_MIDDLEWARE -# Whether the SAML IdP is trusted to have verified the email address it releases. -# When True, a SAML login whose email matches an existing account's verified email -# is merged into that account (see core.models.saml_get_user) -SAML_TRUST_VERIFIED_EMAIL = os.environ.get("SAML_TRUST_VERIFIED_EMAIL", "False").lower() == "true" - SAML2_AUTH = { # Optional settings below "DEFAULT_NEXT_URL": "/helx/workspaces/login/success", # Custom target redirect URL after the user get logged in. Default to /admin if not set. This setting will be overwritten if you have parameter ?next= specificed in the login URL. @@ -477,7 +472,6 @@ }, "TRIGGER": { "CREATE_USER": "core.models.update_user", - "GET_USER": "core.models.saml_get_user", }, "ASSERTION_URL": os.environ.get("SAML2_AUTH_ASSERTION_URL"), "ENTITY_ID": os.environ.get( diff --git a/appstore/core/models.py b/appstore/core/models.py index 60f12c80..65b7fd60 100644 --- a/appstore/core/models.py +++ b/appstore/core/models.py @@ -4,83 +4,12 @@ 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, get_user_id +from django_saml2_auth.user import get_user from datetime import timedelta from string import ascii_letters, digits, punctuation UserModel = get_user_model() - -# Provider key used for the allauth SocialAccount row that links a SAML -# identity (the assertion uid / onyen) to a Django user. This gives SAML users -# a stable identity independent of their username, so resolution never depends -# on the (shared, collidable) username namespace. -SAML_PROVIDER = "saml" - - -def saml_get_user(user): - # TRIGGER.GET_USER hook for django_saml2_auth. Decides which existing - # account, if any, a SAML login resolves to. The library's default is to log - # into ANY user whose username equals the assertion uid, with no identity - # check -- a takeover risk once accounts are also created via allauth/Google, - # and it cannot give a stable account to a user whose uid collides with a - # different person's username. - # - # We resolve by a stable SocialAccount(provider="saml", uid) link instead of - # by username. Order: - # 1. Linked SAML identity exists -> return its user (username-independent). - # 2. Verified-email merge (only if this IdP is trusted): assertion email - # matches an account's verified allauth EmailAddress -> link this SAML - # identity to that account and return it. - # 3. Legacy SAML user predating the link: a user whose username equals the - # uid and has no SocialAccount -> adopt it and backfill the link. - # 4. Otherwise return None: the library creates a fresh user (suffixing the - # username for uniqueness if needed); update_user then creates the link. - from django.conf import settings - from allauth.socialaccount.models import SocialAccount - from allauth.account.models import EmailAddress - - user_id = get_user_id(user) - if not user_id: - return None - - # 1. Stable SAML identity link. - link = ( - SocialAccount.objects.filter(provider=SAML_PROVIDER, uid=user_id) - .select_related("user") - .first() - ) - if link: - return link.user - - # 2. Verified-email merge, only when this deployment's IdP is trusted. - email = (user.get("email") or "").lower() if isinstance(user, dict) else "" - if email and settings.SAML_TRUST_VERIFIED_EMAIL: - verified = EmailAddress.objects.filter( - email__iexact=email, verified=True - ).select_related("user").first() - if verified: - SocialAccount.objects.get_or_create( - provider=SAML_PROVIDER, uid=user_id, defaults={"user": verified.user} - ) - return verified.user - - # 3. Legacy SAML account created before this link existed (username == uid, - # no social account). Adopt it and backfill the link. - try: - existing = UserModel.objects.get(username__iexact=user_id) - except UserModel.DoesNotExist: - existing = None - if existing and not SocialAccount.objects.filter(user=existing).exists(): - SocialAccount.objects.get_or_create( - provider=SAML_PROVIDER, uid=user_id, defaults={"user": existing} - ) - return existing - - # 4. New SAML identity: let the library create a fresh user. update_user - # (the CREATE_USER trigger) creates the SocialAccount link afterward. - return None - def generate_token(): token = "".join(secrets.choice(ascii_letters + digits) for i in range(256)) # Should realistically never occur, but it's possible. @@ -97,19 +26,10 @@ def update_user(user): # https://github.com/grafana/django-saml2-auth/blob/11b97beaa2a431209e2c54103cb49c033c42ff54/django_saml2_auth/user.py#L93 # https://github.com/grafana/django-saml2-auth/blob/11b97beaa2a431209e2c54103cb49c033c42ff54/django_saml2_auth/user.py#L165 # This trigger gets and set the email field in the django user db - from allauth.socialaccount.models import SocialAccount - _user = get_user(user) if user['email']: _user.email = user['email'] _user.save() - # Ensure the stable SAML identity link exists (created here for fresh users; - # saml_get_user backfills legacy ones). Keyed on the assertion uid. - user_id = get_user_id(user) - if user_id: - SocialAccount.objects.get_or_create( - provider=SAML_PROVIDER, uid=user_id, defaults={"user": _user} - ) return _user class AuthorizedUser(models.Model): From 872f74075c530fff6e2003eb2da906d4d85e0ad3 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Fri, 12 Jun 2026 14:57:43 -0400 Subject: [PATCH 07/14] replace django-saml2-auth with django-allauth[saml] --- appstore/api/v1/views.py | 7 +- appstore/appstore/settings/base.py | 109 +++++++++++++++++------------ appstore/appstore/urls.py | 27 ++++++- appstore/core/models.py | 13 ---- requirements.txt | 3 +- 5 files changed, 91 insertions(+), 68 deletions(-) diff --git a/appstore/api/v1/views.py b/appstore/api/v1/views.py index b9c8bde4..02f5f433 100644 --- a/appstore/api/v1/views.py +++ b/appstore/api/v1/views.py @@ -984,14 +984,9 @@ def _get_product_providers(self, settings): """ if settings.ALLOW_SAML_LOGIN == "true": - # TODO can we get the provider name from metadata so that if - # we support something beyond UNC we dont need another func - # or clause? What happens if we have multiple SAML SSO providers - # today it's handled with SAML_URL and the saml2_auth package, - # but appears to be setup for one provider at a time. return asdict( LoginProvider( - "UNC Chapel Hill Single Sign-On", + settings.SAML_PROVIDER_NAME, settings.SAML_URL, ) ) diff --git a/appstore/appstore/settings/base.py b/appstore/appstore/settings/base.py index 6478cc13..83fc8525 100644 --- a/appstore/appstore/settings/base.py +++ b/appstore/appstore/settings/base.py @@ -83,7 +83,6 @@ "django.contrib.auth", "django.contrib.messages", "django.contrib.sites", - "django_saml2_auth", ] THIRD_PARTY_APPS = [ @@ -128,6 +127,71 @@ if PROVIDER != '': THIRD_PARTY_APPS.append(f"allauth.socialaccount.providers.{PROVIDER}") +# The SAML provider is loaded whenever SAML login is enabled. ALLOW_SAML_LOGIN +# is kept as a lower-cased string above for compatibility with templates and +# views that compare it to the literal "true". +SAML_PROVIDER_SLUG = os.environ.get("SAML_PROVIDER_SLUG", "saml") +SAML_PROVIDER_NAME = os.environ.get("SAML_PROVIDER_NAME", "Single Sign-On") +if ALLOW_SAML_LOGIN == "true": + THIRD_PARTY_APPS.append("allauth.socialaccount.providers.saml") + + _saml_entity_id = os.environ["SAML2_AUTH_ENTITY_ID"] + _saml_metadata_source = os.environ["SAML_METADATA_SOURCE"] + + # Allauth-SAML accepts either a remote `metadata_url` (which it fetches + # and caches itself) or a fully-explicit dict with entity_id/x509cert/ + # sso_url. The deployment may hand us either: a metadata URL when the + # chart's fetch sidecar is disabled, or a local XML file path when the + # sidecar is mirroring the IdP metadata onto a PVC. The file-path case + # is parsed once here so allauth gets explicit IdP fields. + if _saml_metadata_source.startswith(("http://", "https://")): + _saml_idp = { + "entity_id": _saml_entity_id, + "metadata_url": _saml_metadata_source, + } + else: + from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser + _parsed = OneLogin_Saml2_IdPMetadataParser.parse_file( + _saml_metadata_source, + entity_id=_saml_entity_id or None, + )["idp"] + _saml_idp = { + "entity_id": _saml_entity_id or _parsed["entityId"], + "sso_url": _parsed["singleSignOnService"]["url"], + "x509cert": _parsed["x509cert"], + } + _slo = _parsed.get("singleLogoutService") or {} + if _slo.get("url"): + _saml_idp["slo_url"] = _slo["url"] + + SOCIALACCOUNT_PROVIDERS["saml"] = { + "APPS": [{ + "provider_id": SAML_PROVIDER_SLUG, + "client_id": SAML_PROVIDER_SLUG, + "name": SAML_PROVIDER_NAME, + "settings": { + "idp": _saml_idp, + # Preserve the legacy username-from-uid mapping. Both `uid` + # (SocialAccount.uid, the stable external identifier) and + # `username` (Django auth_user.username) are populated from + # the SAML `uid` attribute so returning users keep their + # existing usernames. + "attribute_mapping": { + "uid": ["uid"], + "username": ["uid"], + "email": ["mail"], + "first_name": ["givenName"], + "last_name": ["sn"], + }, + "advanced": { + "want_assertion_signed": True, + "authn_request_signed": False, + "want_message_signed": False, + }, + }, + }], + } + # get the OIDC name if exists OIDC_NAME = os.environ.get("OIDC_NAME", "") @@ -454,47 +518,4 @@ # middleware that would disrupt in the process MIDDLEWARE[1:1] = DEBUG_MIDDLEWARE -SAML2_AUTH = { - # Optional settings below - "DEFAULT_NEXT_URL": "/helx/workspaces/login/success", # Custom target redirect URL after the user get logged in. Default to /admin if not set. This setting will be overwritten if you have parameter ?next= specificed in the login URL. - "CREATE_USER": "TRUE", # Create a new Django user when a new user logs in. Defaults to True. - "NEW_USER_PROFILE": { - "USER_GROUPS": [], # The default group name when a new user logs in - "ACTIVE_STATUS": True, # The default active status for new users - "STAFF_STATUS": True, # The staff status for new users - "SUPERUSER_STATUS": False, # The superuser status for new users - }, - "ATTRIBUTES_MAP": { # Change Email/UserName/FirstName/LastName to corresponding SAML2 userprofile attributes. - "email": "mail", - "username": "uid", - "first_name": "givenName", - "last_name": "sn", - }, - "TRIGGER": { - "CREATE_USER": "core.models.update_user", - }, - "ASSERTION_URL": os.environ.get("SAML2_AUTH_ASSERTION_URL"), - "ENTITY_ID": os.environ.get( - "SAML2_AUTH_ENTITY_ID" - ), # Populates the Issuer element in authn request - "USE_JWT": False, - 'WANT_ASSERTIONS_SIGNED': True, - 'AUTHN_REQUESTS_SIGNED': False, - 'WANT_RESPONSE_SIGNED': False, - 'TOKEN_REQUIRED': False, -} - -# Metadata is required, either remote url or local file path, check the environment -# determine the type based on the form of the value. Default to UNC if there's nothing - -metadata_source = os.environ.get("SAML_METADATA_SOURCE") -if metadata_source != None and type(metadata_source) is str and len(metadata_source) != 0: - metadata_source_components = metadata_source.split(':') - if len(metadata_source_components) > 1: - metadata_source_scheme = metadata_source_components[0] - if metadata_source_scheme == "http" or metadata_source_scheme == "https": - SAML2_AUTH["METADATA_AUTO_CONF_URL"] = metadata_source - else: SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = metadata_source - else: SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = metadata_source - DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/appstore/appstore/urls.py b/appstore/appstore/urls.py index ba91ab52..d2f1db05 100644 --- a/appstore/appstore/urls.py +++ b/appstore/appstore/urls.py @@ -1,10 +1,10 @@ from django.conf import settings from django.contrib import admin from django.urls import include, path, re_path +from django.views.decorators.csrf import csrf_exempt from django.views.generic import RedirectView from django.views.static import serve -from django_saml2_auth import views as saml2_auth_views from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from core.views import custom404 @@ -13,10 +13,31 @@ handler404 = custom404 + +def _saml_legacy_login(request): + # Legacy entry point preserved so existing IdP metadata and external + # links continue to resolve. Dispatches to allauth-SAML's login view + # under the configured organization slug. + from allauth.socialaccount.providers.saml import views as saml_views + return saml_views.login(request, organization_slug=settings.SAML_PROVIDER_SLUG) + + +@csrf_exempt +def _saml_legacy_acs(request): + # Legacy ACS endpoint preserved so the IdP can POST SAMLResponses to + # the same URL it was configured with under django-saml2-auth. A redirect + # would convert the POST to a GET and drop the assertion, so we invoke + # the allauth-SAML ACS view in-process instead. csrf_exempt is required + # on this outer wrapper because Django's CSRF middleware checks the + # exemption attribute on the URL-resolved callback, not the inner view. + from allauth.socialaccount.providers.saml import views as saml_views + return saml_views.acs(request, organization_slug=settings.SAML_PROVIDER_SLUG) + + urlpatterns = [ path("admin/", admin.site.urls), - path("saml2_auth/", include("django_saml2_auth.urls")), - path("accounts/saml/", saml2_auth_views.signin), + path("saml2_auth/acs/", _saml_legacy_acs), + path("accounts/saml/", _saml_legacy_login), path(r"accounts/login/", HelxLoginView.as_view(), name="helx_login"), path("accounts/", include("allauth.urls")), ] diff --git a/appstore/core/models.py b/appstore/core/models.py index 65b7fd60..ad2109e2 100644 --- a/appstore/core/models.py +++ b/appstore/core/models.py @@ -4,7 +4,6 @@ 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 @@ -20,18 +19,6 @@ def generate_token(): def user_token_expires(): return timezone.now() + timedelta(days=31) -def update_user(user): - # as of Django_saml2_auth v3.12.0 does not add email address by default - # to the created use entry in django db according to: - # https://github.com/grafana/django-saml2-auth/blob/11b97beaa2a431209e2c54103cb49c033c42ff54/django_saml2_auth/user.py#L93 - # https://github.com/grafana/django-saml2-auth/blob/11b97beaa2a431209e2c54103cb49c033c42ff54/django_saml2_auth/user.py#L165 - # This trigger gets and set the email field in the django user db - _user = get_user(user) - if user['email']: - _user.email = user['email'] - _user.save() - return _user - class AuthorizedUser(models.Model): email = models.EmailField(max_length=254, blank=True) username = models.CharField(max_length=128, blank=True) diff --git a/requirements.txt b/requirements.txt index ed4c76a2..c44af4df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,13 +4,12 @@ django-cors-headers==4.3.1 django-crispy-forms==2.1 django-debug-toolbar==4.3.0 django-extensions==3.2.3 -grafana-django-saml2-auth==3.12.0 djangorestframework==3.15.2 drf-spectacular==0.27.1 flake8==3.9.0 gunicorn==23.0.0 mock==4.0.2 -pysaml2==7.4.2 +python3-saml==1.16.0 python3-openid==3.2.0 requests-oauthlib==1.4.0 selenium==3.141.0 From 23b3baca9ac9c30eb3aa17b443b95761517895a5 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Fri, 12 Jun 2026 16:18:10 -0400 Subject: [PATCH 08/14] fix --- appstore/appstore/settings/base.py | 24 ++++++++++++++++++------ appstore/core/apps.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/appstore/appstore/settings/base.py b/appstore/appstore/settings/base.py index 83fc8525..0085e4f0 100644 --- a/appstore/appstore/settings/base.py +++ b/appstore/appstore/settings/base.py @@ -135,7 +135,17 @@ if ALLOW_SAML_LOGIN == "true": THIRD_PARTY_APPS.append("allauth.socialaccount.providers.saml") - _saml_entity_id = os.environ["SAML2_AUTH_ENTITY_ID"] + # SP entity ID — what the IdP has registered to identify this service. + # Kept under the legacy SAML2_AUTH_ENTITY_ID name so existing chart values + # and IdP registrations continue to work. Exposed as a top-level setting + # so the appstore AppConfig.ready() hook can pin allauth-SAML's SP entityId + # to it (allauth would otherwise derive a different value from URL routing, + # which would not match what the IdP has registered). + SAML_SP_ENTITY_ID = os.environ["SAML2_AUTH_ENTITY_ID"] + # IdP entity ID — selects which IdP within a federation aggregate the + # OneLogin metadata parser should extract. Required when the metadata + # source is a multi-IdP aggregate (e.g. UNC's federation metadata). + SAML_IDP_ENTITY_ID = os.environ.get("SAML_IDP_ENTITY_ID") or None _saml_metadata_source = os.environ["SAML_METADATA_SOURCE"] # Allauth-SAML accepts either a remote `metadata_url` (which it fetches @@ -146,17 +156,19 @@ # is parsed once here so allauth gets explicit IdP fields. if _saml_metadata_source.startswith(("http://", "https://")): _saml_idp = { - "entity_id": _saml_entity_id, + "entity_id": SAML_IDP_ENTITY_ID, "metadata_url": _saml_metadata_source, } else: from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser - _parsed = OneLogin_Saml2_IdPMetadataParser.parse_file( - _saml_metadata_source, - entity_id=_saml_entity_id or None, + with open(_saml_metadata_source, "r") as _f: + _xml = _f.read() + _parsed = OneLogin_Saml2_IdPMetadataParser.parse( + _xml, + entity_id=SAML_IDP_ENTITY_ID, )["idp"] _saml_idp = { - "entity_id": _saml_entity_id or _parsed["entityId"], + "entity_id": _parsed["entityId"], "sso_url": _parsed["singleSignOnService"]["url"], "x509cert": _parsed["x509cert"], } diff --git a/appstore/core/apps.py b/appstore/core/apps.py index 296eed9d..44d60a86 100644 --- a/appstore/core/apps.py +++ b/appstore/core/apps.py @@ -1,8 +1,35 @@ from django.apps import AppConfig +from django.conf import settings class AppsCoreServicesConfig(AppConfig): name = 'core' def ready(self): - import core.signals \ No newline at end of file + import core.signals + self._pin_saml_sp_entity_id() + + @staticmethod + def _pin_saml_sp_entity_id(): + # Allauth-SAML derives the SP `entityId` from the per-org metadata + # URL it serves at runtime. That diverges from the SP entity ID the + # IdP has registered for this service (the legacy + # `https:///saml2_auth/acs/` value). The IdP rejects an + # AuthnRequest whose Issuer doesn't match the registered SP entity + # ID, so we monkey-patch `build_sp_config` to pin entityId to the + # configured value. The metadata endpoint uses the same builder, so + # the SP metadata advertised to the IdP stays consistent. + if getattr(settings, "ALLOW_SAML_LOGIN", "").lower() != "true": + return + sp_entity_id = getattr(settings, "SAML_SP_ENTITY_ID", None) + if not sp_entity_id: + return + from allauth.socialaccount.providers.saml import utils as saml_utils + original = saml_utils.build_sp_config + + def build_sp_config_pinned(request, provider_config, org): + config = original(request, provider_config, org) + config["entityId"] = sp_entity_id + return config + + saml_utils.build_sp_config = build_sp_config_pinned \ No newline at end of file From ecd02a05de0adb6751bc93df6fe36ef7dcde3533 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Fri, 12 Jun 2026 16:48:13 -0400 Subject: [PATCH 09/14] hopefully fix saml rejection --- appstore/appstore/settings/base.py | 18 ++++++++++++++++-- appstore/core/apps.py | 23 ++++++++++++++--------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/appstore/appstore/settings/base.py b/appstore/appstore/settings/base.py index 0085e4f0..a196200c 100644 --- a/appstore/appstore/settings/base.py +++ b/appstore/appstore/settings/base.py @@ -130,6 +130,8 @@ # The SAML provider is loaded whenever SAML login is enabled. ALLOW_SAML_LOGIN # is kept as a lower-cased string above for compatibility with templates and # views that compare it to the literal "true". +SAML_URL = "/accounts/saml" +SAML_ACS_URL = "/saml2_auth/acs/" SAML_PROVIDER_SLUG = os.environ.get("SAML_PROVIDER_SLUG", "saml") SAML_PROVIDER_NAME = os.environ.get("SAML_PROVIDER_NAME", "Single Sign-On") if ALLOW_SAML_LOGIN == "true": @@ -142,6 +144,13 @@ # to it (allauth would otherwise derive a different value from URL routing, # which would not match what the IdP has registered). SAML_SP_ENTITY_ID = os.environ["SAML2_AUTH_ENTITY_ID"] + # SP ACS URL — the full assertion-consumer-service URL the IdP has + # registered. Allauth-SAML's default points at /accounts/saml//acs/ + # which does not match what existing IdP registrations use; pinned via + # the same AppConfig.ready() hook. Defaults to the SP host + legacy ACS + # path, which matches the original django-saml2-auth convention. + _sp_host = "/".join(SAML_SP_ENTITY_ID.split("/", 3)[:3]) # scheme://host + SAML_SP_ACS_URL = os.environ.get("SAML_SP_ACS_URL") or (_sp_host + SAML_ACS_URL) # IdP entity ID — selects which IdP within a federation aggregate the # OneLogin metadata parser should extract. Required when the metadata # source is a multi-IdP aggregate (e.g. UNC's federation metadata). @@ -275,8 +284,13 @@ LOGIN_URL = "/accounts/login" LOGIN_WHITELIST_URL = "/helx/workspaces/login?whitelist_required=true" OIDC_SESSION_MANAGEMENT_ENABLE = True -SAML_URL = "/accounts/saml" -SAML_ACS_URL = "/saml2_auth/acs/" +# The ingress terminates TLS and forwards to the pod over plain HTTP, setting +# X-Forwarded-Proto: https. Trust that header so request.build_absolute_uri() +# returns https:// URLs. Critical for allauth-SAML, which puts the ACS URL into +# the AuthnRequest — IdPs reject ACS URLs whose scheme doesn't match the +# registered SP. Only safe behind a proxy that strips/sets these headers. +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +USE_X_FORWARDED_HOST = True #SAML_ACS_URL = "/sso/acs/" SECURE_CROSS_ORIGIN_OPENER_POLICY = None diff --git a/appstore/core/apps.py b/appstore/core/apps.py index 44d60a86..c771fb0a 100644 --- a/appstore/core/apps.py +++ b/appstore/core/apps.py @@ -11,25 +11,30 @@ def ready(self): @staticmethod def _pin_saml_sp_entity_id(): - # Allauth-SAML derives the SP `entityId` from the per-org metadata - # URL it serves at runtime. That diverges from the SP entity ID the + # Allauth-SAML derives the SP `entityId` and ACS URL from the per-org + # metadata/ACS URLs it serves at runtime. Both diverge from what the # IdP has registered for this service (the legacy - # `https:///saml2_auth/acs/` value). The IdP rejects an - # AuthnRequest whose Issuer doesn't match the registered SP entity - # ID, so we monkey-patch `build_sp_config` to pin entityId to the - # configured value. The metadata endpoint uses the same builder, so - # the SP metadata advertised to the IdP stays consistent. + # `https:///saml2_auth/acs/` ACS path and corresponding entity + # ID). The IdP rejects AuthnRequests whose Issuer or ACS URL don't + # match its registration, so we monkey-patch `build_sp_config` to + # pin both to the configured legacy values. The metadata endpoint + # uses the same builder, so the SP metadata advertised to the IdP + # stays internally consistent. if getattr(settings, "ALLOW_SAML_LOGIN", "").lower() != "true": return sp_entity_id = getattr(settings, "SAML_SP_ENTITY_ID", None) - if not sp_entity_id: + sp_acs_url = getattr(settings, "SAML_SP_ACS_URL", None) + if not sp_entity_id and not sp_acs_url: return from allauth.socialaccount.providers.saml import utils as saml_utils original = saml_utils.build_sp_config def build_sp_config_pinned(request, provider_config, org): config = original(request, provider_config, org) - config["entityId"] = sp_entity_id + if sp_entity_id: + config["entityId"] = sp_entity_id + if sp_acs_url: + config["assertionConsumerService"]["url"] = sp_acs_url return config saml_utils.build_sp_config = build_sp_config_pinned \ No newline at end of file From c8c77bdd6af5ba4f9a80742db090257d370227ba Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Tue, 16 Jun 2026 11:59:27 -0400 Subject: [PATCH 10/14] alternative attempt to solve https issue with saml assertion --- appstore/appstore/settings/base.py | 30 +----------------------------- appstore/appstore/urls.py | 9 --------- appstore/core/apps.py | 28 ++++++++++++++++------------ 3 files changed, 17 insertions(+), 50 deletions(-) diff --git a/appstore/appstore/settings/base.py b/appstore/appstore/settings/base.py index a196200c..d0d6204a 100644 --- a/appstore/appstore/settings/base.py +++ b/appstore/appstore/settings/base.py @@ -127,9 +127,6 @@ if PROVIDER != '': THIRD_PARTY_APPS.append(f"allauth.socialaccount.providers.{PROVIDER}") -# The SAML provider is loaded whenever SAML login is enabled. ALLOW_SAML_LOGIN -# is kept as a lower-cased string above for compatibility with templates and -# views that compare it to the literal "true". SAML_URL = "/accounts/saml" SAML_ACS_URL = "/saml2_auth/acs/" SAML_PROVIDER_SLUG = os.environ.get("SAML_PROVIDER_SLUG", "saml") @@ -137,32 +134,12 @@ if ALLOW_SAML_LOGIN == "true": THIRD_PARTY_APPS.append("allauth.socialaccount.providers.saml") - # SP entity ID — what the IdP has registered to identify this service. - # Kept under the legacy SAML2_AUTH_ENTITY_ID name so existing chart values - # and IdP registrations continue to work. Exposed as a top-level setting - # so the appstore AppConfig.ready() hook can pin allauth-SAML's SP entityId - # to it (allauth would otherwise derive a different value from URL routing, - # which would not match what the IdP has registered). SAML_SP_ENTITY_ID = os.environ["SAML2_AUTH_ENTITY_ID"] - # SP ACS URL — the full assertion-consumer-service URL the IdP has - # registered. Allauth-SAML's default points at /accounts/saml//acs/ - # which does not match what existing IdP registrations use; pinned via - # the same AppConfig.ready() hook. Defaults to the SP host + legacy ACS - # path, which matches the original django-saml2-auth convention. - _sp_host = "/".join(SAML_SP_ENTITY_ID.split("/", 3)[:3]) # scheme://host + _sp_host = "/".join(SAML_SP_ENTITY_ID.split("/", 3)[:3]) SAML_SP_ACS_URL = os.environ.get("SAML_SP_ACS_URL") or (_sp_host + SAML_ACS_URL) - # IdP entity ID — selects which IdP within a federation aggregate the - # OneLogin metadata parser should extract. Required when the metadata - # source is a multi-IdP aggregate (e.g. UNC's federation metadata). SAML_IDP_ENTITY_ID = os.environ.get("SAML_IDP_ENTITY_ID") or None _saml_metadata_source = os.environ["SAML_METADATA_SOURCE"] - # Allauth-SAML accepts either a remote `metadata_url` (which it fetches - # and caches itself) or a fully-explicit dict with entity_id/x509cert/ - # sso_url. The deployment may hand us either: a metadata URL when the - # chart's fetch sidecar is disabled, or a local XML file path when the - # sidecar is mirroring the IdP metadata onto a PVC. The file-path case - # is parsed once here so allauth gets explicit IdP fields. if _saml_metadata_source.startswith(("http://", "https://")): _saml_idp = { "entity_id": SAML_IDP_ENTITY_ID, @@ -192,11 +169,6 @@ "name": SAML_PROVIDER_NAME, "settings": { "idp": _saml_idp, - # Preserve the legacy username-from-uid mapping. Both `uid` - # (SocialAccount.uid, the stable external identifier) and - # `username` (Django auth_user.username) are populated from - # the SAML `uid` attribute so returning users keep their - # existing usernames. "attribute_mapping": { "uid": ["uid"], "username": ["uid"], diff --git a/appstore/appstore/urls.py b/appstore/appstore/urls.py index d2f1db05..506a94ee 100644 --- a/appstore/appstore/urls.py +++ b/appstore/appstore/urls.py @@ -15,21 +15,12 @@ def _saml_legacy_login(request): - # Legacy entry point preserved so existing IdP metadata and external - # links continue to resolve. Dispatches to allauth-SAML's login view - # under the configured organization slug. from allauth.socialaccount.providers.saml import views as saml_views return saml_views.login(request, organization_slug=settings.SAML_PROVIDER_SLUG) @csrf_exempt def _saml_legacy_acs(request): - # Legacy ACS endpoint preserved so the IdP can POST SAMLResponses to - # the same URL it was configured with under django-saml2-auth. A redirect - # would convert the POST to a GET and drop the assertion, so we invoke - # the allauth-SAML ACS view in-process instead. csrf_exempt is required - # on this outer wrapper because Django's CSRF middleware checks the - # exemption attribute on the URL-resolved callback, not the inner view. from allauth.socialaccount.providers.saml import views as saml_views return saml_views.acs(request, organization_slug=settings.SAML_PROVIDER_SLUG) diff --git a/appstore/core/apps.py b/appstore/core/apps.py index c771fb0a..b36c1df9 100644 --- a/appstore/core/apps.py +++ b/appstore/core/apps.py @@ -11,15 +11,6 @@ def ready(self): @staticmethod def _pin_saml_sp_entity_id(): - # Allauth-SAML derives the SP `entityId` and ACS URL from the per-org - # metadata/ACS URLs it serves at runtime. Both diverge from what the - # IdP has registered for this service (the legacy - # `https:///saml2_auth/acs/` ACS path and corresponding entity - # ID). The IdP rejects AuthnRequests whose Issuer or ACS URL don't - # match its registration, so we monkey-patch `build_sp_config` to - # pin both to the configured legacy values. The metadata endpoint - # uses the same builder, so the SP metadata advertised to the IdP - # stays internally consistent. if getattr(settings, "ALLOW_SAML_LOGIN", "").lower() != "true": return sp_entity_id = getattr(settings, "SAML_SP_ENTITY_ID", None) @@ -27,14 +18,27 @@ def _pin_saml_sp_entity_id(): if not sp_entity_id and not sp_acs_url: return from allauth.socialaccount.providers.saml import utils as saml_utils - original = saml_utils.build_sp_config + original_build = saml_utils.build_sp_config def build_sp_config_pinned(request, provider_config, org): - config = original(request, provider_config, org) + config = original_build(request, provider_config, org) if sp_entity_id: config["entityId"] = sp_entity_id if sp_acs_url: config["assertionConsumerService"]["url"] = sp_acs_url return config - saml_utils.build_sp_config = build_sp_config_pinned \ No newline at end of file + saml_utils.build_sp_config = build_sp_config_pinned + + # Ambassador overwrites X-Forwarded-Proto based on its own listener + # scheme (http), so Django's request.is_secure() always returns False. + # Force the scheme to match the SP entity ID. + if sp_entity_id and sp_entity_id.startswith("https://"): + original_prepare = saml_utils.prepare_django_request + + def prepare_django_request_pinned(request): + result = original_prepare(request) + result["https"] = "on" + return result + + saml_utils.prepare_django_request = prepare_django_request_pinned \ No newline at end of file From 505fa65cb2bd09827bb45adf072bc84fdce26035 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Tue, 16 Jun 2026 13:04:14 -0400 Subject: [PATCH 11/14] update saml attribute mappings --- appstore/appstore/settings/base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/appstore/appstore/settings/base.py b/appstore/appstore/settings/base.py index d0d6204a..7763a99e 100644 --- a/appstore/appstore/settings/base.py +++ b/appstore/appstore/settings/base.py @@ -170,11 +170,11 @@ "settings": { "idp": _saml_idp, "attribute_mapping": { - "uid": ["uid"], - "username": ["uid"], - "email": ["mail"], - "first_name": ["givenName"], - "last_name": ["sn"], + "uid": ["urn:oid:0.9.2342.19200300.100.1.1"], + "username": ["urn:oid:0.9.2342.19200300.100.1.1"], + "email": ["urn:oid:0.9.2342.19200300.100.1.3", "urn:oid:1.3.6.1.4.1.5923.1.1.1.6"], + "first_name": ["urn:oid:2.5.4.42"], + "last_name": ["urn:oid:2.5.4.4"], }, "advanced": { "want_assertion_signed": True, From c4d4b780364d23a93bb34c1cbc20a5393ef646db Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Mon, 29 Jun 2026 14:02:37 -0400 Subject: [PATCH 12/14] dependency upgrades --- .dockerignore | 6 +++++- Dockerfile | 6 ++++-- requirements.txt | 8 +++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.dockerignore b/.dockerignore index 29fb4d44..f564522d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,4 +12,8 @@ appstore/static/ .vscode/ build/ coverage/ -docs/ \ No newline at end of file +docs/ +venv/ +.venv/ +venv.bak/ +*.egg-info/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b0e3742b..437f5e8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.23-alpine +FROM python:3.10.20-alpine ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 @@ -12,6 +12,7 @@ ENV UID=1000 RUN mkdir $APP_HOME RUN set -x && \ + apk upgrade --no-cache && \ apk add --no-cache make git bash build-base xmlsec libxml2-dev linux-headers openssl && \ adduser -D -s /bin/bash -h $HOME -u $UID $USER && \ chown -R $UID:$UID $APP_HOME @@ -30,7 +31,8 @@ RUN chown -R $USER:0 $APP_HOME && \ RUN if [ -d whl -a "$(ls -A whl/*.whl)" ]; then pip install whl/*.whl; fi RUN export SET_BUILD_ENV_FROM_FILE=false \ - && pip install "cython<3.0.0" wheel \ + && pip install --upgrade "setuptools>=78.1.1" \ + && pip install "cython<3.0.0" "wheel>=0.46.2" \ && pip install "pyyaml==5.4.1" --no-build-isolation \ && make install \ && unset SET_BUILD_ENV_FROM_FILE diff --git a/requirements.txt b/requirements.txt index c44af4df..54b1064d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.2.23 +Django==4.2.30 django-allauth==0.61.1 django-cors-headers==4.3.1 django-crispy-forms==2.1 @@ -18,7 +18,7 @@ asgiref==3.7.2 psycopg2-binary python-irodsclient==1.1.5 deepmerge==1.0.1 -GitPython==3.1.44 +GitPython==3.1.50 Jinja2==3.1.6 jsonschema==3.2.0 kubernetes==25.3.0 @@ -27,4 +27,6 @@ requests==2.32.4 docker-compose==1.29.2 requests_cache==1.2.1 ldap3 -urllib3==2.5.0 +urllib3==2.7.0 +PyJWT==2.13.0 +cryptography==48.0.1 From 19d6fdf9c73a401adc180c8c6aaa02f194245e0b Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Mon, 29 Jun 2026 16:56:32 -0400 Subject: [PATCH 13/14] Rebuild image to trigger scan From e3b64a1b95e2b579abcd2857b91f3339f0fd59cb Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Thu, 23 Jul 2026 13:48:56 -0400 Subject: [PATCH 14/14] move /var/run/helx symlink creation into helx app init container --- appstore/tycho/template/pod.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/appstore/tycho/template/pod.yaml b/appstore/tycho/template/pod.yaml index 3f2990dd..6c0e2583 100644 --- a/appstore/tycho/template/pod.yaml +++ b/appstore/tycho/template/pod.yaml @@ -139,6 +139,11 @@ spec: fi fi + # Create /var/run/helx symlink. + if [ -d "/var/run/helx" ]; then + ln -sfn "/var/run/helx" "$HOME_PATH/helx" + fi + {% if system.enable_trash_cli == "true" %} HOME_TRASH="${XDG_DATA_HOME:-$HOME_PATH/.local/share}/Trash" TRASH_AGGREGATE="$HOME_PATH/Trash Bins"