Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
2d625f6
update send api models to support new `email` field
audreyality May 28, 2025
cc71297
normalize authentication field evaluation order
audreyality Jun 26, 2025
74da4ab
document send response converters
audreyality Jul 1, 2025
360a3da
add FIXME to remove unused constructor argument
audreyality Jul 1, 2025
7b0b949
add FIXME to remove unused constructor argument
audreyality Jul 1, 2025
1e9023d
Merge branch 'main' into tools/pm-21918/send-authentication-commands
audreyality Aug 15, 2025
3271556
introduce `tools-send-email-otp-listing` feature flag
audreyality Aug 15, 2025
cd0a0ad
Merge branch 'main' into tools/pm-21918/send-authentication-commands
audreyality Aug 25, 2025
fb27708
add `ISendOwnerQuery` to dependency graph
audreyality Aug 26, 2025
d7a0282
Merge branch 'main' into tools/pm-21918/send-authentication-commands
audreyality Sep 17, 2025
481f94a
Merge branch 'main' into tools/pm-21918/send-authentication-commands
djsmith85 Sep 26, 2025
4217dba
Merge branch 'main' into tools/pm-21918/send-authentication-commands
itsadrago Nov 13, 2025
b2c0dc8
fix broken tests
harr1424 Dec 16, 2025
c29ad65
Merge branch 'main' into tools/pm-21918/send-authentication-commands
harr1424 Dec 16, 2025
bc22b1d
added AuthType prop to send related models with test coverage and debโ€ฆ
harr1424 Dec 17, 2025
c7ba30b
dotnet format
harr1424 Dec 17, 2025
2fec3db
Merge branch 'main' into tools/pm-21918/send-authentication-commands
harr1424 Dec 17, 2025
ad4c002
add migrations
harr1424 Dec 17, 2025
68b6cad
Merge branch 'main' into tools/pm-21918/send-authentication-commands
harr1424 Dec 17, 2025
25b0ed6
dotnet format
harr1424 Dec 17, 2025
8a273fc
make SendsController null safe (tech debt)
harr1424 Dec 17, 2025
bfd06c0
add AuthType col to Sends table, change Emails col length to 4000, anโ€ฆ
harr1424 Dec 18, 2025
b52f79e
Merge branch 'main' into tools/pm-21918/send-authentication-commands
harr1424 Dec 18, 2025
4f4d63d
dotnet format
harr1424 Dec 18, 2025
179d724
update SPs to expect AuthType
harr1424 Dec 18, 2025
af39424
include SP updates in migrations
harr1424 Dec 18, 2025
7df56e3
remove migrations not intended for merge
harr1424 Dec 18, 2025
94926c5
Revert "remove migrations not intended for merge"
harr1424 Dec 18, 2025
8c1f7ee
Merge branch 'main' into tools/pm-21918/send-authentication-commands
harr1424 Dec 18, 2025
4a179af
extract AuthType inference to util method and remove SQLite file
harr1424 Dec 18, 2025
a934b43
fix lints
harr1424 Dec 18, 2025
47512f9
Merge branch 'main' into tools/pm-21918/send-authentication-commands
itsadrago Dec 20, 2025
c85bd3e
address review comments
harr1424 Dec 22, 2025
1e4b1f3
Merge branch 'main' into tools/pm-21918/send-authentication-commands
harr1424 Dec 22, 2025
f9bdfc0
fix incorrect assignment and adopt SQL conventions
harr1424 Dec 22, 2025
1240fa4
fix column assignment order in Send_Update.sql
harr1424 Dec 22, 2025
cae8b59
Merge branch 'main' into tools/pm-21918/send-authentication-commands
harr1424 Dec 23, 2025
f9b700e
remove space added to email list
harr1424 Dec 23, 2025
6ccb9af
assign SQL default value of NULL to AuthType
harr1424 Dec 23, 2025
52376e5
Merge branch 'main' into tools/pm-21918/send-authentication-commands
harr1424 Dec 23, 2025
f0ddab2
update SPs to match migration changes
harr1424 Dec 23, 2025
6bfacec
Merge branch 'tools/pm-21918/send-authentication-commands' of github.โ€ฆ
harr1424 Dec 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public async Task<IReadOnlyList<Send>> ValidateAsync(User user, IEnumerable<Send
throw new BadRequestException("All existing sends must be included in the rotation.");
}

result.Add(send.ToSend(existing, _sendAuthorizationService));
result.Add(send.UpdateSend(existing, _sendAuthorizationService));
}

return result;
Expand Down
76 changes: 43 additions & 33 deletions src/Api/Tools/Controllers/SendsController.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
๏ปฟ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable

using System.Text.Json;
๏ปฟusing System.Text.Json;
using Azure.Messaging.EventGrid;
using Bit.Api.Models.Response;
using Bit.Api.Tools.Models.Request;
Expand All @@ -16,6 +13,7 @@
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
Expand All @@ -33,6 +31,9 @@ public class SendsController : Controller
private readonly ISendFileStorageService _sendFileStorageService;
private readonly IAnonymousSendCommand _anonymousSendCommand;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;

private readonly ISendOwnerQuery _sendOwnerQuery;

private readonly ILogger<SendsController> _logger;
private readonly GlobalSettings _globalSettings;

Expand All @@ -42,6 +43,7 @@ public SendsController(
ISendAuthorizationService sendAuthorizationService,
IAnonymousSendCommand anonymousSendCommand,
INonAnonymousSendCommand nonAnonymousSendCommand,
ISendOwnerQuery sendOwnerQuery,
ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger,
GlobalSettings globalSettings)
Expand All @@ -51,6 +53,7 @@ public SendsController(
_sendAuthorizationService = sendAuthorizationService;
_anonymousSendCommand = anonymousSendCommand;
_nonAnonymousSendCommand = nonAnonymousSendCommand;
_sendOwnerQuery = sendOwnerQuery;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
_globalSettings = globalSettings;
Expand All @@ -70,7 +73,11 @@ public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestM

var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
var send = await _sendRepository.GetByIdAsync(guid);
SendAccessResult sendAuthResult =
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
var sendAuthResult =
await _sendAuthorizationService.AccessAsync(send, model.Password);
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
{
Expand All @@ -86,7 +93,7 @@ public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestM
throw new NotFoundException();
}

var sendResponse = new SendAccessResponseModel(send, _globalSettings);
var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
{
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
Expand Down Expand Up @@ -181,33 +188,29 @@ public async Task<ObjectResult> AzureValidateFile()
[HttpGet("{id}")]
public async Task<SendResponseModel> Get(string id)
{
var userId = _userService.GetProperUserId(User).Value;
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId)
{
throw new NotFoundException();
}

return new SendResponseModel(send, _globalSettings);
var sendId = new Guid(id);
var send = await _sendOwnerQuery.Get(sendId, User);
return new SendResponseModel(send);
}

[HttpGet("")]
public async Task<ListResponseModel<SendResponseModel>> GetAll()
{
var userId = _userService.GetProperUserId(User).Value;
var sends = await _sendRepository.GetManyByUserIdAsync(userId);
var responses = sends.Select(s => new SendResponseModel(s, _globalSettings));
return new ListResponseModel<SendResponseModel>(responses);
var sends = await _sendOwnerQuery.GetOwned(User);
var responses = sends.Select(s => new SendResponseModel(s));
var result = new ListResponseModel<SendResponseModel>(responses);

return result;
}

[HttpPost("")]
public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
{
model.ValidateCreation();
var userId = _userService.GetProperUserId(User).Value;
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var send = model.ToSend(userId, _sendAuthorizationService);
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send, _globalSettings);
return new SendResponseModel(send);
}

[HttpPost("file/v2")]
Expand All @@ -229,27 +232,27 @@ public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendReque
}

model.ValidateCreation();
var userId = _userService.GetProperUserId(User).Value;
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService);
var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value);
return new SendFileUploadDataResponseModel
{
Url = uploadUrl,
FileUploadType = _sendFileStorageService.FileUploadType,
SendResponse = new SendResponseModel(send, _globalSettings)
SendResponse = new SendResponseModel(send)
};
}

[HttpGet("{id}/file/{fileId}")]
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
{
var userId = _userService.GetProperUserId(User).Value;
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var sendId = new Guid(id);
var send = await _sendRepository.GetByIdAsync(sendId);
var fileData = JsonSerializer.Deserialize<SendFileData>(send?.Data);
var fileData = JsonSerializer.Deserialize<SendFileData>(send?.Data ?? string.Empty);

if (send == null || send.Type != SendType.File || (send.UserId.HasValue && send.UserId.Value != userId) ||
!send.UserId.HasValue || fileData.Id != fileId || fileData.Validated)
!send.UserId.HasValue || fileData?.Id != fileId || fileData.Validated)
{
// Not found if Send isn't found, user doesn't have access, request is faulty,
// or we've already validated the file. This last is to emulate create-only blob permissions for Azure
Expand All @@ -260,7 +263,7 @@ public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, st
{
Url = await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId),
FileUploadType = _sendFileStorageService.FileUploadType,
SendResponse = new SendResponseModel(send, _globalSettings),
SendResponse = new SendResponseModel(send),
};
}

Expand All @@ -270,12 +273,16 @@ public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, st
[DisableFormValueModelBinding]
public async Task PostFileForExistingSend(string id, string fileId)
{
if (!Request?.ContentType.Contains("multipart/") ?? true)
if (!Request?.ContentType?.Contains("multipart/") ?? true)
{
throw new BadRequestException("Invalid content.");
}

var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
await Request.GetFileAsync(async (stream) =>
{
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
Expand All @@ -286,36 +293,39 @@ await Request.GetFileAsync(async (stream) =>
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
{
model.ValidateEdit();
var userId = _userService.GetProperUserId(User).Value;
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId)
{
throw new NotFoundException();
}

Copy link
Contributor

Choose a reason for hiding this comment

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

โ“ API Design: There's an asymmetry here - you can remove password authentication via PUT /sends/{id}/remove-password, but there's no equivalent PUT /sends/{id}/remove-emails endpoint for removing email authentication.

This means:

  • Password auth: Can be removed via dedicated endpoint โœ…
  • Email auth: Can only be removed via full Send update (PUT /sends/{id}) โš ๏ธ

Question: Is this intentional? If users should be able to remove email authentication, consider adding a symmetric endpoint for consistency.

await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService));
return new SendResponseModel(send, _globalSettings);
await _nonAnonymousSendCommand.SaveSendAsync(model.UpdateSend(send, _sendAuthorizationService));
return new SendResponseModel(send);
}

[HttpPut("{id}/remove-password")]
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need a remove-emails endpoint?

Copy link
Contributor

Choose a reason for hiding this comment

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

I am actually not sure, did we solution that? I foresee a situation where only a subset of emails might need to be removed from any given send.

Copy link
Contributor

@itsadrago itsadrago Dec 18, 2025

Choose a reason for hiding this comment

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

Yes, we should be able to add emails and remove some or all as desired by the user. The current process of updating a Send should be able to handle that. I'm not sure why we have a remove-password endpoint versus just letting the owner of the Send remove it through editing the Send

Copy link
Contributor

Choose a reason for hiding this comment

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

I found out that the UI offers the option to delete a password without editing a Send on almost all the clients. I have a question out to product and design about whether emails should be treated the same way

public async Task<SendResponseModel> PutRemovePassword(string id)
{
var userId = _userService.GetProperUserId(User).Value;
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId)
{
throw new NotFoundException();
Copy link
Contributor

Choose a reason for hiding this comment

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

โŒ Critical: This endpoint creates inconsistent state by only clearing Password while leaving Emails intact. If a Send has both authentication methods somehow, this would leave it in an ambiguous state.

After this operation:

  • Password = null โœ…
  • Emails = unchanged โš ๏ธ (could still contain email addresses)
  • AuthType = None โœ…

Recommendation: Also clear the Emails field:

send.Password = null;
send.Emails = null;
send.AuthType = Core.Tools.Enums.AuthType.None;

This ensures a Send truly has "no authentication" after password removal.

Copy link
Contributor

Choose a reason for hiding this comment

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

@harr1424 what do you think about this?

Copy link
Contributor

Choose a reason for hiding this comment

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

@mathewpriya @sukhleenb do either of you have any input why we may have added a dedicated endpoint for removing password based authentication? As opposed to using the existing endpoint to edit the send and remove the password instead?

@itsadrago I am definitely not opposed to adding the endpoint if it will be used, but sometimes Claude's comments are a bit silly once any investigation takes place.

}

// This endpoint exists because PUT preserves existing Password/Emails when not provided.
// This allows clients to update other fields without re-submitting sensitive auth data.
send.Password = null;
send.AuthType = AuthType.None;
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send, _globalSettings);
return new SendResponseModel(send);
}

[HttpDelete("{id}")]
public async Task Delete(string id)
{
var userId = _userService.GetProperUserId(User).Value;
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId)
{
Expand Down
Loading
Loading