1
0
mirror of https://github.com/bitwarden/server synced 2026-01-10 20:44:05 +00:00

[PM-21918] update send api models to support new email field (#5895)

* update send api models to support new `email` field

* normalize authentication field evaluation order

* document send response converters

* add FIXME to remove unused constructor argument

* add FIXME to remove unused constructor argument

* introduce `tools-send-email-otp-listing` feature flag

* add `ISendOwnerQuery` to dependency graph

* fix broken tests

* added AuthType prop to send related models with test coverage and debt cleanup

* dotnet format

* add migrations

* dotnet format

* make SendsController null safe (tech debt)

* add AuthType col to Sends table, change Emails col length to 4000, and run migrations

* dotnet format

* update SPs to expect AuthType

* include SP updates in migrations

* remove migrations not intended for merge

* Revert "remove migrations not intended for merge"

This reverts commit 7df56e346a.

undo migrations removal

* extract AuthType inference to util method and remove SQLite file

* fix lints

* address review comments

* fix incorrect assignment and adopt SQL conventions

* fix column assignment order in Send_Update.sql

* remove space added to email list

* assign SQL default value of NULL to AuthType

* update SPs to match migration changes

---------

Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
Co-authored-by: Alex Dragovich <46065570+itsadrago@users.noreply.github.com>
Co-authored-by: John Harrington <84741727+harr1424@users.noreply.github.com>
This commit is contained in:
✨ Audrey ✨
2025-12-31 15:37:42 -05:00
committed by GitHub
parent cc03842f5f
commit 484a8e42dc
35 changed files with 22505 additions and 55 deletions

View File

@@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator<IEnumerable<SendWithIdRe
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;

View File

@@ -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;
@@ -16,6 +13,7 @@ using Bit.Core.Tools.Models.Data;
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;
@@ -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;
@@ -42,6 +43,7 @@ public class SendsController : Controller
ISendAuthorizationService sendAuthorizationService,
IAnonymousSendCommand anonymousSendCommand,
INonAnonymousSendCommand nonAnonymousSendCommand,
ISendOwnerQuery sendOwnerQuery,
ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger,
GlobalSettings globalSettings)
@@ -51,6 +53,7 @@ public class SendsController : Controller
_sendAuthorizationService = sendAuthorizationService;
_anonymousSendCommand = anonymousSendCommand;
_nonAnonymousSendCommand = nonAnonymousSendCommand;
_sendOwnerQuery = sendOwnerQuery;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
_globalSettings = globalSettings;
@@ -70,7 +73,11 @@ public class SendsController : Controller
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))
{
@@ -86,7 +93,7 @@ public class SendsController : Controller
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);
@@ -181,33 +188,29 @@ public class SendsController : Controller
[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")]
@@ -229,27 +232,27 @@ public class SendsController : Controller
}
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
@@ -260,7 +263,7 @@ public class SendsController : Controller
{
Url = await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId),
FileUploadType = _sendFileStorageService.FileUploadType,
SendResponse = new SendResponseModel(send, _globalSettings),
SendResponse = new SendResponseModel(send),
};
}
@@ -270,12 +273,16 @@ public class SendsController : Controller
[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);
@@ -286,36 +293,39 @@ public class SendsController : Controller
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();
}
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")]
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();
}
// 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)
{

View File

@@ -3,6 +3,7 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Api.Tools.Utilities;
using Bit.Core.Exceptions;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
@@ -10,35 +11,119 @@ using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using static System.StringSplitOptions;
namespace Bit.Api.Tools.Models.Request;
/// <summary>
/// A send request issued by a Bitwarden client
/// </summary>
public class SendRequestModel
{
/// <summary>
/// Indicates whether the send contains text or file data.
/// </summary>
public SendType Type { get; set; }
/// <summary>
/// Specifies the authentication method required to access this Send.
/// </summary>
public AuthType? AuthType { get; set; }
/// <summary>
/// Estimated length of the file accompanying the send. <see langword="null"/> when
/// <see cref="Type"/> is <see cref="SendType.Text"/>.
/// </summary>
public long? FileLength { get; set; } = null;
/// <summary>
/// Label for the send.
/// </summary>
[EncryptedString]
[EncryptedStringLength(1000)]
public string Name { get; set; }
/// <summary>
/// Notes for the send. This is only visible to the owner of the send.
/// </summary>
[EncryptedString]
[EncryptedStringLength(1000)]
public string Notes { get; set; }
/// <summary>
/// A base64-encoded byte array containing the Send's encryption key. This key is
/// also provided to send recipients in the Send's URL.
/// </summary>
[Required]
[EncryptedString]
[EncryptedStringLength(1000)]
public string Key { get; set; }
/// <summary>
/// The maximum number of times a send can be accessed before it expires.
/// When this value is <see langword="null" />, there is no limit.
/// </summary>
[Range(1, int.MaxValue)]
public int? MaxAccessCount { get; set; }
/// <summary>
/// The date after which a send cannot be accessed. When this value is
/// <see langword="null"/>, there is no expiration date.
/// </summary>
public DateTime? ExpirationDate { get; set; }
/// <summary>
/// The date after which a send may be automatically deleted from the server.
/// When this is <see langword="null" />, the send may be deleted after it has
/// exceeded the global send timeout limit.
/// </summary>
[Required]
public DateTime? DeletionDate { get; set; }
/// <summary>
/// Contains file metadata uploaded with the send.
/// The file content is uploaded separately.
/// </summary>
public SendFileModel File { get; set; }
/// <summary>
/// Contains text data uploaded with the send.
/// </summary>
public SendTextModel Text { get; set; }
/// <summary>
/// Base64-encoded byte array of a password hash that grants access to the send.
/// Mutually exclusive with <see cref="Emails"/>.
/// </summary>
[StringLength(1000)]
public string Password { get; set; }
/// <summary>
/// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
[StringLength(4000)]
public string Emails { get; set; }
/// <summary>
/// When <see langword="true"/>, send access is disabled.
/// Defaults to <see langword="false"/>.
/// </summary>
[Required]
public bool? Disabled { get; set; }
/// <summary>
/// When <see langword="true"/> send access hides the user's email address
/// and displays a confirmation message instead. Defaults to <see langword="false"/>.
/// </summary>
public bool? HideEmail { get; set; }
/// <summary>
/// Transforms the request into a send object.
/// </summary>
/// <param name="userId">The user that owns the send.</param>
/// <param name="sendAuthorizationService">Hashes the send password.</param>
/// <returns>The send object</returns>
public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService)
{
var send = new Send
@@ -46,12 +131,21 @@ public class SendRequestModel
Type = Type,
UserId = (Guid?)userId
};
ToSend(send, sendAuthorizationService);
send = UpdateSend(send, sendAuthorizationService);
return send;
}
/// <summary>
/// Transforms the request into a send object and file data.
/// </summary>
/// <param name="userId">The user that owns the send.</param>
/// <param name="fileName">Name of the file uploaded with the send.</param>
/// <param name="sendAuthorizationService">Hashes the send password.</param>
/// <returns>The send object and file data.</returns>
public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService)
{
// FIXME: This method does two things: creates a send and a send file data.
// It should only do one thing.
var send = ToSendBase(new Send
{
Type = Type,
@@ -61,7 +155,13 @@ public class SendRequestModel
return (send, data);
}
public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
/// <summary>
/// Update a send object with request content
/// </summary>
/// <param name="existingSend">The send to update</param>
/// <param name="sendAuthorizationService">Hashes the send password.</param>
/// <returns>The send object</returns>
public Send UpdateSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
{
existingSend = ToSendBase(existingSend, sendAuthorizationService);
switch (existingSend.Type)
@@ -81,6 +181,12 @@ public class SendRequestModel
return existingSend;
}
/// <summary>
/// Validates that the request is internally consistent for send creation.
/// </summary>
/// <exception cref="BadRequestException">
/// Thrown when the send's expiration date has already expired.
/// </exception>
public void ValidateCreation()
{
var now = DateTime.UtcNow;
@@ -94,6 +200,13 @@ public class SendRequestModel
ValidateEdit();
}
/// <summary>
/// Validates that the request is internally consistent for send administration.
/// </summary>
/// <exception cref="BadRequestException">
/// Thrown when the send's deletion date has already expired or when its
/// expiration occurs after its deletion.
/// </exception>
public void ValidateEdit()
{
var now = DateTime.UtcNow;
@@ -134,12 +247,30 @@ public class SendRequestModel
existingSend.ExpirationDate = ExpirationDate;
existingSend.DeletionDate = DeletionDate.Value;
existingSend.MaxAccessCount = MaxAccessCount;
if (!string.IsNullOrWhiteSpace(Password))
if (!string.IsNullOrWhiteSpace(Emails))
{
// normalize encoding
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
existingSend.Emails = string.Join(",", emails);
existingSend.Password = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
}
else if (!string.IsNullOrWhiteSpace(Password))
{
existingSend.Password = authorizationService.HashPassword(Password);
existingSend.Emails = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.Password;
}
else
{
// Neither Password nor Emails provided - preserve existing values and infer AuthType
existingSend.AuthType = SendUtilities.InferAuthType(existingSend);
}
existingSend.Disabled = Disabled.GetValueOrDefault();
existingSend.HideEmail = HideEmail.GetValueOrDefault();
return existingSend;
}
@@ -149,8 +280,15 @@ public class SendRequestModel
}
}
/// <summary>
/// A send request issued by a Bitwarden client
/// </summary>
public class SendWithIdRequestModel : SendRequestModel
{
/// <summary>
/// Identifies the send. When this is <see langword="null" />, the client is requesting
/// a new send.
/// </summary>
[Required]
public Guid? Id { get; set; }
}

View File

@@ -3,7 +3,6 @@
using System.Text.Json;
using Bit.Core.Models.Api;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
@@ -11,9 +10,22 @@ using Bit.Core.Utilities;
namespace Bit.Api.Tools.Models.Response;
/// <summary>
/// A response issued to a Bitwarden client in response to access operations.
/// </summary>
public class SendAccessResponseModel : ResponseModel
{
public SendAccessResponseModel(Send send, GlobalSettings globalSettings)
/// <summary>
/// Instantiates a send access response model
/// </summary>
/// <param name="send">Content to transmit to the client.</param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="send"/> is <see langword="null" />
/// </exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="send" /> has an invalid <see cref="Send.Type"/>.
/// </exception>
public SendAccessResponseModel(Send send)
: base("send-access")
{
if (send == null)
@@ -23,6 +35,7 @@ public class SendAccessResponseModel : ResponseModel
Id = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray());
Type = send.Type;
AuthType = send.AuthType;
SendData sendData;
switch (send.Type)
@@ -45,11 +58,52 @@ public class SendAccessResponseModel : ResponseModel
ExpirationDate = send.ExpirationDate;
}
/// <summary>
/// Identifies the send in a send URL
/// </summary>
public string Id { get; set; }
/// <summary>
/// Indicates whether the send contains text or file data.
/// </summary>
public SendType Type { get; set; }
/// <summary>
/// Specifies the authentication method required to access this Send.
/// </summary>
public AuthType? AuthType { get; set; }
/// <summary>
/// Label for the send. This is only visible to the owner of the send.
/// </summary>
/// <remarks>
/// This field contains a base64-encoded byte array. The array contains
/// the E2E-encrypted encrypted content.
/// </remarks>
public string Name { get; set; }
/// <summary>
/// Describes the file attached to the send.
/// </summary>
/// <remarks>
/// File content is downloaded separately using
/// <see cref="Bit.Api.Tools.Controllers.SendsController.GetSendFileDownloadData" />
/// </remarks>
public SendFileModel File { get; set; }
/// <summary>
/// Contains text data uploaded with the send.
/// </summary>
public SendTextModel Text { get; set; }
/// <summary>
/// The date after which a send cannot be accessed. When this value is
/// <see langword="null"/>, there is no expiration date.
/// </summary>
public DateTime? ExpirationDate { get; set; }
/// <summary>
/// Indicates the person that created the send to the accessor.
/// </summary>
public string CreatorIdentifier { get; set; }
}

View File

