From 771aeed7d9e6e5fbd8e5b90c47c201411926703a Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Mon, 27 Jul 2026 11:28:47 +0500 Subject: [PATCH 1/4] Create missing profile on proxy auth for pre-existing users The profile was only created for newly created users, so users provisioned outside the signup path - such as the JIRA import - never got one. That made /api/users/me/profile/ return 404 and bounced the client back to the login page in a loop. --- .../api/plane/authentication/middleware/proxy_auth.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/api/plane/authentication/middleware/proxy_auth.py b/apps/api/plane/authentication/middleware/proxy_auth.py index 4dce5939790..7c0731f0206 100644 --- a/apps/api/plane/authentication/middleware/proxy_auth.py +++ b/apps/api/plane/authentication/middleware/proxy_auth.py @@ -155,7 +155,7 @@ def _read_proxy_email(request): def _resolve_user(self, email): username_hint = email.split("@")[0] or uuid4().hex try: - user, created = User.objects.get_or_create( + user, _ = User.objects.get_or_create( email=email, defaults={ "username": username_hint, @@ -169,10 +169,13 @@ def _resolve_user(self, email): user = User.objects.get(email=email) except User.DoesNotExist: raise - created = False - if created: - Profile.objects.get_or_create(user=user) + # Run for every user, not just newly created ones. Users provisioned + # outside the signup path — e.g. inserted directly by the JIRA import — + # have no Profile row, which makes /api/users/me/profile/ return 404 and + # bounces the client back to the login page in a loop. get_or_create is + # idempotent, so an existing profile is left untouched. + Profile.objects.get_or_create(user=user) # Run for every user (new or existing) — idempotent, no-op if already joined. self._auto_join_workspace(user) From 21438658b07de7263348c09a11c2492185aeae6a Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Mon, 27 Jul 2026 11:38:50 +0500 Subject: [PATCH 2/4] Address adversarial review findings (round 1) Ensure the profile on the already-authenticated short-circuit path too, gated on a session flag, so users holding a live session are healed instead of looping until the session expires. Collapse the now-dead IntegrityError re-raise. --- .../authentication/middleware/proxy_auth.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/api/plane/authentication/middleware/proxy_auth.py b/apps/api/plane/authentication/middleware/proxy_auth.py index 7c0731f0206..588803638e9 100644 --- a/apps/api/plane/authentication/middleware/proxy_auth.py +++ b/apps/api/plane/authentication/middleware/proxy_auth.py @@ -37,6 +37,9 @@ "is_email_verified": True, } +# Session flag marking that this session's user is known to have a Profile. +_PROFILE_ENSURED_KEY = "proxy_auth_profile_ensured" + def _check_corporate_id(request) -> bool: """Verify the caller's access token contains a corporate_id matching this deployment. @@ -95,6 +98,14 @@ def __call__(self, request): # not pass through ForwardAuth — header absence is not a logout signal). current = _normalise_email(request.user.email or "") if not email or current == email: + # A user provisioned outside the signup path can already hold a + # session while still missing a Profile, and would otherwise + # keep 404ing on /api/users/me/profile/ until the session + # expires. Gate on a session flag so this costs one query per + # session rather than one per request. + if not request.session.get(_PROFILE_ENSURED_KEY): + Profile.objects.get_or_create(user=request.user) + request.session[_PROFILE_ENSURED_KEY] = True return self.get_response(request) # Mismatch detected: proxy asserts a different identity than the @@ -165,10 +176,7 @@ def _resolve_user(self, email): ) except IntegrityError: # Concurrent email insert race — fall back to get(). - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - raise + user = User.objects.get(email=email) # Run for every user, not just newly created ones. Users provisioned # outside the signup path — e.g. inserted directly by the JIRA import — From c3ec3c85193f11562e5fa8756d3d49bffbeb3f83 Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Mon, 27 Jul 2026 11:47:35 +0500 Subject: [PATCH 3/4] Address adversarial review findings (round 2) Complete onboarding for profiles healed on the short-circuit path, set the session flag after login to avoid a redundant re-check, and cover both behaviours with tests. --- .../authentication/middleware/proxy_auth.py | 11 ++- .../authentication/tests/test_proxy_auth.py | 70 ++++++++++++++++++- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/api/plane/authentication/middleware/proxy_auth.py b/apps/api/plane/authentication/middleware/proxy_auth.py index 588803638e9..704a80c7cd7 100644 --- a/apps/api/plane/authentication/middleware/proxy_auth.py +++ b/apps/api/plane/authentication/middleware/proxy_auth.py @@ -102,9 +102,13 @@ def __call__(self, request): # session while still missing a Profile, and would otherwise # keep 404ing on /api/users/me/profile/ until the session # expires. Gate on a session flag so this costs one query per - # session rather than one per request. + # session rather than one per request. The freshly created + # profile still needs onboarding completed, which is why + # _auto_join_workspace runs here too. if not request.session.get(_PROFILE_ENSURED_KEY): - Profile.objects.get_or_create(user=request.user) + _, profile_created = Profile.objects.get_or_create(user=request.user) + if profile_created: + self._auto_join_workspace(request.user) request.session[_PROFILE_ENSURED_KEY] = True return self.get_response(request) @@ -129,6 +133,9 @@ def __call__(self, request): return self.get_response(request) user_login(request=request, user=user, is_app=True) + # _resolve_user just guaranteed the profile, so the next request on this + # session can skip the check instead of re-running it once per login. + request.session[_PROFILE_ENSURED_KEY] = True return self.get_response(request) @staticmethod diff --git a/apps/api/plane/authentication/tests/test_proxy_auth.py b/apps/api/plane/authentication/tests/test_proxy_auth.py index e4ca4d61c0c..504fd73b267 100644 --- a/apps/api/plane/authentication/tests/test_proxy_auth.py +++ b/apps/api/plane/authentication/tests/test_proxy_auth.py @@ -18,14 +18,17 @@ - If path starts with a bypass prefix → pass through immediately (no DB, no login) Default bypass prefixes: ["/god-mode", "/api/instances"] - If request.user.is_authenticated: - * proxy header absent or matches request.user.email → short-circuit + * proxy header absent or matches request.user.email → ensure a Profile exists + (once per session, gated on a session flag) then short-circuit * proxy header asserts a DIFFERENT email → logout() to flush the stale session, then fall through and re-authenticate (defends against the "stale Django session survives upstream logout" class of bug — see TestProxyAuthMiddlewareUserSwitch) - If both identity headers are absent (and no existing session) → pass through unauthenticated -- If identity can be derived from headers → get_or_create User, create Profile on first creation, - then call user_login(request, user, is_app=True) to establish session +- If identity can be derived from headers → get_or_create User, ensure a Profile exists + (for every user, not just newly created ones — the JIRA import inserts users + directly and leaves them profile-less), then call user_login(request, user, is_app=True) + to establish session - New users get: set_unusable_password(), is_password_autoset=True, is_email_verified=True - username is always uuid4().hex (never the Cognito sub — avoids length/collision issues) - Email is normalised (lowercased + stripped) before DB lookup @@ -105,6 +108,67 @@ def test_skips_when_user_already_authenticated(self, django_user_model): mock_login.assert_not_called() assert User.objects.count() == count_before + @pytest.mark.django_db + def test_creates_missing_profile_for_authenticated_user(self, django_user_model): + """ + GIVEN an authenticated user with no Profile — e.g. inserted directly by + the JIRA import, which bypasses Plane's signup path + WHEN the middleware processes the request + THEN the missing Profile is created + AND the session is flagged so later requests skip the check + + Without this the user 404s on /api/users/me/profile/ for the life of + the session and the web client loops back to the login page. + """ + existing_user = django_user_model.objects.create_user( + email="noprofile@example.com", + username="noprofile_user", + password="irrelevant", + ) + Profile.objects.filter(user=existing_user).delete() + assert not Profile.objects.filter(user=existing_user).exists() + + middleware = make_middleware() + request = make_request( + meta={"HTTP_X_AUTH_REQUEST_EMAIL": "noprofile@example.com"}, + authenticated_user=existing_user, + ) + + with patch(PATCH_USER_LOGIN): + middleware(request) + + assert Profile.objects.filter(user=existing_user).exists() + assert request.session.get("proxy_auth_profile_ensured") is True + + @pytest.mark.django_db + def test_profile_check_is_cached_for_the_session(self, django_user_model): + """ + GIVEN a session already flagged as profile-checked + WHEN the middleware processes a further request on that session + THEN it does not re-check the profile + + Asserted by deleting the Profile after the first pass: if the flag were + ignored the second pass would recreate it, which would mean a SELECT on + every request rather than one per session. + """ + existing_user = django_user_model.objects.create_user( + email="cached@example.com", + username="cached_user", + password="irrelevant", + ) + middleware = make_middleware() + request = make_request( + meta={"HTTP_X_AUTH_REQUEST_EMAIL": "cached@example.com"}, + authenticated_user=existing_user, + ) + + with patch(PATCH_USER_LOGIN): + middleware(request) + Profile.objects.filter(user=existing_user).delete() + middleware(request) + + assert not Profile.objects.filter(user=existing_user).exists() + class TestProxyAuthMiddlewareUserSwitch: """Stale Django session must not survive an upstream identity change.""" From 0e5bfbed12477b9c524fb126d3faa2ad1a9a0c3e Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Mon, 27 Jul 2026 11:59:31 +0500 Subject: [PATCH 4/4] Address adversarial review findings (round 3) Gate onboarding on the profile's is_onboarded flag rather than on whether this request created it, so profiles restored by an out-of-band backfill are onboarded too. --- .../authentication/middleware/proxy_auth.py | 12 +++---- .../authentication/tests/test_proxy_auth.py | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/api/plane/authentication/middleware/proxy_auth.py b/apps/api/plane/authentication/middleware/proxy_auth.py index 704a80c7cd7..c4ce082f0de 100644 --- a/apps/api/plane/authentication/middleware/proxy_auth.py +++ b/apps/api/plane/authentication/middleware/proxy_auth.py @@ -101,13 +101,13 @@ def __call__(self, request): # A user provisioned outside the signup path can already hold a # session while still missing a Profile, and would otherwise # keep 404ing on /api/users/me/profile/ until the session - # expires. Gate on a session flag so this costs one query per - # session rather than one per request. The freshly created - # profile still needs onboarding completed, which is why - # _auto_join_workspace runs here too. + # expires. Gate on a session flag so this runs once per session + # rather than once per request. A profile created here — or + # backfilled out of band — still has onboarding incomplete, + # which is what _auto_join_workspace finishes. if not request.session.get(_PROFILE_ENSURED_KEY): - _, profile_created = Profile.objects.get_or_create(user=request.user) - if profile_created: + profile, _ = Profile.objects.get_or_create(user=request.user) + if not profile.is_onboarded: self._auto_join_workspace(request.user) request.session[_PROFILE_ENSURED_KEY] = True return self.get_response(request) diff --git a/apps/api/plane/authentication/tests/test_proxy_auth.py b/apps/api/plane/authentication/tests/test_proxy_auth.py index 504fd73b267..f07b09e9ad7 100644 --- a/apps/api/plane/authentication/tests/test_proxy_auth.py +++ b/apps/api/plane/authentication/tests/test_proxy_auth.py @@ -140,6 +140,37 @@ def test_creates_missing_profile_for_authenticated_user(self, django_user_model) assert Profile.objects.filter(user=existing_user).exists() assert request.session.get("proxy_auth_profile_ensured") is True + @pytest.mark.django_db + def test_completes_onboarding_for_existing_unonboarded_profile(self, django_user_model): + """ + GIVEN an authenticated user whose Profile exists but is not onboarded — + the state left by an out-of-band backfill + WHEN the middleware processes the request + THEN onboarding is completed rather than skipped + + Gating this on "the middleware created the profile" would miss exactly + the backfilled users, leaving them stuck on /onboarding. + """ + existing_user = django_user_model.objects.create_user( + email="unonboarded@example.com", + username="unonboarded_user", + password="irrelevant", + ) + Profile.objects.update_or_create(user=existing_user, defaults={"is_onboarded": False}) + + middleware = make_middleware() + request = make_request( + meta={"HTTP_X_AUTH_REQUEST_EMAIL": "unonboarded@example.com"}, + authenticated_user=existing_user, + ) + + with patch(PATCH_USER_LOGIN), patch.object( + ProxyAuthMiddleware, "_auto_join_workspace" + ) as mock_join: + middleware(request) + + mock_join.assert_called_once_with(existing_user) + @pytest.mark.django_db def test_profile_check_is_cached_for_the_session(self, django_user_model): """