feat: Enhance webhook validation and add webhook client creation command
This commit is contained in:
parent
e9712fb1f7
commit
620a1ae237
@ -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
8
AGENTS.md
Normal 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.
|
||||||
@ -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`)
|
||||||
|
|||||||
@ -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 Credentials(Send Engine 作為 client)
|
使用 OAuth2 Client Credentials(Send Engine 作為 client)
|
||||||
|
|||||||
23
src/SendEngine.Api/Dockerfile
Normal file
23
src/SendEngine.Api/Dockerfile
Normal 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"]
|
||||||
@ -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 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();
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user