@@ -2,8 +2,8 @@
#nullable disable
using System.Text.Json;
using Bit.Api.Tools.Utilities;
using Bit.Core.Models.Api;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
@@ -11,9 +11,23 @@ using Bit.Core.Utilities;
namespace Bit.Api.Tools.Models.Response;
/// <summary>
/// A response issued to a Bitwarden client in response to ownership operations.
/// </summary>
/// <seealso cref="SendAccessResponseModel" />
public class SendResponseModel : ResponseModel
{
public SendResponseModel(Send send, GlobalSettings globalSettings)
/// <summary>
/// Instantiates a send response model
/// </summary>
/// <param name="send">Content to transmit to the client.</param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="send"/> is <see langword="null" />
/// </exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="send" /> has an invalid <see cref="Send.Type"/>.
/// </exception>
public SendResponseModel(Send send)
: base("send")
{
if (send == null)
@@ -24,6 +38,7 @@ public class SendResponseModel : ResponseModel
Id = send.Id;
AccessId = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray());
Type = send.Type;
AuthType = send.AuthType ?? SendUtilities.InferAuthType(send);
Key = send.Key;
MaxAccessCount = send.MaxAccessCount;
AccessCount = send.AccessCount;
@@ -31,6 +46,7 @@ public class SendResponseModel : ResponseModel
ExpirationDate = send.ExpirationDate;
DeletionDate = send.DeletionDate;
Password = send.Password;
Emails = send.Emails;
Disabled = send.Disabled;
HideEmail = send.HideEmail.GetValueOrDefault();
@@ -55,20 +71,113 @@ public class SendResponseModel : ResponseModel
Notes = sendData.Notes;
}
/// <summary>
/// Identifies the send to its owner
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Identifies the send in a send URL
/// </summary>
public string AccessId { get; set; }
/// <summary>
/// Indicates whether the send contains text or file data.
/// </summary>
public SendType Type { get; set; }
/// <summary>
/// Specifies the authentication method required to access this Send.
/// </summary>
public AuthType? AuthType { get; set; }
/// <summary>
/// Label for the send.
/// </summary>
/// <remarks>
/// This field contains a base64-encoded byte array. The array contains
/// the E2E-encrypted encrypted content.
/// </remarks>
public string Name { get; set; }
/// <summary>
/// Notes for the send. This is only visible to the owner of the send.
/// This field is encrypted.
/// </summary>
/// <remarks>
/// This field contains a base64-encoded byte array. The array contains
/// the E2E-encrypted encrypted content.
/// </remarks>
public string Notes { get; set; }
/// <summary>
/// Contains file metadata uploaded with the send.
/// The file content is uploaded separately.
/// </summary>
public SendFileModel File { get; set; }
/// <summary>
/// Contains text data uploaded with the send.
/// </summary>
public SendTextModel Text { get; set; }
/// <summary>
/// A base64-encoded byte array containing the Send's encryption key.
/// It's also provided to send recipients in the Send's URL.
/// </summary>
/// <remarks>
/// This field contains a base64-encoded byte array. The array contains
/// the E2E-encrypted content.
/// </remarks>
public string Key { get; set; }
/// <summary>
/// The maximum number of times a send can be accessed before it expires.
/// When this value is <see langword="null" />, there is no limit.
/// </summary>
public int? MaxAccessCount { get; set; }
/// <summary>
/// The number of times a send has been accessed since it was created.
/// </summary>
public int AccessCount { get; set; }
/// <summary>
/// Base64-encoded byte array of a password hash that grants access to the send.
/// Mutually exclusive with <see cref="Emails"/>.
/// </summary>
public string Password { get; set; }
/// <summary>
/// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
public string Emails { get; set; }
/// <summary>
/// When <see langword="true"/>, send access is disabled.
/// </summary>
public bool Disabled { get; set; }
/// <summary>
/// The last time this send's data changed.
/// </summary>
public DateTime RevisionDate { get; set; }
/// <summary>
/// The date after which a send cannot be accessed. When this value is
/// <see langword="null"/>, there is no expiration date.
/// </summary>
public DateTime? ExpirationDate { get; set; }
/// <summary>
/// The date after which a send may be automatically deleted from the server.
/// </summary>
public DateTime DeletionDate { get; set; }
/// <summary>
/// When <see langword="true"/> send access hides the user's email address
/// and displays a confirmation message instead.
/// </summary>
public bool HideEmail { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace Bit.Api.Tools.Utilities;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
public class SendUtilities
{
public static AuthType InferAuthType(Send send)
{
if (!string.IsNullOrWhiteSpace(send.Password))
{
return AuthType.Password;
}
if (!string.IsNullOrWhiteSpace(send.Emails))
{
return AuthType.Email;
}
return AuthType.None;
}
}

View File

@@ -56,7 +56,7 @@ public class SyncResponseModel() : ResponseModel("sync")
c => new CollectionDetailsResponseModel(c)) ?? new List<CollectionDetailsResponseModel>();
Domains = excludeDomains ? null : new DomainsResponseModel(user, false);
Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List<PolicyResponseModel>();
Sends = sends.Select(s => new SendResponseModel(s, globalSettings));
Sends = sends.Select(s => new SendResponseModel(s));
UserDecryption = new UserDecryptionResponseModel
{
MasterPasswordUnlock = user.HasMasterPassword()

View File

@@ -234,6 +234,10 @@ public static class FeatureFlagKeys
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";
/* Tools Team */
/// <summary>
/// Enable this flag to share the send view used by the web and browser clients
/// on the desktop client.
/// </summary>
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
public const string UseChromiumImporter = "pm-23982-chromium-importer";
@@ -241,6 +245,16 @@ public static class FeatureFlagKeys
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
public const string SendEmailOTP = "pm-19051-send-email-verification";
/// <summary>
/// Enable this flag to output email/OTP authenticated sends from the `GET sends` endpoint. When
/// this flag is disabled, the `GET sends` endpoint omits email/OTP authenticated sends.
/// </summary>
/// <remarks>
/// This flag is server-side only, and only inhibits the endpoint returning all sends.
/// Email/OTP sends can still be created and downloaded through other endpoints.
/// </remarks>
public const string PM19051_ListEmailOtpSends = "tools-send-email-otp-listing";
/* Vault Team */
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";

View File

@@ -37,6 +37,12 @@ public class Send : ITableObject<Guid>
/// </summary>
public SendType Type { get; set; }
/// <summary>
/// Specifies the authentication method required to access this Send.
/// </summary>
/// <seealso cref="Tools.Enums.AuthType"/>
public AuthType? AuthType { get; set; }
/// <summary>
/// Stores data containing or pointing to the transmitted secret. JSON.
/// </summary>
@@ -72,7 +78,7 @@ public class Send : ITableObject<Guid>
/// <remarks>
/// This field is mutually exclusive with <see cref="Password" />
/// </remarks>
[MaxLength(1024)]
[MaxLength(4000)]
public string? Emails { get; set; }
/// <summary>

View File

@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace Bit.Core.Tools.Enums;
/// <summary>
/// Specifies the authentication method required to access a Send.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AuthType : byte
{
/// <summary>
/// Email-based OTP authentication
/// </summary>
Email = 0,
/// <summary>
/// Password-based authentication
/// </summary>
Password = 1,
/// <summary>
/// No authentication required
/// </summary>
None = 2
}

View File

@@ -0,0 +1,38 @@
using System.Security.Claims;
using Bit.Core.Exceptions;
using Bit.Core.Tools.Entities;
namespace Bit.Core.Tools.SendFeatures.Queries.Interfaces;
/// <summary>
/// Queries sends owned by the current user.
/// </summary>
public interface ISendOwnerQuery
{
/// <summary>
/// Gets a send.
/// </summary>
/// <param name="id">Identifies the send</param>
/// <param name="user">The principal requesting the send.</param>
/// <returns>The send</returns>
/// <exception cref="NotFoundException">
/// Thrown when <paramref name="id"/> fails to identify a send
/// owned by the user.
/// </exception>
/// <exception cref="BadRequestException">
/// Thrown when the query cannot identify the current user.
/// </exception>
Task<Send> Get(Guid id, ClaimsPrincipal user);
/// <summary>
/// Gets all sends owned by the current user.
/// </summary>
/// <param name="user">The principal requesting the send.</param>
/// <returns>
/// A sequence of all owned sends.
/// </returns>
/// <exception cref="BadRequestException">
/// Thrown when the query cannot identify the current user.
/// </exception>
Task<ICollection<Send>> GetOwned(ClaimsPrincipal user);
}

View File

@@ -0,0 +1,66 @@

using System.Security.Claims;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
namespace Bit.Core.Tools.SendFeatures.Queries;
/// <inheritdoc cref="ISendOwnerQuery"/>
public class SendOwnerQuery : ISendOwnerQuery
{
private readonly ISendRepository _repository;
private readonly IFeatureService _features;
private readonly IUserService _users;
/// <summary>
/// Instantiates the command
/// </summary>
/// <param name="sendRepository">
/// Retrieves send records
/// </param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="sendRepository"/> is <see langword="null"/>.
/// </exception>
public SendOwnerQuery(ISendRepository sendRepository, IFeatureService features, IUserService users)
{
_repository = sendRepository;
_features = features ?? throw new ArgumentNullException(nameof(features));
_users = users ?? throw new ArgumentNullException(nameof(users));
}
/// <inheritdoc cref="ISendOwnerQuery.Get"/>
public async Task<Send> Get(Guid id, ClaimsPrincipal user)
{
var userId = _users.GetProperUserId(user) ?? throw new BadRequestException("invalid user.");
var send = await _repository.GetByIdAsync(id);
if (send == null || send.UserId != userId)
{
throw new NotFoundException();
}
return send;
}
/// <inheritdoc cref="ISendOwnerQuery.GetOwned"/>
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;
}
}

View File

@@ -17,5 +17,6 @@ public static class SendServiceCollectionExtension
services.AddScoped<ISendValidationService, SendValidationService>();
services.AddScoped<ISendCoreHelperService, SendCoreHelperService>();
services.AddScoped<ISendAuthenticationQuery, SendAuthenticationQuery>();
services.AddScoped<ISendOwnerQuery, SendOwnerQuery>();
}
}

View File

@@ -17,7 +17,8 @@
@CipherId UNIQUEIDENTIFIER = NULL,
-- FIXME: remove null default value once this argument has been
-- in 2 server releases
@Emails NVARCHAR(1024) = NULL
@Emails NVARCHAR(4000) = NULL,
@AuthType TINYINT = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -40,7 +41,8 @@ BEGIN
[Disabled],
[HideEmail],
[CipherId],
[Emails]
[Emails],
[AuthType]
)
VALUES
(
@@ -60,7 +62,8 @@ BEGIN
@Disabled,
@HideEmail,
@CipherId,
@Emails
@Emails,
@AuthType
)
IF @UserId IS NOT NULL

View File

@@ -15,7 +15,8 @@
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(1024) = NULL
@Emails NVARCHAR(4000) = NULL,
@AuthType TINYINT = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -38,7 +39,8 @@ BEGIN
[Disabled] = @Disabled,
[HideEmail] = @HideEmail,
[CipherId] = @CipherId,
[Emails] = @Emails
[Emails] = @Emails,
[AuthType] = @AuthType
WHERE
[Id] = @Id

View File

@@ -6,7 +6,7 @@
[Data] VARCHAR(MAX) NOT NULL,
[Key] VARCHAR (MAX) NOT NULL,
[Password] NVARCHAR (300) NULL,
[Emails] NVARCHAR (1024) NULL,
[Emails] NVARCHAR (4000) NULL,
[MaxAccessCount] INT NULL,
[AccessCount] INT NOT NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
@@ -16,6 +16,7 @@
[Disabled] BIT NOT NULL,
[HideEmail] BIT NULL,
[CipherId] UNIQUEIDENTIFIER NULL,
[AuthType] TINYINT NULL,
CONSTRAINT [PK_Send] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Send_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_Send_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),

View File

