feat: Enhance webhook validation and add webhook client creation command

This commit is contained in:
warrenchen 2026-02-17 17:56:24 +09:00
parent e9712fb1f7
commit 620a1ae237
8 changed files with 359 additions and 6 deletions

View File

@ -8,4 +8,5 @@ Jwt__Audience=send_engine
Jwt__SigningKey=change_me_jwt_signing_key Jwt__SigningKey=change_me_jwt_signing_key
Webhook__Secrets__member_center=change_me_webhook_secret Webhook__Secrets__member_center=change_me_webhook_secret
Webhook__TimestampSkewSeconds=300 Webhook__TimestampSkewSeconds=300
Webhook__AllowNullTenantClient=false
Ses__SkipSignatureValidation=true Ses__SkipSignatureValidation=true

8
AGENTS.md Normal file
View File

@ -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.

View File

@ -6,3 +6,11 @@
- 預設由 API 啟動時自動執行(`Db__AutoMigrate=true` - 預設由 API 啟動時自動執行(`Db__AutoMigrate=true`
- 需要關閉時請設定 `Db__AutoMigrate=false` - 需要關閉時請設定 `Db__AutoMigrate=false`
- 手動執行可用 `dotnet run --project src/SendEngine.Installer -- migrate` - 手動執行可用 `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 <tenant_uuid> --client-id <client_id> --name <display_name> --scopes <scope1,scope2>`
- 例如:`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`

View File

@ -29,6 +29,9 @@ Header 建議:
- nonce 不可重複(重放防護) - nonce 不可重複(重放防護)
- signature 必須匹配 - signature 必須匹配
- client 必須存在且為 active - 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 回寫 ### 3. Send Engine → Member Center 回寫
使用 OAuth2 Client CredentialsSend Engine 作為 client 使用 OAuth2 Client CredentialsSend Engine 作為 client

View File

@ -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"]

View File

@ -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) => 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 secret = builder.Configuration["Webhook:Secrets:member_center"] ?? string.Empty;
var skewSeconds = builder.Configuration.GetValue("Webhook:TimestampSkewSeconds", 300); 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) if (validation is not null)
{ {
return validation; return validation;
@ -215,16 +232,41 @@ app.MapPost("/webhooks/subscriptions", async (HttpContext httpContext, Subscript
}; };
db.EventsInbox.Add(inbox); db.EventsInbox.Add(inbox);
await EnsureListExistsAsync(db, request.TenantId, request.ListId);
await ApplySubscriptionSnapshotAsync(db, request);
inbox.Status = "processed";
inbox.ProcessedAt = DateTimeOffset.UtcNow;
try
{
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
return Results.Conflict(new { error = "duplicate_event_or_unique_violation" });
}
return Results.Ok(); return Results.Ok();
}).WithName("SubscriptionWebhook").WithOpenApi(); }).WithName("SubscriptionWebhook").WithOpenApi();
app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyncBatchRequest request, SendEngineDbContext db) => 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 secret = builder.Configuration["Webhook:Secrets:member_center"] ?? string.Empty;
var skewSeconds = builder.Configuration.GetValue("Webhook:TimestampSkewSeconds", 300); 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) if (validation is not null)
{ {
return validation; return validation;
@ -244,6 +286,27 @@ app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyn
}; };
db.EventsInbox.Add(inbox); 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(); await db.SaveChangesAsync();
return Results.Ok(); return Results.Ok();
@ -284,3 +347,135 @@ static Guid? GetTenantId(ClaimsPrincipal user)
var value = user.FindFirst("tenant_id")?.Value; var value = user.FindFirst("tenant_id")?.Value;
return Guid.TryParse(value, out var tenantId) ? tenantId : null; 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<string, object>? 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
};
}

View File

@ -8,7 +8,13 @@ namespace SendEngine.Api.Security;
public static class WebhookValidator public static class WebhookValidator
{ {
public static async Task<IResult?> ValidateAsync(HttpContext context, SendEngineDbContext db, string secret, int maxSkewSeconds) public static async Task<IResult?> ValidateAsync(
HttpContext context,
SendEngineDbContext db,
string secret,
int maxSkewSeconds,
Guid payloadTenantId,
bool allowNullTenantClient)
{ {
if (string.IsNullOrWhiteSpace(secret)) if (string.IsNullOrWhiteSpace(secret))
{ {
@ -41,8 +47,23 @@ public static class WebhookValidator
return Results.Unauthorized(); return Results.Unauthorized();
} }
var hasClient = await db.AuthClients.AsNoTracking().AnyAsync(x => x.Id == clientId); var authClient = await db.AuthClients.AsNoTracking().FirstOrDefaultAsync(x => x.Id == clientId);
if (!hasClient) 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); return Results.StatusCode(StatusCodes.Status403Forbidden);
} }

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using SendEngine.Domain.Entities;
using SendEngine.Infrastructure; using SendEngine.Infrastructure;
using SendEngine.Infrastructure.Data; using SendEngine.Infrastructure.Data;
@ -11,6 +12,7 @@ if (command is "-h" or "--help" or "help")
Console.WriteLine("SendEngine Installer"); Console.WriteLine("SendEngine Installer");
Console.WriteLine("Usage:"); Console.WriteLine("Usage:");
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate"); Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate");
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> --client-id <string> --name <string> --scopes <csv>");
return; return;
} }
@ -32,4 +34,96 @@ if (command == "migrate")
return; 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<SendEngineDbContext>();
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}"); Console.WriteLine($"Unknown command: {command}");
static Dictionary<string, string> ParseOptions(string[] args)
{
var result = new Dictionary<string, string>(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;
}