Skip to content
Merged
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
56 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
0bc27d0
remove FF, update SendAuthQuery, and update tests
harr1424 Dec 31, 2025
93f93d8
new endpoints added but lack test coverage
harr1424 Jan 2, 2026
7e94c87
Merge branch 'main' into tools/PM-30221-Update-SendAuthenticationQuery
harr1424 Jan 3, 2026
c477d55
dotnet format
harr1424 Jan 3, 2026
6e9b5df
add PutRemoveAuth endpoint with test coverage and tests for new non-a…
harr1424 Jan 5, 2026
26ce39e
update RequireFeatureFlag comment for clarity
harr1424 Jan 5, 2026
e5def1e
respond to Claude's findings
harr1424 Jan 5, 2026
01d1706
add additional validation logic to new auth endpoints
harr1424 Jan 6, 2026
232b1bb
Merge branch 'main' into tools/PM-30221-Update-SendAuthenticationQuery
harr1424 Jan 6, 2026
1ae7821
enforce auth policies on individual action methods
harr1424 Jan 6, 2026
3d949a6
Merge branch 'main' into tools/PM-30221-Update-SendAuthenticationQuery
harr1424 Jan 6, 2026
d30227a
Merge branch 'main' into tools/PM-30221-Update-SendAuthenticationQuery
itsadrago Jan 7, 2026
41f0137
remove JsonConverter directive for AuthType
harr1424 Jan 9, 2026
4819b2f
remove tools-send-email-otp-listing feature flag
harr1424 Jan 9, 2026
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
142 changes: 128 additions & 14 deletions src/Api/Tools/Controllers/SendsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
using Bit.Api.Tools.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.UserFeatures.SendAccess;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
Expand All @@ -22,7 +24,6 @@
namespace Bit.Api.Tools.Controllers;

[Route("sends")]
[Authorize("Application")]
public class SendsController : Controller
{
private readonly ISendRepository _sendRepository;
Expand All @@ -31,11 +32,10 @@ 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;
private readonly IFeatureService _featureService;
private readonly IPushNotificationService _pushNotificationService;

public SendsController(
ISendRepository sendRepository,
Expand All @@ -46,7 +46,8 @@ public SendsController(
ISendOwnerQuery sendOwnerQuery,
ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger,
GlobalSettings globalSettings)
IFeatureService featureService,
IPushNotificationService pushNotificationService)
{
_sendRepository = sendRepository;
_userService = userService;
Expand All @@ -56,10 +57,12 @@ public SendsController(
_sendOwnerQuery = sendOwnerQuery;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
_globalSettings = globalSettings;
_featureService = featureService;
_pushNotificationService = pushNotificationService;
}

#region Anonymous endpoints

[AllowAnonymous]
[HttpPost("access/{id}")]
public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model)
Expand All @@ -73,21 +76,32 @@ public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestM

var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
var send = await _sendRepository.GetByIdAsync(guid);

if (send == null)
{
throw new BadRequestException("Could not locate send");
}

/* This guard can be removed once feature flag is retired*/
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
{
return new UnauthorizedResult();
}

var sendAuthResult =
await _sendAuthorizationService.AccessAsync(send, model.Password);
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
{
return new UnauthorizedResult();
}

if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}

if (sendAuthResult.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
Expand All @@ -99,6 +113,7 @@ public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestM
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}

return new ObjectResult(sendResponse);
}

Expand All @@ -122,28 +137,33 @@ public async Task<IActionResult> GetSendFileDownloadData(string encodedSendId,
throw new BadRequestException("Could not locate send");
}

/* This guard can be removed once feature flag is retired*/
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
{
return new UnauthorizedResult();
}

var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
model.Password);

if (result.Equals(SendAccessResult.PasswordRequired))
{
return new UnauthorizedResult();
}

if (result.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}

if (result.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
}

return new ObjectResult(new SendFileDownloadDataResponseModel()
{
Id = fileId,
Url = url,
});
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, });
}

[AllowAnonymous]
Expand All @@ -157,7 +177,8 @@ public async Task<ObjectResult> AzureValidateFile()
{
try
{
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var blobName =
eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
if (send == null)
Expand All @@ -166,14 +187,16 @@ public async Task<ObjectResult> AzureValidateFile()
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}

return;
}

await _nonAnonymousSendCommand.ConfirmFileSize(send);
}
catch (Exception e)
{
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}",
JsonSerializer.Serialize(eventGridEvent));
return;
}
}
Expand All @@ -185,6 +208,7 @@ public async Task<ObjectResult> AzureValidateFile()

#region Non-anonymous endpoints

