diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Requests/RequestSMAccessCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Requests/RequestSMAccessCommand.cs new file mode 100644 index 000000000..440c0dfee --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Requests/RequestSMAccessCommand.cs @@ -0,0 +1,34 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.SecretsManager.Commands.Requests.Interfaces; +using Bit.Core.Services; + +namespace Bit.Commercial.Core.SecretsManager.Commands.Requests; + +public class RequestSMAccessCommand : IRequestSMAccessCommand +{ + private readonly IMailService _mailService; + + public RequestSMAccessCommand( + IMailService mailService) + { + _mailService = mailService; + } + + public async Task SendRequestAccessToSM(Organization organization, ICollection orgUsers, User user, string emailContent) + { + var emailList = orgUsers.Where(o => o.Type <= OrganizationUserType.Admin) + .Select(a => a.Email).Distinct().ToList(); + + if (!emailList.Any()) + { + throw new BadRequestException("The organization is in an invalid state. Please contact Customer Support."); + } + + var userRequestingAccess = user.Name ?? user.Email; + await _mailService.SendRequestSMAccessToAdminEmailAsync(emailList, organization.Name, userRequestingAccess, emailContent); + } +} diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs index 24051eec7..8d2010028 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs @@ -6,6 +6,7 @@ using Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies; using Bit.Commercial.Core.SecretsManager.Commands.AccessTokens; using Bit.Commercial.Core.SecretsManager.Commands.Porting; using Bit.Commercial.Core.SecretsManager.Commands.Projects; +using Bit.Commercial.Core.SecretsManager.Commands.Requests; using Bit.Commercial.Core.SecretsManager.Commands.Secrets; using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts; using Bit.Commercial.Core.SecretsManager.Commands.Trash; @@ -18,6 +19,7 @@ using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces; using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces; using Bit.Core.SecretsManager.Commands.Porting.Interfaces; using Bit.Core.SecretsManager.Commands.Projects.Interfaces; +using Bit.Core.SecretsManager.Commands.Requests.Interfaces; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Commands.Trash.Interfaces; @@ -56,6 +58,7 @@ public static class SecretsManagerCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/Requests/RequestSMAccessCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/Requests/RequestSMAccessCommandTests.cs new file mode 100644 index 000000000..e9387deec --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/Requests/RequestSMAccessCommandTests.cs @@ -0,0 +1,96 @@ +using Bit.Commercial.Core.SecretsManager.Commands.Requests; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Commercial.Core.Test.SecretsManager.Commands.Requests; + +[SutProviderCustomize] +public class RequestSMAccessCommandTests +{ + [Theory] + [BitAutoData] + public async Task SendRequestAccessToSM_Success( + User user, + Organization organization, + ICollection orgUsers, + string emailContent, + SutProvider sutProvider) + { + foreach (var userDetails in orgUsers) + { + userDetails.Type = OrganizationUserType.Admin; + } + + orgUsers.First().Type = OrganizationUserType.Owner; + + await sutProvider.Sut.SendRequestAccessToSM(organization, orgUsers, user, emailContent); + + var adminEmailList = orgUsers + .Where(o => o.Type <= OrganizationUserType.Admin) + .Select(a => a.Email) + .Distinct() + .ToList(); + + await sutProvider.GetDependency() + .Received(1) + .SendRequestSMAccessToAdminEmailAsync(Arg.Is(AssertHelper.AssertPropertyEqual(adminEmailList)), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task SendRequestAccessToSM_NoAdmins_ThrowsBadRequestException( + User user, + Organization organization, + ICollection orgUsers, + string emailContent, + SutProvider sutProvider) + { + // Set OrgUsers so they are only users, no admins or owners + foreach (OrganizationUserUserDetails userDetails in orgUsers) + { + userDetails.Type = OrganizationUserType.User; + } + + await Assert.ThrowsAsync(() => sutProvider.Sut.SendRequestAccessToSM(organization, orgUsers, user, emailContent)); + } + + + [Theory] + [BitAutoData] + public async Task SendRequestAccessToSM_SomeAdmins_EmailListIsAsExpected( + User user, + Organization organization, + ICollection orgUsers, + string emailContent, + SutProvider sutProvider) + { + foreach (OrganizationUserUserDetails userDetails in orgUsers) + { + userDetails.Type = OrganizationUserType.User; + } + + // Make the first orgUser an admin so it's a mix of Admin + Users + orgUsers.First().Type = OrganizationUserType.Admin; + + var adminEmailList = orgUsers + .Where(o => o.Type == OrganizationUserType.Admin) // Filter by Admin type + .Select(a => a.Email) + .Distinct() + .ToList(); + + await sutProvider.Sut.SendRequestAccessToSM(organization, orgUsers, user, emailContent); + + await sutProvider.GetDependency() + .Received(1) + .SendRequestSMAccessToAdminEmailAsync(Arg.Is(AssertHelper.AssertPropertyEqual(adminEmailList)), Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/src/Api/SecretsManager/Controllers/RequestSMAccessController.cs b/src/Api/SecretsManager/Controllers/RequestSMAccessController.cs new file mode 100644 index 000000000..c9b393bb2 --- /dev/null +++ b/src/Api/SecretsManager/Controllers/RequestSMAccessController.cs @@ -0,0 +1,55 @@ +using Bit.Api.SecretsManager.Models.Request; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Commands.Requests.Interfaces; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.SecretsManager.Controllers; + +[Route("request-access")] +[Authorize("Web")] +public class RequestSMAccessController : Controller +{ + private readonly IRequestSMAccessCommand _requestSMAccessCommand; + private readonly IUserService _userService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly ICurrentContext _currentContext; + + public RequestSMAccessController( + IRequestSMAccessCommand requestSMAccessCommand, IUserService userService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, ICurrentContext currentContext) + { + _requestSMAccessCommand = requestSMAccessCommand; + _userService = userService; + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _currentContext = currentContext; + } + + [HttpPost("request-sm-access")] + public async Task RequestSMAccessFromAdmins([FromBody] RequestSMAccessRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + if (!await _currentContext.OrganizationUser(model.OrganizationId)) + { + throw new NotFoundException(); + } + + var organization = await _organizationRepository.GetByIdAsync(model.OrganizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organization.Id); + await _requestSMAccessCommand.SendRequestAccessToSM(organization, orgUsers, user, model.EmailContent); + } +} diff --git a/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs b/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs new file mode 100644 index 000000000..1f05bad93 --- /dev/null +++ b/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.SecretsManager.Models.Request; + +public class RequestSMAccessRequestModel +{ + [Required] + public Guid OrganizationId { get; set; } + [Required(ErrorMessage = "Add a note is a required field")] + public string EmailContent { get; set; } +} diff --git a/src/Core/MailTemplates/Handlebars/SecretsManagerAccessRequest.html.hbs b/src/Core/MailTemplates/Handlebars/SecretsManagerAccessRequest.html.hbs new file mode 100644 index 000000000..501e09cf1 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/SecretsManagerAccessRequest.html.hbs @@ -0,0 +1,27 @@ +{{#>FullHtmlLayout}} + + + + +
+ + + + + + + +
+ {{UserNameRequestingAccess}} has requested access to secrets manager for {{OrgName}}:

+
{{EmailContent}} - {{UserNameRequestingAccess}}
+
+ + Contact Bitwarden + +
+
+
Stay safe and secure,
+ The Bitwarden Team +
+ +{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/SecretsManagerAccessRequest.text.hbs b/src/Core/MailTemplates/Handlebars/SecretsManagerAccessRequest.text.hbs new file mode 100644 index 000000000..62e9b7491 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/SecretsManagerAccessRequest.text.hbs @@ -0,0 +1,17 @@ +{{#>FullTextLayout}} + +{{UserNameRequestingAccess}} has requested access to secrets manager for {{OrgName}}: + +============ + +{{EmailContent}} - {{UserNameRequestingAccess}} + +============ + +Contact Bitwarden (https://bitwarden.com/contact-sales/?utm_source=sm_request_access_email&utm_medium=email) + +============ + +Stay safe and secure, +The Bitwarden Team +{{/FullTextLayout}} diff --git a/src/Core/SecretsManager/Commands/Requests/Interfaces/IRequestSMAccessCommand.cs b/src/Core/SecretsManager/Commands/Requests/Interfaces/IRequestSMAccessCommand.cs new file mode 100644 index 000000000..330c38553 --- /dev/null +++ b/src/Core/SecretsManager/Commands/Requests/Interfaces/IRequestSMAccessCommand.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; + +namespace Bit.Core.SecretsManager.Commands.Requests.Interfaces; + +public interface IRequestSMAccessCommand +{ + Task SendRequestAccessToSM(Organization organization, ICollection orgUsers, User user, string emailContent); +} diff --git a/src/Core/SecretsManager/Models/Mail/RequestSecretsManagerAccessViewModel.cs b/src/Core/SecretsManager/Models/Mail/RequestSecretsManagerAccessViewModel.cs new file mode 100644 index 000000000..1e35f97d1 --- /dev/null +++ b/src/Core/SecretsManager/Models/Mail/RequestSecretsManagerAccessViewModel.cs @@ -0,0 +1,10 @@ +using Bit.Core.Models.Mail; + +namespace Bit.Core.SecretsManager.Models.Mail; + +public class RequestSecretsManagerAccessViewModel : BaseMailModel +{ + public string UserNameRequestingAccess { get; set; } + public string OrgName { get; set; } + public string EmailContent { get; set; } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 14a08e910..a9f8dfb3f 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -81,5 +81,6 @@ public interface IMailService Task SendTrialInitiationEmailAsync(string email); Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token); Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token); + Task SendRequestSMAccessToAdminEmailAsync(IEnumerable adminEmails, string organizationName, string userRequestingAccess, string emailContent); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index d4f56e472..9d5580715 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -8,6 +8,7 @@ using Bit.Core.Entities; using Bit.Core.Models.Mail; using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.Provider; +using Bit.Core.SecretsManager.Models.Mail; using Bit.Core.Settings; using Bit.Core.Utilities; using HandlebarsDotNet; @@ -395,6 +396,20 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendRequestSMAccessToAdminEmailAsync(IEnumerable emails, string organizationName, string requestingUserName, string emailContent) + { + var message = CreateDefaultMessage("Access Requested for Secrets Manager", emails); + var model = new RequestSecretsManagerAccessViewModel + { + OrgName = CoreHelpers.SanitizeForEmail(organizationName, false), + UserNameRequestingAccess = CoreHelpers.SanitizeForEmail(requestingUserName, false), + EmailContent = CoreHelpers.SanitizeForEmail(emailContent, false), + }; + await AddMessageContentAsync(message, "SecretsManagerAccessRequest", model); + message.Category = "SecretsManagerAccessRequest"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendNewDeviceLoggedInEmail(string email, string deviceType, DateTime timestamp, string ip) { var message = CreateDefaultMessage($"New Device Logged In From {deviceType}", email); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 998714d13..27e920cbe 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -280,5 +280,6 @@ public class NoopMailService : IMailService { return Task.FromResult(0); } + public Task SendRequestSMAccessToAdminEmailAsync(IEnumerable adminEmails, string organizationName, string userRequestingAccess, string emailContent) => throw new NotImplementedException(); } diff --git a/test/Api.Test/SecretsManager/Controllers/RequestSMAccessControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/RequestSMAccessControllerTests.cs new file mode 100644 index 000000000..3c76246a0 --- /dev/null +++ b/test/Api.Test/SecretsManager/Controllers/RequestSMAccessControllerTests.cs @@ -0,0 +1,86 @@ +using System.Security.Claims; +using Bit.Api.SecretsManager.Controllers; +using Bit.Api.SecretsManager.Models.Request; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Commands.Requests.Interfaces; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Api.Test.SecretsManager.Controllers; + +[ControllerCustomize(typeof(RequestSMAccessController))] +[SutProviderCustomize] +public class RequestSMAccessControllerTests +{ + [Theory] + [BitAutoData] + public async Task RequestSMAccessFromAdmins_WhenSendingNoModel_ShouldThrowNotFoundException( + User user, SutProvider sutProvider) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetByIdentifierAsync(Arg.Any()).ReturnsNullForAnyArgs(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RequestSMAccessFromAdmins(new RequestSMAccessRequestModel())); + } + + [Theory] + [BitAutoData] + public async Task RequestSMAccessFromAdmins_WhenSendingValidData_ShouldSucceed( + User user, + RequestSMAccessRequestModel model, + Core.AdminConsole.Entities.Organization org, + ICollection orgUsers, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(model.OrganizationId).Returns(org); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(orgUsers); + sutProvider.GetDependency().OrganizationUser(model.OrganizationId).Returns(true); + + await sutProvider.Sut.RequestSMAccessFromAdmins(model); + + //Also check that the command was called + await sutProvider.GetDependency() + .Received(1) + .SendRequestAccessToSM(org, orgUsers, user, model.EmailContent); + } + + [Theory] + [BitAutoData] + public async Task RequestSMAccessFromAdmins_WhenUserInvalid_ShouldThrowBadRequestException(RequestSMAccessRequestModel model, SutProvider sutProvider) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNullForAnyArgs(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RequestSMAccessFromAdmins(model)); + } + + [Theory] + [BitAutoData] + public async Task RequestSMAccessFromAdmins_WhenOrgInvalid_ShouldThrowNotFoundException(RequestSMAccessRequestModel model, User user, SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdentifierAsync(Arg.Any()).ReturnsNullForAnyArgs(); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsForAnyArgs(user); + sutProvider.GetDependency().OrganizationUser(model.OrganizationId).Returns(true); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RequestSMAccessFromAdmins(model)); + } + + [Theory] + [BitAutoData] + public async Task RequestSMAccessFromAdmins_WhenOrgUserInvalid_ShouldThrowNotFoundException(RequestSMAccessRequestModel model, User user, SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdentifierAsync(Arg.Any()).ReturnsNullForAnyArgs(); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsForAnyArgs(user); + sutProvider.GetDependency().OrganizationUser(model.OrganizationId).Returns(false); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RequestSMAccessFromAdmins(model)); + } +}