Restrict organization management email updates#2104
Conversation
📝 WalkthroughWalkthroughThis PR adds authorization control for ChangesManagement Email Authorization
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
Pull request overview
Restricts updates to an organization’s management_email via the public [PUT] /organization endpoint by requiring billing-level permission (org.update_billing), closing a privilege boundary where an org admin could previously change the management email through the broader settings update path.
Changes:
- Add an extra authorization gate (
org.update_billing) whenmanagement_emailis present in the update payload. - Add a regression test asserting org admins cannot update
management_emailvia[PUT] /organization.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
supabase/functions/_backend/public/organization/put.ts |
Adds a dedicated permission check for management_email updates requiring org.update_billing. |
tests/organization-api.test.ts |
Adds a regression test ensuring org admins receive a 403 and the management email remains unchanged. |
f2757c2 to
2654254
Compare
ShravanthReddy
left a comment
There was a problem hiding this comment.
I think this still leaves one side-effect bypass in place. The dedicated management-email path in supabase/functions/_backend/private/set_org_email.ts checks super-admin rights, calls updateCustomerEmail(...), then updates orgs.management_email and rolls Stripe back if the DB update fails. This PR now lets billing-level users send management_email through PUT /organization, but this handler only adds the field to updateFields; it never calls the Stripe customer-email sync path.\n\nThat means a super admin can still change the billing/management email through the broader organization update endpoint while the Stripe customer email remains stale. If this public endpoint is intended to remain a valid management-email update path, I would route it through the same updateCustomerEmail / rollback flow as set_org_email. If the dedicated endpoint is the only intended billing-email path, reject management_email here and force callers through that route. A regression with an org that has customer_id would help lock down whichever behavior is intended.
|
CI note: the repository I tried to rerun the cancelled job, but GitHub returned 403 for this fork PR. Could a maintainer rerun the cancelled |
2654254 to
afc0553
Compare
SpeedyArt
left a comment
There was a problem hiding this comment.
I think this still leaves the permission boundary open outside this handler.
The new gate only runs for PUT /organization, but orgs is still directly updateable by admin-level users through the Supabase table policy: Allow update for auth (admin+) checks only admin rights for the whole row (supabase/schemas/prod.sql, and the latest policy migration). There is also at least one caller that still writes the protected column directly: cli/src/organization/set.ts does .from('orgs').update({ name, management_email: email }).
So an org admin with a normal authenticated Supabase client can bypass the new endpoint check and change orgs.management_email directly. This is separate from the Stripe-sync path already mentioned above: even if this endpoint’s update flow is fixed or disabled, the table policy still allows the column write.
I’d move this boundary into the database as well, either by splitting/replacing the orgs update policy or by adding a trigger/RPC-only rule that rejects management_email changes unless the caller has the billing/super-admin permission. The CLI/web direct writes for this field should then route through the dedicated private/set_org_email flow. A regression that attempts a direct .from('orgs').update({ management_email }) as an admin user would catch this.
4ab0f60 to
b60021d
Compare
|
Update: I corrected the regression test fixture so it uses an org admin caller without creator/super-admin authority. Latest head |
Co-authored-by: Codex <noreply@openai.com>
b60021d to
d61100f
Compare
|
Update:
|
zinc-builds
left a comment
There was a problem hiding this comment.
🔍 Security Review — Hermes Agent
Verdict: ✅ Approved — Solid defense-in-depth approach to restricting management email updates.
What the PR fixes
Previously, any org admin could update management_email directly on the orgs table or via the organization API. This bypassed the Stripe customer email sync and allowed privilege escalation — a non-super_admin could change the billing contact email. The fix adds enforcement at two layers.
✅ Good patterns
- DB trigger as safety net: The
prevent_org_management_email_non_super_admin_updatetrigger catches direct table writes (UPDATE orgs SET management_email = ...), even from authenticated clients bypassing the API. This is critical — API-level checks alone don't protect against direct PostgREST access. - API-level enforcement:
ensureManagementEmailAccess()checkssuper_adminviacheck_min_rightsRPC before allowing the update through the organization endpoint. - Stripe sync with rollback: The email update now syncs to Stripe (
updateCustomerEmail) before the DB update, withrollbackStripeCustomerEmailon failure. This prevents the Stripe customer email from drifting from the org email. - Service role bypass: The trigger correctly allows
service_roleto update without restriction — needed for backend operations. - Clean test coverage: Tests both API-level rejection (403) and direct DB-level rejection (trigger error message assertion), with proper cleanup in
finallyblocks.
⚠️ Suggestions (non-blocking)
- Race condition on Stripe sync:
updateCustomerEmailis called beforeupdateOrg. If Stripe succeeds but the DB update fails, the rollback restores the Stripe email — good. But what about the reverse? If the DB update succeeds but another concurrent request reads the org between the Stripe sync and DB commit, it sees the old email. Minor timing issue, unlikely in practice. - Trigger grants: The trigger function only grants EXECUTE to
service_role. This is correct for a security trigger, but differs from other security definer functions that grant toauthenticatedtoo. Since this is invoked by the trigger (not called directly), service_role-only is correct — just noting the departure from convention. - CLI changes: The CLI now calls
private/set_org_emailinstead of directly updating theorgstable. This is good (uses the API instead of direct DB access), but the CLI functionupdateOrganizationManagementEmaildoesn't handle the case where the user isn't a super_admin gracefully — it just throws. Consider adding a specific error message like "Only super admins can update the management email."
Strong work — the Stripe rollback pattern is particularly well thought out.
SpeedyArt
left a comment
There was a problem hiding this comment.
Thanks, this closes the admin-level direct-write bypass I was worried about.
One residual invariant looks worth tightening: the new trigger still permits a super_admin user-context PostgREST update to orgs.management_email directly, because it returns NEW once the check_min_rights('super_admin', ...) check passes. That means an authorized super-admin can still bypass the private/set_org_email / PUT /organization Stripe sync and rollback path, leaving the Capgo org email and Stripe customer email out of sync.
If the intended invariant is that every management_email change syncs the billing customer email, I would make this column service-role/RPC-only for all user-context table writes and have the private endpoint perform the actual service-role update after it syncs Stripe. A regression as a super_admin doing a direct .from('orgs').update({ management_email }) would catch whether the table path is still bypassing the sync boundary.
Keep the Stripe-synced endpoints as the only path that can persist management_email changes. The endpoints now perform the final org row write through the service-role client after authorization and Stripe sync.\n\nCo-authored-by: Codex <noreply@openai.com>
|
Pushed Changes:
Validation:
I could not run the Supabase-backed |
Move the new migration after the latest timestamp on origin/main so the repository migration-order check passes.\n\nCo-authored-by: Codex <noreply@openai.com>
Avoids extra shared-user sign-ins from the new management email regression cases while keeping the same user-context coverage. Co-authored-by: Codex <noreply@openai.com>
|
KCDaemon
left a comment
There was a problem hiding this comment.
Rechecked latest head (18cf36e). The management-email mutation path is now confined to the controlled endpoints: direct user-context table writes are blocked, the CLI routes email changes through private/set_org_email, and public org updates require super-admin rights plus Stripe email sync/rollback before the service-role write.
The added tests cover org-admin rejection, direct super-admin table-write rejection, and the service-role write path after Stripe sync. GitHub CI is green; I do not see a remaining blocker.
|
Nice tightening of the management-email path. Two correctness concerns and a smaller defense-in-depth note before this lands. 1. Concurrent In
There's an
There's no guarantee the Stripe Suggestion: extend A regression test that fires two concurrent PUTs with different emails for the same org and asserts the final Stripe email and 2. Post-update Stripe sync failure: rollback ordering can leave Stripe with the new email Around line 117-120 in the post-update Stripe-name-sync error handler, the rollback runs:
If Suggestion: invert order, or wrap each in its own try so a DB-rollback failure doesn't prevent Stripe rollback. Something like:
3. Minor: trigger doesn't cover The migration's
If any hits, route them through the email-sync helper or add an explicit caller-allowlist column to the trigger. Happy to send a PR with the concurrent-update regression test and the rollback restructuring if (1) and (2) get acked. |



Summary
management_emailchanges behind the dedicated email-change path.orgs.management_emailwith a database trigger.organization set --emailthroughprivate/set_org_emailand document the super-admin requirement.Motivation
The management email is used for billing-related communication and customer sync. Org admin update paths could change that field without the dedicated Stripe synchronization and rollback behavior.
Business Impact
This reduces billing-contact takeover risk, keeps Capgo's Stripe customer email aligned with the organization record, and preserves the existing public update endpoint while tightening the sensitive field boundary.
/claim #1667
Test Plan
18cf36e.bunx eslint "supabase/functions/_backend/public/organization/put.ts" "supabase/functions/_backend/private/set_org_email.ts" "tests/organization-api.test.ts" "tests/organization-put-stripe-sync.unit.test.ts" "cli/src/organization/set.ts" "cli/src/index.ts"bunx vitest run tests/organization-put-stripe-sync.unit.test.tsbun run typecheckbun run lintincli/bun run buildincli/bun run test:mcpincli/bun run test:bundleincli/git diff --check