Skip to content
Open
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
14 changes: 8 additions & 6 deletions src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using FluentValidation;
using FSH.Framework.Core.Localization;

Check failure on line 2 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / analyze

The type or namespace name 'Localization' does not exist in the namespace 'FSH.Framework.Core' (are you missing an assembly reference?)

Check failure on line 2 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / analyze

The type or namespace name 'Localization' does not exist in the namespace 'FSH.Framework.Core' (are you missing an assembly reference?)

Check failure on line 2 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / DbMigrator Container Smoke

The type or namespace name 'Localization' does not exist in the namespace 'FSH.Framework.Core' (are you missing an assembly reference?)

Check failure on line 2 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

The type or namespace name 'Localization' does not exist in the namespace 'FSH.Framework.Core' (are you missing an assembly reference?)

Check failure on line 2 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

The type or namespace name 'Localization' does not exist in the namespace 'FSH.Framework.Core' (are you missing an assembly reference?)

Check failure on line 2 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / Integration Tests

The type or namespace name 'Localization' does not exist in the namespace 'FSH.Framework.Core' (are you missing an assembly reference?)
using FSH.Framework.Shared.Persistence;
using Microsoft.Extensions.Localization;

namespace FSH.Framework.Web.Validation;

Expand All @@ -10,31 +12,31 @@
/// <example>
/// public class MyQueryValidator : AbstractValidator&lt;MyQuery&gt;
/// {
/// public MyQueryValidator()
/// public MyQueryValidator(IStringLocalizer&lt;SharedResources&gt; localizer)
/// {
/// Include(new PagedQueryValidator&lt;MyQuery&gt;());
/// Include(new PagedQueryValidator&lt;MyQuery&gt;(localizer));
/// // Add additional rules...
/// }
/// }
/// </example>
public sealed class PagedQueryValidator<T> : AbstractValidator<T>
where T : IPagedQuery
{
public PagedQueryValidator()
public PagedQueryValidator(IStringLocalizer<SharedResources> localizer)

Check failure on line 25 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / analyze

The type or namespace name 'SharedResources' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 25 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / analyze

The type or namespace name 'SharedResources' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 25 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / DbMigrator Container Smoke

The type or namespace name 'SharedResources' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 25 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

The type or namespace name 'SharedResources' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 25 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

The type or namespace name 'SharedResources' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 25 in src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs

View workflow job for this annotation

GitHub Actions / Integration Tests

The type or namespace name 'SharedResources' could not be found (are you missing a using directive or an assembly reference?)
{
RuleFor(q => q.PageNumber)
.GreaterThan(0)
.When(q => q.PageNumber.HasValue)
.WithMessage("Page number must be greater than 0.");
.WithMessage(_ => localizer["Validation.PageNumberMinimum"]);

RuleFor(q => q.PageSize)
.InclusiveBetween(1, 100)
.When(q => q.PageSize.HasValue)
.WithMessage("Page size must be between 1 and 100.");
.WithMessage(_ => localizer["Validation.PageSizeRange"]);

RuleFor(q => q.Sort)
.MaximumLength(200)
.When(q => !string.IsNullOrEmpty(q.Sort))
.WithMessage("Sort expression must not exceed 200 characters.");
.WithMessage(_ => localizer["Validation.SortMaxLength"]);
}
}
7 changes: 7 additions & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,12 @@
AccessViolation). Transitive pinning is enabled, so this entry alone bumps it.
Remove once the SignalR backplane package depends on a patched version itself. -->
<PackageVersion Include="MessagePack" Version="2.5.301" />
<!-- Pulled transitively by the Testcontainers packages; versions up to 2025.1.0 fail
NuGet audit (NU1903, GHSA-q939-rpr3-3284 / CVE-2026-48798: ScpClient recursive
download writes outside the target directory), which breaks restore for the whole
solution under TreatWarningsAsErrors. Testcontainers 4.11.0 and 4.13.0 both depend
on 2025.1.0, so bumping Testcontainers does not help; 2026.0.0 is the first patched
release. Remove once Testcontainers depends on a patched version itself. -->
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions src/FSH.Starter.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<Project Path="Tests/Integration.Tests/Integration.Tests.csproj" />
<Project Path="Tests/Integration.Middleware.Tests/Integration.Middleware.Tests.csproj" />
<Project Path="Tests/Multitenancy.Tests/Multitenancy.Tests.csproj" Id="985345a2-edb4-4ef9-9a1b-59b704f523b6" />
<Project Path="Tests/Tickets.Tests/Tickets.Tests.csproj" />
</Folder>
<!--#if (includeTools) -->
<Folder Name="/Tools/">
Expand Down
11 changes: 10 additions & 1 deletion src/Modules/Auditing/Modules.Auditing/Core/Audit.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FSH.Framework.Core.Exceptions;
using FSH.Modules.Auditing.Contracts;
using System.Diagnostics;

Expand Down Expand Up @@ -56,13 +57,21 @@ public static Builder ForException(Exception ex, ExceptionArea area = ExceptionA
eventType: AuditEventType.Exception,
severity: severity ?? DefaultSeverity(ex),
payload: new ExceptionEventPayload(area,
ex.GetType().FullName ?? "Exception",
RealExceptionType(ex).FullName ?? "Exception",
ex.Message ?? string.Empty,
StackTop(ex, maxFrames: 20),
ToDict(ex.Data),
routeOrLocation));
}

// Localization wrappers (LocalizedKeyNotFoundException, LocalizedUnauthorizedAccessException) exist
// only to translate the response body; for audit type identity and exceptionType filtering they must
// present as their BCL base so queries stay stable. CustomException-derived types keep their own identity.
private static Type RealExceptionType(Exception ex) =>
ex is ILocalizableMessage and not CustomException && ex.GetType().BaseType is { } baseType
? baseType
: ex.GetType();

private static AuditSeverity DefaultSeverity(Exception ex)
{
if (ex is OperationCanceledException)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using FSH.Framework.Core.Exceptions;
using FSH.Modules.Auditing.Contracts;
using FSH.Modules.Auditing.Contracts.Dtos;
using FSH.Modules.Auditing.Localization;
using FSH.Modules.Auditing.Contracts.v1.GetAuditById;
using FSH.Modules.Auditing.Persistence;
using Mediator;
Expand Down Expand Up @@ -31,9 +33,15 @@ public async ValueTask<AuditDetailDto> Handle(GetAuditByIdQuery query, Cancellat

if (record is null)
{
// KeyNotFoundException maps to 404 globally. Kept (not framework NotFoundException)
// because audit exception-type fixtures and severity classification key off this type.
throw new KeyNotFoundException($"Audit record {query.Id} not found.");
// LocalizedKeyNotFoundException maps to 404 globally and still keys off KeyNotFoundException
// (its base) for audit exception-type fixtures and severity classification, while the body
// localizes via MessageKey under the request culture.
throw new LocalizedKeyNotFoundException($"Audit record {query.Id} not found.")
{
MessageKey = "Auditing.AuditRecordNotFound",
MessageArgs = [query.Id],
ResourceSource = typeof(AuditingResources),
};
}

JsonElement payload;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using FSH.Modules.Auditing.Contracts.Authorization;
using FSH.Modules.Auditing.Contracts.Dtos;
using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary;
using FSH.Modules.Auditing.Localization;
using FSH.Modules.Auditing.Persistence;
using FSH.Modules.Identity.Contracts.Services;
using Mediator;
Expand All @@ -13,7 +14,8 @@ namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary;

public sealed class GetAuditSummaryQueryHandler : IQueryHandler<GetAuditSummaryQuery, AuditSummaryAggregateDto>
{
public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(90);
public const int MaxWindowDays = 90;
public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(MaxWindowDays);
public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(7);

private readonly AuditDbContext _dbContext;
Expand Down Expand Up @@ -104,7 +106,11 @@ requested is not null
.ConfigureAwait(false);
if (!allowed)
{
throw new ForbiddenException("Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant.");
throw new ForbiddenException("Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant.")
{
MessageKey = "Error.Auditing.CrossTenantSummaryForbidden",
ResourceSource = typeof(AuditingResources),
};
}

return _dbContext.AuditRecords
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
using FluentValidation;
using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary;
using FSH.Modules.Auditing.Localization;
using Microsoft.Extensions.Localization;

namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary;

public sealed class GetAuditSummaryQueryValidator : AbstractValidator<GetAuditSummaryQuery>
{
public GetAuditSummaryQueryValidator()
public GetAuditSummaryQueryValidator(IStringLocalizer<AuditingResources> localizer)
{
RuleFor(q => q)
.Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc)
.WithMessage("FromUtc must be less than or equal to ToUtc.");
.WithMessage(_ => localizer["Validation.DateRangeOrder"]);

RuleFor(q => q)
.Must(q =>
!q.FromUtc.HasValue
|| !q.ToUtc.HasValue
|| (q.ToUtc.Value - q.FromUtc.Value) <= GetAuditSummaryQueryHandler.MaxWindow)
.WithMessage($"Audit summary window cannot exceed {GetAuditSummaryQueryHandler.MaxWindow.TotalDays:0} days.");
// MaxWindowDays, not MaxWindow.TotalDays: the localizer formats arguments with
// string.Format under the current culture, and a double in a localized message is
// culture-sensitive by construction. An int cannot render a decimal separator.
.WithMessage(_ => localizer["Validation.SummaryWindowExceeded", GetAuditSummaryQueryHandler.MaxWindowDays]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using FSH.Modules.Auditing.Contracts.Authorization;
using FSH.Modules.Auditing.Contracts.Dtos;
using FSH.Modules.Auditing.Contracts.v1.GetAudits;
using FSH.Modules.Auditing.Localization;
using FSH.Modules.Auditing.Persistence;
using FSH.Modules.Identity.Contracts.Services;
using Mediator;
Expand All @@ -21,7 +22,10 @@ public sealed class GetAuditsQueryHandler : IQueryHandler<GetAuditsQuery, PagedR
/// to scan the entire table — without this guard, an unconstrained query
/// degenerates into a full sequential scan as the audit volume grows.
/// </summary>
public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(90);
public const int MaxWindowDays = 90;

/// <inheritdoc cref="MaxWindowDays"/>
public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(MaxWindowDays);

/// <summary>
/// Default lookback when the caller does not supply a from/to. Keeps the
Expand Down Expand Up @@ -157,7 +161,11 @@ requested is not null
.ConfigureAwait(false);
if (!allowed)
{
throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant.");
throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant.")
{
MessageKey = "Error.Auditing.CrossTenantAccessForbidden",
ResourceSource = typeof(AuditingResources),
};
}

return _dbContext.AuditRecords
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
using FluentValidation;
using FSH.Framework.Core.Localization;
using FSH.Framework.Web.Validation;
using FSH.Modules.Auditing.Contracts.v1.GetAudits;
using FSH.Modules.Auditing.Localization;
using Microsoft.Extensions.Localization;

namespace FSH.Modules.Auditing.Features.v1.GetAudits;

public sealed class GetAuditsQueryValidator : AbstractValidator<GetAuditsQuery>
{
public GetAuditsQueryValidator()
public GetAuditsQueryValidator(
IStringLocalizer<SharedResources> localizer,
IStringLocalizer<AuditingResources> auditLocalizer)
{
Include(new PagedQueryValidator<GetAuditsQuery>());
Include(new PagedQueryValidator<GetAuditsQuery>(localizer));

RuleFor(q => q)
.Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc)
.WithMessage("FromUtc must be less than or equal to ToUtc.");
.WithMessage(_ => auditLocalizer["Validation.DateRangeOrder"]);

// Reject oversized windows up-front (user sees a 400, not a silent clamp). The handler
// still clamps as defence in depth (e.g. when only one endpoint is supplied).
Expand All @@ -21,6 +26,9 @@ public GetAuditsQueryValidator()
!q.FromUtc.HasValue
|| !q.ToUtc.HasValue
|| (q.ToUtc.Value - q.FromUtc.Value) <= GetAuditsQueryHandler.MaxWindow)
.WithMessage($"Audit query window cannot exceed {GetAuditsQueryHandler.MaxWindow.TotalDays:0} days.");
// MaxWindowDays, not MaxWindow.TotalDays: the localizer formats arguments with
// string.Format under the current culture, and a double in a localized message is
// culture-sensitive by construction. An int cannot render a decimal separator.
.WithMessage(_ => auditLocalizer["Validation.WindowExceeded", GetAuditsQueryHandler.MaxWindowDays]);
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
using FluentValidation;
using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation;
using FSH.Modules.Auditing.Localization;
using Microsoft.Extensions.Localization;

namespace FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation;

public sealed class GetAuditsByCorrelationQueryValidator : AbstractValidator<GetAuditsByCorrelationQuery>
{
public GetAuditsByCorrelationQueryValidator()
public GetAuditsByCorrelationQueryValidator(IStringLocalizer<AuditingResources> localizer)
{
RuleFor(q => q.CorrelationId)
.NotEmpty();

RuleFor(q => q)
.Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc)
.WithMessage("FromUtc must be less than or equal to ToUtc.");
.WithMessage(_ => localizer["Validation.DateRangeOrder"]);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
using FluentValidation;
using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace;
using FSH.Modules.Auditing.Localization;
using Microsoft.Extensions.Localization;

namespace FSH.Modules.Auditing.Features.v1.GetAuditsByTrace;

public sealed class GetAuditsByTraceQueryValidator : AbstractValidator<GetAuditsByTraceQuery>
{
public GetAuditsByTraceQueryValidator()
public GetAuditsByTraceQueryValidator(IStringLocalizer<AuditingResources> localizer)
{
RuleFor(q => q.TraceId)
.NotEmpty();

RuleFor(q => q)
.Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc)
.WithMessage("FromUtc must be less than or equal to ToUtc.");
.WithMessage(_ => localizer["Validation.DateRangeOrder"]);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
using FluentValidation;
using FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits;
using FSH.Modules.Auditing.Localization;
using Microsoft.Extensions.Localization;

namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits;

public sealed class GetExceptionAuditsQueryValidator : AbstractValidator<GetExceptionAuditsQuery>
{
public GetExceptionAuditsQueryValidator()
public GetExceptionAuditsQueryValidator(IStringLocalizer<AuditingResources> localizer)
{
RuleFor(q => q)
.Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc)
.WithMessage("FromUtc must be less than or equal to ToUtc.");
.WithMessage(_ => localizer["Validation.DateRangeOrder"]);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
using FluentValidation;
using FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits;
using FSH.Modules.Auditing.Localization;
using Microsoft.Extensions.Localization;

namespace FSH.Modules.Auditing.Features.v1.GetSecurityAudits;

public sealed class GetSecurityAuditsQueryValidator : AbstractValidator<GetSecurityAuditsQuery>
{
public GetSecurityAuditsQueryValidator()
public GetSecurityAuditsQueryValidator(IStringLocalizer<AuditingResources> localizer)
{
RuleFor(q => q)
.Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc)
.WithMessage("FromUtc must be less than or equal to ToUtc.");
.WithMessage(_ => localizer["Validation.DateRangeOrder"]);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace FSH.Modules.Auditing.Localization;

/// <summary>Marker type binding <c>IStringLocalizer&lt;AuditingResources&gt;</c> to the Auditing resx catalog.</summary>
public sealed class AuditingResources;
Loading
Loading