feat(oauth): block impersonated oauth when org disabled ai processing#60942
Conversation
Organizations can opt out of AI processing of their data via Organization.is_ai_data_processing_approved. When a staff member is impersonating a customer, they must not be able to authorize an OAuth client (the MCP being the motivating case) that would feed such an organization's data into AI. Adds a guard on all three token-minting paths of OAuthAuthorizationView (first-party shortcut, auto-approval, and consent POST) that returns 403 access_denied during impersonation when any in-scope organization is not approved for AI processing. Customers authorizing clients themselves are unaffected. Mirrors the fail-closed convention used elsewhere for AI features: only an explicit True counts as approved. Generated-By: PostHog Code Task-Id: 39394b5e-83bb-4693-96fb-52efb752a721
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
posthog/test/test_oauth_impersonation.py:225
`_build_request` is annotated as returning `RequestFactory` but actually returns the request object produced by `RequestFactory().get("/")` (a `WSGIRequest`). The annotation is misleading and would fail a strict type check.
```suggestion
def _build_request(self, target: User, *, impersonating: bool) -> WSGIRequest:
```
### Issue 2 of 2
posthog/api/oauth/views.py:692-695
**Auto-approval block ignores grant scope**
The auto-approval branch calls `_impersonation_ai_processing_block(request)` without `access_level`, `scoped_organization_ids`, or `scoped_team_ids`, so it always checks **all** of the impersonated user's organisations. The consent POST path is scope-aware (lines 750–756). In a multi-org scenario where the user belongs to an approved org and a disabled org, re-authorizing a token that is scoped exclusively to the approved org would be incorrectly blocked here, while the same grant via a fresh consent POST would succeed. If the existing token's scope information isn't easily recoverable at this point, the asymmetry is worth a comment explaining why the conservative fallback is intentional.
Reviews (1): Last reviewed commit: "feat(oauth): block impersonated oauth wh..." | Re-trigger Greptile |
| for token in tokens: | ||
| if token.allow_scopes(scope_str.split()): | ||
| if block := _impersonation_ai_processing_block(request): | ||
| return block |
There was a problem hiding this comment.
Auto-approval block ignores grant scope
The auto-approval branch calls _impersonation_ai_processing_block(request) without access_level, scoped_organization_ids, or scoped_team_ids, so it always checks all of the impersonated user's organisations. The consent POST path is scope-aware (lines 750–756). In a multi-org scenario where the user belongs to an approved org and a disabled org, re-authorizing a token that is scoped exclusively to the approved org would be incorrectly blocked here, while the same grant via a fresh consent POST would succeed. If the existing token's scope information isn't easily recoverable at this point, the asymmetry is worth a comment explaining why the conservative fallback is intentional.
Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/api/oauth/views.py
Line: 692-695
Comment:
**Auto-approval block ignores grant scope**
The auto-approval branch calls `_impersonation_ai_processing_block(request)` without `access_level`, `scoped_organization_ids`, or `scoped_team_ids`, so it always checks **all** of the impersonated user's organisations. The consent POST path is scope-aware (lines 750–756). In a multi-org scenario where the user belongs to an approved org and a disabled org, re-authorizing a token that is scoped exclusively to the approved org would be incorrectly blocked here, while the same grant via a fresh consent POST would succeed. If the existing token's scope information isn't easily recoverable at this point, the asymmetry is worth a comment explaining why the conservative fallback is intentional.
How can I resolve this? If you propose a fix, please make it concise.…llback Fixes ty type-check failure: `_build_request` is annotated as returning the request object (`HttpRequest`), not `RequestFactory`. Also documents why the auto-approval path uses the conservative all-orgs check rather than the matched token's scope (per Greptile review feedback). Generated-By: PostHog Code Task-Id: 39394b5e-83bb-4693-96fb-52efb752a721
|
⏭️ Skipped snapshot commit because branch advanced to The new commit will trigger its own snapshot update workflow. If you expected this workflow to succeed: This can happen due to concurrent commits. To get a fresh workflow run, either:
|
|
Should this strip the AI-related scopes like As written, this 403s every OAuth client during impersonation on an opted-out org, not just MCP. Maybe that's intended tho, just thinking out loud |
No, sending their data to Anthropic via our Claude Code instance shouldn't be allowed
There's no way to detect it's an MCP vs. just any random OAuth flow. There are some clients that say who they are, but some like Cursor don't! |
|
🎭 Playwright report · View test results →
These issues are not necessarily caused by your changes. |
Problem
We allow staff to connect an MCP (and other OAuth clients) on behalf of a customer while impersonating their account — this is intended and useful. However, some organizations explicitly opt out of AI processing of their data via the org-level
Organization.is_ai_data_processing_approvedsetting. In that case, a staff member impersonating the account should not be able to authorize an OAuth client that would route that organization's data into AI.This restriction applies only to impersonation. Customers authorizing clients themselves are unaffected — they have already consented for their own data and know whether they can connect it.
Changes
_impersonation_ai_processing_block(...)inposthog/api/oauth/views.pyreturns a403 access_deniedresponse when the session is an impersonation session and any in-scope organization is not approved for AI processing._scoped_organization_ids(...)resolves the organizations a grant would actually reach:organization-level scope → the listed orgs,team-level scope → the orgs owning those teams, andall/unscoped → every org the impersonated user belongs to.OAuthAuthorizationView: the first-party GET shortcut, the auto-approval GET path, and the consent POST path (only whenallow=True).Truecounts as approved — aNULL/unset value is treated as not approved.How did you test this code?
I'm an agent (PostHog Code). I added automated tests in
posthog/test/test_oauth_impersonation.py(TestImpersonationAIProcessingBlock) covering:False) or unset (None)True)I could not execute the test suite — the working environment has no Python/Django runtime or
flox/hogliavailable. I verified both modified files compile (python3 -m py_compile), but the tests themselves have not been run. They should be run in a full dev environment before merge.Automatic notifications
🤖 Agent context
Authored by PostHog Code (Claude Opus 4.8).
Key decisions:
/oauth/authorizestep (grant creation) rather than at/oauth/tokencode exchange — blocking authorize means no grant is created in the first place, which is the right gate. Pre-existing impersonation grants are already cleaned up by the logout-revocation signal.NULL/unset as not approved to stay fail-closed and consistent with existing AI-feature gates (sync_vectors, summarization, embedding worker).