@@ -1,6 +1,9 @@
using System.Text.Json;
using System.Security.Claims;
using System.Text.Json;
using AutoFixture.Xunit2;
using Bit.Api.Models.Response;
using Bit.Api.Tools.Controllers;
using Bit.Api.Tools.Models;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Tools.Models.Response;
using Bit.Core.Entities;
@@ -12,6 +15,7 @@ using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
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.Mvc;
@@ -29,6 +33,7 @@ public class SendsControllerTests : IDisposable
private readonly ISendRepository _sendRepository;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
private readonly IAnonymousSendCommand _anonymousSendCommand;
private readonly ISendOwnerQuery _sendOwnerQuery;
private readonly ISendAuthorizationService _sendAuthorizationService;
private readonly ISendFileStorageService _sendFileStorageService;
private readonly ILogger<SendsController> _logger;
@@ -39,6 +44,7 @@ public class SendsControllerTests : IDisposable
_sendRepository = Substitute.For<ISendRepository>();
_nonAnonymousSendCommand = Substitute.For<INonAnonymousSendCommand>();
_anonymousSendCommand = Substitute.For<IAnonymousSendCommand>();
_sendOwnerQuery = Substitute.For<ISendOwnerQuery>();
_sendAuthorizationService = Substitute.For<ISendAuthorizationService>();
_sendFileStorageService = Substitute.For<ISendFileStorageService>();
_globalSettings = new GlobalSettings();
@@ -50,6 +56,7 @@ public class SendsControllerTests : IDisposable
_sendAuthorizationService,
_anonymousSendCommand,
_nonAnonymousSendCommand,
_sendOwnerQuery,
_sendFileStorageService,
_logger,
_globalSettings
@@ -109,4 +116,641 @@ public class SendsControllerTests : IDisposable
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostFile(request));
Assert.Equal(expected, exception.Message);
}
[Theory, AutoData]
public async Task Get_WithValidId_ReturnsSendResponseModel(Guid sendId, Send send)
{
send.Type = SendType.Text;
var textData = new SendTextData("Test Send", "Notes", "Sample text", false);
send.Data = JsonSerializer.Serialize(textData);
_sendOwnerQuery.Get(sendId, Arg.Any<ClaimsPrincipal>()).Returns(send);
var result = await _sut.Get(sendId.ToString());
Assert.NotNull(result);
Assert.IsType<SendResponseModel>(result);
Assert.Equal(send.Id, result.Id);
await _sendOwnerQuery.Received(1).Get(sendId, Arg.Any<ClaimsPrincipal>());
}
[Theory, AutoData]
public async Task Get_WithInvalidGuid_ThrowsException(string invalidId)
{
await Assert.ThrowsAsync<FormatException>(() => _sut.Get(invalidId));
}
[Fact]
public async Task GetAllOwned_ReturnsListResponseModelWithSendResponseModels()
{
var textSendData = new SendTextData("Test Send 1", "Notes 1", "Sample text", false);
var fileSendData = new SendFileData("Test Send 2", "Notes 2", "test.txt") { Id = "file-123", Size = 1024 };
var sends = new List<Send>
{
new Send { Id = Guid.NewGuid(), Type = SendType.Text, Data = JsonSerializer.Serialize(textSendData) },
new Send { Id = Guid.NewGuid(), Type = SendType.File, Data = JsonSerializer.Serialize(fileSendData) }
};
_sendOwnerQuery.GetOwned(Arg.Any<ClaimsPrincipal>()).Returns(sends);
var result = await _sut.GetAll();
Assert.NotNull(result);
Assert.IsType<ListResponseModel<SendResponseModel>>(result);
Assert.Equal(2, result.Data.Count());
var sendResponseModels = result.Data.ToList();
Assert.Equal(sends[0].Id, sendResponseModels[0].Id);
Assert.Equal(sends[1].Id, sendResponseModels[1].Id);
await _sendOwnerQuery.Received(1).GetOwned(Arg.Any<ClaimsPrincipal>());
}
[Fact]
public async Task GetAllOwned_WhenNoSends_ReturnsEmptyListResponseModel()
{
_sendOwnerQuery.GetOwned(Arg.Any<ClaimsPrincipal>()).Returns(new List<Send>());
var result = await _sut.GetAll();
Assert.NotNull(result);
Assert.IsType<ListResponseModel<SendResponseModel>>(result);
Assert.Empty(result.Data);
await _sendOwnerQuery.Received(1).GetOwned(Arg.Any<ClaimsPrincipal>());
}
[Theory, AutoData]
public async Task Post_WithPassword_InfersAuthTypePassword(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
Password = "password",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Post(request);
Assert.NotNull(result);
Assert.Equal(AuthType.Password, result.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.AuthType == AuthType.Password &&
s.Password != null &&
s.Emails == null &&
s.UserId == userId &&
s.Type == SendType.Text));
_userService.Received(1).GetProperUserId(Arg.Any<ClaimsPrincipal>());
}
[Theory, AutoData]
public async Task Post_WithEmails_InfersAuthTypeEmail(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
Emails = "test@example.com",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Post(request);
Assert.NotNull(result);
Assert.Equal(AuthType.Email, result.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.AuthType == AuthType.Email &&
s.Emails != null &&
s.Password == null &&
s.UserId == userId &&
s.Type == SendType.Text));
_userService.Received(1).GetProperUserId(Arg.Any<ClaimsPrincipal>());
}
[Theory, AutoData]
public async Task Post_WithoutPasswordOrEmails_InfersAuthTypeNone(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Post(request);
Assert.NotNull(result);
Assert.Equal(AuthType.None, result.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.AuthType == AuthType.None &&
s.Password == null &&
s.Emails == null &&
s.UserId == userId &&
s.Type == SendType.Text));
_userService.Received(1).GetProperUserId(Arg.Any<ClaimsPrincipal>());
}
[Theory]
[InlineData(AuthType.Password)]
[InlineData(AuthType.Email)]
[InlineData(AuthType.None)]
public async Task Access_ReturnsCorrectAuthType(AuthType authType)
{
var sendId = Guid.NewGuid();
var accessId = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
var send = new Send
{
Id = sendId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new Dictionary<string, string>()),
AuthType = authType
};
_sendRepository.GetByIdAsync(sendId).Returns(send);
_sendAuthorizationService.AccessAsync(send, "pwd123").Returns(SendAccessResult.Granted);
var request = new SendAccessRequestModel();
var actionResult = await _sut.Access(accessId, request);
var response = (actionResult as ObjectResult)?.Value as SendAccessResponseModel;
Assert.NotNull(response);
Assert.Equal(authType, response.AuthType);
}
[Theory]
[InlineData(AuthType.Password)]
[InlineData(AuthType.Email)]
[InlineData(AuthType.None)]
public async Task Get_ReturnsCorrectAuthType(AuthType authType)
{
var sendId = Guid.NewGuid();
var send = new Send
{
Id = sendId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("a", "b", "c", false)),
AuthType = authType
};
_sendOwnerQuery.Get(sendId, Arg.Any<ClaimsPrincipal>()).Returns(send);
var result = await _sut.Get(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(authType, result.AuthType);
}
[Theory, AutoData]
public async Task Put_WithValidSend_UpdatesSuccessfully(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
AuthType = AuthType.None
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
Password = "new-password",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s => s.Id == sendId));
}
[Theory, AutoData]
public async Task Put_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
await Assert.ThrowsAsync<NotFoundException>(() => _sut.Put(sendId.ToString(), request));
}
[Theory, AutoData]
public async Task Put_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = otherUserId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false))
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
await Assert.ThrowsAsync<NotFoundException>(() => _sut.Put(sendId.ToString(), request));
}
[Theory, AutoData]
public async Task PutRemovePassword_WithValidSend_RemovesPasswordAndSetsAuthTypeNone(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = "hashed-password",
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var result = await _sut.PutRemovePassword(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
Assert.Equal(AuthType.None, result.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.Password == null &&
s.AuthType == AuthType.None));
}
[Theory, AutoData]
public async Task PutRemovePassword_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemovePassword(sendId.ToString()));
}
[Theory, AutoData]
public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = otherUserId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = "hashed-password"
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemovePassword(sendId.ToString()));
}
[Theory, AutoData]
public async Task Delete_WithValidSend_DeletesSuccessfully(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false))
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
await _sut.Delete(sendId.ToString());
await _nonAnonymousSendCommand.Received(1).DeleteSendAsync(Arg.Is<Send>(s => s.Id == sendId));
}
[Theory, AutoData]
public async Task Delete_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.Delete(sendId.ToString()));
}
[Theory, AutoData]
public async Task Delete_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = otherUserId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false))
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.Delete(sendId.ToString()));
}
[Theory, AutoData]
public async Task PostFile_WithPassword_InfersAuthTypePassword(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any<Send>(), Arg.Any<SendFileData>(), Arg.Any<long>())
.Returns("https://example.com/upload")
.AndDoes(callInfo =>
{
var send = callInfo.ArgAt<Send>(0);
var data = callInfo.ArgAt<SendFileData>(1);
send.Data = JsonSerializer.Serialize(data);
});
var request = new SendRequestModel
{
Type = SendType.File,
Key = "key",
File = new SendFileModel { FileName = "test.txt" },
FileLength = 1024L,
Password = "password",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.PostFile(request);
Assert.NotNull(result);
Assert.NotNull(result.SendResponse);
Assert.Equal(AuthType.Password, result.SendResponse.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveFileSendAsync(
Arg.Is<Send>(s =>
s.AuthType == AuthType.Password &&
s.Password != null &&
s.Emails == null &&
s.UserId == userId),
Arg.Any<SendFileData>(),
1024L);
}
[Theory, AutoData]
public async Task PostFile_WithEmails_InfersAuthTypeEmail(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any<Send>(), Arg.Any<SendFileData>(), Arg.Any<long>())
.Returns("https://example.com/upload")
.AndDoes(callInfo =>
{
var send = callInfo.ArgAt<Send>(0);
var data = callInfo.ArgAt<SendFileData>(1);
send.Data = JsonSerializer.Serialize(data);
});
var request = new SendRequestModel
{
Type = SendType.File,
Key = "key",
File = new SendFileModel { FileName = "test.txt" },
FileLength = 1024L,
Emails = "test@example.com",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.PostFile(request);
Assert.NotNull(result);
Assert.NotNull(result.SendResponse);
Assert.Equal(AuthType.Email, result.SendResponse.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveFileSendAsync(
Arg.Is<Send>(s =>
s.AuthType == AuthType.Email &&
s.Emails != null &&
s.Password == null &&
s.UserId == userId),
Arg.Any<SendFileData>(),
1024L);
}
[Theory, AutoData]
public async Task PostFile_WithoutPasswordOrEmails_InfersAuthTypeNone(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any<Send>(), Arg.Any<SendFileData>(), Arg.Any<long>())
.Returns("https://example.com/upload")
.AndDoes(callInfo =>
{
var send = callInfo.ArgAt<Send>(0);
var data = callInfo.ArgAt<SendFileData>(1);
send.Data = JsonSerializer.Serialize(data);
});
var request = new SendRequestModel
{
Type = SendType.File,
Key = "key",
File = new SendFileModel { FileName = "test.txt" },
FileLength = 1024L,
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.PostFile(request);
Assert.NotNull(result);
Assert.NotNull(result.SendResponse);
Assert.Equal(AuthType.None, result.SendResponse.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveFileSendAsync(
Arg.Is<Send>(s =>
s.AuthType == AuthType.None &&
s.Password == null &&
s.Emails == null &&
s.UserId == userId),
Arg.Any<SendFileData>(),
1024L);
}
[Theory, AutoData]
public async Task Put_ChangingFromPasswordToEmails_UpdatesAuthTypeToEmail(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Password = "hashed-password",
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
Emails = "new@example.com",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.Email &&
s.Emails != null &&
s.Password == null));
}
[Theory, AutoData]
public async Task Put_ChangingFromEmailToPassword_UpdatesAuthTypeToPassword(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Emails = "old@example.com",
AuthType = AuthType.Email
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
Password = "new-password",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.Password &&
s.Password != null &&
s.Emails == null));
}
[Theory, AutoData]
public async Task Put_WithoutPasswordOrEmails_PreservesExistingPassword(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Password = "hashed-password",
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.Password &&
s.Password == "hashed-password" &&
s.Emails == null));
}
[Theory, AutoData]
public async Task Put_WithoutPasswordOrEmails_PreservesExistingEmails(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Emails = "test@example.com",
AuthType = AuthType.Email
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.Email &&
s.Emails == "test@example.com" &&
s.Password == null));
}
[Theory, AutoData]
public async Task Put_WithoutPasswordOrEmails_PreservesNoneAuthType(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Password = null,
Emails = null,
AuthType = AuthType.None
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.None &&
s.Password == null &&
s.Emails == null));
}
}

View File

@@ -0,0 +1,169 @@
using System.Security.Claims;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Queries;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Tools.Services;
public class SendOwnerQueryTests
{
private readonly ISendRepository _sendRepository;
private readonly IFeatureService _featureService;
private readonly IUserService _userService;
private readonly SendOwnerQuery _sendOwnerQuery;
private readonly Guid _currentUserId = Guid.NewGuid();
private readonly ClaimsPrincipal _user;
public SendOwnerQueryTests()
{
_sendRepository = Substitute.For<ISendRepository>();
_featureService = Substitute.For<IFeatureService>();
_userService = Substitute.For<IUserService>();
_user = new ClaimsPrincipal();
_userService.GetProperUserId(_user).Returns(_currentUserId);
_sendOwnerQuery = new SendOwnerQuery(_sendRepository, _featureService, _userService);
}
[Fact]
public async Task Get_WithValidSendOwnedByUser_ReturnsExpectedSend()
{
// Arrange
var sendId = Guid.NewGuid();
var expectedSend = CreateSend(sendId, _currentUserId);
_sendRepository.GetByIdAsync(sendId).Returns(expectedSend);
// Act
var result = await _sendOwnerQuery.Get(sendId, _user);
// Assert
Assert.Same(expectedSend, result);
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Fact]
public async Task Get_WithNonExistentSend_ThrowsNotFoundException()
{
// Arrange
var sendId = Guid.NewGuid();
_sendRepository.GetByIdAsync(sendId).Returns((Send?)null);
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() => _sendOwnerQuery.Get(sendId, _user));
}
[Fact]
public async Task Get_WithSendOwnedByDifferentUser_ThrowsNotFoundException()
{
// Arrange
var sendId = Guid.NewGuid();
var differentUserId = Guid.NewGuid();
var send = CreateSend(sendId, differentUserId);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() => _sendOwnerQuery.Get(sendId, _user));
}
[Fact]
public async Task Get_WithNullCurrentUserId_ThrowsBadRequestException()
{
// Arrange
var sendId = Guid.NewGuid();
var send = CreateSend(sendId, _currentUserId);
_sendRepository.GetByIdAsync(sendId).Returns(send);
var nullUser = new ClaimsPrincipal();
_userService.GetProperUserId(nullUser).Returns((Guid?)null);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sendOwnerQuery.Get(sendId, nullUser));
Assert.Equal("invalid user.", exception.Message);
}
[Fact]
public async Task GetOwned_WithFeatureFlagEnabled_ReturnsAllSends()
{
// Arrange
var sends = new List<Send>
{
CreateSend(Guid.NewGuid(), _currentUserId, emails: null),
CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com"),
CreateSend(Guid.NewGuid(), _currentUserId, emails: "other@example.com")
};
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends);
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true);
// Act
var result = await _sendOwnerQuery.GetOwned(_user);
// Assert
Assert.Equal(3, result.Count);
Assert.Contains(sends[0], result);
Assert.Contains(sends[1], result);
Assert.Contains(sends[2], result);
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
_featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
}
[Fact]
public async Task GetOwned_WithFeatureFlagDisabled_FiltersOutEmailOtpSends()
{
// Arrange
var sendWithoutEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: null);
var sendWithEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com");
var sends = new List<Send> { sendWithoutEmails, sendWithEmails };
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends);
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(false);
// Act
var result = await _sendOwnerQuery.GetOwned(_user);
// Assert
Assert.Single(result);
Assert.Contains(sendWithoutEmails, result);
Assert.DoesNotContain(sendWithEmails, result);
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
_featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
}
[Fact]
public async Task GetOwned_WithNullCurrentUserId_ThrowsBadRequestException()
{
// Arrange
var nullUser = new ClaimsPrincipal();
_userService.GetProperUserId(nullUser).Returns((Guid?)null);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sendOwnerQuery.GetOwned(nullUser));
Assert.Equal("invalid user.", exception.Message);
}
[Fact]
public async Task GetOwned_WithEmptyCollection_ReturnsEmptyCollection()
{
// Arrange
var emptySends = new List<Send>();
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(emptySends);
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true);
// Act
var result = await _sendOwnerQuery.GetOwned(_user);
// Assert
Assert.Empty(result);
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
}
private static Send CreateSend(Guid id, Guid userId, string? emails = null)
{
return new Send
{
Id = id,
UserId = userId,
Emails = emails
};
}
}

View File

@@ -0,0 +1,142 @@
IF COL_LENGTH('[dbo].[Send]', 'AuthType') IS NULL
BEGIN
ALTER TABLE [dbo].[Send]
ADD [AuthType] TINYINT NULL;
END
GO
-- Update Send_Create to include AuthType parameter
CREATE OR ALTER PROCEDURE [dbo].[Send_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data VARCHAR(MAX),
@Key VARCHAR(MAX),
@Password NVARCHAR(300),
@MaxAccessCount INT,
@AccessCount INT,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ExpirationDate DATETIME2(7),
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(1024) = NULL,
@AuthType TINYINT = NULL
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Send]
(
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Key],
[Password],
[MaxAccessCount],
[AccessCount],
[CreationDate],
[RevisionDate],
[ExpirationDate],
[DeletionDate],
[Disabled],
[HideEmail],
[CipherId],
[Emails],
[AuthType]
)
VALUES
(
@Id,
@UserId,
@OrganizationId,
@Type,
@Data,
@Key,
@Password,
@MaxAccessCount,
@AccessCount,
@CreationDate,
@RevisionDate,
@ExpirationDate,
@DeletionDate,
@Disabled,
@HideEmail,
@CipherId,
@Emails,
@AuthType
)
IF @UserId IS NOT NULL
BEGIN
IF @Type = 1 --File
BEGIN
EXEC [dbo].[User_UpdateStorage] @UserId
END
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
-- TODO: OrganizationId bump?
END
GO
-- Update Send_Update to include AuthType parameter
CREATE OR ALTER PROCEDURE [dbo].[Send_Update]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data VARCHAR(MAX),
@Key VARCHAR(MAX),
@Password NVARCHAR(300),
@MaxAccessCount INT,
@AccessCount INT,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ExpirationDate DATETIME2(7),
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(1024) = NULL,
@AuthType TINYINT = NULL
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Send]
SET
[UserId] = @UserId,
[OrganizationId] = @OrganizationId,
[Type] = @Type,
[Data] = @Data,
[Key] = @Key,
[Password] = @Password,
[MaxAccessCount] = @MaxAccessCount,
[AccessCount] = @AccessCount,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[ExpirationDate] = @ExpirationDate,
[DeletionDate] = @DeletionDate,
[Disabled] = @Disabled,
[HideEmail] = @HideEmail,
[CipherId] = @CipherId,
[Emails] = @Emails,
[AuthType] = @AuthType
WHERE
[Id] = @Id
IF @UserId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
-- TODO: OrganizationId bump?
END
GO
EXECUTE sp_refreshview N'[dbo].[SendView]'
GO

