Skip to content

Commit cd99418

Browse files
authored
Add comprehensive rate limiting module with policy management (#64)
* Add RateLimiting module with framework-level rate limiting support - Add rate limiting abstractions to SimpleModule.Core: policy types (FixedWindow, SlidingWindow, TokenBucket), target modes (IP, User, IpAndUser, Global), IRateLimitBuilder, IRateLimitPolicyRegistry, and .RateLimit() endpoint extension - Add ConfigureRateLimits() lifecycle hook to IModule interface - Integrate ASP.NET Core RateLimiter middleware in SimpleModule.Hosting with automatic policy registration from module definitions - Add X-RateLimit-* response headers via RateLimitHeaderMiddleware - Update source generator to discover and wire ConfigureRateLimits on modules - Create RateLimiting module with: - Contracts: RateLimitRule entity, CRUD DTOs, IRateLimitingContracts - Implementation: DbContext, CRUD service, REST endpoints, permissions - Admin UI: React/Inertia page for managing stored rules and viewing active policies - 4 built-in policies: fixed-default, sliding-strict, token-bucket, auth-strict - Navigation menu in admin sidebar - Add 18 unit tests covering policy registry and CRUD service - Register module in Host project, solution, and test infrastructure * Simplify RateLimiting module: address code review findings - Use Dictionary<string, RateLimitPolicyDefinition> for O(1) policy lookup instead of linear List.Find on every rate-limited request - Pre-build rate limiter options at registration time instead of allocating per-request in the partition factory - Accept IRateLimitPolicyRegistry interface instead of concrete type in AddSimpleModuleRateLimiting to avoid leaking implementation details - Extract CreateRequestValidator class following the codebase convention used by Products, Orders, and Tenants modules - Extract API_BASE constant in Admin.tsx to eliminate hardcoded URL strings - Fix handleToggle to send only UpdateRateLimitRuleRequest fields instead of spreading the full entity (which includes id, policyName, timestamps) * Add E2E tests for RateLimiting module - Add page object (RateLimitingAdminPage) with selectors for heading, tabs, create dialog, rules table, and toggle/delete actions - Add smoke tests: page loads, tabs visible, active policies table - Add flow tests: full CRUD via API (create, read, update, delete), active policies endpoint verification, UI visibility after API create, create dialog opens from admin page * ci: retrigger CI after merge with main * Fix E2E failures: add EnsureTablesCreated for RateLimitingDbContext The RateLimiting admin page returned 500 because the database tables were never created in the test infrastructure. Added the missing EnsureTablesCreated<RateLimitingDbContext> call to the WebApplicationFactory initialization alongside all other module contexts. Also adds integration tests that verify the admin page and API endpoints return 200 for authenticated admin users. * Add EF Core migration for RateLimiting module The E2E tests failed because the RateLimiting_Rules table didn't exist in the SQLite database. The server uses MigrateAsync() on the HostDbContext which requires an explicit migration to create new tables. This migration creates the RateLimiting_Rules table with all columns, the unique index on PolicyName, and also includes pending BackgroundJobs and Email tables from the main branch merge.
1 parent 0e526bb commit cd99418

60 files changed

Lines changed: 4730 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎SimpleModule.slnx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@
114114
<Project Path="modules/Localization/src/SimpleModule.Localization/SimpleModule.Localization.csproj" />
115115
<Project Path="modules/Localization/tests/SimpleModule.Localization.Tests/SimpleModule.Localization.Tests.csproj" />
116116
</Folder>
117+
<Folder Name="/modules/RateLimiting/">
118+
<Project Path="modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/SimpleModule.RateLimiting.Contracts.csproj" />
119+
<Project Path="modules/RateLimiting/src/SimpleModule.RateLimiting/SimpleModule.RateLimiting.csproj" />
120+
<Project Path="modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/SimpleModule.RateLimiting.Tests.csproj" />
121+
</Folder>
117122
<Folder Name="/modules/Email/">
118123
<Project Path="modules/Email/src/SimpleModule.Email.Contracts/SimpleModule.Email.Contracts.csproj" />
119124
<Project Path="modules/Email/src/SimpleModule.Email/SimpleModule.Email.csproj" />

‎framework/SimpleModule.Core/IModule.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using SimpleModule.Core.Authorization;
88
using SimpleModule.Core.FeatureFlags;
99
using SimpleModule.Core.Menu;
10+
using SimpleModule.Core.RateLimiting;
1011
using SimpleModule.Core.Settings;
1112

1213
namespace SimpleModule.Core;
@@ -21,6 +22,7 @@ virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { }
2122
virtual void ConfigureSettings(ISettingsBuilder settings) { }
2223
virtual void ConfigureFeatureFlags(IFeatureFlagBuilder builder) { }
2324
virtual void ConfigureAgents(IAgentBuilder builder) { }
25+
virtual void ConfigureRateLimits(IRateLimitBuilder builder) { }
2426

2527
/// <summary>
2628
/// Called once during application startup after all services are built but before
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using Microsoft.AspNetCore.Builder;
2+
3+
namespace SimpleModule.Core.RateLimiting;
4+
5+
public static class EndpointRateLimitExtensions
6+
{
7+
public static TBuilder RateLimit<TBuilder>(this TBuilder builder, string policyName)
8+
where TBuilder : IEndpointConventionBuilder
9+
{
10+
builder.RequireRateLimiting(policyName);
11+
return builder;
12+
}
13+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace SimpleModule.Core.RateLimiting;
2+
3+
public interface IRateLimitBuilder
4+
{
5+
IRateLimitBuilder Add(RateLimitPolicyDefinition policy);
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace SimpleModule.Core.RateLimiting;
2+
3+
public interface IRateLimitPolicyRegistry
4+
{
5+
IReadOnlyList<RateLimitPolicyDefinition> GetPolicies();
6+
RateLimitPolicyDefinition? GetPolicy(string name);
7+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace SimpleModule.Core.RateLimiting;
2+
3+
public sealed class RateLimitPolicyDefinition
4+
{
5+
public required string Name { get; init; }
6+
public RateLimitPolicyType PolicyType { get; init; } = RateLimitPolicyType.FixedWindow;
7+
public RateLimitTarget Target { get; init; } = RateLimitTarget.Ip;
8+
public int PermitLimit { get; init; } = 60;
9+
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
10+
public int SegmentsPerWindow { get; init; } = 4;
11+
public int TokenLimit { get; init; } = 100;
12+
public int TokensPerPeriod { get; init; } = 10;
13+
public TimeSpan ReplenishmentPeriod { get; init; } = TimeSpan.FromSeconds(10);
14+
public int QueueLimit { get; init; }
15+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace SimpleModule.Core.RateLimiting;
2+
3+
public sealed class RateLimitPolicyRegistry : IRateLimitBuilder, IRateLimitPolicyRegistry
4+
{
5+
private readonly Dictionary<string, RateLimitPolicyDefinition> _policies = new(
6+
StringComparer.OrdinalIgnoreCase
7+
);
8+
9+
public IRateLimitBuilder Add(RateLimitPolicyDefinition policy)
10+
{
11+
_policies[policy.Name] = policy;
12+
return this;
13+
}
14+
15+
public IReadOnlyList<RateLimitPolicyDefinition> GetPolicies() =>
16+
_policies.Values.ToList().AsReadOnly();
17+
18+
public RateLimitPolicyDefinition? GetPolicy(string name) => _policies.GetValueOrDefault(name);
19+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace SimpleModule.Core.RateLimiting;
2+
3+
public enum RateLimitPolicyType
4+
{
5+
FixedWindow,
6+
SlidingWindow,
7+
TokenBucket,
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace SimpleModule.Core.RateLimiting;
2+
3+
public enum RateLimitTarget
4+
{
5+
Ip,
6+
User,
7+
IpAndUser,
8+
Global,
9+
}

‎framework/SimpleModule.Generator/Discovery/DiscoveryData.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ internal readonly record struct ModuleInfoRecord(
126126
bool HasConfigureSettings,
127127
bool HasConfigureFeatureFlags,
128128
bool HasConfigureAgents,
129+
bool HasConfigureRateLimits,
129130
bool HasRazorComponents,
130131
string RoutePrefix,
131132
string ViewPrefix,
@@ -146,6 +147,7 @@ public bool Equals(ModuleInfoRecord other)
146147
&& HasConfigureSettings == other.HasConfigureSettings
147148
&& HasConfigureFeatureFlags == other.HasConfigureFeatureFlags
148149
&& HasConfigureAgents == other.HasConfigureAgents
150+
&& HasConfigureRateLimits == other.HasConfigureRateLimits
149151
&& HasRazorComponents == other.HasRazorComponents
150152
&& RoutePrefix == other.RoutePrefix
151153
&& ViewPrefix == other.ViewPrefix
@@ -167,6 +169,7 @@ public override int GetHashCode()
167169
hash = HashHelper.Combine(hash, HasConfigureSettings.GetHashCode());
168170
hash = HashHelper.Combine(hash, HasConfigureFeatureFlags.GetHashCode());
169171
hash = HashHelper.Combine(hash, HasConfigureAgents.GetHashCode());
172+
hash = HashHelper.Combine(hash, HasConfigureRateLimits.GetHashCode());
170173
hash = HashHelper.Combine(hash, HasRazorComponents.GetHashCode());
171174
hash = HashHelper.Combine(hash, (RoutePrefix ?? "").GetHashCode());
172175
hash = HashHelper.Combine(hash, (ViewPrefix ?? "").GetHashCode());
@@ -462,6 +465,7 @@ internal sealed class ModuleInfo
462465
public bool HasConfigureSettings { get; set; }
463466
public bool HasConfigureFeatureFlags { get; set; }
464467
public bool HasConfigureAgents { get; set; }
468+
public bool HasConfigureRateLimits { get; set; }
465469
public bool HasRazorComponents { get; set; }
466470
public string RoutePrefix { get; set; } = "";
467471
public string ViewPrefix { get; set; } = "";

0 commit comments

Comments
 (0)