Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ public sealed class GetExceptionAuditsQuery : IQuery<IReadOnlyList<AuditSummaryD
public DateTime? FromUtc { get; init; }

public DateTime? ToUtc { get; init; }

public int? Skip { get; init; }

public int? Take { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ public sealed class GetSecurityAuditsQuery : IQuery<IReadOnlyList<AuditSummaryDt
public DateTime? FromUtc { get; init; }

public DateTime? ToUtc { get; init; }

public int? Skip { get; init; }

public int? Take { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits;

public sealed class GetExceptionAuditsQueryHandler : IQueryHandler<GetExceptionAuditsQuery, IReadOnlyList<AuditSummaryDto>>
{
private const int MaxPageSize = 200;
private const int DefaultPageSize = 50;

private readonly AuditDbContext _dbContext;

public GetExceptionAuditsQueryHandler(AuditDbContext dbContext)
Expand All @@ -26,7 +29,11 @@ public async ValueTask<IReadOnlyList<AuditSummaryDto>> Handle(GetExceptionAudits
audits = ApplySeverityFilter(audits, query);
audits = ApplyPayloadFilters(audits, query);

return await ProjectToDto(audits, cancellationToken);
// Cap server-side so an unpaged call can't materialize a tenant's whole exception history.
var take = query.Take is >= 1 and <= MaxPageSize ? query.Take.Value : DefaultPageSize;
var skip = query.Skip is > 0 ? query.Skip.Value : 0;

return await ProjectToDto(audits, skip, take, cancellationToken);
}

private IQueryable<AuditRecord> GetBaseQuery()
Expand Down Expand Up @@ -87,10 +94,12 @@ private static IQueryable<AuditRecord> ApplyPayloadFilters(IQueryable<AuditRecor
return audits;
}

private static async Task<IReadOnlyList<AuditSummaryDto>> ProjectToDto(IQueryable<AuditRecord> audits, CancellationToken cancellationToken)
private static async Task<IReadOnlyList<AuditSummaryDto>> ProjectToDto(IQueryable<AuditRecord> audits, int skip, int take, CancellationToken cancellationToken)
{
return await audits
.OrderByDescending(a => a.OccurredAtUtc)
.Skip(skip)
.Take(take)
.Select(a => new AuditSummaryDto
{
Id = a.Id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ namespace FSH.Modules.Auditing.Features.v1.GetSecurityAudits;

public sealed class GetSecurityAuditsQueryHandler : IQueryHandler<GetSecurityAuditsQuery, IReadOnlyList<AuditSummaryDto>>
{
private const int MaxPageSize = 200;
private const int DefaultPageSize = 50;

private readonly AuditDbContext _dbContext;

public GetSecurityAuditsQueryHandler(AuditDbContext dbContext)
Expand Down Expand Up @@ -49,8 +52,14 @@ public async ValueTask<IReadOnlyList<AuditSummaryDto>> Handle(GetSecurityAuditsQ
EF.Functions.ILike(AsText(a.PayloadJson), $"%\"action\": \"{actionValue}\"%"));
}

// Cap server-side so an unpaged call can't materialize a tenant's whole audit history.
var take = query.Take is >= 1 and <= MaxPageSize ? query.Take.Value : DefaultPageSize;
var skip = query.Skip is > 0 ? query.Skip.Value : 0;

var list = await audits
.OrderByDescending(a => a.OccurredAtUtc)
.Skip(skip)
.Take(take)
.Select(a => new AuditSummaryDto
{
Id = a.Id,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Auditing;
using FSH.Modules.Auditing.Contracts;
using FSH.Modules.Auditing.Contracts.Dtos;
using FSH.Modules.Auditing.Persistence;
using Integration.Tests.Infrastructure;
using Integration.Tests.Infrastructure.Extensions;

namespace Integration.Tests.Tests.Auditing;

/// <summary>
/// Runtime repro for audit finding DATA-01 (unbounded audit list). The security-audit list handler
/// does ToListAsync with no Skip/Take/cap and the validator does not require a time window, so an
/// unpaged call materializes the whole matching set. A bounded API would cap the page size.
/// </summary>
[Collection(FshCollectionDefinition.Name)]
public sealed class SecurityAuditListBoundsTests
{
private const int SeededRows = 500;

// Analogous to the 200-row server cap that SessionService.GetTenantSessionsAsync already applies
// "so an over-eager client can't pull a tenant's full session table in one round-trip".
private const int ExpectedServerCap = 200;

private readonly FshWebApplicationFactory _factory;
private readonly AuthHelper _auth;

public SecurityAuditListBoundsTests(FshWebApplicationFactory factory)
{
_factory = factory;
_auth = new AuthHelper(factory);
}

[Fact]
public async Task GetSecurityAudits_Should_CapResults_When_NoPagingOrWindowProvided()
{
await SeedSecurityAuditsAsync(SeededRows);

using var client = await _auth.CreateRootAdminClientAsync();
using var response = await client.GetAsync($"{TestConstants.AuditsBasePath}/security");
response.StatusCode.ShouldBe(HttpStatusCode.OK);

var list = await response.DeserializeAsync<IReadOnlyList<AuditSummaryDto>>();

list.Count.ShouldBeLessThanOrEqualTo(
ExpectedServerCap,
$"an unpaged security-audit list must cap results; seeded {SeededRows} rows and the endpoint " +
$"returned {list.Count} with no Skip/Take/default page size.");
}

private async Task SeedSecurityAuditsAsync(int count)
{
using var scope = _factory.Services.CreateScope();

// Tenant context is AsyncLocal — set inline in the same method as the DbContext call.
var tenantStore = scope.ServiceProvider.GetRequiredService<IMultiTenantStore<AppTenantInfo>>();
var tenant = await tenantStore.GetAsync(TestConstants.RootTenantId);
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>().MultiTenantContext =
new MultiTenantContext<AppTenantInfo>(tenant);

var db = scope.ServiceProvider.GetRequiredService<AuditDbContext>();
var now = DateTime.UtcNow;
var rows = new List<AuditRecord>(count);
for (int i = 0; i < count; i++)
{
rows.Add(new AuditRecord
{
Id = Guid.NewGuid(),
OccurredAtUtc = now.AddSeconds(-i),
ReceivedAtUtc = now,
EventType = (int)AuditEventType.Security,
Severity = (byte)AuditSeverity.Information,
TenantId = TestConstants.RootTenantId,
UserId = "data01-seed-user",
UserName = "data01-seed",
Source = "DATA-01-test",
Tags = 0,
PayloadJson = "{}",
});
}

db.AuditRecords.AddRange(rows);
await db.SaveChangesAsync();
}
}
Loading