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
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,52 @@ All notable changes to the MMCA.Common packages are documented here. The format
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/)
and are derived from git tags by MinVer (see [the published versioning policy](https://ivanball.github.io/docs/guides/common-VERSIONING.html)).

## [1.178.0] - 2026-09-01

Two move-to-Common extractions from the 2026-08-31 drift run: the E2E gateway rate-limit lift both
AppHosts carried inline, and the Azure Service Bus emulator test fixture both consumers hand-copied.
Both are additive, and both stay inert until a consumer opts in. No breaking changes.

### Added

- **`WithE2eGatewayRateLimitLift`** (`MMCA.Common.Aspire.Hosting`). AppHost extension on a
`ProjectResource` (the gateway) that lifts the edge rate limiter for an E2E run: it reads
`E2E_LIFT_REGISTRATION_THROTTLE` itself, OR-ed with an optional `alsoLiftWhen` call-site flag for a
host that implies the lift from another E2E switch of its own, and when triggered sets
`GatewayRateLimiting__PermitLimit`, `GatewayRateLimiting__GlobalConcurrencyLimit` and
`MmcaGateway__RateLimiterPolicies__auth-tight__PermitLimit`. Untriggered it returns the builder
unchanged, so it is a no-op locally and in production, where the gateway keeps the real limits from
its own `appsettings.json`. The lift exists because a whole E2E suite arrives from ONE loopback
client IP, which the per-IP fixed window reads as the single-source flood it was built to stop. A
unit test cross-asserts the emitted keys against `GatewayRateLimitingSettings.SectionName` and
`GatewaySettings.SectionName`, so a section rename cannot silently orphan the lift. Mirrors the
shipped `WithE2eRegistrationThrottleLift`, and both lifts now read one shared trigger constant.
- **`ServiceBusEmulatorFixtureBase`** (`MMCA.Common.Testing`). Collection-fixture base for the Azure
Service Bus emulator broker-parity tier, beside `CrossServiceFixtureBase`. It owns the pinned
emulator container (`DefaultEmulatorImage` 2.0.1, overridable through a virtual `EmulatorImage`),
the process-global MassTransit v8 entity-default override the emulator's one-hour TTL quota
requires, the AMQP and admin-plane (5300) clients with a pure static
`ComposeAdminConnectionString`, and wall-clock-bounded start and stop phases (virtual
`ContainerStartTimeout` / `BusStartTimeout` / `BusStopTimeout` with named PHASE 1 / PHASE 2
`TimeoutException` text, so a hang names its phase instead of being killed at the job timeout with
its log discarded). Hosting the tier's one bus is opt-in through virtual `ReceiveQueueName` plus
`ConfigureReceiveEndpoint`, with a `Consumed` bag on the base; the base provisions exactly ONE
receive endpoint, so each additional contract costs a topic and a subscription rather than another
queue against an admin plane throttled at roughly one operation per second. The sealed subclass,
the `[CollectionDefinition]` class, the integration-event contracts and the assertions stay
app-side. Adds `Testcontainers.ServiceBus`, `Azure.Messaging.ServiceBus` and
`MassTransit.Azure.ServiceBus.Core` (v8 by policy) to the package, the same Docker-only test-tier
profile as its existing Testcontainers references.

### Dependencies

- `System.Linq.Dynamic.Core` 1.7.3 -> 1.7.4.
- `Meziantou.Analyzer` 3.0.190 -> 3.0.200; no new finding at error severity, so the shared
`.editorconfig` baseline is unchanged.
- New test-tier pins for the fixture base above: `Testcontainers.ServiceBus` 4.14.0 and
`Azure.Messaging.ServiceBus` 7.20.2 (the first line with working emulator admin-plane support).
`MassTransit.Azure.ServiceBus.Core` reuses the existing 8.5.10 pin.

## [1.177.0] - 2026-08-31

Write-side registration and validation quality-of-life: `AddEntityCrud` now completes the update
Expand Down
13 changes: 11 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.2" />
<PackageVersion Include="Scrutor" Version="7.0.0" />
<PackageVersion Include="System.Linq.Dynamic.Core" Version="1.7.3" />
<PackageVersion Include="System.Linq.Dynamic.Core" Version="1.7.4" />
<!-- Infrastructure -->
<PackageVersion Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.11" />
<!-- Pinned to a patched version to override the vulnerable MessagePack pulled transitively by
Expand Down Expand Up @@ -95,6 +95,11 @@
<PackageVersion Include="MassTransit" Version="8.5.10" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.10" />
<PackageVersion Include="MassTransit.Azure.ServiceBus.Core" Version="8.5.10" />
<!-- Service Bus emulator test tier (ServiceBusEmulatorFixtureBase in MMCA.Common.Testing): the two
clients the MassTransit v8 custom-clients Host() overload takes. 7.20.2 is the first line with
working emulator admin-plane support (>= 7.20.1 required), and it also lifts the older
transitive MassTransit.Azure.ServiceBus.Core would otherwise resolve. -->
<PackageVersion Include="Azure.Messaging.ServiceBus" Version="7.20.2" />
<!-- Native push (ADR-044): Azure Notification Hubs installations + FCM v1 / APNs native sends. -->
<PackageVersion Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<!-- Managed file storage + avatar image normalization (ADR-045). ImageSharp is under the
Expand Down Expand Up @@ -186,7 +191,7 @@
binds to the MAUI train: bump together with Microsoft.Maui.Controls. -->
<PackageVersion Include="ZXing.Net.Maui.Controls" Version="0.10.4" />
<!-- Analyzers -->
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.190" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.200" />
<!-- Public API surface gate (RS0016/RS0017): every packable Source project carries a
PublicAPI.Shipped.txt baseline, so adding or removing a public member is a deliberate,
reviewable diff instead of an accident discovered by a consumer. Source-only (see the
Expand Down Expand Up @@ -218,6 +223,10 @@
RabbitMQ containers for consumer cross-service integration tiers. Test-tier only. -->
<PackageVersion Include="Testcontainers.MsSql" Version="4.14.0" />
<PackageVersion Include="Testcontainers.RabbitMq" Version="4.14.0" />
<!-- Service Bus emulator fixture base in MMCA.Common.Testing: same Testcontainers family version.
The module starts the emulator plus its companion SQL container, so consumer broker-parity
tiers need a Docker daemon (nightly/dispatch jobs only, never a merge gate). -->
<PackageVersion Include="Testcontainers.ServiceBus" Version="4.14.0" />
<!-- Pinned directly to force transitive resolution to the patched version: every Testcontainers
package floors SSH.NET at 2025.1.0, which carries GHSA-q939-rpr3-3284 (high, published
2026-08-12: ScpClient recursive download lets a malicious server write arbitrary files via
Expand Down
12 changes: 6 additions & 6 deletions Source/Core/MMCA.Common.Application/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.190, )",
"resolved": "3.0.190",
"contentHash": "CFX3BWg27iLO78RORLAGWghpP+1afiAaijAIfhU7+JI+n6V0uVpNIr6H7LYVnRTFb2WAH/450FcvarFl7STOQA=="
"requested": "[3.0.200, )",
"resolved": "3.0.200",
"contentHash": "xDYQDG/pBdvC3r1PcqQw9d8/9dRYxh9xxljjgVydBezpxOUSABjuRjVWNoUmqNByrtWqwJ+nRdAwUuWPbU8uXQ=="
},
"Microsoft.CodeAnalysis.PublicApiAnalyzers": {
"type": "Direct",
Expand Down Expand Up @@ -108,9 +108,9 @@
},
"System.Linq.Dynamic.Core": {
"type": "Direct",
"requested": "[1.7.3, )",
"resolved": "1.7.3",
"contentHash": "0m52+B4Jce/9AXLXPZnnFW7NeLotHBuYvbsVF3D/DTvvbCGJZdskPn5xjr5sjnnFI3XekNdPqG2djyJE3UU7SA=="
"requested": "[1.7.4, )",
"resolved": "1.7.4",
"contentHash": "2VJGKUdPjA59aqv/ehvBS+oVoFQqqsiVZlbcyDmuwypgy96Ss1/b8U13Yufk0TXtzveMWAPQAfnZzH+bs1r7Vw=="
},
"FluentValidation": {
"type": "Transitive",
Expand Down
6 changes: 3 additions & 3 deletions Source/Core/MMCA.Common.Domain/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.190, )",
"resolved": "3.0.190",
"contentHash": "CFX3BWg27iLO78RORLAGWghpP+1afiAaijAIfhU7+JI+n6V0uVpNIr6H7LYVnRTFb2WAH/450FcvarFl7STOQA=="
"requested": "[3.0.200, )",
"resolved": "3.0.200",
"contentHash": "xDYQDG/pBdvC3r1PcqQw9d8/9dRYxh9xxljjgVydBezpxOUSABjuRjVWNoUmqNByrtWqwJ+nRdAwUuWPbU8uXQ=="
},
"Microsoft.CodeAnalysis.PublicApiAnalyzers": {
"type": "Direct",
Expand Down
35 changes: 18 additions & 17 deletions Source/Core/MMCA.Common.Infrastructure/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.190, )",
"resolved": "3.0.190",
"contentHash": "CFX3BWg27iLO78RORLAGWghpP+1afiAaijAIfhU7+JI+n6V0uVpNIr6H7LYVnRTFb2WAH/450FcvarFl7STOQA=="
"requested": "[3.0.200, )",
"resolved": "3.0.200",
"contentHash": "xDYQDG/pBdvC3r1PcqQw9d8/9dRYxh9xxljjgVydBezpxOUSABjuRjVWNoUmqNByrtWqwJ+nRdAwUuWPbU8uXQ=="
},
"Microsoft.AspNetCore.SignalR.StackExchangeRedis": {
"type": "Direct",
Expand Down Expand Up @@ -270,16 +270,6 @@
"System.Memory.Data": "1.0.2"
}
},
"Azure.Messaging.ServiceBus": {
"type": "Transitive",
"resolved": "7.20.1",
"contentHash": "DxCkedWPQuiXrIyFcriOhsQcZmDZW+j9d55Ev4nnK3yjMUFjlVe4Hj37fuZTJlNhC3P+7EumqBTt33R6DfOxGA==",
"dependencies": {
"Azure.Core": "1.46.2",
"Azure.Core.Amqp": "1.3.1",
"Microsoft.Azure.Amqp": "2.7.0"
}
},
"Azure.Storage.Common": {
"type": "Transitive",
"resolved": "12.28.0",
Expand Down Expand Up @@ -777,7 +767,7 @@
"Microsoft.FeatureManagement": "[4.7.0, )",
"MiniProfiler.Shared": "[4.5.4, )",
"Scrutor": "[7.0.0, )",
"System.Linq.Dynamic.Core": "[1.7.3, )"
"System.Linq.Dynamic.Core": "[1.7.4, )"
}
},
"mmca.common.domain": {
Expand All @@ -789,6 +779,17 @@
"mmca.common.shared": {
"type": "Project"
},
"Azure.Messaging.ServiceBus": {
"type": "CentralTransitive",
"requested": "[7.20.2, )",
"resolved": "7.20.1",
"contentHash": "DxCkedWPQuiXrIyFcriOhsQcZmDZW+j9d55Ev4nnK3yjMUFjlVe4Hj37fuZTJlNhC3P+7EumqBTt33R6DfOxGA==",
"dependencies": {
"Azure.Core": "1.46.2",
"Azure.Core.Amqp": "1.3.1",
"Microsoft.Azure.Amqp": "2.7.0"
}
},
"FluentValidation.DependencyInjectionExtensions": {
"type": "CentralTransitive",
"requested": "[12.1.1, )",
Expand Down Expand Up @@ -850,9 +851,9 @@
},
"System.Linq.Dynamic.Core": {
"type": "CentralTransitive",
"requested": "[1.7.3, )",
"resolved": "1.7.3",
"contentHash": "0m52+B4Jce/9AXLXPZnnFW7NeLotHBuYvbsVF3D/DTvvbCGJZdskPn5xjr5sjnnFI3XekNdPqG2djyJE3UU7SA=="
"requested": "[1.7.4, )",
"resolved": "1.7.4",
"contentHash": "2VJGKUdPjA59aqv/ehvBS+oVoFQqqsiVZlbcyDmuwypgy96Ss1/b8U13Yufk0TXtzveMWAPQAfnZzH+bs1r7Vw=="
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions Source/Core/MMCA.Common.Shared/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.190, )",
"resolved": "3.0.190",
"contentHash": "CFX3BWg27iLO78RORLAGWghpP+1afiAaijAIfhU7+JI+n6V0uVpNIr6H7LYVnRTFb2WAH/450FcvarFl7STOQA=="
"requested": "[3.0.200, )",
"resolved": "3.0.200",
"contentHash": "xDYQDG/pBdvC3r1PcqQw9d8/9dRYxh9xxljjgVydBezpxOUSABjuRjVWNoUmqNByrtWqwJ+nRdAwUuWPbU8uXQ=="
},
"Microsoft.CodeAnalysis.PublicApiAnalyzers": {
"type": "Direct",
Expand Down
108 changes: 106 additions & 2 deletions Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
Expand Down Expand Up @@ -34,6 +34,55 @@ public static class Extensions
/// </summary>
public const int E2eRegistrationsPerIpPerHour = 1000;

/// <summary>
/// The environment variable an E2E workflow sets to ask for both E2E lifts (the Identity
/// registration throttle and the gateway edge limiter). Absent locally and in production, which
/// is what makes every lift a no-op there.
/// </summary>
internal const string E2eLiftTriggerVariable = "E2E_LIFT_REGISTRATION_THROTTLE";

/// <summary>
/// Requests per window per client IP the gateway allows while an E2E suite is running (see
/// <c>WithE2eGatewayRateLimitLift</c>). High enough that no suite can reach it.
/// </summary>
internal const int E2eGatewayPermitLimit = 100000;

/// <summary>
/// Concurrent requests in flight the gateway allows while an E2E suite is running (see
/// <c>WithE2eGatewayRateLimitLift</c>).
/// </summary>
internal const int E2eGatewayGlobalConcurrencyLimit = 10000;

/// <summary>
/// Requests per window the tighter named auth policy allows while an E2E suite is running (see
/// <c>WithE2eGatewayRateLimitLift</c>).
/// </summary>
internal const int E2eAuthTightPermitLimit = 100000;

/// <summary>
/// Configuration section the gateway's edge rate limiter binds from. Mirrors
/// <c>MMCA.Common.Aspire</c>'s <c>GatewayRateLimitingSettings.SectionName</c> rather than
/// referencing it: this AppHost-tier package must not pull in the service-defaults graph (Azure
/// Monitor, OpenTelemetry, Key Vault) to spell one configuration key, the same reasoning
/// <c>MMCA.Common.Gateway</c> records for its own independence. A unit test cross-asserts the two
/// are equal, so a section rename cannot silently orphan the lift.
/// </summary>
internal const string GatewayRateLimitingSection = "GatewayRateLimiting";

/// <summary>
/// Configuration section the YARP gateway building blocks bind from (mirrors
/// <c>MMCA.Common.Gateway</c>'s <c>GatewaySettings.SectionName</c>, cross-asserted by the same
/// test as <see cref="GatewayRateLimitingSection"/>).
/// </summary>
internal const string MmcaGatewaySection = "MmcaGateway";

/// <summary>
/// Named per-route rate-limiter policy the auth route carries in both consumers' gateway
/// configuration. A route policy name is app configuration rather than a framework constant, so
/// this is the one part of the key with no type to derive it from.
/// </summary>
internal const string AuthTightPolicyName = "auth-tight";

/// <summary>
/// Default Aspire resource name used for the Azure Service Bus emulator container.
/// </summary>
Expand Down Expand Up @@ -346,7 +395,7 @@ public IResourceBuilder<ProjectResource> WithE2eRegistrationThrottleLift(bool al

var lift = alsoLiftWhen
|| string.Equals(
Environment.GetEnvironmentVariable("E2E_LIFT_REGISTRATION_THROTTLE"),
Environment.GetEnvironmentVariable(E2eLiftTriggerVariable),
"true",
StringComparison.OrdinalIgnoreCase);

Expand All @@ -360,6 +409,61 @@ public IResourceBuilder<ProjectResource> WithE2eRegistrationThrottleLift(bool al

extension(IResourceBuilder<ProjectResource> service)
{
/// <summary>
/// CI/E2E only: lifts the gateway's edge rate limiter (the <c>GatewayRateLimiting</c> per-client-IP
/// fixed window chained with the replica-wide concurrency ceiling, plus the tighter named
/// <c>auth-tight</c> route policy) to allowances no suite can reach.
/// <para>
/// The whole E2E suite arrives from ONE loopback client IP, so the per-IP window that protects
/// production from a single-source flood reads the suite itself as that flood (2026-08-18 ADC run
/// 32185349945: 12 login failures once the window saturated). The named auth policy has the same
/// problem for the same reason: the suite logs in and registers far more often than a human ever
/// would, all from the one loopback IP the policy partitions on, so the anti-credential-stuffing
/// window would read the suite as the attack. Production keeps the real limits from the gateway's
/// own <c>appsettings.json</c>.
/// </para>
/// <para>
/// The trigger is the same <c>E2E_LIFT_REGISTRATION_THROTTLE</c> environment variable the Identity
/// throttle lift reads (set by the E2E workflow, absent locally and in production, so this is a
/// no-op there), OR <paramref name="alsoLiftWhen"/> for an AppHost that implies the lift from
/// another E2E switch of its own. A silent regression here surfaces as login/register E2E reds,
/// never as a build failure.
/// </para>
/// </summary>
/// <param name="alsoLiftWhen">
/// An extra trigger evaluated at the call site and OR-ed with the environment variable. Pass the
/// AppHost's own E2E flag (for example a forced-render-mode switch that implies the same request
/// volume); defaults to <see langword="false"/>, leaving the environment variable as the only
/// trigger.
/// </param>
/// <returns>The gateway resource builder for chaining.</returns>
public IResourceBuilder<ProjectResource> WithE2eGatewayRateLimitLift(bool alsoLiftWhen = false)
{
ArgumentNullException.ThrowIfNull(service);

var lift = alsoLiftWhen
|| string.Equals(
Environment.GetEnvironmentVariable(E2eLiftTriggerVariable),
"true",
StringComparison.OrdinalIgnoreCase);

if (!lift)
{
return service;
}

return service
.WithEnvironment(
$"{GatewayRateLimitingSection}__PermitLimit",
E2eGatewayPermitLimit.ToString(CultureInfo.InvariantCulture))
.WithEnvironment(
$"{GatewayRateLimitingSection}__GlobalConcurrencyLimit",
E2eGatewayGlobalConcurrencyLimit.ToString(CultureInfo.InvariantCulture))
.WithEnvironment(
$"{MmcaGatewaySection}__RateLimiterPolicies__{AuthTightPolicyName}__PermitLimit",
E2eAuthTightPermitLimit.ToString(CultureInfo.InvariantCulture));
}

/// <summary>
/// Wires a service project to its own SQL Server database ("database per microservice", ADR-006).
/// References the given database and injects its connection string as
Expand Down
Loading