Skip to content

[PM-39979] Add full API support for Item-type Sends - #8192

Open
mcamirault wants to merge 1 commit into
mainfrom
tools/pm-39979/item-type-send-full-support
Open

[PM-39979] Add full API support for Item-type Sends#8192
mcamirault wants to merge 1 commit into
mainfrom
tools/pm-39979/item-type-send-full-support

Conversation

@mcamirault

Copy link
Copy Markdown
Contributor

🎟️ Tracking

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

📔 Objective

This PR adds the full backend logic necessary to create and edit Item-type Sends.

@mcamirault
mcamirault requested a review from a team as a code owner August 12, 2026 04:50
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Reviewed the full API support for Item-type Sends: the structured SendDataModel/SendItemData payload replacing the raw string blob, the pm-34203-temporary-item-sharing flag gating on Get/GetAll/AccessUsingAuth/Post/Put, the new premium and email-verification requirements, and the accompanying unit tests. The response-model changes correctly keep owner-only Notes out of SendAccessResponseModel, and an empty Emails list resolves to EmailOtp([]), which denies access. Three findings concern the enforcement of the new email-verification invariant, gating on client-supplied Type during edits, and back-compat for Item Sends stored in the previous format.

Code Review Details
  • ❌ : Item Sends created without an explicit AuthType end up with Emails == null and AuthType.Email, which SendAuthenticationQuery resolves to NotAuthenticated — the grant validator auto-issues an access token, so the link works with no email OTP while the API reports email verification. PUT /sends/{id}/remove-auth bypasses the same invariant.
    • src/Api/Tools/Utilities/InferAuthType.cs:10
  • ⚠️ : Put evaluates the flag, premium, and email-verification guards against the client-supplied model.Type, while UpdateSend branches on the stored existingSend.Type, letting a client edit an Item Send by declaring Type: Text.
    • src/Api/Tools/Controllers/SendsController.cs:361
  • ⚠️ : GetAll filters after projecting, so Item Send data is deserialized even when the flag is off; Item Sends persisted in the previous raw-string format now throw JsonException and fail the entire list with no migration path.
    • src/Api/Tools/Controllers/SendsController.cs:136
  • ❓ : SyncResponseModel builds SendResponseModel for every Send with no flag filter, so Item Sends still reach clients through /sync when TemporaryItemSharing is off — was leaving sync ungated intentional?
    • src/Api/Vault/Models/Response/SyncResponseModel.cs:68

Comment on lines +10 to +13
if (send.Type == SendType.Item)
{
return AuthType.Email;
}

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.

CRITICAL: Item Sends created without an explicit AuthType end up accessible with no verification while reporting AuthType.Email

Details and fix

SendRequestModel.AuthType is optional, and the new "Item Sends require email verification" guard in ToSendBase only runs inside if (AuthType != null). When a request omits AuthType, the legacy else branch runs and, with no Emails/Password supplied, leaves send.Emails == null and then sets send.AuthType = InferAuthType(send) — which now returns Email for Item Sends.

SendAuthenticationQuery.GetAuthenticationMethod then evaluates:

var s when s.AuthType == AuthType.Email && s.Emails is not null => EmailOtp(s.Emails), // false: Emails is null
var s when s.AuthType == AuthType.Password && s.Password is not null => ...,           // false
_ => NOT_AUTHENTICATED

NotAuthenticated causes SendAccessGrantValidator to auto-issue a send access token, so anyone with the link accesses the Item Send without email OTP — while SendResponseModel.AuthType and SendAccessResponseModel.AuthType both report Email.

Note Emails = "" (explicit AuthType.Email with an empty list) is safe because it yields EmailOtp([]), which denies everyone. The dangerous state is specifically Emails == null.

Suggested fix: enforce the Item requirement independently of whether the client sent AuthType (e.g. validate in UpdateSend/ToSendItemData that the resulting Send has AuthType.Email and a non-empty Emails list), and only infer Email here when send.Emails is actually populated. The same invariant is also bypassable via PUT /sends/{id}/remove-auth, which sets AuthType = None with no type check.

Comment on lines +361 to +371
if (model.Type == SendType.Item && !_featureService.IsEnabled(FeatureFlagKeys.TemporaryItemSharing))
{
throw new BadRequestException("Item type Sends are not yet enabled");
}
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var hasPremium = await _hasPremiumAccessQuery.HasPremiumAccessAsync(userId);

if (!hasPremium && model.Type == SendType.Item)
{
throw new BadRequestException("Item type Sends require a premium membership");
}

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: Put gates on the client-supplied model.Type, but UpdateSend branches on the stored existingSend.Type

Details and fix

A client editing an existing Item Send can send Type: Text in the body and skip all three Item guards:

  1. the feature-flag check here,
  2. the premium check below,
  3. the Type == SendType.Item && AuthType != Email guard in SendRequestModel.ToSendBase.

UpdateSend switches on existingSend.Type, so the Send is still written through the Item branch — meaning an Item Send can be edited while the flag is off, by a non-premium user, and can be given AuthType.Password despite the new email-verification requirement. NonAnonymousSendCommand.SaveSendAsync performs no type check either.

Fix: load the Send first and evaluate the Item guards against send.Type (or reject when model.Type != send.Type):

var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId) { throw new NotFoundException(); }
if (send.Type == SendType.Item || model.Type == SendType.Item) { /* flag + premium checks */ }

Comment on lines 136 to +140
var responses = sends.Select(s => new SendResponseModel(s));
if (!_featureService.IsEnabled(FeatureFlagKeys.TemporaryItemSharing))
{
responses = responses.Where(s => s.Type != SendType.Item);
}

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: Item Sends are still deserialized when the flag is off, so bad Item data fails the whole list

Details and fix

Select is lazy, so the Where predicate forces the SendResponseModel constructor to run for every Item Send even when the kill switch is off. The constructor now does JsonSerializer.Deserialize<SendItemData>(send.Data) and throws JsonException on anything that is not SendItemData JSON.

Item Sends persisted by the previous implementation stored send.Data as the raw client string (existingSend.Data = Data), and this PR adds no migration for them. Such a row makes GET /sends fail entirely for that user — and turning the flag off does not mitigate it.

Filter the entities before projecting:

var sends = await _sendOwnerQuery.GetOwned(User);
if (!_featureService.IsEnabled(FeatureFlagKeys.TemporaryItemSharing))
{
    sends = sends.Where(s => s.Type != SendType.Item);
}
var result = new ListResponseModel<SendResponseModel>(sends.Select(s => new SendResponseModel(s)));

Worth confirming separately whether any Item Sends already exist in the old format in cloud/QA data.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant