1
0
mirror of https://github.com/bitwarden/server synced 2026-02-22 04:13:43 +00:00

[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
This commit is contained in:
Thomas Avery
2023-10-19 16:57:14 -05:00
committed by GitHub
parent c1cf07d764
commit 728cd1c0b5
15 changed files with 461 additions and 28 deletions

View File

@@ -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<ListResponseModel<EventResponseModel>> 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<EventResponseModel>(responses, result.ContinuationToken);
}
private Tuple<DateTime, DateTime> 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<DateTime, DateTime>(start.Value, end.Value);
}
}

View File

@@ -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<ListResponseModel<EventResponseModel>> 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<EventResponseModel>(responses, result.ContinuationToken);
}
}

View File

@@ -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);
}
/// <summary>
/// Validates and returns a date range. Currently used for fetching events.
/// </summary>
/// <param name="start">start date and time</param>
/// <param name="end">end date and time</param>
/// <remarks>
/// 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.
/// </remarks>
public static Tuple<DateTime, DateTime> 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<DateTime, DateTime>(start.Value, end.Value);
}
}

View File

@@ -19,4 +19,6 @@ public interface IEventRepository
PageOptions pageOptions);
Task CreateAsync(IEvent e);
Task CreateManyAsync(IEnumerable<IEvent> e);
Task<PagedResult<IEvent>> GetManyByOrganizationServiceAccountAsync(Guid organizationId, Guid serviceAccountId,
DateTime startDate, DateTime endDate, PageOptions pageOptions);
}

View File

@@ -61,6 +61,14 @@ public class EventRepository : IEventRepository
return await GetManyAsync(partitionKey, $"CipherId={cipher.Id}__Date={{0}}", startDate, endDate, pageOptions);
}
public async Task<PagedResult<IEvent>> 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))

View File

@@ -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) };
}

View File

@@ -118,6 +118,18 @@ public class EventRepository : Repository<Event, Guid>, IEventRepository
}
}
public async Task<PagedResult<IEvent>> GetManyByOrganizationServiceAccountAsync(Guid organizationId, Guid serviceAccountId,
DateTime startDate, DateTime endDate,
PageOptions pageOptions)
{
return await GetManyAsync($"[{Schema}].[Event_ReadPageByOrganizationIdServiceAccountId]",
new Dictionary<string, object>
{
["@OrganizationId"] = organizationId,
["@ServiceAccountId"] = serviceAccountId
}, startDate, endDate, pageOptions);
}
private async Task<PagedResult<IEvent>> GetManyAsync(string sprocName,
IDictionary<string, object> sprocParams, DateTime startDate, DateTime endDate, PageOptions pageOptions)
{
@@ -187,6 +199,10 @@ public class EventRepository : Repository<Event, Guid>, 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<Event, Guid>, 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);
}

View File

@@ -49,6 +49,32 @@ public class EventRepository : Repository<Core.Entities.Event, Event, Guid>, IEv
}
}
public async Task<PagedResult<IEvent>> 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<IEvent>();
if (events.Any() && events.Count >= pageOptions.PageSize)
{
result.ContinuationToken = events.Last().Date.ToBinary().ToString();
}
result.Data.AddRange(events);
return result;
}
public async Task<PagedResult<IEvent>> GetManyByCipherAsync(Cipher cipher, DateTime startDate, DateTime endDate, PageOptions pageOptions)
{
DateTime? beforeDate = null;

View File

@@ -0,0 +1,38 @@
using Bit.Core.Models.Data;
using Bit.Infrastructure.EntityFramework.Models;
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
public class EventReadPageByOrganizationIdServiceAccountIdQuery : IQuery<Event>
{
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<Event> 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);
}
}

View File

@@ -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