Skip to content

[PM-39894] fix: Exclude provider-linked organizations from add-existing-organization flow#7988

Open
cyprain-okeke wants to merge 2 commits into
mainfrom
billing/pm-39894/provider-portal-add-existing-organization-allows-absorbing-an-organization
Open

[PM-39894] fix: Exclude provider-linked organizations from add-existing-organization flow#7988
cyprain-okeke wants to merge 2 commits into
mainfrom
billing/pm-39894/provider-portal-add-existing-organization-allows-absorbing-an-organization

Conversation

@cyprain-okeke

Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-39894

📔 Objective

The Provider Portal's "Add existing organization" flow (POST /providers/{id}/clients/existing) never checked whether the target organization already had a ProviderOrganization row. A reseller-linked org keeps Status = Created and its own gateway fields, so it passed every filter in the addable query and could be absorbed into an MSP's consolidated billing — leaving the org linked to two providers (confirmed in production; see ticket).

This adds the missing guard at two layers:

  • Eligibility queryOrganization_ReadAddableToProviderByUserId (Dapper stored procedure + EF twin) now excludes any organization with an existing ProviderOrganization row via NOT EXISTS. Linked orgs no longer appear in the picker, and the endpoint's eligibility re-check returns 404 for them.
  • Service guard (defense in depth)ProviderBillingService.AddExistingOrganization now guards first with the same check as the legacy ProviderService.AddOrganization path (GetByOrganizationId) and throws ConflictException (409 "Organization already belongs to a provider.") before any Stripe or repository side effects. This covers the check-then-act race and direct service callers. Note: this intentionally returns 409 rather than the legacy path's 400 — the caller has no control over whether a ProviderOrganization row exists.

Includes a dated idempotent migration (CREATE OR ALTER), unit tests asserting the guard fires before any billing side effect, and integration tests for both ORM tracks covering standalone (included), reseller-linked, MSP-linked, and BusinessUnit-branch (excluded) cases.

Verified end-to-end locally against a running Api + Identity with the ticket's replication fixture (reseller-linked org with a real Stripe test-mode trialing subscription, billable MSP, owner-who-is-provider-admin):

Sproc version GET /clients/addable POST /clients/existing
old (pre-fix) 200 — linked org listed (bug reproduced) 409 — new guard blocked it, zero side effects
new (post-migration) 200 — [] 404 — filtered before the service

Org row, both providers, the single ProviderOrganization link, and the Stripe subscription were verified untouched after every rejection.

QA note: GetAddableOrganizations also filters on live Stripe subscription status (Active/Trialing/PastDue). The target org must have a real test-mode subscription, or it vanishes from the addable list regardless of this fix and can masquerade as a pass.

Out of scope / follow-ups: cleanup of existing dual-linked production rows and a unique index on ProviderOrganization.OrganizationId (requires the cleanup first) — tracked with dbops via the ticket.

…ng-organization flow

The Provider Portal's "Add existing organization" flow never checked
whether the target organization already had a ProviderOrganization row,
so a reseller-linked org (Status stays Created) could be absorbed into
an MSP's consolidated billing, leaving the org linked to two providers.

Two layers:
- Organization_ReadAddableToProviderByUserId (Dapper sproc + EF twin)
  now excludes any org with an existing ProviderOrganization row, so
  linked orgs never appear in the picker and the endpoint's eligibility
  re-check returns 404 for them.
- ProviderBillingService.AddExistingOrganization guards first with the
  same check as the legacy ProviderService.AddOrganization path and
  throws ConflictException (409) before any Stripe or repository side
  effects, covering the check-then-act race and direct service callers.
@cyprain-okeke cyprain-okeke added the ai-review Request a Claude code review label Jul 15, 2026
…g-organization-allows-absorbing-an-organization
@cyprain-okeke cyprain-okeke added the t:feature Change Type - Feature Development label Jul 15, 2026
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the fix excluding provider-linked organizations from the "Add existing organization" flow. The change guards at two layers: the eligibility query (Dapper stored procedure plus its EF Core twin) now excludes organizations with an existing ProviderOrganization row via NOT EXISTS, and ProviderBillingService.AddExistingOrganization adds a defense-in-depth ConflictException guard before any Stripe or repository side effects. Verified dual-ORM parity, correct T-SQL operator precedence (the appended NOT EXISTS is joined at top level with AND outside the parenthesized plan-type OR group), an idempotent CREATE OR ALTER dated migration, and comprehensive unit and integration test coverage for standalone, reseller-linked, MSP-linked, and BusinessUnit cases.

No findings meeting the confidence threshold.

Code Review Details

No blocking or actionable findings.

Notes verified during review:

  • SQL precedence is correct — the new NOT EXISTS predicate applies to all rows regardless of @ProviderType, confirmed by the LinkedOrg_ExcludedForBusinessUnit integration test.
  • EF Core !dbContext.ProviderOrganizations.Any(...) mirrors the stored procedure exactly, preserving dual-ORM parity.
  • The intentional 409 (ConflictException) versus the legacy path's 400 (BadRequestException) is documented in the PR description with sound rationale.
  • Unit test asserts the guard short-circuits before Release, Stripe calls, and repository writes.

@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 66.64%. Comparing base (c2d97d5) to head (a0d3261).

Additional details and impacted files
@@             Coverage Diff             @@
##             main    #7988       +/-   ##
===========================================
+ Coverage   14.86%   66.64%   +51.78%     
===========================================
  Files        1386     2284      +898     
  Lines       60245    99672    +39427     
  Branches     4799     9017     +4218     
===========================================
+ Hits         8957    66431    +57474     
+ Misses      51137    30970    -20167     
- Partials      151     2271     +2120     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cyprain-okeke cyprain-okeke marked this pull request as ready for review July 15, 2026 17:25
@cyprain-okeke cyprain-okeke requested review from a team as code owners July 15, 2026 17:25

@mkincaid-bw mkincaid-bw left a comment

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.

DB changes look good. The formatting of the code doesn't match our style guide but since you are only adding a small piece, I don't expect you to change it (unless you want to 😄).

@vincentsalucci vincentsalucci requested a review from JimmyVo16 July 15, 2026 18:08
@JimmyVo16 JimmyVo16 requested review from JimmyVo16 and removed request for JimmyVo16 July 15, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants