From 620a1ae237e71c07070bb53e7cfeec07ede2f009 Mon Sep 17 00:00:00 2001 From: warrenchen Date: Tue, 17 Feb 2026 17:56:24 +0900 Subject: [PATCH] feat: Enhance webhook validation and add webhook client creation command --- .env.example | 1 + AGENTS.md | 8 + docs/INSTALL.md | 8 + docs/OPENAPI.md | 3 + src/SendEngine.Api/Dockerfile | 23 ++ src/SendEngine.Api/Program.cs | 201 +++++++++++++++++- .../Security/WebhookValidator.cs | 27 ++- src/SendEngine.Installer/Program.cs | 94 ++++++++ 8 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/SendEngine.Api/Dockerfile diff --git a/.env.example b/.env.example index 4678b56..6507a14 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,5 @@ Jwt__Audience=send_engine Jwt__SigningKey=change_me_jwt_signing_key Webhook__Secrets__member_center=change_me_webhook_secret Webhook__TimestampSkewSeconds=300 +Webhook__AllowNullTenantClient=false Ses__SkipSignatureValidation=true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b0f7ba6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +## Dotnet Execution Policy + +- For `.NET` commands (`dotnet restore`, `dotnet build`, `dotnet test`, `dotnet run`), run in sandbox first. +- If the command fails or hangs due to sandbox limits (for example restore/build stalls), rerun with `sandbox_permissions: "require_escalated"`. +- The escalation request must include a short justification explaining that sandbox restrictions are blocking normal .NET execution. +- Do not change project paths or command intent when escalating; rerun the same command with elevated permissions. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9eed1e5..5ead951 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -6,3 +6,11 @@ - 預設由 API 啟動時自動執行(`Db__AutoMigrate=true`) - 需要關閉時請設定 `Db__AutoMigrate=false` - 手動執行可用 `dotnet run --project src/SendEngine.Installer -- migrate` +- Webhook Auth 初始化(不使用 SQL 檔,改用 Installer): + - 使用 Installer 建立 webhook client(`id` 自動隨機產生): + - `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id --client-id --name --scopes ` + - 例如:`dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id 11111111-1111-1111-1111-111111111111 --client-id member-center-webhook --name "Member Center Webhook" --scopes newsletter:events.write` + - 建立成功後,Member Center webhook header `X-Client-Id` 請帶回傳的 `id` + - Webhook 驗證規則為 tenant 綁定:`auth_clients.tenant_id` 必須等於 payload `tenant_id` + - 不支援 `X-Client-Id` fallback + - 預設拒絕 `tenant_id = NULL` 的通用 client(`Webhook__AllowNullTenantClient=false`) diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index c6a4103..68e86d2 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -29,6 +29,9 @@ Header 建議: - nonce 不可重複(重放防護) - signature 必須匹配 - client 必須存在且為 active +- `auth_clients.tenant_id` 必須與 payload `tenant_id` 一致 +- 不使用任何 `X-Client-Id` fallback;缺少 tenant 對應 client 時應由來源端跳過發送 +- 預設拒絕 `auth_clients.tenant_id = NULL` 的通用 client(可透過 `Webhook__AllowNullTenantClient=true` 明確開啟) ### 3. Send Engine → Member Center 回寫 使用 OAuth2 Client Credentials(Send Engine 作為 client) diff --git a/src/SendEngine.Api/Dockerfile b/src/SendEngine.Api/Dockerfile new file mode 100644 index 0000000..3ec55c7 --- /dev/null +++ b/src/SendEngine.Api/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY ["Directory.Build.props", "./"] +COPY ["NuGet.Config", "./"] +COPY ["src/SendEngine.Domain/SendEngine.Domain.csproj", "src/SendEngine.Domain/"] +COPY ["src/SendEngine.Application/SendEngine.Application.csproj", "src/SendEngine.Application/"] +COPY ["src/SendEngine.Infrastructure/SendEngine.Infrastructure.csproj", "src/SendEngine.Infrastructure/"] +COPY ["src/SendEngine.Api/SendEngine.Api.csproj", "src/SendEngine.Api/"] + +RUN dotnet restore "src/SendEngine.Api/SendEngine.Api.csproj" + +COPY . . +RUN dotnet publish "src/SendEngine.Api/SendEngine.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "SendEngine.Api.dll"] diff --git a/src/SendEngine.Api/Program.cs b/src/SendEngine.Api/Program.cs index f49d8ed..9797cf7 100644 --- a/src/SendEngine.Api/Program.cs +++ b/src/SendEngine.Api/Program.cs @@ -193,9 +193,26 @@ app.MapPost("/api/send-jobs/{id:guid}/cancel", async (HttpContext httpContext, G app.MapPost("/webhooks/subscriptions", async (HttpContext httpContext, SubscriptionEventRequest request, SendEngineDbContext db) => { + if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty || request.Subscriber.Id == Guid.Empty) + { + return Results.UnprocessableEntity(new { error = "tenant_id_list_id_subscriber_id_required" }); + } + + if (!IsSupportedSubscriptionEvent(request.EventType)) + { + return Results.UnprocessableEntity(new { error = "unsupported_event_type" }); + } + var secret = builder.Configuration["Webhook:Secrets:member_center"] ?? string.Empty; var skewSeconds = builder.Configuration.GetValue("Webhook:TimestampSkewSeconds", 300); - var validation = await WebhookValidator.ValidateAsync(httpContext, db, secret, skewSeconds); + var allowNullTenantClient = builder.Configuration.GetValue("Webhook:AllowNullTenantClient", false); + var validation = await WebhookValidator.ValidateAsync( + httpContext, + db, + secret, + skewSeconds, + request.TenantId, + allowNullTenantClient); if (validation is not null) { return validation; @@ -215,16 +232,41 @@ app.MapPost("/webhooks/subscriptions", async (HttpContext httpContext, Subscript }; db.EventsInbox.Add(inbox); - await db.SaveChangesAsync(); + + await EnsureListExistsAsync(db, request.TenantId, request.ListId); + await ApplySubscriptionSnapshotAsync(db, request); + + inbox.Status = "processed"; + inbox.ProcessedAt = DateTimeOffset.UtcNow; + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + return Results.Conflict(new { error = "duplicate_event_or_unique_violation" }); + } return Results.Ok(); }).WithName("SubscriptionWebhook").WithOpenApi(); app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyncBatchRequest request, SendEngineDbContext db) => { + if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty) + { + return Results.UnprocessableEntity(new { error = "tenant_id_list_id_required" }); + } + var secret = builder.Configuration["Webhook:Secrets:member_center"] ?? string.Empty; var skewSeconds = builder.Configuration.GetValue("Webhook:TimestampSkewSeconds", 300); - var validation = await WebhookValidator.ValidateAsync(httpContext, db, secret, skewSeconds); + var allowNullTenantClient = builder.Configuration.GetValue("Webhook:AllowNullTenantClient", false); + var validation = await WebhookValidator.ValidateAsync( + httpContext, + db, + secret, + skewSeconds, + request.TenantId, + allowNullTenantClient); if (validation is not null) { return validation; @@ -244,6 +286,27 @@ app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyn }; db.EventsInbox.Add(inbox); + + await EnsureListExistsAsync(db, request.TenantId, request.ListId); + foreach (var subscriber in request.Subscribers) + { + if (subscriber.Id == Guid.Empty || string.IsNullOrWhiteSpace(subscriber.Email)) + { + continue; + } + + await UpsertSubscriberAndListMemberAsync( + db, + request.TenantId, + request.ListId, + subscriber.Id, + subscriber.Email, + NormalizeStatus(subscriber.Status, "active"), + subscriber.Preferences); + } + + inbox.Status = "processed"; + inbox.ProcessedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); return Results.Ok(); @@ -284,3 +347,135 @@ static Guid? GetTenantId(ClaimsPrincipal user) var value = user.FindFirst("tenant_id")?.Value; return Guid.TryParse(value, out var tenantId) ? tenantId : null; } + +static bool IsSupportedSubscriptionEvent(string eventType) +{ + return eventType is "subscription.activated" or "subscription.unsubscribed" or "preferences.updated"; +} + +static async Task EnsureListExistsAsync(SendEngineDbContext db, Guid tenantId, Guid listId) +{ + var listExists = await db.Lists.AsNoTracking() + .AnyAsync(x => x.Id == listId && x.TenantId == tenantId); + if (listExists) + { + return; + } + + db.Lists.Add(new MailingList + { + Id = listId, + TenantId = tenantId, + Name = $"list-{listId:N}", + CreatedAt = DateTimeOffset.UtcNow + }); +} + +static async Task ApplySubscriptionSnapshotAsync(SendEngineDbContext db, SubscriptionEventRequest request) +{ + var status = request.EventType switch + { + "subscription.activated" => "active", + "subscription.unsubscribed" => "unsubscribed", + "preferences.updated" => NormalizeStatus(request.Subscriber.Status, "active"), + _ => "active" + }; + + await UpsertSubscriberAndListMemberAsync( + db, + request.TenantId, + request.ListId, + request.Subscriber.Id, + request.Subscriber.Email, + status, + request.Subscriber.Preferences); +} + +static async Task UpsertSubscriberAndListMemberAsync( + SendEngineDbContext db, + Guid tenantId, + Guid listId, + Guid subscriberId, + string email, + string status, + Dictionary? preferences) +{ + var now = DateTimeOffset.UtcNow; + var normalizedEmail = email.Trim().ToLowerInvariant(); + var effectiveSubscriberId = subscriberId; + + var subscriber = await db.Subscribers + .FirstOrDefaultAsync(x => x.Id == subscriberId && x.TenantId == tenantId); + if (subscriber is null) + { + subscriber = await db.Subscribers + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Email == normalizedEmail); + if (subscriber is not null) + { + effectiveSubscriberId = subscriber.Id; + } + } + + var preferenceJson = preferences is null ? null : JsonSerializer.Serialize(preferences); + if (subscriber is null) + { + subscriber = new Subscriber + { + Id = subscriberId, + TenantId = tenantId, + Email = normalizedEmail, + Status = status, + Preferences = preferenceJson, + CreatedAt = now, + UpdatedAt = now + }; + db.Subscribers.Add(subscriber); + } + else + { + subscriber.Email = normalizedEmail; + subscriber.Status = status; + subscriber.Preferences = preferenceJson; + subscriber.UpdatedAt = now; + } + + var listMember = await db.ListMembers + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.ListId == listId && x.SubscriberId == effectiveSubscriberId); + if (listMember is null) + { + listMember = new ListMember + { + Id = Guid.NewGuid(), + TenantId = tenantId, + ListId = listId, + SubscriberId = effectiveSubscriberId, + Status = status, + CreatedAt = now, + UpdatedAt = now + }; + db.ListMembers.Add(listMember); + } + else + { + listMember.Status = status; + listMember.UpdatedAt = now; + } +} + +static string NormalizeStatus(string? status, string fallback) +{ + if (string.IsNullOrWhiteSpace(status)) + { + return fallback; + } + + var value = status.Trim().ToLowerInvariant(); + return value switch + { + "active" => "active", + "unsubscribed" => "unsubscribed", + "bounced" => "bounced", + "complaint" => "complaint", + _ => fallback + }; +} diff --git a/src/SendEngine.Api/Security/WebhookValidator.cs b/src/SendEngine.Api/Security/WebhookValidator.cs index 0f2ec1d..3c89847 100644 --- a/src/SendEngine.Api/Security/WebhookValidator.cs +++ b/src/SendEngine.Api/Security/WebhookValidator.cs @@ -8,7 +8,13 @@ namespace SendEngine.Api.Security; public static class WebhookValidator { - public static async Task ValidateAsync(HttpContext context, SendEngineDbContext db, string secret, int maxSkewSeconds) + public static async Task ValidateAsync( + HttpContext context, + SendEngineDbContext db, + string secret, + int maxSkewSeconds, + Guid payloadTenantId, + bool allowNullTenantClient) { if (string.IsNullOrWhiteSpace(secret)) { @@ -41,8 +47,23 @@ public static class WebhookValidator return Results.Unauthorized(); } - var hasClient = await db.AuthClients.AsNoTracking().AnyAsync(x => x.Id == clientId); - if (!hasClient) + var authClient = await db.AuthClients.AsNoTracking().FirstOrDefaultAsync(x => x.Id == clientId); + if (authClient is null) + { + return Results.StatusCode(StatusCodes.Status403Forbidden); + } + + if (!string.Equals(authClient.Status, "active", StringComparison.OrdinalIgnoreCase)) + { + return Results.StatusCode(StatusCodes.Status403Forbidden); + } + + if (authClient.TenantId is null && !allowNullTenantClient) + { + return Results.StatusCode(StatusCodes.Status403Forbidden); + } + + if (authClient.TenantId is not null && authClient.TenantId.Value != payloadTenantId) { return Results.StatusCode(StatusCodes.Status403Forbidden); } diff --git a/src/SendEngine.Installer/Program.cs b/src/SendEngine.Installer/Program.cs index eda82fd..43a8e22 100644 --- a/src/SendEngine.Installer/Program.cs +++ b/src/SendEngine.Installer/Program.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using SendEngine.Domain.Entities; using SendEngine.Infrastructure; using SendEngine.Infrastructure.Data; @@ -11,6 +12,7 @@ if (command is "-h" or "--help" or "help") Console.WriteLine("SendEngine Installer"); Console.WriteLine("Usage:"); Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate"); + Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id --client-id --name --scopes "); return; } @@ -32,4 +34,96 @@ if (command == "migrate") return; } +if (command == "add-webhook-client") +{ + var options = ParseOptions(args); + options.TryGetValue("tenant-id", out var tenantIdRaw); + options.TryGetValue("client-id", out var clientId); + options.TryGetValue("name", out var name); + options.TryGetValue("scopes", out var scopesRaw); + + if (string.IsNullOrWhiteSpace(tenantIdRaw) || + string.IsNullOrWhiteSpace(clientId) || + string.IsNullOrWhiteSpace(name) || + string.IsNullOrWhiteSpace(scopesRaw)) + { + Console.WriteLine("Missing required options."); + Console.WriteLine("Required: --tenant-id, --client-id, --name, --scopes"); + Environment.Exit(1); + } + + if (!Guid.TryParse(tenantIdRaw, out var tenantId)) + { + Console.WriteLine("Invalid --tenant-id, expected UUID."); + Environment.Exit(1); + } + + var scopes = scopesRaw + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (scopes.Length == 0) + { + Console.WriteLine("Invalid --scopes, expected comma-separated values."); + Environment.Exit(1); + } + + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var exists = await db.AuthClients.AsNoTracking().AnyAsync(x => x.ClientId == clientId); + if (exists) + { + Console.WriteLine($"client_id already exists: {clientId}"); + Environment.Exit(1); + } + + var entity = new AuthClient + { + Id = Guid.NewGuid(), + TenantId = tenantId, + ClientId = clientId, + Name = name, + Scopes = scopes, + Status = "active", + CreatedAt = DateTimeOffset.UtcNow + }; + + db.AuthClients.Add(entity); + await db.SaveChangesAsync(); + + Console.WriteLine("Webhook client created."); + Console.WriteLine($"id={entity.Id}"); + Console.WriteLine($"tenant_id={entity.TenantId}"); + Console.WriteLine($"client_id={entity.ClientId}"); + Console.WriteLine($"scopes={string.Join(",", entity.Scopes)}"); + return; +} + Console.WriteLine($"Unknown command: {command}"); + +static Dictionary ParseOptions(string[] args) +{ + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 1; i < args.Length; i++) + { + var token = args[i]; + if (!token.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var key = token[2..]; + if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + result[key] = string.Empty; + continue; + } + + result[key] = args[i + 1]; + i++; + } + + return result; +}