backend phase 0: foundation, cross-cutting seams & starter cleanup
Remove the Order demo (entity/feature/repo/config/gRPC/proto) and the three pre-marketplace migrations; regenerate a fresh InitialBaseline migration. Stand up the REST surface (PingController + System/Ping CQRS) proving the Mediator -> behaviors -> OperationResult -> ApiResult envelope end to end. Close wiring gaps: register LoggingBehavior (outermost) and add the built-in rate limiter (per-IP global + otp/auth/sensitive policies), placed before authentication. Add current-user + audit plumbing: ICurrentUser (HttpContext + null impls), rename BaseEntity audit fields to CreatedAt/ModifiedAt (DateTimeOffset) + CreatedById/ModifiedById, stamped by a new AuditFieldInterceptor. Introduce five cross-cutting seams (IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher) with in-memory/local mocks registered via AddCrossCuttingSeams. Add Baya.Test.Foundation (encryptor, audit interceptor, ping handler) and update docs, contracts (swagger.v1.json), handoff, report, and mocks registry.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.System.Queries.Ping;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Baya.WebFramework.ServiceConfiguration;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Display(Description = "Service liveness and pipeline-smoke endpoints")]
|
||||
public sealed class PingController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<PingQueryResult>]
|
||||
public async Task<IActionResult> GetStatus(CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new PingQuery(), cancellationToken));
|
||||
|
||||
[HttpGet("[action]")]
|
||||
[EnableRateLimiting(RateLimitingServiceExtension.GlobalPolicy)]
|
||||
[ProducesOkApiResponseType<PingQueryResult>]
|
||||
public async Task<IActionResult> GetStatusRateLimited(CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new PingQuery(), cancellationToken));
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Baya.Application.Models.Identity;
|
||||
using Baya.Application.ServiceConfiguration;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.CrossCutting.Logging;
|
||||
using Baya.Infrastructure.CrossCutting.ServiceConfiguration;
|
||||
using Baya.Infrastructure.Identity.Identity.Dtos;
|
||||
using Baya.Infrastructure.Identity.Jwt;
|
||||
using Baya.Infrastructure.Identity.ServiceConfiguration;
|
||||
@@ -69,7 +70,9 @@ builder.Services.AddSwagger("v1","v1.1");
|
||||
builder.Services.AddApplicationServices()
|
||||
.RegisterIdentityServices(identitySettings)
|
||||
.AddPersistenceServices(configuration)
|
||||
.AddWebFrameworkServices();
|
||||
.AddCrossCuttingSeams(configuration)
|
||||
.AddWebFrameworkServices()
|
||||
.AddRateLimitingPolicies();
|
||||
|
||||
builder.Services.RegisterValidatorsAsServices();
|
||||
builder.Services.AddExceptionHandler<ExceptionHandler>();
|
||||
@@ -105,6 +108,8 @@ app.UseSwaggerAndUi();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"SqlServer": "Server=sql_server2022;Database=Baya_DB_Docker;User Id=SA;Password=A&VeryComplex123Password;MultipleActiveResultSets=true;encrypt=false",
|
||||
"SqlServer": "Server=192.168.100.14,1433;Database=Baya;User Id=sa;Password=Pa##w0rd;TrustServerCertificate=True;Encrypt=False;",
|
||||
"logDb": "Server=sql_server2022;Database=Baya_Log_DB_Docker;User Id=SA;Password=A&VeryComplex123Password;MultipleActiveResultSets=true;encrypt=false"
|
||||
},
|
||||
"IdentitySettings": {
|
||||
@@ -11,6 +11,15 @@
|
||||
"NotBeforeMinutes": "0",
|
||||
"ExpirationMinutes": "10000"
|
||||
},
|
||||
"Seams": {
|
||||
"FieldEncryption": {
|
||||
"Key": "local-dev-field-encryption-key-change-me",
|
||||
"HashKey": "local-dev-field-hash-key-change-me"
|
||||
},
|
||||
"ObjectStorage": {
|
||||
"RootPath": ""
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"EndpointDefaults": {
|
||||
|
||||
@@ -20,4 +20,8 @@
|
||||
<ProjectReference Include="..\..\Infrastructure\Baya.Infrastructure.Persistence\Baya.Infrastructure.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Baya.WebFramework.ServiceConfiguration;
|
||||
|
||||
public static class RateLimitingServiceExtension
|
||||
{
|
||||
/// <summary>Per-IP baseline applied to every endpoint that doesn't opt into a named policy.</summary>
|
||||
public const string GlobalPolicy = "global";
|
||||
|
||||
/// <summary>Tighter limit for OTP request/verify endpoints (applied in backend-phase-2).</summary>
|
||||
public const string OtpPolicy = "otp";
|
||||
|
||||
/// <summary>Limit for login/refresh and other auth endpoints.</summary>
|
||||
public const string AuthPolicy = "auth";
|
||||
|
||||
/// <summary>Limit for money-sensitive actions (refund/payout) applied in later phases.</summary>
|
||||
public const string SensitivePolicy = "sensitive";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the built-in rate limiter with a per-IP global limit plus named policies that
|
||||
/// auth/OTP/sensitive endpoints opt into via <c>[EnableRateLimiting(name)]</c>. Over-limit
|
||||
/// requests get <c>429 Too Many Requests</c>. Pair with <c>app.UseRateLimiter()</c> placed
|
||||
/// before <c>app.UseAuthentication()</c>.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddRateLimitingPolicies(this IServiceCollection services)
|
||||
{
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
PartitionKey(context),
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 100,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0
|
||||
}));
|
||||
|
||||
AddFixedWindowPolicy(options, OtpPolicy, permitLimit: 5, windowSeconds: 60);
|
||||
AddFixedWindowPolicy(options, AuthPolicy, permitLimit: 10, windowSeconds: 60);
|
||||
AddFixedWindowPolicy(options, SensitivePolicy, permitLimit: 20, windowSeconds: 60);
|
||||
|
||||
// A deliberately tiny policy used by the phase-0 ping endpoint to demonstrate 429s.
|
||||
AddFixedWindowPolicy(options, GlobalPolicy, permitLimit: 5, windowSeconds: 10);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void AddFixedWindowPolicy(RateLimiterOptions options, string name, int permitLimit, int windowSeconds)
|
||||
{
|
||||
options.AddPolicy(name, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
PartitionKey(context),
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = permitLimit,
|
||||
Window = TimeSpan.FromSeconds(windowSeconds),
|
||||
QueueLimit = 0
|
||||
}));
|
||||
}
|
||||
|
||||
private static string PartitionKey(HttpContext context) =>
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="ProtoModels\UserGrpcServiceModels.proto" GrpcServices="Server" />
|
||||
<Protobuf Include="ProtoModels\OrderGrpcServiceModels.proto" GrpcServices="Server" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ public static class GrpcPluginStartup
|
||||
{
|
||||
|
||||
app.MapGrpcService<UserGrpcServices>();
|
||||
app.MapGrpcService<OrderGrpcServices>();
|
||||
app.MapGrpcReflectionService();
|
||||
|
||||
app.MapGet("/GrpcUser", async context =>
|
||||
@@ -29,11 +28,5 @@ public static class GrpcPluginStartup
|
||||
await context.Response.WriteAsync(
|
||||
"Communication with this gRPC endpoint must be made through a gRPC client.");
|
||||
});
|
||||
|
||||
app.MapGet("/GrpcUserOrder", async context =>
|
||||
{
|
||||
await context.Response.WriteAsync(
|
||||
"Communication with this gRPC endpoint must be made through a gRPC client.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option csharp_namespace = "Baya.Web.Plugins.Grpc.ProtoModels";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
package GrpcOrderController;
|
||||
|
||||
|
||||
service OrderServices {
|
||||
rpc GetUserOrders(google.protobuf.Empty) returns (stream GetUserOrdersModel);
|
||||
}
|
||||
|
||||
message GetUserOrdersModel{
|
||||
int32 OrderId=1;
|
||||
string OrderName=2;
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
using Baya.Application.Features.Order.Queries.GetUserOrders;
|
||||
using Baya.SharedKernel.Extensions;
|
||||
using Baya.Web.Plugins.Grpc.ProtoModels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Baya.Web.Plugins.Grpc.Services
|
||||
{
|
||||
[Authorize]
|
||||
public class OrderGrpcServices:OrderServices.OrderServicesBase
|
||||
{
|
||||
|
||||
|
||||
private readonly ISender _sender;
|
||||
|
||||
public OrderGrpcServices(ISender sender)
|
||||
{
|
||||
_sender = sender;
|
||||
}
|
||||
|
||||
public override async Task GetUserOrders(Empty request, IServerStreamWriter<GetUserOrdersModel> responseStream, ServerCallContext context)
|
||||
{
|
||||
var userId = int.Parse(context.GetHttpContext().User.Identity.GetUserId());
|
||||
|
||||
var query = await _sender.Send(new GetUserOrdersQueryModel(userId));
|
||||
|
||||
if (!query.IsSuccess)
|
||||
{
|
||||
context.Status = new Status(StatusCode.InvalidArgument, query.GetErrorMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var getUsersQueryResultModel in query.Result)
|
||||
{
|
||||
await responseStream.WriteAsync(new GetUserOrdersModel()
|
||||
{ OrderId = getUsersQueryResultModel.OrderId, OrderName = getUsersQueryResultModel.OrderName });
|
||||
|
||||
await Task.Delay(400);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user