Skip to content

feat: email-based account linking for existing users in agentic provisioning#53348

Open
MattBro wants to merge 8 commits intomasterfrom
matt/email-based-linking
Open

feat: email-based account linking for existing users in agentic provisioning#53348
MattBro wants to merge 8 commits intomasterfrom
matt/email-based-linking

Conversation

@MattBro
Copy link
Copy Markdown
Contributor

@MattBro MattBro commented Apr 3, 2026

Problem

Existing PostHog users hitting the Stripe agentic provisioning flow get redirected to a browser auth page (requires_auth response), which Stripe says causes developer friction. Stripe's V2 requirements (deadline 4/17) need us to eliminate the interactive flow for existing users.

Since account_requests are HMAC-signed by Stripe, we can trust the email identity and issue OAuth tokens directly.

Changes

  • Rewrote _handle_existing_user to issue an auth code directly instead of returning requires_auth
  • Added _resolve_team_for_existing_user helper: single team auto-selects, only demo teams creates new project, multiple teams creates new project (or uses configuration.team_id if specified)
  • Added _get_available_teams_for_user to return team list in token exchange response (account.available_teams)
  • Added OIDC_RSA_PRIVATE_KEY override to test base for sandbox environments
  • Fixed services-list returning empty next_cursor (protocol schema requires min length 1 or omit)
  • Fixed deep-link auth failing when STRIPE_POSTHOG_OAUTH_CLIENT_ID is not configured (falls back to matching OAuth app by name)

How did you test this code?

Automated tests: 132 pass, 1 pre-existing failure (test_full_a1_flow_with_token_exchange - OIDC key issue on master)

Manual testing with Stripe spec toolkit (posthog-spec-toolkit orchestrator against local dev server):

  • health - 200 OK
  • services-list - 200, returns analytics service
  • account-request --email test-stripe@posthog.com - 200, existing user gets credentials directly with available_teams
  • provision-resource --service-id analytics - 200, returns api_key + personal_api_key
  • rotate-credentials - 200, returns new credentials
  • deep-link --purpose dashboard - 200, returns login URL with team_id

Publish to changelog?

No

Docs update

N/A - internal Stripe integration, no public-facing docs

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 3, 2026

Comments Outside Diff (1)

  1. ee/api/agentic_provisioning/views.py, line 258-265 (link)

    P2 Unused parameter — superfluous part

    confirmation_secret is still declared in the signature but is never referenced in the new body of _handle_existing_user. The old code used it as the cache key for PENDING_AUTH_CACHE_PREFIX; the new code generates its own random code and ignores this argument entirely. It can be removed from the signature and all call sites.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: ee/api/agentic_provisioning/views.py
    Line: 258-265
    
    Comment:
    **Unused parameter — superfluous part**
    
    `confirmation_secret` is still declared in the signature but is never referenced in the new body of `_handle_existing_user`. The old code used it as the cache key for `PENDING_AUTH_CACHE_PREFIX`; the new code generates its own random `code` and ignores this argument entirely. It can be removed from the signature and all call sites.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: ee/api/agentic_provisioning/views.py
Line: 320-329

Comment:
**Duplicate logic — OnceAndOnlyOnce violation**

After the `len(non_demo_teams) == 1` early return, both remaining branches (`if not non_demo_teams` and the final `return`) call `Team.objects.create_with_data(initiating_user=user, organization=organization)` with identical arguments. The `if not non_demo_teams` guard is dead code because whether there are 0 or ≥2 teams, the result is the same. The whole block can be simplified to:

```suggestion
    organization = memberships[0].organization
    return Team.objects.create_with_data(initiating_user=user, organization=organization)
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ee/api/agentic_provisioning/views.py
Line: 258-265

Comment:
**Unused parameter — superfluous part**

`confirmation_secret` is still declared in the signature but is never referenced in the new body of `_handle_existing_user`. The old code used it as the cache key for `PENDING_AUTH_CACHE_PREFIX`; the new code generates its own random `code` and ignores this argument entirely. It can be removed from the signature and all call sites.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ee/api/agentic_provisioning/views.py
Line: 241-246

Comment:
**Silent coercion of invalid `team_id` bypasses explicit team selection**

When `configuration.team_id` is present but cannot be cast to `int` (e.g. `"abc"`), `requested_team_id` is silently set to `None` and `_resolve_team_for_existing_user` auto-selects a team instead of returning an error. A caller that passes an explicitly wrong `team_id` value presumably does not expect silent auto-selection; returning a 400 (as done for a non-existent integer id) would be more consistent and easier to debug.

There is currently no test covering a non-numeric `team_id` value — `test_existing_user_with_invalid_team_id_returns_400` only tests an integer that does not exist in the database.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: add OIDC_RSA_PRIVATE_KEY to provisi..." | Re-trigger Greptile

Comment on lines +320 to +329
non_demo_teams = list(Team.objects.filter(organization_id__in=org_ids, is_demo=False))

if len(non_demo_teams) == 1:
return non_demo_teams[0]

organization = memberships[0].organization
if not non_demo_teams:
return Team.objects.create_with_data(initiating_user=user, organization=organization)

return Team.objects.create_with_data(initiating_user=user, organization=organization)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Duplicate logic — OnceAndOnlyOnce violation

After the len(non_demo_teams) == 1 early return, both remaining branches (if not non_demo_teams and the final return) call Team.objects.create_with_data(initiating_user=user, organization=organization) with identical arguments. The if not non_demo_teams guard is dead code because whether there are 0 or ≥2 teams, the result is the same. The whole block can be simplified to:

Suggested change
non_demo_teams = list(Team.objects.filter(organization_id__in=org_ids, is_demo=False))
if len(non_demo_teams) == 1:
return non_demo_teams[0]
organization = memberships[0].organization
if not non_demo_teams:
return Team.objects.create_with_data(initiating_user=user, organization=organization)
return Team.objects.create_with_data(initiating_user=user, organization=organization)
organization = memberships[0].organization
return Team.objects.create_with_data(initiating_user=user, organization=organization)
Prompt To Fix With AI
This is a comment left during a code review.
Path: ee/api/agentic_provisioning/views.py
Line: 320-329

Comment:
**Duplicate logic — OnceAndOnlyOnce violation**

After the `len(non_demo_teams) == 1` early return, both remaining branches (`if not non_demo_teams` and the final `return`) call `Team.objects.create_with_data(initiating_user=user, organization=organization)` with identical arguments. The `if not non_demo_teams` guard is dead code because whether there are 0 or ≥2 teams, the result is the same. The whole block can be simplified to:

```suggestion
    organization = memberships[0].organization
    return Team.objects.create_with_data(initiating_user=user, organization=organization)
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Simplified - removed the dead branch.

Comment on lines +241 to +246
requested_team_id = configuration.get("team_id")
if requested_team_id is not None:
try:
requested_team_id = int(requested_team_id)
except (ValueError, TypeError):
requested_team_id = None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Silent coercion of invalid team_id bypasses explicit team selection

When configuration.team_id is present but cannot be cast to int (e.g. "abc"), requested_team_id is silently set to None and _resolve_team_for_existing_user auto-selects a team instead of returning an error. A caller that passes an explicitly wrong team_id value presumably does not expect silent auto-selection; returning a 400 (as done for a non-existent integer id) would be more consistent and easier to debug.

There is currently no test covering a non-numeric team_id value — test_existing_user_with_invalid_team_id_returns_400 only tests an integer that does not exist in the database.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ee/api/agentic_provisioning/views.py
Line: 241-246

Comment:
**Silent coercion of invalid `team_id` bypasses explicit team selection**

When `configuration.team_id` is present but cannot be cast to `int` (e.g. `"abc"`), `requested_team_id` is silently set to `None` and `_resolve_team_for_existing_user` auto-selects a team instead of returning an error. A caller that passes an explicitly wrong `team_id` value presumably does not expect silent auto-selection; returning a 400 (as done for a non-existent integer id) would be more consistent and easier to debug.

There is currently no test covering a non-numeric `team_id` value — `test_existing_user_with_invalid_team_id_returns_400` only tests an integer that does not exist in the database.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Returns 400 now for non-numeric team_id. Added test.

MattBro and others added 2 commits April 3, 2026 17:05
…sioning

Instead of redirecting existing users to a browser auth page, issue OAuth
tokens directly based on the Stripe-verified email. This eliminates the
interactive flow that was causing developer friction.

Also returns available teams in the token exchange response so orchestrators
can specify which team to use at resource creation time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The OAuthApplication model validates RSA keys are available when
using RS256 algorithm. This was failing in sandbox environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@MattBro MattBro force-pushed the matt/email-based-linking branch from 78f3bb8 to 02e606d Compare April 3, 2026 21:06
MattBro and others added 4 commits April 3, 2026 17:21
…ted OAuth apps

- Remove empty `next_cursor` from services response (protocol schema requires min length 1 or omit)
- Fall back to matching OAuth app by name when STRIPE_POSTHOG_OAUTH_CLIENT_ID is not configured

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Return 400 for non-numeric team_id instead of silently falling back to auto-selection
- Remove dead code branch in _resolve_team_for_existing_user (both paths did the same thing)
- Add test for non-numeric team_id

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 3, 2026

🎭 Playwright report · View test results →

⚠️ 2 flaky tests:

  • Creating a SQL insight with a variable and overriding it on a dashboard (chromium)
  • Materialize view pane (chromium)

These issues are not necessarily caused by your changes.
Annoyed by this comment? Help fix flakies and failures and it'll disappear!

MattBro and others added 2 commits April 3, 2026 18:08
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CodeQL flagged `secret` + SHA256 as insecure password hashing. This is
HMAC signature verification for Stripe webhooks, not password hashing.
Renaming the parameter to `signing_key` resolves the false positive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant