From 728cd1c0b5eff982351cab7bab32a38caa66ef71 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Thu, 19 Oct 2023 16:57:14 -0500 Subject: [PATCH] [SM-949] Add endpoint to fetch events by service account (#3336) * Add ability to fetch events by service account * Extract GetDateRange into ApiHelpers util * Add dapper implementation * Add EF repo implementation * Add authz handler case * unit + integration tests for controller * swap to read check * Adding comments * Fix integration tests from merge * Enabled SM events controller for self-hosting --- .../ServiceAccountAuthorizationHandler.cs | 18 +++++ ...ServiceAccountAuthorizationHandlerTests.cs | 59 ++++++++++++++ src/Api/Controllers/EventsController.cs | 35 ++------ .../SecretsManagerEventsController.cs | 52 ++++++++++++ src/Api/Utilities/ApiHelpers.cs | 32 ++++++++ src/Core/Repositories/IEventRepository.cs | 2 + .../TableStorage/EventRepository.cs | 8 ++ .../ServiceAccountOperationRequirement.cs | 1 + .../Repositories/EventRepository.cs | 18 +++++ .../Repositories/EventRepository.cs | 26 ++++++ ...geByOrganizationIdServiceAccountIdQuery.cs | 38 +++++++++ ...adPageByOrganizationIdServiceAccountId.sql | 25 ++++++ .../SecretsManagerEventsControllerTests.cs | 71 +++++++++++++++++ .../SecretsManagerEventsControllerTests.cs | 79 +++++++++++++++++++ ...adPageByOrganizationIdServiceAccountId.sql | 25 ++++++ 15 files changed, 461 insertions(+), 28 deletions(-) create mode 100644 src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs create mode 100644 src/Infrastructure.EntityFramework/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs create mode 100644 src/Sql/SecretsManager/dbo/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql create mode 100644 test/Api.IntegrationTest/SecretsManager/Controllers/SecretsManagerEventsControllerTests.cs create mode 100644 test/Api.Test/SecretsManager/Controllers/SecretsManagerEventsControllerTests.cs create mode 100644 util/Migrator/DbScripts/2023-10-09_00_Event_ReadPageByOrganizationIdServiceAccountId.sql diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/ServiceAccounts/ServiceAccountAuthorizationHandler.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/ServiceAccounts/ServiceAccountAuthorizationHandler.cs index 2c4e78d10..e1a4405f1 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/ServiceAccounts/ServiceAccountAuthorizationHandler.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/ServiceAccounts/ServiceAccountAuthorizationHandler.cs @@ -56,6 +56,9 @@ public class case not null when requirement == ServiceAccountOperations.RevokeAccessTokens: await CanRevokeAccessTokensAsync(context, requirement, resource); break; + case not null when requirement == ServiceAccountOperations.ReadEvents: + await CanReadEventsAsync(context, requirement, resource); + break; default: throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement)); @@ -169,4 +172,19 @@ public class context.Succeed(requirement); } } + + private async Task CanReadEventsAsync(AuthorizationHandlerContext context, + ServiceAccountOperationRequirement requirement, ServiceAccount resource) + { + var (accessClient, userId) = + await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId); + var access = + await _serviceAccountRepository.AccessToServiceAccountAsync(resource.Id, userId, + accessClient); + + if (access.Read) + { + context.Succeed(requirement); + } + } } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/ServiceAccounts/ServiceAccountAuthorizationHandlerTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/ServiceAccounts/ServiceAccountAuthorizationHandlerTests.cs index b7804a700..eec69095d 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/ServiceAccounts/ServiceAccountAuthorizationHandlerTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/ServiceAccounts/ServiceAccountAuthorizationHandlerTests.cs @@ -497,4 +497,63 @@ public class ServiceAccountAuthorizationHandlerTests Assert.Equal(expected, authzContext.HasSucceeded); } + + [Theory] + [BitAutoData] + public async Task CanReadEvents_AccessToSecretsManagerFalse_DoesNotSucceed( + SutProvider sutProvider, ServiceAccount serviceAccount, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ServiceAccountOperations.ReadEvents; + sutProvider.GetDependency().AccessSecretsManager(serviceAccount.OrganizationId) + .Returns(false); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, serviceAccount); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData] + public async Task CanReadEvents_NullResource_DoesNotSucceed( + SutProvider sutProvider, ServiceAccount serviceAccount, + ClaimsPrincipal claimsPrincipal, + Guid userId) + { + var requirement = ServiceAccountOperations.ReadEvents; + SetupPermission(sutProvider, PermissionType.RunAsAdmin, serviceAccount.OrganizationId, userId); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, null); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData(PermissionType.RunAsAdmin, true, true, true)] + [BitAutoData(PermissionType.RunAsUserWithPermission, false, false, false)] + [BitAutoData(PermissionType.RunAsUserWithPermission, false, true, false)] + [BitAutoData(PermissionType.RunAsUserWithPermission, true, false, true)] + [BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true)] + public async Task CanReadEvents_AccessCheck(PermissionType permissionType, bool read, bool write, + bool expected, + SutProvider sutProvider, ServiceAccount serviceAccount, + ClaimsPrincipal claimsPrincipal, + Guid userId) + { + var requirement = ServiceAccountOperations.ReadEvents; + SetupPermission(sutProvider, permissionType, serviceAccount.OrganizationId, userId); + sutProvider.GetDependency() + .AccessToServiceAccountAsync(serviceAccount.Id, userId, Arg.Any()) + .Returns((read, write)); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, serviceAccount); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.Equal(expected, authzContext.HasSucceeded); + } } diff --git a/src/Api/Controllers/EventsController.cs b/src/Api/Controllers/EventsController.cs index 585568db6..4cf3b83dc 100644 --- a/src/Api/Controllers/EventsController.cs +++ b/src/Api/Controllers/EventsController.cs @@ -1,4 +1,5 @@ using Bit.Api.Models.Response; +using Bit.Api.Utilities; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Data; @@ -41,7 +42,7 @@ public class EventsController : Controller public async Task> GetUser( [FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null) { - var dateRange = GetDateRange(start, end); + var dateRange = ApiHelpers.GetDateRange(start, end); var userId = _userService.GetProperUserId(User).Value; var result = await _eventRepository.GetManyByUserAsync(userId, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = continuationToken }); @@ -75,7 +76,7 @@ public class EventsController : Controller throw new NotFoundException(); } - var dateRange = GetDateRange(start, end); + var dateRange = ApiHelpers.GetDateRange(start, end); var result = await _eventRepository.GetManyByCipherAsync(cipher, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = continuationToken }); var responses = result.Data.Select(e => new EventResponseModel(e)); @@ -92,7 +93,7 @@ public class EventsController : Controller throw new NotFoundException(); } - var dateRange = GetDateRange(start, end); + var dateRange = ApiHelpers.GetDateRange(start, end); var result = await _eventRepository.GetManyByOrganizationAsync(orgId, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = continuationToken }); var responses = result.Data.Select(e => new EventResponseModel(e)); @@ -110,7 +111,7 @@ public class EventsController : Controller throw new NotFoundException(); } - var dateRange = GetDateRange(start, end); + var dateRange = ApiHelpers.GetDateRange(start, end); var result = await _eventRepository.GetManyByOrganizationActingUserAsync(organizationUser.OrganizationId, organizationUser.UserId.Value, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = continuationToken }); @@ -127,7 +128,7 @@ public class EventsController : Controller throw new NotFoundException(); } - var dateRange = GetDateRange(start, end); + var dateRange = ApiHelpers.GetDateRange(start, end); var result = await _eventRepository.GetManyByProviderAsync(providerId, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = continuationToken }); var responses = result.Data.Select(e => new EventResponseModel(e)); @@ -145,33 +146,11 @@ public class EventsController : Controller throw new NotFoundException(); } - var dateRange = GetDateRange(start, end); + var dateRange = ApiHelpers.GetDateRange(start, end); var result = await _eventRepository.GetManyByProviderActingUserAsync(providerUser.ProviderId, providerUser.UserId.Value, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = continuationToken }); var responses = result.Data.Select(e => new EventResponseModel(e)); return new ListResponseModel(responses, result.ContinuationToken); } - - private Tuple GetDateRange(DateTime? start, DateTime? end) - { - if (!end.HasValue || !start.HasValue) - { - end = DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1); - start = DateTime.UtcNow.Date.AddDays(-30); - } - else if (start.Value > end.Value) - { - var newEnd = start; - start = end; - end = newEnd; - } - - if ((end.Value - start.Value) > TimeSpan.FromDays(367)) - { - throw new BadRequestException("Range too large."); - } - - return new Tuple(start.Value, end.Value); - } } diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs new file mode 100644 index 000000000..91d350b68 --- /dev/null +++ b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs @@ -0,0 +1,52 @@ +using Bit.Api.Models.Response; +using Bit.Api.Utilities; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.AuthorizationRequirements; +using Bit.Core.SecretsManager.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.SecretsManager.Controllers; + +[Authorize("secrets")] +public class SecretsManagerEventsController : Controller +{ + private readonly IAuthorizationService _authorizationService; + private readonly IEventRepository _eventRepository; + private readonly IServiceAccountRepository _serviceAccountRepository; + + public SecretsManagerEventsController( + IEventRepository eventRepository, + IServiceAccountRepository serviceAccountRepository, + IAuthorizationService authorizationService) + { + _authorizationService = authorizationService; + _serviceAccountRepository = serviceAccountRepository; + _eventRepository = eventRepository; + } + + [HttpGet("sm/events/service-accounts/{serviceAccountId}")] + public async Task> GetServiceAccountEventsAsync(Guid serviceAccountId, + [FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, + [FromQuery] string continuationToken = null) + { + var serviceAccount = await _serviceAccountRepository.GetByIdAsync(serviceAccountId); + var authorizationResult = + await _authorizationService.AuthorizeAsync(User, serviceAccount, ServiceAccountOperations.ReadEvents); + + if (!authorizationResult.Succeeded) + { + throw new NotFoundException(); + } + + var dateRange = ApiHelpers.GetDateRange(start, end); + + var result = await _eventRepository.GetManyByOrganizationServiceAccountAsync(serviceAccount.OrganizationId, + serviceAccount.Id, dateRange.Item1, dateRange.Item2, + new PageOptions { ContinuationToken = continuationToken }); + var responses = result.Data.Select(e => new EventResponseModel(e)); + return new ListResponseModel(responses, result.ContinuationToken); + } +} diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index 58097089f..f4f1830e1 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Azure.Messaging.EventGrid; using Azure.Messaging.EventGrid.SystemEvents; +using Bit.Core.Exceptions; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; @@ -69,4 +70,35 @@ public static class ApiHelpers return new OkObjectResult(response); } + + /// + /// Validates and returns a date range. Currently used for fetching events. + /// + /// start date and time + /// end date and time + /// + /// If start or end are null, will return a range of the last 30 days. + /// If a time span greater than 367 days is passed will throw BadRequestException. + /// + public static Tuple GetDateRange(DateTime? start, DateTime? end) + { + if (!end.HasValue || !start.HasValue) + { + end = DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1); + start = DateTime.UtcNow.Date.AddDays(-30); + } + else if (start.Value > end.Value) + { + var newEnd = start; + start = end; + end = newEnd; + } + + if ((end.Value - start.Value) > TimeSpan.FromDays(367)) + { + throw new BadRequestException("Range too large."); + } + + return new Tuple(start.Value, end.Value); + } } diff --git a/src/Core/Repositories/IEventRepository.cs b/src/Core/Repositories/IEventRepository.cs index 493c8c787..dda9b589c 100644 --- a/src/Core/Repositories/IEventRepository.cs +++ b/src/Core/Repositories/IEventRepository.cs @@ -19,4 +19,6 @@ public interface IEventRepository PageOptions pageOptions); Task CreateAsync(IEvent e); Task CreateManyAsync(IEnumerable e); + Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, Guid serviceAccountId, + DateTime startDate, DateTime endDate, PageOptions pageOptions); } diff --git a/src/Core/Repositories/TableStorage/EventRepository.cs b/src/Core/Repositories/TableStorage/EventRepository.cs index 3822ce35d..704485003 100644 --- a/src/Core/Repositories/TableStorage/EventRepository.cs +++ b/src/Core/Repositories/TableStorage/EventRepository.cs @@ -61,6 +61,14 @@ public class EventRepository : IEventRepository return await GetManyAsync(partitionKey, $"CipherId={cipher.Id}__Date={{0}}", startDate, endDate, pageOptions); } + public async Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, + Guid serviceAccountId, DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + + return await GetManyAsync($"OrganizationId={organizationId}", + $"ServiceAccountId={serviceAccountId}__Date={{0}}", startDate, endDate, pageOptions); + } + public async Task CreateAsync(IEvent e) { if (!(e is EventTableEntity entity)) diff --git a/src/Core/SecretsManager/AuthorizationRequirements/ServiceAccountOperationRequirement.cs b/src/Core/SecretsManager/AuthorizationRequirements/ServiceAccountOperationRequirement.cs index 23f312d0b..df09cc710 100644 --- a/src/Core/SecretsManager/AuthorizationRequirements/ServiceAccountOperationRequirement.cs +++ b/src/Core/SecretsManager/AuthorizationRequirements/ServiceAccountOperationRequirement.cs @@ -15,4 +15,5 @@ public static class ServiceAccountOperations public static readonly ServiceAccountOperationRequirement ReadAccessTokens = new() { Name = nameof(ReadAccessTokens) }; public static readonly ServiceAccountOperationRequirement CreateAccessToken = new() { Name = nameof(CreateAccessToken) }; public static readonly ServiceAccountOperationRequirement RevokeAccessTokens = new() { Name = nameof(RevokeAccessTokens) }; + public static readonly ServiceAccountOperationRequirement ReadEvents = new() { Name = nameof(ReadEvents) }; } diff --git a/src/Infrastructure.Dapper/Repositories/EventRepository.cs b/src/Infrastructure.Dapper/Repositories/EventRepository.cs index 1c5b805c4..b41687daa 100644 --- a/src/Infrastructure.Dapper/Repositories/EventRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/EventRepository.cs @@ -118,6 +118,18 @@ public class EventRepository : Repository, IEventRepository } } + public async Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, Guid serviceAccountId, + DateTime startDate, DateTime endDate, + PageOptions pageOptions) + { + return await GetManyAsync($"[{Schema}].[Event_ReadPageByOrganizationIdServiceAccountId]", + new Dictionary + { + ["@OrganizationId"] = organizationId, + ["@ServiceAccountId"] = serviceAccountId + }, startDate, endDate, pageOptions); + } + private async Task> GetManyAsync(string sprocName, IDictionary sprocParams, DateTime startDate, DateTime endDate, PageOptions pageOptions) { @@ -187,6 +199,10 @@ public class EventRepository : Repository, IEventRepository eventsTable.Columns.Add(ipAddressColumn); var dateColumn = new DataColumn(nameof(e.Date), typeof(DateTime)); eventsTable.Columns.Add(dateColumn); + var secretIdColumn = new DataColumn(nameof(e.SecretId), typeof(Guid)); + eventsTable.Columns.Add(secretIdColumn); + var serviceAccountIdColumn = new DataColumn(nameof(e.ServiceAccountId), typeof(Guid)); + eventsTable.Columns.Add(serviceAccountIdColumn); foreach (DataColumn col in eventsTable.Columns) { @@ -217,6 +233,8 @@ public class EventRepository : Repository, IEventRepository row[deviceTypeColumn] = ev.DeviceType.HasValue ? (object)ev.DeviceType.Value : DBNull.Value; row[ipAddressColumn] = ev.IpAddress != null ? (object)ev.IpAddress : DBNull.Value; row[dateColumn] = ev.Date; + row[secretIdColumn] = ev.SecretId.HasValue ? ev.SecretId.Value : DBNull.Value; + row[serviceAccountIdColumn] = ev.ServiceAccountId.HasValue ? ev.ServiceAccountId.Value : DBNull.Value; eventsTable.Rows.Add(row); } diff --git a/src/Infrastructure.EntityFramework/Repositories/EventRepository.cs b/src/Infrastructure.EntityFramework/Repositories/EventRepository.cs index a2e56b370..3e3ebb21a 100644 --- a/src/Infrastructure.EntityFramework/Repositories/EventRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/EventRepository.cs @@ -49,6 +49,32 @@ public class EventRepository : Repository, IEv } } + public async Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, Guid serviceAccountId, + DateTime startDate, DateTime endDate, + PageOptions pageOptions) + { + DateTime? beforeDate = null; + if (!string.IsNullOrWhiteSpace(pageOptions.ContinuationToken) && + long.TryParse(pageOptions.ContinuationToken, out var binaryDate)) + { + beforeDate = DateTime.SpecifyKind(DateTime.FromBinary(binaryDate), DateTimeKind.Utc); + } + + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var query = new EventReadPageByOrganizationIdServiceAccountIdQuery(organizationId, serviceAccountId, + startDate, endDate, beforeDate, pageOptions); + var events = await query.Run(dbContext).ToListAsync(); + + var result = new PagedResult(); + if (events.Any() && events.Count >= pageOptions.PageSize) + { + result.ContinuationToken = events.Last().Date.ToBinary().ToString(); + } + result.Data.AddRange(events); + return result; + } + public async Task> GetManyByCipherAsync(Cipher cipher, DateTime startDate, DateTime endDate, PageOptions pageOptions) { DateTime? beforeDate = null; diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs new file mode 100644 index 000000000..01f3a1fe1 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs @@ -0,0 +1,38 @@ +using Bit.Core.Models.Data; +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class EventReadPageByOrganizationIdServiceAccountIdQuery : IQuery +{ + private readonly Guid _organizationId; + private readonly Guid _serviceAccountId; + private readonly DateTime _startDate; + private readonly DateTime _endDate; + private readonly DateTime? _beforeDate; + private readonly PageOptions _pageOptions; + + public EventReadPageByOrganizationIdServiceAccountIdQuery(Guid organizationId, Guid serviceAccountId, + DateTime startDate, DateTime endDate, DateTime? beforeDate, PageOptions pageOptions) + { + _organizationId = organizationId; + _serviceAccountId = serviceAccountId; + _startDate = startDate; + _endDate = endDate; + _beforeDate = beforeDate; + _pageOptions = pageOptions; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var q = from e in dbContext.Events + where e.Date >= _startDate && + (_beforeDate != null || e.Date <= _endDate) && + (_beforeDate == null || e.Date < _beforeDate.Value) && + e.OrganizationId == _organizationId && + e.ServiceAccountId == _serviceAccountId + orderby e.Date descending + select e; + return q.Skip(0).Take(_pageOptions.PageSize); + } +} diff --git a/src/Sql/SecretsManager/dbo/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql b/src/Sql/SecretsManager/dbo/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql new file mode 100644 index 000000000..5dc950fff --- /dev/null +++ b/src/Sql/SecretsManager/dbo/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql @@ -0,0 +1,25 @@ +CREATE PROCEDURE [dbo].[Event_ReadPageByOrganizationIdServiceAccountId] + @OrganizationId UNIQUEIDENTIFIER, + @ServiceAccountId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EventView] + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [OrganizationId] = @OrganizationId + AND [ServiceAccountId] = @ServiceAccountId + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY +END diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsManagerEventsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsManagerEventsControllerTests.cs new file mode 100644 index 000000000..4c053c3a2 --- /dev/null +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsManagerEventsControllerTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Http.Headers; +using Bit.Api.IntegrationTest.Factories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Xunit; + +namespace Bit.Api.IntegrationTest.SecretsManager.Controllers; + +public class SecretsManagerEventsControllerTests : IClassFixture, IAsyncLifetime +{ + private const string _mockEncryptedString = + "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; + + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + + private readonly IServiceAccountRepository _serviceAccountRepository; + + private string _email = null!; + private SecretsManagerOrganizationHelper _organizationHelper = null!; + + public SecretsManagerEventsControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + _serviceAccountRepository = _factory.GetService(); + } + + public async Task InitializeAsync() + { + _email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_email); + _organizationHelper = new SecretsManagerOrganizationHelper(_factory, _email); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + private async Task LoginAsync(string email) + { + var tokens = await _factory.LoginAsync(email); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + public async Task GetServiceAccountEvents_SmNotEnabled_NotFound(bool useSecrets, bool accessSecrets, bool organizationEnabled) + { + var (org, _) = await _organizationHelper.Initialize(useSecrets, accessSecrets, organizationEnabled); + await LoginAsync(_email); + + var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount + { + OrganizationId = org.Id, + Name = _mockEncryptedString + }); + + var response = await _client.GetAsync($"/sm/events/service-accounts/{serviceAccount.Id}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/test/Api.Test/SecretsManager/Controllers/SecretsManagerEventsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/SecretsManagerEventsControllerTests.cs new file mode 100644 index 000000000..63eecf148 --- /dev/null +++ b/test/Api.Test/SecretsManager/Controllers/SecretsManagerEventsControllerTests.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using Bit.Api.SecretsManager.Controllers; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.SecretsManager.Controllers; + +[ControllerCustomize(typeof(SecretsManagerEventsController))] +[SutProviderCustomize] +[JsonDocumentCustomize] +public class SecretsManagerEventsControllerTests +{ + [Theory] + [BitAutoData] + public async void GetServiceAccountEvents_NoAccess_Throws(SutProvider sutProvider, + ServiceAccount data) + { + sutProvider.GetDependency().GetByIdAsync(default).ReturnsForAnyArgs(data); + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), data, + Arg.Any>()).ReturnsForAnyArgs(AuthorizationResult.Failed()); + + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetServiceAccountEventsAsync(data.Id)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyByOrganizationServiceAccountAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async void GetServiceAccountEvents_DateRangeOver_Throws( + SutProvider sutProvider, + ServiceAccount data) + { + sutProvider.GetDependency().GetByIdAsync(default).ReturnsForAnyArgs(data); + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), data, + Arg.Any>()).ReturnsForAnyArgs(AuthorizationResult.Success()); + + var start = DateTime.UtcNow.AddYears(-1); + var end = DateTime.UtcNow.AddYears(1); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetServiceAccountEventsAsync(data.Id, start, end)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyByOrganizationServiceAccountAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async void GetServiceAccountEvents_Success(SutProvider sutProvider, + ServiceAccount data) + { + sutProvider.GetDependency().GetByIdAsync(default).ReturnsForAnyArgs(data); + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), data, + Arg.Any>()).ReturnsForAnyArgs(AuthorizationResult.Success()); + sutProvider.GetDependency() + .GetManyByOrganizationServiceAccountAsync(default, default, default, default, default) + .ReturnsForAnyArgs(new PagedResult()); + + await sutProvider.Sut.GetServiceAccountEventsAsync(data.Id); + + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationServiceAccountAsync(data.OrganizationId, data.Id, Arg.Any(), + Arg.Any(), Arg.Any()); + } +} diff --git a/util/Migrator/DbScripts/2023-10-09_00_Event_ReadPageByOrganizationIdServiceAccountId.sql b/util/Migrator/DbScripts/2023-10-09_00_Event_ReadPageByOrganizationIdServiceAccountId.sql new file mode 100644 index 000000000..334ebc6ca --- /dev/null +++ b/util/Migrator/DbScripts/2023-10-09_00_Event_ReadPageByOrganizationIdServiceAccountId.sql @@ -0,0 +1,25 @@ +CREATE OR ALTER PROCEDURE [dbo].[Event_ReadPageByOrganizationIdServiceAccountId] + @OrganizationId UNIQUEIDENTIFIER, + @ServiceAccountId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EventView] + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [OrganizationId] = @OrganizationId + AND [ServiceAccountId] = @ServiceAccountId + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY +END