Compare commits

...

3 Commits

31 changed files with 823 additions and 112 deletions

View File

@ -2,3 +2,4 @@ ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
SendEngine__BaseUrl=http://localhost:6060
SendEngine__WebhookSecret=change-me
Testing__DisableSubscriptionDryRunNoDb=false

View File

@ -43,7 +43,7 @@
- roles / user_roles (Identity)
- id, name, created_at
- OpenIddictApplications
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties`tenant_id`, `usage=tenant_api|webhook_outbound`
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties`tenant_id`, `usage=tenant_api|webhook_outbound|platform_service`
- OpenIddictAuthorizations
- id, application_id, status, subject, type, scopes
- OpenIddictTokens
@ -104,9 +104,15 @@
4) Send Engine 驗證 tenant scope更新本地名單快照
### 6.7 Send Engine 退信/黑名單回寫(選用)
1) Send Engine 判定黑名單(例如 hard bounce / complaint
2) 呼叫 Member Center API 將 email 加入 `email_blacklist`
3) Member Center 對該 email 的訂閱事件全部忽略且不再推送
1) Send Engine 依事件類型決定回寫時機與原因碼:
2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
3) `complaint`:先取消訂閱,再回寫黑名單
4) 呼叫 Member Center API 將 email 加入 `email_blacklist`
5) Member Center 對該 email 的訂閱事件全部忽略且不再推送
6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`Member Center 端做租戶邊界驗證
7) 回寫授權可用:
- tenant client scope`newsletter:events.write`
- platform client scope`newsletter:events.write.global`SES 聚合事件)
### 6.5 註冊後銜接
1) 使用者完成註冊
@ -128,20 +134,22 @@
- POST `/newsletter/preferences`
- POST `/webhooks/subscriptions`Send Engine 端點Member Center 呼叫)
- POST `/webhooks/lists/full-sync`Send Engine 端點Member Center 呼叫)
- POST `/api/subscriptions/disable`Member Center 端點Send Engine 呼叫)
- POST `/subscriptions/disable`Member Center 端點Send Engine 呼叫)
- POST `/integrations/send-engine/webhook-clients/upsert`Member Center 端點Send Engine 呼叫)
## 7.1 待新增 API / Auth規劃中
### API
- `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單
- `POST /webhooks/subscriptions`Member Center → Send Engine 事件推送
- `POST /webhooks/lists/full-sync`Member Center → Send Engine 全量同步
- `POST /api/subscriptions/disable`Send Engine → Member Center 黑名單回寫
- `POST /subscriptions/disable`Send Engine → Member Center 黑名單回寫
### Auth / Scope
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途:`tenant_api` `webhook_outbound`(禁止混用)
- `tenant_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途:`tenant_api` / `webhook_outbound` / `platform_service`(禁止混用)
- 新增 scope`newsletter:list.read``newsletter:events.read`
- 新增 scope`newsletter:events.write`
- 新增 scope`newsletter:events.write.global`
### 租戶端取 TokenClient Credentials
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token

View File

@ -40,15 +40,25 @@
- [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`scope: `newsletter:events.write`
- [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id`
- [API] `X-Client-Id` 對應 Send Engine `auth_clients.id`UUID
- [API] Member Center `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 選擇 header 值
- [API] Member Center 從 DB 的 tenant 設定讀取對應 webhook client id
- [API] Send Engine 驗證簽章 + timestamp + nonce重放防護後入庫
- [API] Send Engine 更新名單快照
## F-11 黑名單回寫Send Engine → Member Center
- [API] Send Engine 判定黑名單(例如 hard bounce / complaint
- [API] 呼叫 `POST /api/subscriptions/disable`scope: `newsletter:events.write`
- [API] Send Engine 依事件規則處理:
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
- [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單
- [API] 呼叫 `POST /subscriptions/disable`
- [API] tenant client 用 `newsletter:events.write`
- [API] 平台 clientSES 聚合事件)用 `newsletter:events.write.global`
- [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送
## F-12 Webhook Client Mapping 回填Send Engine → Member Center
- [API] Send Engine 建立/更新 tenant 對應的 webhook client`auth_clients.id`
- [API] 呼叫 `POST /integrations/send-engine/webhook-clients/upsert`scope: `newsletter:events.write.global`
- [API] Member Center 更新 tenant 設定DB
## F-07 會員資料查看
- [API] 站點讀取 `/user/profile`
- [UI] 會員中心提供個人資料頁

View File

@ -42,13 +42,19 @@ ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
SendEngine__BaseUrl=http://localhost:6060
SendEngine__WebhookSecret=change-me
SendEngine__TenantWebhookClientIds__REPLACE_WITH_TENANT_ID=11111111-1111-1111-1111-111111111111
```
`SendEngine` 設定說明:
- `SendEngine__BaseUrl`: Send Engine API base URL
- `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致
- `SendEngine__TenantWebhookClientIds__{tenant_uuid}`: 每個 tenant 對應的 `X-Client-Id`Send Engine `auth_clients.id`
- tenant 對應 `X-Client-Id` 改由 DB 管理Tenant 設定)
- 可透過管理 UI 設定,或由 Send Engine 呼叫 `POST /integrations/send-engine/webhook-clients/upsert` 自動回填
測試旗標TEST-ONLY
- `Testing__DisableSubscriptionDryRunNoDb=true`
- 作用:`POST /subscriptions/disable` 完全不做 DB read/write只寫 log含預計執行的 DB 動作)
- 用途SES/Send Engine 串接初次測試,避免測試資料污染
- 測試結束請改回 `false`
### 1) `installer init`
用途:首次安裝(含 migrations + seed + superuser

View File

@ -49,38 +49,69 @@
- `X-Client-Id`
- `X-Client-Id` 來源:
- 由 Send Engine 的 `auth_clients.id`UUID提供
- Member Center 以 `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 設定每個租戶的對應值
- Member Center 以 DB 設定tenant 設定欄位)保存每個租戶的對應值
- 可由管理 UITenant 編輯)或整合 API `POST /integrations/send-engine/webhook-clients/upsert` 更新
- 簽章建議:
- `HMAC-SHA256(secret, "{raw_body}")`(對齊 Send Engine 驗證器)
- 驗證規則:
- timestamp 在允許時間窗(例如 ±5 分鐘)
- nonce 不可重複(防重放)
- `X-Client-Id` 必須存在且 active且 tenant 綁定一致
- `X-Client-Id` 必須存在且 active`auth_clients.tenant_id` 與 payload `tenant_id` 一致
- 不使用 `X-Client-Id` fallback缺少 tenant 對應 client 時應略過發送
- 預設拒絕 `auth_clients.tenant_id = NULL` 的通用 client除非 Send Engine 明確開啟)
- signature 必須匹配
## OAuth Client 用途分離(強制)
- `usage=tenant_api`
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API
- scope 僅給業務所需(如 `newsletter:send.write`、`newsletter:list.read`
- scope 僅給業務所需(如 `newsletter:events.write`
- `usage=webhook_outbound`
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
- 不可用於租戶 API 呼叫
- `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準
- `usage=platform_service`
- 供平台級 S2S例如 SES 聚合事件回寫)
- 可不綁定 `tenant_id`scope 使用 `newsletter:events.write.global`
- `tenant_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
- 管理規則:
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`
- 平台級流程另建 `platform_service` 憑證
- secret 分開輪替,禁止共用
## 待新增 API / Auth規劃中
## 整合 API / Auth狀態
### API
- `GET /newsletter/subscriptions?list_id=...`:回傳清單內所有訂閱(供發送引擎同步用)
- `POST /webhooks/subscriptions`Member Center → Send Engine 事件推送Send Engine 端點scope: `newsletter:events.write`
- `POST /webhooks/lists/full-sync`Member Center → Send Engine 全量同步Send Engine 端點scope: `newsletter:events.write`
- `POST /api/subscriptions/disable`Send Engine → Member Center 黑名單回寫(全租戶 emailscope: `newsletter:events.write`
- `GET /newsletter/subscriptions?list_id=...`:已實作(供發送引擎同步)
- `POST /webhooks/subscriptions`已實作Member Center 發送Send Engine 接收)
- `POST /webhooks/lists/full-sync`規格已定義Member Center 發送Send Engine 接收)
- `POST /subscriptions/disable`已實作Send Engine 回寫黑名單)
- `POST /integrations/send-engine/webhook-clients/upsert`已實作Send Engine 回填 tenant webhook client id
### Auth / Scope
- OAuth Client 需綁定 `tenant_id`
- `tenant_api` / `webhook_outbound` 類型需綁定 `tenant_id`
- `platform_service` 可不綁定 `tenant_id`
- 新增 scope
- `newsletter:list.read`
- `newsletter:events.read`
- `newsletter:events.write`
- `newsletter:events.write.global`
- 發送引擎僅能用上述 scope禁止 admin 權限
- `POST /subscriptions/disable` 需 Bearer token 且包含下列其一:
- `newsletter:events.write`tenant-scoped
- `newsletter:events.write.global`platform-scopedSES 回寫用)
- 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token
### 回寫原因碼Send Engine -> Member Center
- `hard_bounce`
- `soft_bounce_threshold`
- `complaint`
- `suppression`
### `/subscriptions/disable` 請求欄位Send Engine -> Member Center
- `tenant_id`UUID
- `subscriber_id`UUID
- `list_id`UUID
- `reason``hard_bounce | soft_bounce_threshold | complaint | suppression`
- `disabled_by`(建議固定 `send_engine`
- `occurred_at`RFC3339
Member Center 會用 `subscriber_id + list_id` 查詢訂閱,再驗證 `tenant_id` 邊界;驗證通過後才寫入全域 email 黑名單。

View File

@ -48,7 +48,8 @@
### 管理者端(統一 UI
- UC-11 租戶管理: `/admin/tenants`
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉需選 `usage=tenant_api``usage=webhook_outbound`
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`UUID
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉可選 `usage=tenant_api` / `webhook_outbound` / `platform_service``platform_service` 可不指定 tenant
- UC-13 電子報清單管理: `/admin/newsletter-lists`
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
- UC-15 審計紀錄查詢: `/admin/audit-logs`

View File

@ -436,11 +436,11 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/subscriptions/disable:
/subscriptions/disable:
post:
summary: Disable email (global blacklist)
security: [{ BearerAuth: [] }]
description: Requires scope `newsletter:events.write`.
description: Requires scope `newsletter:events.write` (tenant client) or `newsletter:events.write.global` (platform client). Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`.
requestBody:
required: true
content:
@ -463,10 +463,33 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/integrations/send-engine/webhook-clients/upsert:
post:
summary: Upsert Send Engine webhook client mapping for tenant
security: [{ BearerAuth: [] }]
description: Requires scope `newsletter:events.write.global`.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpsertSendEngineWebhookClientRequest'
responses:
'200':
description: Updated
'400':
description: Bad request
'401':
description: Unauthorized
'403':
description: Forbidden
'404':
description: Tenant not found
/webhooks/subscriptions:
post:
summary: (NOTE) Member Center -> Send Engine subscription events webhook
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID).
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID), tenant-bound, no fallback client id.
security: []
parameters:
- in: header
@ -498,7 +521,7 @@ paths:
/webhooks/lists/full-sync:
post:
summary: (NOTE) Member Center -> Send Engine full list sync webhook
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID).
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID), tenant-bound, no fallback client id.
security: []
parameters:
- in: header
@ -722,6 +745,10 @@ components:
openid: OpenID Connect
email: Email
profile: Basic profile
newsletter:list.read: Read newsletter subscriptions by list
newsletter:events.read: Read newsletter events
newsletter:events.write: Write newsletter events (tenant scoped)
newsletter:events.write.global: Write newsletter events (platform scoped)
BearerAuth:
type: http
scheme: bearer
@ -829,10 +856,14 @@ components:
DisableSubscriptionRequest:
type: object
required: [email, reason, disabled_by, occurred_at]
required: [tenant_id, subscriber_id, list_id, reason, disabled_by, occurred_at]
properties:
email: { type: string, format: email }
reason: { type: string }
tenant_id: { type: string, format: uuid }
subscriber_id: { type: string, format: uuid }
list_id: { type: string, format: uuid }
reason:
type: string
enum: [hard_bounce, soft_bounce_threshold, complaint, suppression]
disabled_by: { type: string }
occurred_at: { type: string, format: date-time }
@ -890,6 +921,17 @@ components:
name: { type: string }
domains: { type: array, items: { type: string } }
status: { type: string }
send_engine_webhook_client_id:
type: string
format: uuid
nullable: true
UpsertSendEngineWebhookClientRequest:
type: object
required: [tenant_id, webhook_client_id]
properties:
tenant_id: { type: string, format: uuid }
webhook_client_id: { type: string, format: uuid }
NewsletterList:
type: object
@ -903,8 +945,10 @@ components:
type: object
properties:
id: { type: string }
tenant_id: { type: string }
tenant_id:
type: string
nullable: true
name: { type: string }
usage: { type: string, enum: [tenant_api, webhook_outbound] }
usage: { type: string, enum: [tenant_api, webhook_outbound, platform_service] }
redirect_uris: { type: array, items: { type: string } }
client_type: { type: string, enum: [public, confidential] }

View File

@ -1,7 +1,7 @@
namespace MemberCenter.Api.Contracts;
public sealed record TenantRequest(string Name, List<string> Domains, string Status);
public sealed record TenantRequest(string Name, List<string> Domains, string Status, Guid? SendEngineWebhookClientId = null);
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
public sealed record OAuthClientRequest(Guid TenantId, string Name, List<string> RedirectUris, string ClientType);
public sealed record OAuthClientRequest(Guid? TenantId, string Name, List<string> RedirectUris, string ClientType, string Usage = "tenant_api");

View File

@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace MemberCenter.Api.Contracts;
public sealed record UpsertSendEngineWebhookClientRequest(
[property: JsonPropertyName("tenant_id")] Guid TenantId,
[property: JsonPropertyName("webhook_client_id")] Guid WebhookClientId);

View File

@ -21,7 +21,9 @@ public sealed record UpdatePreferencesRequest(
[property: JsonPropertyName("preferences")] Dictionary<string, object> Preferences);
public sealed record DisableSubscriptionRequest(
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("tenant_id")] Guid TenantId,
[property: JsonPropertyName("subscriber_id")] Guid SubscriberId,
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("disabled_by")] string DisabledBy,
[property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt);

View File

@ -41,30 +41,40 @@ public class AdminOAuthClientsController : ControllerBase
[HttpPost]
public async Task<IActionResult> Create([FromBody] OAuthClientRequest request)
{
if (!IsValidUsage(request.Usage))
{
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
{
return BadRequest("tenant_id is required for this usage.");
}
if (RequiresClientCredentials(request.Usage)
&& !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
return BadRequest("client_type must be confidential for this usage.");
}
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = Guid.NewGuid().ToString("N"),
DisplayName = request.Name,
ClientType = request.ClientType,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.ResponseTypes.Code,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Profile,
"scp:openid"
}
ClientType = request.ClientType
};
ApplyPermissions(descriptor, request.Usage);
foreach (var uri in request.RedirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString());
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
}
descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage);
await _applicationManager.CreateAsync(descriptor);
@ -100,6 +110,22 @@ public class AdminOAuthClientsController : ControllerBase
[HttpPut("{id}")]
public async Task<IActionResult> Update(string id, [FromBody] OAuthClientRequest request)
{
if (!IsValidUsage(request.Usage))
{
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
{
return BadRequest("tenant_id is required for this usage.");
}
if (RequiresClientCredentials(request.Usage)
&& !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
return BadRequest("client_type must be confidential for this usage.");
}
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
@ -111,12 +137,21 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.DisplayName = request.Name;
descriptor.ClientType = request.ClientType;
ApplyPermissions(descriptor, request.Usage);
descriptor.RedirectUris.Clear();
foreach (var uri in request.RedirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString());
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
}
else
{
descriptor.Properties.Remove("tenant_id");
}
descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage);
await _applicationManager.UpdateAsync(app, descriptor);
@ -141,4 +176,51 @@ public class AdminOAuthClientsController : ControllerBase
await _applicationManager.DeleteAsync(app);
return NoContent();
}
private static bool IsValidUsage(string usage)
{
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTenantOptionalUsage(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool RequiresClientCredentials(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
}
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
{
descriptor.Permissions.Clear();
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:events.write.global");
}
else
{
descriptor.Permissions.Add("scp:newsletter:events.write");
}
return;
}
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
descriptor.Permissions.Add("scp:openid");
}
}

View File

@ -27,7 +27,7 @@ public class AdminTenantsController : ControllerBase
[HttpPost]
public async Task<IActionResult> Create([FromBody] TenantRequest request)
{
var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status);
var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId);
return Created($"/admin/tenants/{tenant.Id}", tenant);
}
@ -46,7 +46,7 @@ public class AdminTenantsController : ControllerBase
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] TenantRequest request)
{
var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status);
var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId);
if (tenant is null)
{
return NotFound();

View File

@ -0,0 +1,54 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("integrations/send-engine")]
public class SendEngineIntegrationController : ControllerBase
{
private readonly ITenantService _tenantService;
public SendEngineIntegrationController(ITenantService tenantService)
{
_tenantService = tenantService;
}
[Authorize]
[HttpPost("webhook-clients/upsert")]
public async Task<IActionResult> UpsertWebhookClient([FromBody] UpsertSendEngineWebhookClientRequest request)
{
if (!HasScope(User, "newsletter:events.write.global"))
{
return Forbid();
}
if (request.TenantId == Guid.Empty || request.WebhookClientId == Guid.Empty)
{
return BadRequest("tenant_id and webhook_client_id are required.");
}
var updated = await _tenantService.SetSendEngineWebhookClientAsync(request.TenantId, request.WebhookClientId);
if (!updated)
{
return NotFound("Tenant not found.");
}
return Ok(new
{
tenant_id = request.TenantId,
webhook_client_id = request.WebhookClientId,
status = "updated"
});
}
private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)
{
var values = user.FindAll(OpenIddictConstants.Claims.Scope)
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
return values.Contains(scope, StringComparer.Ordinal);
}
}

View File

@ -1,45 +1,133 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("api/subscriptions")]
[Route("subscriptions")]
public class SubscriptionsController : ControllerBase
{
private readonly IEmailBlacklistService _emailBlacklistService;
private static readonly HashSet<string> AllowedReasons = new(StringComparer.OrdinalIgnoreCase)
{
"hard_bounce",
"soft_bounce_threshold",
"complaint",
"suppression"
};
public SubscriptionsController(IEmailBlacklistService emailBlacklistService)
// TEST-ONLY SWITCH: keep this key stable so it is easy to find/remove after SES integration test.
private const string DisableSubscriptionDryRunNoDbKey = "Testing:DisableSubscriptionDryRunNoDb";
private readonly IEmailBlacklistService _emailBlacklistService;
private readonly MemberCenterDbContext _dbContext;
private readonly IConfiguration _configuration;
private readonly ILogger<SubscriptionsController> _logger;
public SubscriptionsController(
IEmailBlacklistService emailBlacklistService,
MemberCenterDbContext dbContext,
IConfiguration configuration,
ILogger<SubscriptionsController> logger)
{
_emailBlacklistService = emailBlacklistService;
_dbContext = dbContext;
_configuration = configuration;
_logger = logger;
}
[Authorize]
[HttpPost("disable")]
public async Task<IActionResult> Disable([FromBody] DisableSubscriptionRequest request)
{
if (!HasScope(User, "newsletter:events.write"))
var hasTenantScope = HasScope(User, "newsletter:events.write");
var hasGlobalScope = HasScope(User, "newsletter:events.write.global");
if (!hasTenantScope && !hasGlobalScope)
{
return Forbid();
}
if (string.IsNullOrWhiteSpace(request.Email) ||
if (request.TenantId == Guid.Empty ||
request.SubscriberId == Guid.Empty ||
request.ListId == Guid.Empty ||
string.IsNullOrWhiteSpace(request.Reason) ||
string.IsNullOrWhiteSpace(request.DisabledBy))
{
return BadRequest("email, reason, disabled_by are required.");
return BadRequest("tenant_id, subscriber_id, list_id, reason, disabled_by are required.");
}
if (!AllowedReasons.Contains(request.Reason))
{
return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression.");
}
// TEST-ONLY BEHAVIOR: in dry-run mode, do not execute DB read/write; only emit planned operations.
if (_configuration.GetValue<bool>(DisableSubscriptionDryRunNoDbKey))
{
_logger.LogWarning(
"TEST-ONLY DRY RUN ENABLED ({ConfigKey}). Skip DB access for /subscriptions/disable. Incoming payload: {@Payload}",
DisableSubscriptionDryRunNoDbKey,
request);
_logger.LogInformation(
"TEST-ONLY PLAN: would query newsletter_subscriptions by subscriber_id={SubscriberId}, list_id={ListId}, then verify tenant_id={TenantId}.",
request.SubscriberId,
request.ListId,
request.TenantId);
_logger.LogInformation(
"TEST-ONLY PLAN: would blacklist resolved email with reason={Reason}, disabled_by={DisabledBy}, occurred_at={OccurredAt}.",
request.Reason,
request.DisabledBy,
request.OccurredAt);
return Ok(new
{
status = "dry_run_no_db",
message = "DB access skipped by test flag.",
config_key = DisableSubscriptionDryRunNoDbKey
});
}
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
{
return BadRequest("tenant_id does not match token tenant scope.");
}
var target = await (
from subscription in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
where subscription.Id == request.SubscriberId && subscription.ListId == request.ListId
select new
{
subscription.Email,
list.TenantId
})
.FirstOrDefaultAsync();
if (target is null)
{
return NotFound("Subscription not found.");
}
if (target.TenantId != request.TenantId)
{
return BadRequest("tenant_id does not match subscription/list tenant boundary.");
}
await _emailBlacklistService.AddOrUpdateAsync(
request.Email,
target.Email,
request.Reason,
request.DisabledBy,
request.OccurredAt);
return Ok(new { email = request.Email, status = "blacklisted" });
return Ok(new { email = target.Email, status = "blacklisted" });
}
private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)
@ -48,4 +136,11 @@ public class SubscriptionsController : ControllerBase
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
return values.Contains(scope, StringComparer.Ordinal);
}
private static bool TryGetTenantId(System.Security.Claims.ClaimsPrincipal user, out Guid tenantId)
{
tenantId = Guid.Empty;
var value = user.FindFirst("tenant_id")?.Value;
return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out tenantId);
}
}

View File

@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;
using System.Text.Json;
namespace MemberCenter.Api.Controllers;
@ -13,11 +15,16 @@ public class TokenController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IOpenIddictApplicationManager _applicationManager;
public TokenController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
public TokenController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IOpenIddictApplicationManager applicationManager)
{
_userManager = userManager;
_signInManager = signInManager;
_applicationManager = applicationManager;
}
[HttpPost("/oauth/token")]
@ -69,6 +76,61 @@ public class TokenController : ControllerBase
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsClientCredentialsGrantType())
{
if (string.IsNullOrWhiteSpace(request.ClientId))
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var app = await _applicationManager.FindByClientIdAsync(request.ClientId);
if (app is null)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var identity = new ClaimsIdentity(
authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
nameType: OpenIddictConstants.Claims.Name,
roleType: OpenIddictConstants.Claims.Role);
identity.AddClaim(OpenIddictConstants.Claims.Subject, request.ClientId);
identity.AddClaim(OpenIddictConstants.Claims.ClientId, request.ClientId);
var properties = await _applicationManager.GetPropertiesAsync(app);
if (properties.TryGetValue("tenant_id", out var tenantValue))
{
var tenantId = tenantValue.ValueKind == JsonValueKind.String
? tenantValue.GetString()
: tenantValue.ToString();
if (!string.IsNullOrWhiteSpace(tenantId))
{
identity.AddClaim("tenant_id", tenantId);
}
}
if (properties.TryGetValue("usage", out var usageValue))
{
var usage = usageValue.ValueKind == JsonValueKind.String
? usageValue.GetString()
: usageValue.ToString();
if (!string.IsNullOrWhiteSpace(usage))
{
identity.AddClaim("client_usage", usage);
}
}
var principal = new ClaimsPrincipal(identity);
principal.SetScopes(request.Scope.GetScopesOrDefault());
foreach (var claim in principal.Claims)
{
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
}
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
return BadRequest("Unsupported grant type.");
}
}

View File

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using OpenIddict.Validation.AspNetCore;
EnvLoader.LoadDotEnvIfDevelopment();
@ -36,8 +37,9 @@ builder.Services
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
builder.Services.AddOpenIddict()
@ -56,22 +58,33 @@ builder.Services.AddOpenIddict()
.RequireProofKeyForCodeExchange();
options.AllowRefreshTokenFlow();
options.AllowPasswordFlow();
options.AllowClientCredentialsFlow();
options.AcceptAnonymousClients();
options.RegisterScopes(
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile);
OpenIddictConstants.Scopes.Profile,
"newsletter:list.read",
"newsletter:events.read",
"newsletter:events.write",
"newsletter:events.write.global");
options.AddDevelopmentEncryptionCertificate();
options.AddDevelopmentSigningCertificate();
options.UseAspNetCore()
var aspNetCore = options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableLogoutEndpointPassthrough()
.EnableStatusCodePagesIntegration();
if (builder.Environment.IsDevelopment())
{
// TEST/LOCAL ONLY: allow HTTP for local Docker integration testing.
aspNetCore.DisableTransportSecurityRequirement();
}
})
.AddValidation(options =>
{

View File

@ -6,7 +6,8 @@ public interface ITenantService
{
Task<IReadOnlyList<TenantDto>> ListAsync();
Task<TenantDto?> GetAsync(Guid id);
Task<TenantDto> CreateAsync(string name, List<string> domains, string status);
Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status);
Task<TenantDto> CreateAsync(string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
Task<bool> SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId);
Task<bool> DeleteAsync(Guid id);
}

View File

@ -1,3 +1,8 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record TenantDto(Guid Id, string Name, List<string> Domains, string Status);
public sealed record TenantDto(
Guid Id,
string Name,
List<string> Domains,
string Status,
Guid? SendEngineWebhookClientId);

View File

@ -5,5 +5,4 @@ public sealed class SendEngineWebhookOptions
public string? BaseUrl { get; set; }
public string SubscriptionEventsPath { get; set; } = "/webhooks/subscriptions";
public string? WebhookSecret { get; set; }
public Dictionary<string, string> TenantWebhookClientIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@ -37,14 +37,19 @@ public sealed class NewsletterService : INewsletterService
public async Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences)
{
var normalizedEmail = email.Trim().ToLowerInvariant();
_logger.LogInformation("Newsletter subscribe requested. list_id={ListId}, email={Email}", listId, normalizedEmail);
if (await _emailBlacklist.IsBlacklistedAsync(email))
{
_logger.LogWarning("Newsletter subscribe blocked by blacklist. list_id={ListId}, email={Email}", listId, normalizedEmail);
return null;
}
var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == listId);
if (list is null)
{
_logger.LogWarning("Newsletter subscribe failed: list not found. list_id={ListId}, email={Email}", listId, normalizedEmail);
return null;
}
@ -85,11 +90,19 @@ public sealed class NewsletterService : INewsletterService
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"Newsletter subscribe pending token issued. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}, status={Status}",
subscription.Id,
subscription.ListId,
normalizedEmail,
subscription.Status);
return new PendingSubscriptionResult(MapSubscription(subscription), confirmToken);
}
public async Task<SubscriptionDto?> ConfirmAsync(string token)
{
_logger.LogInformation("Newsletter confirm requested.");
var confirmToken = await FindTokenAsync(token, ConfirmTokenPurpose);
if (confirmToken is null && token.Contains(' '))
{
@ -99,16 +112,27 @@ public sealed class NewsletterService : INewsletterService
if (confirmToken?.Subscription is null)
{
_logger.LogWarning("Newsletter confirm failed: token not found or already consumed.");
return null;
}
if (await _emailBlacklist.IsBlacklistedAsync(confirmToken.Subscription.Email))
{
_logger.LogWarning(
"Newsletter confirm blocked by blacklist. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}",
confirmToken.Subscription.Id,
confirmToken.Subscription.ListId,
confirmToken.Subscription.Email);
return null;
}
if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow)
{
_logger.LogWarning(
"Newsletter confirm failed: token expired. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}",
confirmToken.Subscription.Id,
confirmToken.Subscription.ListId,
confirmToken.Subscription.Email);
return null;
}
@ -117,12 +141,19 @@ public sealed class NewsletterService : INewsletterService
await _dbContext.SaveChangesAsync();
var confirmed = MapSubscription(confirmToken.Subscription);
_logger.LogInformation(
"Newsletter confirm succeeded. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}, status={Status}",
confirmed.Id,
confirmed.ListId,
confirmed.Email,
confirmed.Status);
await PublishSubscriptionEventSafeAsync("subscription.activated", confirmed);
return confirmed;
}
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
{
_logger.LogInformation("Newsletter unsubscribe requested.");
var tokenHash = HashToken(token, UnsubscribeTokenPurpose);
var unsubscribeToken = await _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
@ -130,11 +161,17 @@ public sealed class NewsletterService : INewsletterService
if (unsubscribeToken?.Subscription is null)
{
_logger.LogWarning("Newsletter unsubscribe failed: token not found or already consumed.");
return null;
}
if (await _emailBlacklist.IsBlacklistedAsync(unsubscribeToken.Subscription.Email))
{
_logger.LogWarning(
"Newsletter unsubscribe blocked by blacklist. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}",
unsubscribeToken.Subscription.Id,
unsubscribeToken.Subscription.ListId,
unsubscribeToken.Subscription.Email);
return null;
}
@ -143,14 +180,24 @@ public sealed class NewsletterService : INewsletterService
await _dbContext.SaveChangesAsync();
var unsubscribed = MapSubscription(unsubscribeToken.Subscription);
_logger.LogInformation(
"Newsletter unsubscribe succeeded. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}, status={Status}",
unsubscribed.Id,
unsubscribed.ListId,
unsubscribed.Email,
unsubscribed.Status);
await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", unsubscribed);
return unsubscribed;
}
public async Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email)
{
var normalizedEmail = email.Trim().ToLowerInvariant();
_logger.LogInformation("Newsletter unsubscribe-token requested. list_id={ListId}, email={Email}", listId, normalizedEmail);
if (await _emailBlacklist.IsBlacklistedAsync(email))
{
_logger.LogWarning("Newsletter unsubscribe-token blocked by blacklist. list_id={ListId}, email={Email}", listId, normalizedEmail);
return null;
}
@ -161,6 +208,7 @@ public sealed class NewsletterService : INewsletterService
if (subscription is null)
{
_logger.LogWarning("Newsletter unsubscribe-token failed: subscription not found. list_id={ListId}, email={Email}", listId, normalizedEmail);
return null;
}
@ -174,6 +222,11 @@ public sealed class NewsletterService : INewsletterService
});
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"Newsletter unsubscribe-token issued. subscription_id={SubscriptionId}, list_id={ListId}, email={Email}",
subscription.Id,
subscription.ListId,
normalizedEmail);
return token;
}

View File

@ -4,24 +4,31 @@ using System.Text.Json;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Newsletter;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Services;
public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher
{
private const string SendEngineWebhookClientKeyPrefix = "send_engine:webhook_client:";
private readonly HttpClient _httpClient;
private readonly IOptions<SendEngineWebhookOptions> _options;
private readonly MemberCenterDbContext _dbContext;
private readonly ILogger<SendEngineWebhookPublisher> _logger;
public SendEngineWebhookPublisher(
HttpClient httpClient,
IOptions<SendEngineWebhookOptions> options,
MemberCenterDbContext dbContext,
ILogger<SendEngineWebhookPublisher> logger)
{
_httpClient = httpClient;
_options = options;
_dbContext = dbContext;
_logger = logger;
}
@ -38,7 +45,7 @@ public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher
return;
}
var clientId = ResolveWebhookClientId(options, tenantId);
var clientId = await ResolveWebhookClientIdAsync(tenantId, cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
{
_logger.LogWarning("Skip webhook publish: missing webhook client id for tenant {TenantId}", tenantId);
@ -105,15 +112,18 @@ public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher
return normalizedBase + normalizedPath;
}
private static string? ResolveWebhookClientId(SendEngineWebhookOptions options, Guid tenantId)
private async Task<string?> ResolveWebhookClientIdAsync(Guid tenantId, CancellationToken cancellationToken)
{
var tenantKey = tenantId.ToString();
if (options.TenantWebhookClientIds.TryGetValue(tenantKey, out var tenantClientId)
&& !string.IsNullOrWhiteSpace(tenantClientId))
var key = $"{SendEngineWebhookClientKeyPrefix}{tenantId:D}";
var flag = await _dbContext.SystemFlags
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Key == key, cancellationToken);
if (flag is null || string.IsNullOrWhiteSpace(flag.Value))
{
return tenantClientId;
return null;
}
return null;
return flag.Value.Trim();
}
private static string ComputeHmacHex(string secret, string payload)

View File