View File

@@ -0,0 +1,146 @@
IF EXISTS (
SELECT *
FROM INFORMATION_SCHEMA.COLUMNS
WHERE COLUMN_NAME = 'Emails' AND
DATA_TYPE = 'nvarchar' AND
TABLE_NAME = 'Send' AND
CHARACTER_MAXIMUM_LENGTH = 1024)
BEGIN
ALTER TABLE [dbo].[Send]
ALTER COLUMN [Emails] NVARCHAR (4000) NULL
END
GO
-- Update Send_Create to use NVARCHAR(4000) for Emails
CREATE OR ALTER PROCEDURE [dbo].[Send_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data VARCHAR(MAX),
@Key VARCHAR(MAX),
@Password NVARCHAR(300),
@MaxAccessCount INT,
@AccessCount INT,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ExpirationDate DATETIME2(7),
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(4000) = NULL,
@AuthType TINYINT = NULL
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Send]
(
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Key],
[Password],
[MaxAccessCount],
[AccessCount],
[CreationDate],
[RevisionDate],
[ExpirationDate],
[DeletionDate],
[Disabled],
[HideEmail],
[CipherId],
[Emails],
[AuthType]
)
VALUES
(
@Id,
@UserId,
@OrganizationId,
@Type,
@Data,
@Key,
@Password,
@MaxAccessCount,
@AccessCount,
@CreationDate,
@RevisionDate,
@ExpirationDate,
@DeletionDate,
@Disabled,
@HideEmail,
@CipherId,
@Emails,
@AuthType
)
IF @UserId IS NOT NULL
BEGIN
IF @Type = 1 --File
BEGIN
EXEC [dbo].[User_UpdateStorage] @UserId
END
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
-- TODO: OrganizationId bump?
END
GO
-- Update Send_Update to use NVARCHAR(4000) for Emails
CREATE OR ALTER PROCEDURE [dbo].[Send_Update]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data VARCHAR(MAX),
@Key VARCHAR(MAX),
@Password NVARCHAR(300),
@MaxAccessCount INT,
@AccessCount INT,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ExpirationDate DATETIME2(7),
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(4000) = NULL,
@AuthType TINYINT = NULL
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Send]
SET
[UserId] = @UserId,
[OrganizationId] = @OrganizationId,
[Type] = @Type,
[Data] = @Data,
[Key] = @Key,
[Password] = @Password,
[MaxAccessCount] = @MaxAccessCount,
[AccessCount] = @AccessCount,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[ExpirationDate] = @ExpirationDate,
[DeletionDate] = @DeletionDate,
[Disabled] = @Disabled,
[HideEmail] = @HideEmail,
[CipherId] = @CipherId,
[Emails] = @Emails,
[AuthType] = @AuthType
WHERE
[Id] = @Id
IF @UserId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
-- TODO: OrganizationId bump?
END
GO

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class AddAuthTypeToSend : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte>(
name: "AuthType",
table: "Send",
type: "tinyint unsigned",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AuthType",
table: "Send");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class SendAuthTypeAndEmailLength : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Emails",
table: "Send",
type: "varchar(4000)",
maxLength: 4000,
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(1024)",
oldMaxLength: 1024,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Emails",
table: "Send",
type: "varchar(1024)",
maxLength: 1024,
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(4000)",
oldMaxLength: 4000,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}

View File

@@ -1632,6 +1632,9 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<int>("AccessCount")
.HasColumnType("int");
b.Property<byte?>("AuthType")
.HasColumnType("tinyint unsigned");
b.Property<Guid?>("CipherId")
.HasColumnType("char(36)");
@@ -1648,8 +1651,8 @@ namespace Bit.MySqlMigrations.Migrations
.HasColumnType("tinyint(1)");
b.Property<string>("Emails")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
.HasMaxLength(4000)
.HasColumnType("varchar(4000)");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("datetime(6)");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class AddAuthTypeToSend : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte>(
name: "AuthType",
table: "Send",
type: "smallint",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AuthType",
table: "Send");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class SendAuthTypeAndEmailLength : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Emails",
table: "Send",
type: "character varying(4000)",
maxLength: 4000,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(1024)",
oldMaxLength: 1024,
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Emails",
table: "Send",
type: "character varying(1024)",
maxLength: 1024,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(4000)",
oldMaxLength: 4000,
oldNullable: true);
}
}

View File

@@ -1637,6 +1637,9 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<int>("AccessCount")
.HasColumnType("integer");
b.Property<byte?>("AuthType")
.HasColumnType("smallint");
b.Property<Guid?>("CipherId")
.HasColumnType("uuid");
@@ -1653,8 +1656,8 @@ namespace Bit.PostgresMigrations.Migrations
.HasColumnType("boolean");
b.Property<string>("Emails")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("timestamp with time zone");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class AddAuthTypeToSend : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte>(
name: "AuthType",
table: "Send",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AuthType",
table: "Send");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class SendAuthTypeAndEmailLength : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}

View File

@@ -1621,6 +1621,9 @@ namespace Bit.SqliteMigrations.Migrations
b.Property<int>("AccessCount")
.HasColumnType("INTEGER");
b.Property<byte?>("AuthType")
.HasColumnType("INTEGER");
b.Property<Guid?>("CipherId")
.HasColumnType("TEXT");
@@ -1637,7 +1640,7 @@ namespace Bit.SqliteMigrations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("Emails")
.HasMaxLength(1024)
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpirationDate")