Skip to content

[PM-41481] Fix policy enforcement when accepting invite link - #8146

Merged
eliykat merged 11 commits into
mainfrom
ac/pm-41481/account-recovery-auto-enrolment-not-working
Aug 5, 2026
Merged

[PM-41481] Fix policy enforcement when accepting invite link#8146
eliykat merged 11 commits into
mainfrom
ac/pm-41481/account-recovery-auto-enrolment-not-working

Conversation

@eliykat

@eliykat eliykat commented Aug 5, 2026

Copy link
Copy Markdown
Member

🎟️ Tracking

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

📔 Objective

Target organization policy checks were not being enforced properly via the accept invite link flow for new users. This is because PolicyRequirementQuery expects an Invited organizationUser to exist for pre-acceptance checks, assuming that a direct invite has been sent. However, the invite link flow handles 3 separate states:

  • no organizationUser yet - nothing for PolicyRequirementQuery to query/join
  • a staged organizationUser - filtered out by the PolicyRequirementQuery sprocs
  • an invited organizationUser - correctly handled by PolicyRequirementQuery

This affected:

  • account recovery auto-enrollment
  • 2FA
  • single org (target org only)
  • autoconfirm (target org only)

The solution here is not DRY, but is relatively contained and is intended to unblock the feature asap.

Rather than try to separate out these 3 possible states, reimplement the policy checks that we need:

  • check policy status directly for target-org checks, copying over provider and exempt role checks
  • still use PolicyRequirementQuery for checking other organizations' policies
  • add lots of integration tests here out of an abundance of caution, given our mistaken assumptions about state so far

This breaks the PolicyRequirementQuery abstraction, however in my view this shows that it's too closely tied to database state and we need to revisit this (milestone 3). This is a temporary workaround in the meantime.

Options considered and discarded:

  • injecting fake PolicyDetails into PolicyRequirementQuery - unclear what the status should be or how the different IPolicyRequirement implementations would handle this - too unpredictable.
  • creating an invited OrganizationUser - user-visible change that would not meet product requirements

📸 Screenshots

@eliykat eliykat added the ai-review Request a Claude code review label Aug 5, 2026
@eliykat
eliykat requested a review from a team as a code owner August 5, 2026 04:55
@eliykat
eliykat requested a review from BTreston August 5, 2026 04:55
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

This PR extracts invite-link accept eligibility into AcceptInviteLinkMembershipValidator and enforces the target organization's Auto-Confirm, Single Org, and 2FA policies directly through IPolicyQuery, because IPolicyRequirementQuery cannot resolve policies for a brand-new or Staged joiner. The stopgap duplication is clearly documented and scoped to milestone 3 (PM-34429), and the account-recovery auto-enroll path is now resolved from the policy row rather than the requirement query. Integration coverage across the new-member, existing-invitation, and Staged branches is thorough and the earlier Staged gap is closed. Two findings relate to the replicated provider exemption logic.

Code Review Details
  • ⚠️ : Provider exemption uses IProviderOrganizationRepository.GetManyByUserAsync, which resolves provider-managed orgs the user is an OrganizationUser of — not orgs the user is a ProviderUser for — so the Single Org / 2FA exemption never fires on this path
    • src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptInviteLinkMembershipValidator.cs:140
  • ❓ : Blanket provider-user block on accepting invite links is narrowed to Auto-Confirm organizations only, while ConfirmOrganizationInviteLinkValidator retains the unconditional block
    • src/Core/AdminConsole/OrganizationFeatures/InviteLinks/AcceptInviteLinkMembershipValidator.cs:114

@eliykat eliykat added the t:feature Change Type - Feature Development label Aug 5, 2026
@eliykat
eliykat requested review from r-tome and removed request for r-tome August 5, 2026 04:57
@eliykat
eliykat requested review from JimmyVo16 and jrmccannon and removed request for BTreston August 5, 2026 05:08
@eliykat
eliykat marked this pull request as draft August 5, 2026 09:12
@eliykat
eliykat marked this pull request as ready for review August 5, 2026 10:48
Comment on lines +140 to +141
var isProviderForOrganization = (await providerOrganizationRepository.GetManyByUserAsync(user.Id))
.Any(po => po.OrganizationId == organization.Id);

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.

⚠️ IMPORTANT: IProviderOrganizationRepository.GetManyByUserAsync does not answer "is this user a provider user for this org", so the exemption never fires here.

Details and fix

ProviderOrganizationProviderDetails_ReadByUserId is:

FROM [dbo].[ProviderOrganizationView] PO
INNER JOIN [dbo].[OrganizationUser] OU ON PO.OrganizationId = OU.OrganizationId
INNER JOIN [dbo].[Provider] P ON PO.ProviderId = P.Id
WHERE OU.UserId = @UserId

It never touches ProviderUser. It returns provider-managed organizations that the user is already an OrganizationUser of. (ProviderOrganizationReadByUserIdQuery in EF is the same join.) CurrentContext.GetOrganizationProviderDetails uses it in exactly that sense.

That is a different predicate from PolicyDetails.IsProvider, which PolicyDetails_ReadByUserId computes as:

EXISTS (SELECT 1 FROM [dbo].[ProviderUserView] PU
        INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId]
        WHERE PU.[UserId] = OU.[UserId] AND PO.[OrganizationId] = P.[OrganizationId])

Consequence on this path: the joiner reaching ValidatePoliciesAsync has either no OrganizationUser row for the target org (brand-new), or an Invited/Staged row with UserId = nullAccepted/Confirmed/Revoked are already rejected by ValidateExistingMembershipStatus. The OU.UserId = @UserId join therefore matches nothing for the target org, so isProviderForOrganization is always false and the Single-Org / 2FA provider exemption is dead. Conversely, if a user-linked row ever did reach here, it would exempt any member of a provider-managed org, provider user or not.

The unit tests stub IProviderOrganizationRepository directly, so ValidateAsync_ProviderForOrganization_* pass without exercising this.

Using the ProviderUser-rooted query matches PolicyDetails.IsProvider:

var isProviderForOrganization = (await providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id))
    .Any(o => o.OrganizationId == organization.Id);

(ProviderUserProviderOrganizationDetailsView joins ProviderUserProviderOrganizationOrganization. IProviderOrganizationRepository then becomes an unused dependency.)

Comment on lines +114 to +122
// Automatic User Confirmation - this organization
if (request.AutoConfirmPolicyEnabled)
{
// Autoconfirm enforcement: any provider user cannot be a member of an autoconfirm org.
var isProviderUserForAnyOrganization = (await providerUserRepository.GetManyByUserAsync(user.Id)).Count != 0;
if (isProviderUserForAnyOrganization)
{
return new ProviderUsersCannotAcceptInviteLink();
}

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.

QUESTION: This narrows the provider-user block from "always" to "only when Auto-Confirm is on" — intended, and should the confirm path follow?

Details

AcceptOrganizationInviteLinkCommand previously rejected provider users unconditionally:

// Provider users cannot accept invite links
if ((await providerUserRepository.GetManyByUserAsync(user.Id)).Count != 0)
{
    return new ProviderUsersCannotAcceptInviteLink();
}

After this PR a provider user can accept an invite link into any organization that does not have Auto-Confirm enabled. The reasoning is sound if the original block was only ever an Auto-Confirm concern, but the PR description scopes this change to policy-enforcement gaps, so the relaxation is easy to miss on review.

ConfirmOrganizationInviteLinkValidator (line 75) still carries the unconditional block via ConfirmProviderUsersCannotAcceptInviteLink, so accept and confirm now disagree on whether a provider user may join through a link.

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.87500% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.01%. Comparing base (883fd90) to head (d10ed43).

Files with missing lines Patch % Lines
...InviteLinks/AcceptOrganizationInviteLinkCommand.cs 88.00% 2 Missing and 1 partial ⚠️
...nks/AcceptInviteLinkMembershipValidationRequest.cs 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8146      +/-   ##
==========================================
+ Coverage   62.98%   63.01%   +0.03%     
==========================================
  Files        2312     2314       +2     
  Lines      100300   100377      +77     
  Branches     9021     9033      +12     
==========================================
+ Hits        63174    63256      +82     
+ Misses      34944    34938       -6     
- Partials     2182     2183       +1     

☔ 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.

Comment on lines +168 to +178
private async Task<Error?> ValidateFreeOrganizationAdminLimitAsync(AcceptInviteLinkMembershipValidationRequest request)
{
if (request.ExistingMembership?.Type is OrganizationUserType.Owner or OrganizationUserType.Admin &&
request.Organization.PlanType == PlanType.Free &&
await organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(request.User.Id) > 0)
{
return new OnlyOneFreeOrganizationAdminAllowed();
}

return null;
}

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.

This should be moved to the OrganizationUserValidationService as an overload on ValidateFreeOrgAdminLimitAsync

{
var user = request.User;
var organization = request.Organization;
var allOrganizationMemberships = await organizationUserRepository.GetManyByUserAsync(user.Id);

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.

This method GetManyByUserAsync(user.Id) will miss invited users since they'll have an email but not a UserId. The stored procedures that PolicyRequirementQuery uses check for both UserId and email addresses.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This isn't used to pull policies, this is used to check if the user is part of any other organizations if Single Org or Autoconfirm apply. That restriction doesn't apply to invited users, so that's OK. It mirrors the existing check here: https://github.com/bitwarden/server/blob/main/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs#L161

@JimmyVo16 JimmyVo16 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.

Looks good to me. We just need to address my comment to make sure it isn't an issue.

@JimmyVo16 JimmyVo16 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.

I went ahead and approved this in case my comment turns out to be a non-issue, since we have a pretty big time zone gap.

@JaredSnider-Bitwarden

Copy link
Copy Markdown
Contributor

@eliykat I tested this against my work in bitwarden/clients#21574, and it does fix the issue w/ admin account recovery enrollment not working.

Before:

PM-39706.-.Open.Org.Invite.Link.-.Non.SSO.Org.with.Admin.Acct.Recovery.Policy.-.New.User.Registration.+.Acceptance.Works.+.User.isn.t.enrolled.bug.mov

After:

PM-39706.-.Open.Org.Invite.Link.-.Non.SSO.Org.with.Admin.Acct.Recovery.Policy.-.New.User.Registration.+.Acceptance.Works.+.User.is.enrolled.in.admin.account.recovery.mov

:shipit: thank you for the quick turn around on the fix!

@eliykat
eliykat dismissed jrmccannon’s stale review August 5, 2026 21:35

Will address in a follow-up PR

@eliykat
eliykat merged commit 3eba269 into main Aug 5, 2026
80 of 81 checks passed
@eliykat
eliykat deleted the ac/pm-41481/account-recovery-auto-enrolment-not-working branch August 5, 2026 21:37
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.

4 participants