@ -8,6 +8,8 @@ namespace MemberCenter.Infrastructure.Services;
public sealed class TenantService : ITenantService
{
private const string SendEngineWebhookClientKeyPrefix = "send_engine:webhook_client:";
private readonly MemberCenterDbContext _dbContext;
public TenantService(MemberCenterDbContext dbContext)
@ -18,16 +20,23 @@ public sealed class TenantService : ITenantService
public async Task<IReadOnlyList<TenantDto>> ListAsync()
{
var tenants = await _dbContext.Tenants.ToListAsync();
return tenants.Select(MapTenant).ToList();
var mappings = await LoadWebhookClientMappingsAsync(tenants.Select(t => t.Id));
return tenants.Select(t => MapTenant(t, mappings.TryGetValue(t.Id, out var clientId) ? clientId : null)).ToList();
}
public async Task<TenantDto?> GetAsync(Guid id)
{
var tenant = await _dbContext.Tenants.FindAsync(id);
return tenant is null ? null : MapTenant(tenant);
if (tenant is null)
{
return null;
}
var mapping = await LoadWebhookClientMappingAsync(id);
return MapTenant(tenant, mapping);
}
public async Task<TenantDto> CreateAsync(string name, List<string> domains, string status)
public async Task<TenantDto> CreateAsync(string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null)
{
var tenant = new Tenant
{
@ -39,10 +48,11 @@ public sealed class TenantService : ITenantService
_dbContext.Tenants.Add(tenant);
await _dbContext.SaveChangesAsync();
return MapTenant(tenant);
await SetWebhookClientMappingInternalAsync(tenant.Id, sendEngineWebhookClientId);
return MapTenant(tenant, sendEngineWebhookClientId);
}
public async Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status)
public async Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null)
{
var tenant = await _dbContext.Tenants.FindAsync(id);
if (tenant is null)
@ -54,7 +64,20 @@ public sealed class TenantService : ITenantService
tenant.Domains = domains;
tenant.Status = status;
await _dbContext.SaveChangesAsync();
return MapTenant(tenant);
await SetWebhookClientMappingInternalAsync(id, sendEngineWebhookClientId);
return MapTenant(tenant, sendEngineWebhookClientId);
}
public async Task<bool> SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId)
{
var exists = await _dbContext.Tenants.AnyAsync(t => t.Id == tenantId);
if (!exists)
{
return false;
}
await SetWebhookClientMappingInternalAsync(tenantId, webhookClientId);
return true;
}
public async Task<bool> DeleteAsync(Guid id)
@ -65,13 +88,104 @@ public sealed class TenantService : ITenantService
return false;
}
var mapping = await _dbContext.SystemFlags.FirstOrDefaultAsync(x => x.Key == BuildWebhookClientKey(id));
if (mapping is not null)
{
_dbContext.SystemFlags.Remove(mapping);
}
_dbContext.Tenants.Remove(tenant);
await _dbContext.SaveChangesAsync();
return true;
}
private static TenantDto MapTenant(Tenant tenant)
private static TenantDto MapTenant(Tenant tenant, Guid? webhookClientId)
{
return new TenantDto(tenant.Id, tenant.Name, tenant.Domains, tenant.Status);
return new TenantDto(tenant.Id, tenant.Name, tenant.Domains, tenant.Status, webhookClientId);
}
private async Task<Dictionary<Guid, Guid>> LoadWebhookClientMappingsAsync(IEnumerable<Guid> tenantIds)
{
var keys = tenantIds.Select(BuildWebhookClientKey).ToHashSet(StringComparer.Ordinal);
if (keys.Count == 0)
{
return new Dictionary<Guid, Guid>();
}
var flags = await _dbContext.SystemFlags
.Where(f => keys.Contains(f.Key))
.ToListAsync();
var result = new Dictionary<Guid, Guid>();
foreach (var flag in flags)
{
var tenantPart = flag.Key.Replace(SendEngineWebhookClientKeyPrefix, string.Empty, StringComparison.Ordinal);
if (!Guid.TryParse(tenantPart, out var tenantId))
{
continue;
}
if (!Guid.TryParse(flag.Value, out var webhookClientId))
{
continue;
}
result[tenantId] = webhookClientId;
}
return result;
}
private async Task<Guid?> LoadWebhookClientMappingAsync(Guid tenantId)
{
var key = BuildWebhookClientKey(tenantId);
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(x => x.Key == key);
if (flag is null || !Guid.TryParse(flag.Value, out var webhookClientId))
{
return null;
}
return webhookClientId;
}
private async Task SetWebhookClientMappingInternalAsync(Guid tenantId, Guid? webhookClientId)
{
var key = BuildWebhookClientKey(tenantId);
var existing = await _dbContext.SystemFlags.FirstOrDefaultAsync(x => x.Key == key);
if (!webhookClientId.HasValue)
{
if (existing is not null)
{
_dbContext.SystemFlags.Remove(existing);
await _dbContext.SaveChangesAsync();
}
return;
}
var value = webhookClientId.Value.ToString();
if (existing is null)
{
_dbContext.SystemFlags.Add(new Domain.Entities.SystemFlag
{
Id = Guid.NewGuid(),
Key = key,
Value = value,
UpdatedAt = DateTimeOffset.UtcNow
});
}
else
{
existing.Value = value;
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
await _dbContext.SaveChangesAsync();
}
private static string BuildWebhookClientKey(Guid tenantId)
{
return $"{SendEngineWebhookClientKeyPrefix}{tenantId:D}";
}
}

View File

@ -61,7 +61,18 @@ public class OAuthClientsController : Controller
{
if (!IsValidUsage(model.Usage))
{
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound.");
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
{
ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage.");
}
if (RequiresClientCredentials(model.Usage)
&& !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
}
if (!ModelState.IsValid)
@ -75,23 +86,7 @@ public class OAuthClientsController : Controller
? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
: null;
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = clientId,
DisplayName = model.Name,
ClientType = model.ClientType,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.ResponseTypes.Code,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Profile,
"scp:openid"
}
};
var descriptor = BuildDescriptor(clientId, model.Name, model.ClientType, model.Usage);
if (!string.IsNullOrWhiteSpace(clientSecret))
{
descriptor.ClientSecret = clientSecret;
@ -102,7 +97,10 @@ public class OAuthClientsController : Controller
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
}
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
await _applicationManager.CreateAsync(descriptor);
@ -134,7 +132,7 @@ public class OAuthClientsController : Controller
return View(new OAuthClientFormViewModel
{
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : Guid.Empty,
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : null,
Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty,
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage,
@ -148,7 +146,18 @@ public class OAuthClientsController : Controller
{
if (!IsValidUsage(model.Usage))
{
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound.");
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
}
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
{
ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage.");
}
if (RequiresClientCredentials(model.Usage)
&& !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
}
if (!ModelState.IsValid)
@ -168,12 +177,20 @@ public class OAuthClientsController : Controller
descriptor.DisplayName = model.Name;
descriptor.ClientType = model.ClientType;
ApplyPermissions(descriptor, model.Usage);
descriptor.RedirectUris.Clear();
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
}
else
{
descriptor.Properties.Remove("tenant_id");
}
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
await _applicationManager.UpdateAsync(app, descriptor);
@ -225,6 +242,60 @@ public class OAuthClientsController : Controller
private static bool IsValidUsage(string usage)
{
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTenantOptionalUsage(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool RequiresClientCredentials(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
}
private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage)
{
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = clientId,
DisplayName = name,
ClientType = clientType
};
ApplyPermissions(descriptor, usage);
return descriptor;
}
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
{
descriptor.Permissions.Clear();
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:events.write.global");
}
else
{
descriptor.Permissions.Add("scp:newsletter:events.write");
}
return;
}
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
descriptor.Permissions.Add("scp:openid");
}
}

View File

@ -37,8 +37,14 @@ public class TenantsController : Controller
return View(model);
}
if (!TryParseWebhookClientId(model.SendEngineWebhookClientId, out var webhookClientId))
{
ModelState.AddModelError(nameof(model.SendEngineWebhookClientId), "Webhook client id must be a valid UUID.");
return View(model);
}
var domains = model.Domains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
await _tenantService.CreateAsync(model.Name, domains, model.Status);
await _tenantService.CreateAsync(model.Name, domains, model.Status, webhookClientId);
return RedirectToAction("Index");
}
@ -56,7 +62,8 @@ public class TenantsController : Controller
Id = tenant.Id,
Name = tenant.Name,
Domains = string.Join(",", tenant.Domains),
Status = tenant.Status
Status = tenant.Status,
SendEngineWebhookClientId = tenant.SendEngineWebhookClientId?.ToString()
});
}
@ -68,8 +75,14 @@ public class TenantsController : Controller
return View(model);
}
if (!TryParseWebhookClientId(model.SendEngineWebhookClientId, out var webhookClientId))
{
ModelState.AddModelError(nameof(model.SendEngineWebhookClientId), "Webhook client id must be a valid UUID.");
return View(model);
}
var domains = model.Domains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
var updated = await _tenantService.UpdateAsync(id, model.Name, domains, model.Status);
var updated = await _tenantService.UpdateAsync(id, model.Name, domains, model.Status, webhookClientId);
if (updated is null)
{
return NotFound();
@ -84,4 +97,21 @@ public class TenantsController : Controller
await _tenantService.DeleteAsync(id);
return RedirectToAction("Index");
}
private static bool TryParseWebhookClientId(string? value, out Guid? webhookClientId)
{
webhookClientId = null;
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
if (!Guid.TryParse(value.Trim(), out var parsed))
{
return false;
}
webhookClientId = parsed;
return true;
}
}

View File

@ -4,8 +4,7 @@ namespace MemberCenter.Web.Models.Admin;
public sealed class OAuthClientFormViewModel
{
[Required]
public Guid TenantId { get; set; }
public Guid? TenantId { get; set; }
[Required]
public string Name { get; set; } = string.Empty;

View File

@ -13,4 +13,6 @@ public sealed class TenantFormViewModel
[Required]
public string Status { get; set; } = "active";
public string? SendEngineWebhookClientId { get; set; }
}

View File

@ -27,6 +27,7 @@
<select asp-for="Usage">
<option value="tenant_api">tenant_api</option>
<option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option>
</select>
<span asp-validation-for="Usage"></span>

View File

@ -27,6 +27,7 @@
<select asp-for="Usage">
<option value="tenant_api">tenant_api</option>
<option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option>
</select>
<span asp-validation-for="Usage"></span>

View File

@ -13,5 +13,9 @@
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<label>Send Engine Webhook Client Id (UUID)</label>
<input asp-for="SendEngineWebhookClientId" />
<span asp-validation-for="SendEngineWebhookClientId"></span>
<button type="submit">Save</button>
</form>

View File

@ -13,5 +13,9 @@
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<label>Send Engine Webhook Client Id (UUID)</label>
<input asp-for="SendEngineWebhookClientId" />
<span asp-validation-for="SendEngineWebhookClientId"></span>
<button type="submit">Save</button>
</form>

View File

@ -4,7 +4,7 @@
<p><a href="/admin/tenants/create">Create</a></p>
<table>
<thead>
<tr><th>Name</th><th>Tenant Id</th><th>Domains</th><th>Status</th><th></th></tr>
<tr><th>Name</th><th>Tenant Id</th><th>Webhook Client Id</th><th>Domains</th><th>Status</th><th></th></tr>
</thead>
<tbody>
@foreach (var tenant in Model)
@ -12,6 +12,7 @@
<tr>
<td>@tenant.Name</td>
<td><code>@tenant.Id</code></td>
<td><code>@(tenant.SendEngineWebhookClientId?.ToString() ?? "-")</code></td>
<td>@string.Join(",", tenant.Domains)</td>
<td>@tenant.Status</td>
<td>