feat(agentic-provisioning): add partner-scoped GitHub grant store and endpoints - #69857
Conversation
… endpoints
Adds a server-side GitHub grant store to the agentic provisioning API so a
partner (e.g. posthog.com) can exchange a GitHub OAuth code without ever
holding GitHub tokens: the tokens are encrypted and cached region-locally
under an opaque grant_id bound to the creating partner.
- POST /api/agentic/provisioning/github/grants exchanges the code, reads the
user's primary verified email, and returns {grant_id, gh_login, email}
- GET /api/agentic/provisioning/github/grants/:id/repositories lists the
user's installations of our GitHub App and their repos, for the repo picker
(pollable while the visitor installs the App, with a per-grant budget)
- new github_grants partner rate-limit bucket + OAuthApplication override field
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migration SQL ChangesHey 👋, we've detected some migrations on this PR. Here's the SQL output for each migration, make sure they make sense:
|
🔍 Migration Risk AnalysisWe've analyzed your migrations for potential risks. Summary: 1 Safe | 0 Needs Review | 0 Blocked ✅ SafeBrief or no lock, backwards compatible 📚 How to Deploy These Changes SafelyAddField: This operation acquires a brief lock but doesn't rewrite the table. Deployment uses lock timeouts with automatic retries, so lock contention will cause retries rather than connection pile-up. Last updated: 2026-07-14 03:23 UTC (0022a7c) |
3873684 to
ee5d7c1
Compare
|
Reviews (1): Last reviewed commit: "feat(agentic-provisioning): add partner-..." | Re-trigger Greptile |
PR overviewThis pull request adds a partner-scoped GitHub grant store and related API endpoints for agentic provisioning. The touched GitHub grant flow lists GitHub App installations and repositories associated with a grant. There is one open security concern remaining after two issues have been addressed. The remaining issue is an authenticated resource-amplification path where listing GitHub installations can trigger a large number of outbound GitHub repository requests per poll, creating avoidable load and potential rate-limit pressure. The risk appears bounded to authenticated use of this endpoint rather than direct data exposure or privilege bypass. Open issues (1)
Fixed/addressed: 2 · PR risk: 5/10 |
…b errors Restrict GitHub grant endpoints to proof-bearing (hmac/bearer) partners so a public PKCE client_id can't exchange codes or read grant data, and map github_user_from_code request/JSON failures to github_unavailable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…elcome email Lands the send_provisioning_welcome repository kwarg (and its template paragraph) ahead of the account_requests caller that passes it, so a rolling deploy never hands the new kwarg to an old Celery worker. The caller ships in a later PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ee5d7c1 to
a21d787
Compare
|
Note 🤖 stamphog reviewed Gates denied this PR (deny-list hit on auth/migrations, and it's classified T2-never) so it cannot be auto-approved. It's squarely in risky territory (GitHub token custody, new partner-facing auth endpoints, DB migration), and while the author says they fixed several high/medium severity findings from security bots, those bot reviews are all on an older commit — no bot or human has re-reviewed the current head to confirm the fixes are correct.
Gate mechanics and policy version
|
a21d787 to
a57ad91
Compare
🤖 CI report
|
| File | Patch | Uncovered changed lines |
|---|---|---|
ee/api/agentic_provisioning/github_grants.py |
86.7% | 70, 136, 143–145, 153, 174, 180, 186, 219, 223, 249, 254, 259, 262 |
ee/api/agentic_provisioning/views.py |
88.6% | 825–826, 855, 922–924, 947–948, 970, 974 |
🤖 Agents: add a test covering the lines above, or note why under "How did you test this code?". Machine-readable gap list: the patch-coverage artifact on this run (gh run download 29303254102 -n patch-coverage), or the coverage-data block at the end of this comment.
Per-product line coverage (touched products)
| Product | Coverage | Lines |
|---|---|---|
platform_features |
██░░░░░░░░░░░░░░░░░░ 12.1% |
7 / 58 |
batch_exports |
████████░░░░░░░░░░░░ 39.5% |
8,345 / 21,104 |
demo |
███████████░░░░░░░░░ 55.2% |
1,436 / 2,601 |
warehouse_sources_queue |
████████████░░░░░░░░ 59.2% |
148 / 250 |
tasks |
█████████████░░░░░░░ 66.7% |
24,376 / 36,570 |
data_tools |
██████████████░░░░░░ 70.0% |
63 / 90 |
ai_gateway |
███████████████░░░░░ 75.0% |
9 / 12 |
data_modeling |
████████████████░░░░ 78.4% |
4,696 / 5,987 |
signals |
████████████████░░░░ 78.5% |
18,074 / 23,028 |
cdp |
████████████████░░░░ 80.6% |
3,105 / 3,851 |
wizard |
████████████████░░░░ 82.5% |
772 / 936 |
cohorts |
█████████████████░░░ 82.9% |
3,048 / 3,675 |
notebooks |
█████████████████░░░ 83.8% |
6,086 / 7,259 |
agent_platform |
█████████████████░░░ 84.1% |
3,095 / 3,678 |
actions |
█████████████████░░░ 86.6% |
717 / 828 |
engineering_analytics |
█████████████████░░░ 87.3% |
4,269 / 4,889 |
product_tours |
█████████████████░░░ 87.5% |
1,266 / 1,447 |
exports |
██████████████████░░ 88.3% |
6,857 / 7,763 |
visual_review |
██████████████████░░ 88.5% |
5,565 / 6,287 |
business_knowledge |
██████████████████░░ 88.5% |
4,400 / 4,969 |
conversations |
██████████████████░░ 88.9% |
15,924 / 17,921 |
mcp_analytics |
██████████████████░░ 89.1% |
2,485 / 2,790 |
dashboards |
██████████████████░░ 89.1% |
5,650 / 6,342 |
error_tracking |
██████████████████░░ 89.2% |
9,473 / 10,615 |
streamlit_apps |
██████████████████░░ 90.4% |
2,499 / 2,764 |
slack_app |
██████████████████░░ 90.6% |
9,460 / 10,444 |
links |
██████████████████░░ 90.6% |
183 / 202 |
marketing_analytics |
██████████████████░░ 90.7% |
11,476 / 12,646 |
product_analytics |
██████████████████░░ 91.2% |
5,621 / 6,164 |
managed_migrations |
██████████████████░░ 91.9% |
908 / 988 |
workflows |
██████████████████░░ 92.0% |
4,795 / 5,210 |
mcp_store |
██████████████████░░ 92.1% |
3,665 / 3,981 |
data_warehouse |
██████████████████░░ 92.1% |
17,282 / 18,765 |
alerts |
██████████████████░░ 92.1% |
3,389 / 3,678 |
web_analytics |
███████████████████░ 92.7% |
13,702 / 14,787 |
notifications |
███████████████████░ 92.7% |
1,026 / 1,107 |
ai_observability |
███████████████████░ 92.7% |
14,670 / 15,822 |
surveys |
███████████████████░ 92.9% |
5,660 / 6,094 |
posthog_ai |
███████████████████░ 93.2% |
1,311 / 1,407 |
tracing |
███████████████████░ 93.2% |
2,423 / 2,599 |
approvals |
███████████████████░ 93.3% |
3,395 / 3,640 |
reminders |
███████████████████░ 93.4% |
468 / 501 |
early_access_features |
███████████████████░ 93.8% |
848 / 904 |
legal_documents |
███████████████████░ 94.1% |
1,568 / 1,667 |
endpoints |
███████████████████░ 94.1% |
8,606 / 9,143 |
messaging |
███████████████████░ 94.3% |
2,366 / 2,508 |
skills |
███████████████████░ 94.4% |
2,819 / 2,987 |
revenue_analytics |
███████████████████░ 94.4% |
3,586 / 3,797 |
growth |
███████████████████░ 94.9% |
2,393 / 2,522 |
logs |
███████████████████░ 95.3% |
9,429 / 9,895 |
experiments |
███████████████████░ 95.6% |
24,057 / 25,166 |
replay_vision |
███████████████████░ 95.6% |
12,690 / 13,272 |
feature_flags |
███████████████████░ 96.1% |
14,368 / 14,957 |
warehouse_sources |
███████████████████░ 96.1% |
211,729 / 220,375 |
annotations |
███████████████████░ 96.2% |
732 / 761 |
user_interviews |
███████████████████░ 96.4% |
2,242 / 2,325 |
access_control |
███████████████████░ 96.8% |
849 / 877 |
customer_analytics |
███████████████████░ 97.3% |
7,277 / 7,481 |
data_catalog |
███████████████████░ 97.3% |
1,288 / 1,324 |
analytics_platform |
████████████████████ 98.2% |
2,098 / 2,137 |
metrics |
████████████████████ 98.3% |
2,363 / 2,403 |
live_debugger |
████████████████████ 99.2% |
613 / 618 |
field_notes |
████████████████████ 99.4% |
158 / 159 |
Report-only. Patch coverage = changed backend lines covered vs origin/master. Sorted lowest first.
Known gaps: lines covered only by Temporal tests show as uncovered; core line numbers may drift if master changed the same file.
f710ebb to
077c216
Compare
| </p> | ||
| {% if repository %} | ||
| <p> | ||
| We're setting up PostHog in <strong>{{ repository }}</strong> in the background — a pull request will appear on the repository shortly. |
There was a problem hiding this comment.
🚨 freeze, you've been pulled over by the em-dash police
There was a problem hiding this comment.
This one is actually right tho, this should be an em-dash lol
| @@ -2,6 +2,11 @@ | |||
| <p> | |||
| Welcome to PostHog! Your account was created{% if partner_name %} via {{ partner_name }}{% endif %}. | |||
There was a problem hiding this comment.
make sure to update this on customer.io
fercgomes
left a comment
There was a problem hiding this comment.
i'm still a bit confused on how this works, but i'm not blocking it
| # --------------------------------------------------------------------------- | ||
| # POST /provisioning/github/grants | ||
| # GET /provisioning/github/grants/:grant_id/repositories | ||
| # GitHub grants for drop-style partner flows: the partner forwards a GitHub OAuth |
There was a problem hiding this comment.
Can you improve the documentation a bit? I'm still not sure what "drop-style" is, the link in the PR description is broken.
There was a problem hiding this comment.
Yeah, this is not a good explanation, I'll reword this
It's supposed to mean cloudflare.com/drop where people can just click a button and get something live
| if partner is None: | ||
| return ( | ||
| Response( | ||
| {"type": "error", "error": {"code": "unauthorized", "message": "Authentication required"}}, |
There was a problem hiding this comment.
I feel there's a lot of duplication in how we handle responses in the provisioning API
There was a problem hiding this comment.
Absolutely, this should be cleaned up eventually
| installations: list[dict[str, Any]] = [] | ||
| repositories: list[dict[str, Any]] = [] | ||
| raw_installations = response.json().get("installations", []) | ||
| if not isinstance(raw_installations, list): |
There was a problem hiding this comment.
doesn't the default value for get already handle this? honest question tho
There was a problem hiding this comment.
Not really
{}.get("installations", []) # []
{"installations": None}.get("installations, []) # None| def _list_installation_repositories(access_token: str, installation_id: str) -> list[dict[str, Any]]: | ||
| repositories: list[dict[str, Any]] = [] | ||
| for page in range(1, _REPOSITORY_MAX_PAGES + 1): | ||
| response = github_request( |
There was a problem hiding this comment.
what's the purpose of using a github specific client, if you're passing all the request parameters inline?
There was a problem hiding this comment.
This was something @webjunkie added. This means we can have a single place controlling rate limits.
| "repository_selection": installation.get("repository_selection"), | ||
| } | ||
| ) | ||
| repositories.extend(_list_installation_repositories(access_token, installation_id)) |
There was a problem hiding this comment.
i don't this will ever return a considerable amount of installations, but it's wise to be mindful of the fanout pattern here
There was a problem hiding this comment.
Can someone connect a single App installation more than once? That would be weird. But yeah, agreed we should keep this in mind
077c216 to
0022a7c
Compare
| "repository_selection": installation.get("repository_selection"), | ||
| } | ||
| ) | ||
| repositories.extend(_list_installation_repositories(access_token, installation_id)) |
There was a problem hiding this comment.
Medium: Unbounded installation fan-out
An authenticated caller can install the GitHub App across many accounts and repeatedly poll this endpoint, causing up to 300 repository requests per poll because all 100 returned installations receive three pages each. The per-grant limit still permits 120 such polls; enforce a small total outbound-request budget across the entire listing operation, or paginate the installations instead of expanding all of them synchronously.
Merge activity
|
…source actions (#69858) ## Problem Part 2 of the wizard-drop stack (base: #69857). After posthog.com provisions an account (or an existing user completes the consent flow), the partner holds a bearer token scoped to a team and a `grant_id` from #69857. It now needs to (a) link the visitor's GitHub installation to that team and (b) kick off a cloud wizard run, without a PostHog session. ## Changes Two team-scoped resource actions, following the exact `rotate_credentials` shape (bearer + HMAC-if-present, `scoped_teams` authorization, `bearer_lookup` region proxy): - `POST /api/agentic/provisioning/resources/:team_id/github_integration` with `{grant_id, installation_id}`: validates the grant, proves installation ownership with the grant's stored user token (`verify_user_installation_access`), creates both the team `Integration` and the personal `UserIntegration`, consumes the grant, and marks unclaimed provisioned accounts as onboarding-skipped. Idempotent: a retry after the grant was consumed succeeds if the installation is already linked (`already_linked: true`). - `POST /api/agentic/provisioning/resources/:team_id/wizard_runs` with `{repository, branch?}`: gates on `WIZARD_CLOUD_RUN_OAUTH_CLIENT_ID` (mirroring the session endpoint), applies per-user budgets equivalent to the session throttles (2/hour, 5/day, cache counters since DRF's `UserRateThrottle` can't run on a session-less request) plus a `wizard_runs` partner bucket, then calls `tasks_facade.create_wizard_cloud_run`. Supporting changes: - Extracted a request-free `link_github_installation_for_user` from `create_team_github_integration_from_oauth_code` in `posthog/api/github_callback/team_services.py`. The original function now consumes state, exchanges the code, and delegates. One source of truth for the ownership check. Two `ValidationError`s in the shared path gained explicit codes (`installation_access_denied`, `installation_verify_failed`); the messages users see are unchanged. - New `OnboardingSkippedReason.PROVISIONED` so provisioned accounts don't get routed into onboarding on first login, applied only to unclaimed accounts (never logged in, no password). The choices change is wrapped in `SeparateDatabaseAndState` since choices emit no SQL, so no DDL touches the hot `posthog_user` table (verified with `sqlmigrate`, shows `-- (no-op)`). The enum value flows into the generated frontend/MCP types. - `provisioning_rate_limit_wizard_runs` override field on `OAuthApplication` (nullable `AddField`). ## How did you test this code? Added `test_wizard_resource_actions.py` (14 tests). Regressions guarded: both integration rows created and grant consumed exactly once, onboarding flags applied only to unclaimed accounts, ownership failure and GitHub network failure preserving the grant for retry (403 vs 502 distinction the website branches on), scoped-teams authorization, the idempotent retry path, the availability gate, and the per-user burst budget. The `team_services.py` refactor is covered by the existing suite: `posthog/api/test/test_integration.py` (212 passed). Full `ee/api/agentic_provisioning/` suite: 398 passed. ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams? ## Docs update None: partner-facing provisioning endpoints aren't publicly documented yet. ## 🤖 Agent context **Autonomy:** Human-driven (agent-assisted) Claude Code implemented this from the assignee's RFC, autonomously overnight. Skills invoked: /improving-drf-endpoints, /django-migrations, /writing-tests. Decisions worth noting: grant consumption happens exactly when both integration rows are created (never in `wizard_runs`), so the retry matrix stays clean; the per-user wizard budgets are shared cache keys with the bundled path in the next PR so retries can't double-spend. Migrations were renumbered from 1250/1251 to 1251/1252 via `manage.py rebase_migration` after master took 1250 mid-implementation.

Problem
We're exploring a cloudflare.com/drop-style flow on posthog.com: a visitor connects GitHub, picks a repo, and one click later they have a PostHog account and a cloud wizard run opening a PR against their repo.
posthog.com would be registered as an HMAC agentic-provisioning partner, but it runs on Vercel and must never hold GitHub tokens or the GitHub App client secret.
This PR is part 1 of a 3-PR stack: it gives partners a way to hand us a GitHub OAuth code and get back an opaque handle, with all token custody staying inside PostHog.
Changes
ee/api/agentic_provisioning/github_grants.py): exchanges the code via the existingGitHubIntegration.github_user_from_code, fetches the user's primary verified email fromGET /user/emails, and caches the resulting user authorization for 60 minutes, encrypted with the same MultiFernet keys that backEncryptedJSONField, bound to the creating partner. Partners only ever see an opaquegrant_id.POST /api/agentic/provisioning/github/grants(HMAC partner auth,provisioning_can_create_accountsgated) returns{grant_id, gh_login, email, expires_in}.emailisnullwhen the GitHub user has no verified email — the grant is still created and the partner collects an email inline. Only an access refusal onGET /user/emails(App permission misconfiguration) hard-fails, withemail_unavailable(502), so a permission regression stays loud instead of degrading every drop into inline-email mode.GET /api/agentic/provisioning/github/grants/:id/repositorieslists the user's installations of our GitHub App and the repos each grants, using the stored user token. Only the creating partner can read its grants; a wrong-partner read looks identical to a missing grant so the endpoint can't be used as an existence oracle. Pollable while the visitor installs the App in another tab, with a per-grant budget.github_grantspartner rate-limit bucket (10/h default) plus aprovisioning_rate_limit_github_grantsoverride field onOAuthApplication(nullableAddField, safe).api/agentic/...andapi/provisioning/...URL trees. No region proxy: grants are region-local by design and v1 is US-only (documented in the module docstring).Note: user-to-server tokens only ever see installations of the App that minted them, so no app-slug filtering is needed when listing installations (same as the existing
personal_finish.pyflow).How did you test this code?
Added
test_github_grants.py(15 tests). The regressions they guard: forgetting to threadredirect_uriinto the code exchange (GitHub rejects mismatches), storing tokens unencrypted in Redis (asserts the raw cache value doesn't contain the token), cross-partner grant reads (existence-oracle guard), the no-verified-email →email: nullgrant path, the permission-refusal →email_unavailablehard error, failed-exchange error mapping, and both rate limiters. Also extendedtest_url_aliases.pyfor the alias tree. Ran the fullee/api/agentic_provisioning/suite (398 passed) plushogli ci:preflight.Automatic notifications
Docs update
None: partner-facing provisioning endpoints aren't publicly documented yet.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Claude Code implemented this from an RFC written by the assignee, working autonomously overnight. Skills invoked: /improving-drf-endpoints, /django-migrations, /writing-tests. Notable decisions: encrypted the cached grant payload (no prior precedent for encrypting cache values, but these are GitHub user tokens), folded partner-mismatch into
grant_not_foundto avoid an existence oracle, and made the RFC correction thatgithub_user_from_codenever fetched emails, so the grant endpoint fetchesGET /user/emailsitself (requires the App's "Email addresses (read)" permission, which is an ops prerequisite).