Compare commits
3 Commits
ae6edae39c
...
5c7d4cdf5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7d4cdf5a | ||
|
|
035a7ca821 | ||
|
|
b355ed9e14 |
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
### 租戶端取 Token(Client Credentials)
|
||||
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
|
||||
|
||||
@ -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] 平台 client(SES 聚合事件)用 `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] 會員中心提供個人資料頁
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 設定欄位)保存每個租戶的對應值
|
||||
- 可由管理 UI(Tenant 編輯)或整合 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 黑名單回寫(全租戶 email,scope: `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-scoped,SES 回寫用)
|
||||
- 建議 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 黑名單。
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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] }
|
||||
|
||||
@ -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");
|
||||
|
||||
7
src/MemberCenter.Api/Contracts/IntegrationRequests.cs
Normal file
7
src/MemberCenter.Api/Contracts/IntegrationRequests.cs
Normal 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);
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 =>
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -13,4 +13,6 @@ public sealed class TenantFormViewModel
|
||||
|
||||
[Required]
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
public string? SendEngineWebhookClientId { get; set; }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user