[Authorize(Policies.Application)]
[HttpGet("{id}")]
public async Task<SendResponseModel> Get(string id)
{
Expand All @@ -193,6 +217,7 @@ public async Task<SendResponseModel> Get(string id)
return new SendResponseModel(send);
}

[Authorize(Policies.Application)]
[HttpGet("")]
public async Task<ListResponseModel<SendResponseModel>> GetAll()
{
Expand All @@ -203,6 +228,67 @@ public async Task<ListResponseModel<SendResponseModel>> GetAll()
return result;
}

[Authorize(Policy = Policies.Send)]
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
[HttpPost("access/")]
public async Task<IActionResult> AccessUsingAuth()
{
var guid = User.GetSendId();
var send = await _sendRepository.GetByIdAsync(guid);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}

var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
{
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}

send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);

return new ObjectResult(sendResponse);
}

[Authorize(Policy = Policies.Send)]
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
[HttpPost("access/file/{fileId}")]
public async Task<IActionResult> GetSendFileDownloadDataUsingAuth(string fileId)
{
var sendId = User.GetSendId();
var send = await _sendRepository.GetByIdAsync(sendId);

if (send == null)
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}

var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);

send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);

return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
}

[Authorize(Policies.Application)]
[HttpPost("")]
public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
{
Expand All @@ -213,6 +299,7 @@ public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
return new SendResponseModel(send);
}

[Authorize(Policies.Application)]
[HttpPost("file/v2")]
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
{
Expand Down Expand Up @@ -243,6 +330,7 @@ public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendReque
};
}

[Authorize(Policies.Application)]
[HttpGet("{id}/file/{fileId}")]
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
{
Expand All @@ -267,6 +355,7 @@ public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, st
};
}

[Authorize(Policies.Application)]
[HttpPost("{id}/file/{fileId}")]
[SelfHosted(SelfHostedOnly = true)]
[RequestSizeLimit(Constants.FileSize501mb)]
Expand All @@ -283,12 +372,14 @@ public async Task PostFileForExistingSend(string id, string fileId)
{
throw new BadRequestException("Could not locate send");
}

await Request.GetFileAsync(async (stream) =>
{
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
});
}

[Authorize(Policies.Application)]
[HttpPut("{id}")]
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
{
Expand All @@ -304,6 +395,7 @@ public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel
return new SendResponseModel(send);
}

[Authorize(Policies.Application)]
[HttpPut("{id}/remove-password")]
public async Task<SendResponseModel> PutRemovePassword(string id)
{
Expand All @@ -322,6 +414,28 @@ public async Task<SendResponseModel> PutRemovePassword(string id)
return new SendResponseModel(send);
}

// Removes ALL authentication (email or password) if any is present
[Authorize(Policies.Application)]
[HttpPut("{id}/remove-auth")]
public async Task<SendResponseModel> PutRemoveAuth(string id)
{
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();
}

// 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.Emails = null;
send.AuthType = AuthType.None;
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send);
}

[Authorize(Policies.Application)]
[HttpDelete("{id}")]
public async Task Delete(string id)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;

Expand Down Expand Up @@ -37,8 +38,8 @@ public async Task<SendAuthenticationMethod> GetAuthenticationMethod(Guid sendId)
{
null => NEVER_AUTHENTICATE,
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
var s when s.Emails is not null => emailOtp(s.Emails),
var s when s.Password is not null => new ResourcePassword(s.Password),
var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};

Expand Down
14 changes: 1 addition & 13 deletions src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ namespace Bit.Core.Tools.SendFeatures.Queries;
public class SendOwnerQuery : ISendOwnerQuery
{
private readonly ISendRepository _repository;
private readonly IFeatureService _features;
private readonly IUserService _users;

/// <summary>
Expand All @@ -24,10 +23,9 @@ public class SendOwnerQuery : ISendOwnerQuery
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="sendRepository"/> is <see langword="null"/>.
/// </exception>
public SendOwnerQuery(ISendRepository sendRepository, IFeatureService features, IUserService users)
public SendOwnerQuery(ISendRepository sendRepository, IUserService users)
{
_repository = sendRepository;
_features = features ?? throw new ArgumentNullException(nameof(features));
_users = users ?? throw new ArgumentNullException(nameof(users));
}

Expand All @@ -51,16 +49,6 @@ public async Task<ICollection<Send>> GetOwned(ClaimsPrincipal user)
var userId = _users.GetProperUserId(user) ?? throw new BadRequestException("invalid user.");
var sends = await _repository.GetManyByUserIdAsync(userId);

var removeEmailOtp = !_features.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
if (removeEmailOtp)
{
// reify list to avoid invalidating the enumerator
foreach (var s in sends.Where(s => s.Emails != null).ToList())
{
sends.Remove(s);
}
}

return sends;
}
}
Loading
Loading