diff --git a/docs/DESIGN.md b/docs/DESIGN.md index cd75d52..697ab61 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -62,7 +62,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|send_api|webhook_outbound|platform_service`) + - id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|send_api|web_login|webhook_outbound|platform_service|file_api`) - OpenIddictAuthorizations - id, application_id, status, subject, type, scopes - OpenIddictTokens @@ -197,7 +197,7 @@ 2) Download 採 delegated short-lived token: - client 向 `A service` 要求下載 - `A service` 驗商業規則與檔案權限 - - `A service` 取得或簽發短效 download token + - `A service` 向新 File Access 專案取得短效 download token,或由 File Access 專案提供簽發元件 - client 帶短效 token 直接向 access agent / file space 請求檔案 - access agent 驗 token 後放行 @@ -259,6 +259,13 @@ resource registry 至少需定義: - delegated token 與一般 S2S token 共用同一套 resource registry,不重複維護 audience 規則 - 對外資料讀寫授權完全由 scope 決定,只要 client 被授權該 scope 即可存取對應能力 +目前實作狀態: +- DB registry 第一版已加入 `auth_resources`、`auth_resource_scopes`、`auth_client_usage_permissions` +- 預設 seed 已包含 `member_center_api`、`send_engine_api`、`file_access_api` +- OAuth client usage-scope matrix 已由 DB 驅動,包含 `file_api` +- resource registry 管理 UI 仍待補 +- delegated download token 發放流程不放在 Member Center,會在新 File Access 專案實作 + ## 7. API 介面(草案) - GET `/oauth/authorize` - POST `/oauth/token` @@ -314,7 +321,7 @@ resource registry 至少需定義: - token 內含 `tenant_id` 與 scope - Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS) - 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫 -- File Access 與未來外部服務亦沿用此模型,差異由 scopes / audience / delegated token 規則表達 +- File Access 與未來外部服務亦沿用此模型;File Access 的 delegated token 規則由新 File Access 專案處理 - 若其他服務要讀取會員個資,應只授予必要的 profile read scopes,不應沿用過寬的 `profile` OIDC scope - service API 為主要整合模式,存取控制以 scopes 為唯一授權來源 @@ -324,7 +331,7 @@ resource registry 至少需定義: - `subscription.linked_to_user` 事件已發送 - 安全設定頁(access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime - Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足 -- resource registry 與 file access token issuing 尚未實作,現況仍是兩組 audience 硬編碼 +- resource registry 已完成 DB 驅動第一版;file access delegated token issuing 不屬於 Member Center,會在新專案實作 ## 8. 安全與合規 - 密碼強度與防暴力破解(rate limit + lockout) diff --git a/docs/FLOWS.md b/docs/FLOWS.md index f0718e6..025c400 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -34,7 +34,7 @@ ## F-02d 檔案下載(A service -> client -> File Space) - [API] client 向 `A service` 請求下載檔案 - [API] `A service` 驗證該 request 是否可讀取指定檔案 -- [API] `A service` 取得或簽發短效 download token +- [API] `A service` 向新 File Access 專案取得短效 download token,或使用 File Access 專案提供的簽發元件 - [API] download token 需至少綁定 `tenant_id + file_id/object_key + method=GET + exp` - [UI/API] `A service` 將帶短效 token 的下載 URL 回給 client - [UI/API] client 直接向 access agent / file space 請求檔案 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7aef51d..eaa1853 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -49,11 +49,15 @@ SendEngine__WebhookSecret=change-me ``` 相容性說明: -- 現行程式仍使用: +- 現行程式已優先使用 resource registry 與目標型態: + - `Auth__Resources__MemberCenter__Audience` + - `Auth__Resources__SendEngine__Audience` + - `Auth__Resources__FileAccess__Audience` +- 舊 key 仍保留相容讀取: - `Auth__MemberCenterAudience` - `Auth__SendEngineAudience` -- 規劃上將收斂為 `Auth__Resources__*__Audience` 形式,避免每新增一個外部服務就再增加一組平行 env key。 -- `File Access` 落地時,應直接採用 resource registry 形式,不再新增第三組硬編碼 audience 判斷。 +- 規劃上將收斂為 DB resource registry;`.env` 僅作為初始 seed / 部署覆寫來源,不應再為每個新服務新增平行 hardcoded key。 +- `File Access` 已直接採用 resource registry 形式,不新增第三組硬編碼 audience 判斷。 `SendEngine` 設定說明: - `SendEngine__BaseUrl`: Send Engine API base URL diff --git a/docs/MEMBER_UPGRADE_PLAN.md b/docs/MEMBER_UPGRADE_PLAN.md index 78bd3bc..c20d794 100644 --- a/docs/MEMBER_UPGRADE_PLAN.md +++ b/docs/MEMBER_UPGRADE_PLAN.md @@ -123,8 +123,7 @@ - profile / addresses / subscriptions 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示 待續作: -- OAuth client usage 與 profile scopes 的最終授權矩陣仍偏靜態,尚未進到 resource registry / DB 驅動 -- Auth resource registry 與 audience/scope 資料驅動化 +- resource registry 管理 UI - Email 驗證信 / 重設信的正式模板與文案優化 - rate limit 與 lockout 規則補齊: - one-click unsubscribe token 申請 @@ -164,20 +163,22 @@ - `MemberCenter.Api` 已有 `GET /user/profile`,但目前只提供基礎欄位,不足以支撐其他服務查詢完整會員資料。 - `docs/DESIGN.md` / `docs/FLOWS.md` 已定義 File Access 流程: - upload: `client_credentials` 取得 upload token 後由外部服務直連 file space - - download: 由業務服務驗權後再簽發短效 delegated download token -- `TokenController.ResolveResources()` 已依 scope 決定 token audience,目前內建: - - `member_center_api` - - `send_engine_api` + - download: 由業務服務驗權後,交由新 File Access 專案簽發或管理短效 delegated download token +- Auth resource registry 第一版已落地: + - 新增 `auth_resources`、`auth_resource_scopes`、`auth_client_usage_permissions` + - `TokenController` 已改由 scope 查 registry 決定 token audiences + - OAuth client usage 可使用的 scopes 已改由 DB permission matrix 決定 + - 預設資源包含 `member_center_api`、`send_engine_api`、`file_access_api` ### 部分實作 - `SendEngine__BaseUrl`、`SendEngine__WebhookSecret` 仍停留在設定來源與 options binding,尚未進入可編輯的管理畫面。 -- Auth 資源 / audience / scope registry 仍為靜態實作,尚未資料驅動化。 +- Auth 資源 / audience / scope registry 已完成 DB 驅動第一版,仍缺管理 UI。 - 帳號治理後台、角色模型與 disabled account 規則已完成第一版,但仍缺進一步細化與保護規則。 - profile / addresses / subscriptions 畫面與驗證目前為最小可用版本,尚未完成 UI refinement。 ### 待補項 -- File Access 的 OAuth client usage、scope、audience 與 delegated token 發放流程尚未落地到程式。 -- Token resource / audience 的設定方式目前仍偏硬編碼,尚未抽象成可擴充模型。 +- File Access 的 OAuth client usage、scope、audience 已落地;delegated token 發放流程不放在 Member Center,會在新 File Access 專案實作。 +- Token resource / audience 已抽象為 registry;後續需補 resource registry 管理畫面。 - Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。 - rate limit 仍缺少 `one-click unsubscribe token` 與更細的風控觀測。 @@ -535,7 +536,7 @@ 目前進度: - `profile:*` scopes 已註冊並接上 policy - current-user 與 by-email service API 已完成第一版 -- audience / resource registry 仍為靜態,不在本輪完成範圍內 +- audience / resource registry DB 驅動第一版已完成 #### 6.3 API 邊界建議 - 其他服務 API: @@ -604,7 +605,7 @@ - 兩種入口最終都回到同一組 subscription 資料。 ### 8. 會員中心作為外部服務的 Token / Auth 中心 -狀態:`部分完成,audience/scope 抽象化待續作` +狀態:`Auth 基礎已完成,管理 UI 待補` #### 8.1 共通模型 - Send Engine 與 File Access 本質上是同一套模型: @@ -627,7 +628,7 @@ - `files:metadata.read` - 新增 audience: - `file_access_api` -- 新增 delegated token 規則: +- File Access 新專案需實作 delegated token 規則: - 下載 token 必須短效 - 必須綁定 `tenant_id` - 必須綁定 `file_id` 或 `object_key` @@ -642,7 +643,7 @@ - scopes - client usages - 是否需要 `tenant_id` - - 是否允許 delegated token + - 是否允許 delegated token(供 File Access 判斷,不代表 Member Center 負責簽發檔案下載 token) - 新增資源服務時,只擴充 registry 與授權規則,不再修改硬編碼 audience 分支。 - 所有外部資料存取均以 scope 作為唯一授權依據。 @@ -653,12 +654,13 @@ - `file_api` -> `file_access_api` - scope 用於細粒度權限控制。 - `TokenController` 依 scope 與 usage 對照 resource registry 計算 `resources/audiences`。 -- delegated token 與一般 S2S token 共用同一套 token issuing abstraction。 +- delegated download token issuing 屬於新 File Access 專案責任;Member Center 只負責發出可呼叫 File Access 的 OAuth access token。 目前進度: -- Send Engine 與 profile scopes 已有基礎 audience / resource 映射 -- File Access 流程仍停留在文件與設計層 -- resource registry / delegated token abstraction 尚未實作 +- Send Engine、Member Center profile/newsletter scopes、File Access scopes 已進 registry +- `TokenController` 已以 registry 解析 audiences +- OAuth client usage-scope matrix 已以 `auth_client_usage_permissions` 驅動 +- File Access delegated token issuing、流程測試與 sample code 會在新 File Access 專案實作 ### 9. 審計紀錄 狀態:`大致完成,少數治理事件待續作` @@ -766,16 +768,13 @@ ### API - 若設定畫面走 API,需新增設定讀寫端點。 - 若帳號管理後台走 API,需新增角色管理與帳號查詢端點。 -- 若要支援 File Access,需新增或擴充: - - resource / audience mapping - - file scopes 註冊 - - delegated download token issuing +- File Access 的 resource / audience mapping 與 file scopes 已完成;流程測試與 sample code 會在新 File Access 專案實作。 - 需新增 current-user 型 profile / addresses / subscriptions API 與對應 scopes。 ### Infrastructure - 新增 SMTP sender 與設定存取服務。 - 調整帳號 provisioning 與角色管理服務。 -- 抽出 token resource resolver / delegated token issuer,避免 audience 與 scope 判斷散落在 controller。 +- token resource resolver 已抽到 registry service;File Access delegated token issuer 不屬於 Member Center。 - 新增會員基本資料與地址簿的資料模型與服務層。 ### Installer diff --git a/docs/MESSMAIL.md b/docs/MESSMAIL.md index 4eeb14e..6a3f01b 100644 --- a/docs/MESSMAIL.md +++ b/docs/MESSMAIL.md @@ -81,7 +81,7 @@ curl -s -X POST https://{send-engine}/api/send-jobs \ - Send Engine 以 Member Center 的 JWKS 驗簽 access token(JWS)。 - 驗證重點:`iss`、`aud`、`scope`、`tenant_id`、`exp`。 - `iss`:由 Member Center `Auth__Issuer` 設定(例:`http://localhost:7850/`) -- `aud`:Send Engine 流程預設 `send_engine_api`(可用 `Auth__SendEngineAudience` 覆寫) +- `aud`:Send Engine 流程預設 `send_engine_api`,由 Auth resource registry 決定;舊版 `Auth__SendEngineAudience` 仍作為相容 seed 來源 --- diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index bc55f81..f76f4be 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -72,6 +72,10 @@ - `usage=send_api`: - 供租戶站台呼叫 Send Engine 發信流程 - 內建 scope:`newsletter:send.write`、`newsletter:send.read` +- `usage=file_api`: + - 供檔案上傳 / 下載服務使用 + - 使用 `client_credentials` + - 內建 scope:`files:upload.write`、`files:download.read`、`files:download.delegate`、`files:metadata.read`、`files:delete` - `usage=web_login`: - 供外部網站使用 Member Center 統一登入 UI - 使用 Authorization Code + PKCE @@ -86,7 +90,7 @@ - `usage=platform_service`: - 供平台級 S2S(例如 SES 聚合事件回寫) - 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global` -- `tenant_api` / `send_api` / `platform_service` 建議(且實作要求)`client_type=confidential` +- `tenant_api` / `send_api` / `platform_service` / `file_api` 建議(且實作要求)`client_type=confidential` - `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要;其他 usage 可為空 - 管理規則: - 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`) @@ -104,7 +108,7 @@ - `POST /newsletter/one-click-unsubscribe-tokens`:已實作(Send Engine 批次申請 one-click token) ### Auth / Scope -- `tenant_api` / `send_api` / `webhook_outbound` 類型需綁定 `tenant_id` +- `tenant_api` / `send_api` / `file_api` / `webhook_outbound` 類型需綁定 `tenant_id` - `platform_service` 可不綁定 `tenant_id` - 新增 scope: - `newsletter:list.read` @@ -179,6 +183,7 @@ 補充: - File Access 與 Send Engine 同屬「外部資源服務」,驗證模型一致 - 差異在於 File Access download token 為 delegated short-lived token,而非一般 client credentials token +- delegated download token issuing、流程測試與 sample code 會在新 File Access 專案實作,不屬於 Member Center API ### 回寫原因碼(Send Engine -> Member Center) - `hard_bounce` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ddc917d..12ddbe5 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1081,6 +1081,6 @@ components: type: string nullable: true name: { type: string } - usage: { type: string, enum: [tenant_api, send_api, webhook_outbound, platform_service] } + usage: { type: string, enum: [tenant_api, send_api, web_login, webhook_outbound, platform_service, file_api] } redirect_uris: { type: array, items: { type: string } } client_type: { type: string, enum: [public, confidential] } diff --git a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs index b331b08..d69e730 100644 --- a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs @@ -1,4 +1,5 @@ using MemberCenter.Api.Contracts; +using MemberCenter.Application.Abstractions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; @@ -12,10 +13,14 @@ namespace MemberCenter.Api.Controllers; public class AdminOAuthClientsController : ControllerBase { private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IAuthResourceRegistryService _authResourceRegistry; - public AdminOAuthClientsController(IOpenIddictApplicationManager applicationManager) + public AdminOAuthClientsController( + IOpenIddictApplicationManager applicationManager, + IAuthResourceRegistryService authResourceRegistry) { _applicationManager = applicationManager; + _authResourceRegistry = authResourceRegistry; } [HttpGet] @@ -43,7 +48,7 @@ public class AdminOAuthClientsController : ControllerBase { if (!IsValidUsage(request.Usage)) { - return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service."); + return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api."); } if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty)) @@ -73,7 +78,7 @@ public class AdminOAuthClientsController : ControllerBase DisplayName = request.Name, ClientType = request.ClientType }; - ApplyPermissions(descriptor, request.Usage); + await ApplyPermissionsAsync(descriptor, request.Usage); foreach (var uri in redirectUris) { @@ -122,7 +127,7 @@ public class AdminOAuthClientsController : ControllerBase { if (!IsValidUsage(request.Usage)) { - return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service."); + return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api."); } if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty)) @@ -157,7 +162,7 @@ public class AdminOAuthClientsController : ControllerBase descriptor.DisplayName = request.Name; descriptor.ClientType = request.ClientType; - ApplyPermissions(descriptor, request.Usage); + await ApplyPermissionsAsync(descriptor, request.Usage); descriptor.RedirectUris.Clear(); foreach (var uri in redirectUris) { @@ -203,7 +208,8 @@ public class AdminOAuthClientsController : ControllerBase || string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase) - || string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); + || string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase); } private static bool IsTenantOptionalUsage(string usage) @@ -216,7 +222,8 @@ public class AdminOAuthClientsController : ControllerBase { return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase) - || string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase); + || string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase); } private static bool UsesAuthorizationCodeFlow(string usage) @@ -249,54 +256,27 @@ public class AdminOAuthClientsController : ControllerBase return (values, null); } - private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage) + private async Task ApplyPermissionsAsync(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) - || string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)) + if (UsesAuthorizationCodeFlow(usage)) + { + 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); + } + else { descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials); - if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)) - { - descriptor.Permissions.Add("scp:newsletter:events.write.global"); - AddProfileScopePermissions(descriptor); - } - else if (string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)) - { - descriptor.Permissions.Add("scp:newsletter:send.write"); - descriptor.Permissions.Add("scp:newsletter:send.read"); - } - else - { - descriptor.Permissions.Add("scp:newsletter:events.write"); - AddProfileScopePermissions(descriptor); - } - 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"); - if (string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)) + var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted); + foreach (var scope in scopes) { - descriptor.Permissions.Add("scp:profile:basic.read"); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope); } } - - private static void AddProfileScopePermissions(OpenIddictApplicationDescriptor descriptor) - { - descriptor.Permissions.Add("scp:profile:basic.read"); - descriptor.Permissions.Add("scp:profile:basic.write"); - descriptor.Permissions.Add("scp:profile:addresses.read"); - descriptor.Permissions.Add("scp:profile:addresses.write"); - descriptor.Permissions.Add("scp:profile:subscriptions.read"); - descriptor.Permissions.Add("scp:profile:subscriptions.write"); - } } diff --git a/src/MemberCenter.Api/Controllers/TokenController.cs b/src/MemberCenter.Api/Controllers/TokenController.cs index 1900f4a..213167d 100644 --- a/src/MemberCenter.Api/Controllers/TokenController.cs +++ b/src/MemberCenter.Api/Controllers/TokenController.cs @@ -1,4 +1,5 @@ using MemberCenter.Api.Extensions; +using MemberCenter.Application.Abstractions; using MemberCenter.Infrastructure.Identity; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; @@ -14,23 +15,21 @@ namespace MemberCenter.Api.Controllers; public class TokenController : ControllerBase { private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp"; - private readonly string _memberCenterAudience; - private readonly string _sendEngineAudience; private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IAuthResourceRegistryService _authResourceRegistry; public TokenController( - IConfiguration configuration, UserManager userManager, SignInManager signInManager, - IOpenIddictApplicationManager applicationManager) + IOpenIddictApplicationManager applicationManager, + IAuthResourceRegistryService authResourceRegistry) { - _memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api"; - _sendEngineAudience = configuration["Auth:SendEngineAudience"] ?? "send_engine_api"; _userManager = userManager; _signInManager = signInManager; _applicationManager = applicationManager; + _authResourceRegistry = authResourceRegistry; } [HttpPost("/oauth/token")] @@ -65,7 +64,7 @@ public class TokenController : ControllerBase } var scopes = request.Scope.GetScopesOrDefault(); principal.SetScopes(scopes); - principal.SetResources(ResolveResources(scopes)); + principal.SetResources(await ResolveResourcesAsync(scopes)); foreach (var claim in principal.Claims) { @@ -143,7 +142,7 @@ public class TokenController : ControllerBase var principal = new ClaimsPrincipal(identity); var scopes = request.Scope.GetScopesOrDefault(); principal.SetScopes(scopes); - principal.SetResources(ResolveResources(scopes)); + principal.SetResources(await ResolveResourcesAsync(scopes)); foreach (var claim in principal.Claims) { @@ -176,36 +175,8 @@ public class TokenController : ControllerBase || string.Equals(tokenSecurityStamp, user.SecurityStamp, StringComparison.Ordinal); } - private IEnumerable ResolveResources(IEnumerable scopes) + private async Task> ResolveResourcesAsync(IEnumerable scopes) { - var scopeSet = scopes as ISet ?? new HashSet(scopes, StringComparer.Ordinal); - if (scopeSet.Count == 0) - { - return [_memberCenterAudience]; - } - - var resources = new HashSet(StringComparer.Ordinal); - - if (scopeSet.Contains("newsletter:send.write") || scopeSet.Contains("newsletter:send.read")) - { - resources.Add(_sendEngineAudience); - } - - if (scopeSet.Any(scope => - (scope.StartsWith("newsletter:", StringComparison.Ordinal) && scope is not "newsletter:send.write" && scope is not "newsletter:send.read") - || scope.StartsWith("profile:", StringComparison.Ordinal)) - || scopeSet.Contains(OpenIddictConstants.Scopes.OpenId) - || scopeSet.Contains(OpenIddictConstants.Scopes.Email) - || scopeSet.Contains(OpenIddictConstants.Scopes.Profile)) - { - resources.Add(_memberCenterAudience); - } - - if (resources.Count == 0) - { - resources.Add(_memberCenterAudience); - } - - return resources; + return await _authResourceRegistry.ResolveAudiencesAsync(scopes, HttpContext.RequestAborted); } } diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 423bca4..cbf9971 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -110,7 +110,12 @@ builder.Services.AddOpenIddict() "newsletter:send.read", "newsletter:events.read", "newsletter:events.write", - "newsletter:events.write.global"); + "newsletter:events.write.global", + "files:upload.write", + "files:download.read", + "files:download.delegate", + "files:metadata.read", + "files:delete"); options.AddDevelopmentEncryptionCertificate(); options.AddDevelopmentSigningCertificate(); @@ -187,10 +192,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); @@ -198,6 +205,8 @@ builder.Services.AddScoped CreateFixedWindowLimiter( AutoReplenishment = true }); } + +static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services) +{ + await using var scope = services.CreateAsyncScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + await registry.EnsureDefaultsAsync(); +} diff --git a/src/MemberCenter.Application/Abstractions/IAuthResourceRegistryService.cs b/src/MemberCenter.Application/Abstractions/IAuthResourceRegistryService.cs new file mode 100644 index 0000000..5db7e7d --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IAuthResourceRegistryService.cs @@ -0,0 +1,8 @@ +namespace MemberCenter.Application.Abstractions; + +public interface IAuthResourceRegistryService +{ + Task EnsureDefaultsAsync(CancellationToken cancellationToken = default); + Task> ResolveAudiencesAsync(IEnumerable scopes, CancellationToken cancellationToken = default); + Task> GetAllowedScopesForUsageAsync(string usage, CancellationToken cancellationToken = default); +} diff --git a/src/MemberCenter.Domain/Entities/AuthClientUsagePermission.cs b/src/MemberCenter.Domain/Entities/AuthClientUsagePermission.cs new file mode 100644 index 0000000..896c34c --- /dev/null +++ b/src/MemberCenter.Domain/Entities/AuthClientUsagePermission.cs @@ -0,0 +1,10 @@ +namespace MemberCenter.Domain.Entities; + +public sealed class AuthClientUsagePermission +{ + public Guid Id { get; set; } + public string Usage { get; set; } = string.Empty; + public string Scope { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/MemberCenter.Domain/Entities/AuthResource.cs b/src/MemberCenter.Domain/Entities/AuthResource.cs new file mode 100644 index 0000000..e0c0fd9 --- /dev/null +++ b/src/MemberCenter.Domain/Entities/AuthResource.cs @@ -0,0 +1,16 @@ +namespace MemberCenter.Domain.Entities; + +public sealed class AuthResource +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public string? Description { get; set; } + public bool RequireTenant { get; set; } + public bool AllowDelegatedToken { get; set; } + public bool IsEnabled { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public List Scopes { get; set; } = new(); +} diff --git a/src/MemberCenter.Domain/Entities/AuthResourceScope.cs b/src/MemberCenter.Domain/Entities/AuthResourceScope.cs new file mode 100644 index 0000000..ba5a132 --- /dev/null +++ b/src/MemberCenter.Domain/Entities/AuthResourceScope.cs @@ -0,0 +1,13 @@ +namespace MemberCenter.Domain.Entities; + +public sealed class AuthResourceScope +{ + public Guid Id { get; set; } + public Guid ResourceId { get; set; } + public string Scope { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsEnabled { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public AuthResource? Resource { get; set; } +} diff --git a/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj b/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj index 04807e2..a5b3d7d 100644 --- a/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj +++ b/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj @@ -12,6 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs index 06fa2e4..15d42f9 100644 --- a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs +++ b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs @@ -23,6 +23,9 @@ public class MemberCenterDbContext public DbSet UnsubscribeTokens => Set(); public DbSet AuditLogs => Set(); public DbSet SystemFlags => Set(); + public DbSet AuthResources => Set(); + public DbSet AuthResourceScopes => Set(); + public DbSet AuthClientUsagePermissions => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -199,6 +202,49 @@ public class MemberCenterDbContext entity.HasIndex(x => x.Key).IsUnique(); }); + builder.Entity(entity => + { + entity.ToTable("auth_resources"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).IsRequired().HasMaxLength(100); + entity.Property(x => x.Audience).IsRequired().HasMaxLength(200); + entity.Property(x => x.Description).HasMaxLength(500); + entity.Property(x => x.RequireTenant).HasDefaultValue(false); + entity.Property(x => x.AllowDelegatedToken).HasDefaultValue(false); + entity.Property(x => x.IsEnabled).HasDefaultValue(true); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()"); + entity.HasIndex(x => x.Name).IsUnique(); + entity.HasIndex(x => x.Audience).IsUnique(); + }); + + builder.Entity(entity => + { + entity.ToTable("auth_resource_scopes"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Scope).IsRequired().HasMaxLength(200); + entity.Property(x => x.Description).HasMaxLength(500); + entity.Property(x => x.IsEnabled).HasDefaultValue(true); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); + entity.HasIndex(x => new { x.ResourceId, x.Scope }).IsUnique(); + entity.HasIndex(x => x.Scope).HasDatabaseName("idx_auth_resource_scopes_scope"); + entity.HasOne(x => x.Resource) + .WithMany(x => x.Scopes) + .HasForeignKey(x => x.ResourceId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.ToTable("auth_client_usage_permissions"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Usage).IsRequired().HasMaxLength(100); + entity.Property(x => x.Scope).IsRequired().HasMaxLength(200); + entity.Property(x => x.IsEnabled).HasDefaultValue(true); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); + entity.HasIndex(x => new { x.Usage, x.Scope }).IsUnique(); + }); + builder.Entity(entity => { entity.ToTable("users"); diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.Designer.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.Designer.cs new file mode 100644 index 0000000..dfecfc8 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.Designer.cs @@ -0,0 +1,1215 @@ +// +using System; +using System.Collections.Generic; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260422154035_AddAuthResourceRegistry")] + partial class AddAuthResourceRegistry + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthClientUsagePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Usage") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Usage", "Scope") + .IsUnique(); + + b.ToTable("auth_client_usage_permissions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowDelegatedToken") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Audience") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequireTenant") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Audience") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("auth_resources", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ResourceId") + .HasColumnType("uuid"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("idx_auth_resource_scopes_scope"); + + b.HasIndex("ResourceId", "Scope") + .IsUnique(); + + b.ToTable("auth_resource_scopes", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlacklistedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("BlacklistedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("email_blacklist", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_email_verifications_email"); + + b.HasIndex("TenantId"); + + b.ToTable("email_verifications", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("newsletter_lists", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ListId") + .HasColumnType("uuid"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_newsletter_subscriptions_email"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_newsletter_subscriptions_list_id"); + + b.HasIndex("UserId"); + + b.HasIndex("ListId", "Email") + .IsUnique(); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.SystemFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("system_flags", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property>("Domains") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("unsubscribe_tokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLine2") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressMetaJson") + .HasColumnType("jsonb"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("District") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RecipientName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RecipientPhone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StateRegion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Usage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("shipping"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .HasDatabaseName("idx_user_addresses_user_id"); + + b.HasIndex("UserId", "Usage") + .HasDatabaseName("idx_user_addresses_user_id_usage"); + + b.HasIndex("UserId", "Usage", "IsDefault") + .IsUnique() + .HasDatabaseName("ux_user_addresses_default_per_usage") + .HasFilter("\"is_default\" = true"); + + b.ToTable("user_addresses", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Department") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("unspecified"); + + b.Property("InvoiceTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("JobTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LandlinePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MobilePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NickName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Remark") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TaxId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("UserId"); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("BlacklistedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BlacklistedBy") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlacklisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.HasOne("MemberCenter.Domain.Entities.AuthResource", "Resource") + .WithMany("Scopes") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resource"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant") + .WithMany("NewsletterLists") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterList", "List") + .WithMany("Subscriptions") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("List"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterSubscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany("Addresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithOne("Profile") + .HasForeignKey("MemberCenter.Domain.Entities.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Navigation("Scopes"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Navigation("NewsletterLists"); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Navigation("Addresses"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.cs new file mode 100644 index 0000000..eadfb2e --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAuthResourceRegistry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "auth_client_usage_permissions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Usage = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Scope = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_auth_client_usage_permissions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "auth_resources", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Audience = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + RequireTenant = table.Column(type: "boolean", nullable: false, defaultValue: false), + AllowDelegatedToken = table.Column(type: "boolean", nullable: false, defaultValue: false), + IsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_auth_resources", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "auth_resource_scopes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ResourceId = table.Column(type: "uuid", nullable: false), + Scope = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + IsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_auth_resource_scopes", x => x.Id); + table.ForeignKey( + name: "FK_auth_resource_scopes_auth_resources_ResourceId", + column: x => x.ResourceId, + principalTable: "auth_resources", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_auth_client_usage_permissions_Usage_Scope", + table: "auth_client_usage_permissions", + columns: new[] { "Usage", "Scope" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_auth_resource_scopes_scope", + table: "auth_resource_scopes", + column: "Scope"); + + migrationBuilder.CreateIndex( + name: "IX_auth_resource_scopes_ResourceId_Scope", + table: "auth_resource_scopes", + columns: new[] { "ResourceId", "Scope" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_auth_resources_Audience", + table: "auth_resources", + column: "Audience", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_auth_resources_Name", + table: "auth_resources", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "auth_client_usage_permissions"); + + migrationBuilder.DropTable( + name: "auth_resource_scopes"); + + migrationBuilder.DropTable( + name: "auth_resources"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs index 60f576a..d0f619c 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs @@ -54,6 +54,135 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("audit_logs", (string)null); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthClientUsagePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Usage") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Usage", "Scope") + .IsUnique(); + + b.ToTable("auth_client_usage_permissions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowDelegatedToken") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Audience") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequireTenant") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Audience") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("auth_resources", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ResourceId") + .HasColumnType("uuid"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("idx_auth_resource_scopes_scope"); + + b.HasIndex("ResourceId", "Scope") + .IsUnique(); + + b.ToTable("auth_resource_scopes", (string)null); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b => { b.Property("Id") @@ -893,6 +1022,17 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("OpenIddictTokens", (string)null); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.HasOne("MemberCenter.Domain.Entities.AuthResource", "Resource") + .WithMany("Scopes") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resource"); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => { b.HasOne("MemberCenter.Domain.Entities.Tenant", null) @@ -1033,6 +1173,11 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.Navigation("Authorization"); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Navigation("Scopes"); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => { b.Navigation("Subscriptions"); diff --git a/src/MemberCenter.Infrastructure/Services/AuthResourceRegistryService.cs b/src/MemberCenter.Infrastructure/Services/AuthResourceRegistryService.cs new file mode 100644 index 0000000..935d0c2 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/AuthResourceRegistryService.cs @@ -0,0 +1,288 @@ +using MemberCenter.Application.Abstractions; +using MemberCenter.Domain.Entities; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using OpenIddict.Abstractions; + +namespace MemberCenter.Infrastructure.Services; + +public sealed class AuthResourceRegistryService : IAuthResourceRegistryService +{ + private const string MemberCenterResourceName = "member_center_api"; + + private readonly MemberCenterDbContext _dbContext; + private readonly IConfiguration _configuration; + + public AuthResourceRegistryService(MemberCenterDbContext dbContext, IConfiguration configuration) + { + _dbContext = dbContext; + _configuration = configuration; + } + + public async Task EnsureDefaultsAsync(CancellationToken cancellationToken = default) + { + var memberCenter = await EnsureResourceAsync( + MemberCenterResourceName, + ResolveAudience("MemberCenter", "Auth:MemberCenterAudience", "member_center_api"), + "Member Center API", + requireTenant: false, + allowDelegatedToken: true, + cancellationToken); + + var sendEngine = await EnsureResourceAsync( + "send_engine_api", + ResolveAudience("SendEngine", "Auth:SendEngineAudience", "send_engine_api"), + "Send Engine API", + requireTenant: true, + allowDelegatedToken: false, + cancellationToken); + + var fileAccess = await EnsureResourceAsync( + "file_access_api", + ResolveAudience("FileAccess", null, "file_access_api"), + "File upload/download API", + requireTenant: true, + allowDelegatedToken: true, + cancellationToken); + + await EnsureScopesAsync(memberCenter.Id, [ + OpenIddictConstants.Scopes.OpenId, + OpenIddictConstants.Scopes.Email, + OpenIddictConstants.Scopes.Profile, + "profile:basic.read", + "profile:basic.write", + "profile:addresses.read", + "profile:addresses.write", + "profile:subscriptions.read", + "profile:subscriptions.write", + "newsletter:list.read", + "newsletter:events.read", + "newsletter:events.write", + "newsletter:events.write.global" + ], cancellationToken); + + await EnsureScopesAsync(sendEngine.Id, [ + "newsletter:send.write", + "newsletter:send.read" + ], cancellationToken); + + await EnsureScopesAsync(fileAccess.Id, [ + "files:upload.write", + "files:download.read", + "files:download.delegate", + "files:metadata.read", + "files:delete" + ], cancellationToken); + + await EnsureUsagePermissionsAsync("web_login", [ + OpenIddictConstants.Scopes.OpenId, + OpenIddictConstants.Scopes.Email, + OpenIddictConstants.Scopes.Profile, + "profile:basic.read" + ], cancellationToken); + + await EnsureUsagePermissionsAsync("webhook_outbound", [ + OpenIddictConstants.Scopes.OpenId, + OpenIddictConstants.Scopes.Email, + OpenIddictConstants.Scopes.Profile, + "newsletter:events.write" + ], cancellationToken); + + await EnsureUsagePermissionsAsync("tenant_api", [ + "newsletter:events.write", + "newsletter:list.read", + "profile:basic.read", + "profile:basic.write", + "profile:addresses.read", + "profile:addresses.write", + "profile:subscriptions.read", + "profile:subscriptions.write" + ], cancellationToken); + + await EnsureUsagePermissionsAsync("platform_service", [ + "newsletter:events.write.global", + "newsletter:list.read", + "profile:basic.read", + "profile:basic.write", + "profile:addresses.read", + "profile:addresses.write", + "profile:subscriptions.read", + "profile:subscriptions.write" + ], cancellationToken); + + await EnsureUsagePermissionsAsync("send_api", [ + "newsletter:send.write", + "newsletter:send.read" + ], cancellationToken); + + await EnsureUsagePermissionsAsync("file_api", [ + "files:upload.write", + "files:download.read", + "files:download.delegate", + "files:metadata.read", + "files:delete" + ], cancellationToken); + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task> ResolveAudiencesAsync( + IEnumerable scopes, + CancellationToken cancellationToken = default) + { + var requestedScopes = scopes + .Where(scope => !string.IsNullOrWhiteSpace(scope)) + .Select(scope => scope.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (requestedScopes.Count == 0) + { + var defaultAudience = await _dbContext.AuthResources + .Where(resource => resource.Name == MemberCenterResourceName && resource.IsEnabled) + .Select(resource => resource.Audience) + .SingleOrDefaultAsync(cancellationToken); + + return string.IsNullOrWhiteSpace(defaultAudience) + ? ["member_center_api"] + : [defaultAudience]; + } + + var resourceScopes = await _dbContext.AuthResourceScopes + .AsNoTracking() + .Include(resourceScope => resourceScope.Resource) + .Where(resourceScope => + resourceScope.IsEnabled + && requestedScopes.Contains(resourceScope.Scope) + && resourceScope.Resource != null + && resourceScope.Resource.IsEnabled) + .ToListAsync(cancellationToken); + + var resolvedScopes = resourceScopes + .Select(resourceScope => resourceScope.Scope) + .ToHashSet(StringComparer.Ordinal); + + var unknownScopes = requestedScopes + .Where(scope => !resolvedScopes.Contains(scope)) + .ToList(); + if (unknownScopes.Count > 0) + { + throw new InvalidOperationException($"Unknown auth scope(s): {string.Join(", ", unknownScopes)}"); + } + + return resourceScopes + .Select(resourceScope => resourceScope.Resource!.Audience) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + public async Task> GetAllowedScopesForUsageAsync( + string usage, + CancellationToken cancellationToken = default) + { + var normalizedUsage = usage.Trim(); + return await _dbContext.AuthClientUsagePermissions + .AsNoTracking() + .Where(permission => permission.Usage == normalizedUsage && permission.IsEnabled) + .OrderBy(permission => permission.Scope) + .Select(permission => permission.Scope) + .ToListAsync(cancellationToken); + } + + private async Task EnsureResourceAsync( + string name, + string audience, + string description, + bool requireTenant, + bool allowDelegatedToken, + CancellationToken cancellationToken) + { + var resource = await _dbContext.AuthResources + .SingleOrDefaultAsync(item => item.Name == name, cancellationToken); + + if (resource is null) + { + resource = new AuthResource + { + Id = Guid.NewGuid(), + Name = name, + Audience = audience + }; + _dbContext.AuthResources.Add(resource); + } + + resource.Audience = audience; + resource.Description = description; + resource.RequireTenant = requireTenant; + resource.AllowDelegatedToken = allowDelegatedToken; + resource.IsEnabled = true; + resource.UpdatedAt = DateTimeOffset.UtcNow; + return resource; + } + + private string ResolveAudience(string resourceKey, string? legacyKey, string defaultValue) + { + var configured = _configuration[$"Auth:Resources:{resourceKey}:Audience"]; + if (string.IsNullOrWhiteSpace(configured) && !string.IsNullOrWhiteSpace(legacyKey)) + { + configured = _configuration[legacyKey]; + } + + return string.IsNullOrWhiteSpace(configured) ? defaultValue : configured; + } + + private async Task EnsureScopesAsync( + Guid resourceId, + IEnumerable scopes, + CancellationToken cancellationToken) + { + var existingScopes = await _dbContext.AuthResourceScopes + .Where(scope => scope.ResourceId == resourceId) + .ToDictionaryAsync(scope => scope.Scope, StringComparer.Ordinal, cancellationToken); + + foreach (var scope in scopes.Distinct(StringComparer.Ordinal)) + { + if (existingScopes.TryGetValue(scope, out var existing)) + { + existing.IsEnabled = true; + continue; + } + + _dbContext.AuthResourceScopes.Add(new AuthResourceScope + { + Id = Guid.NewGuid(), + ResourceId = resourceId, + Scope = scope, + IsEnabled = true + }); + } + } + + private async Task EnsureUsagePermissionsAsync( + string usage, + IEnumerable scopes, + CancellationToken cancellationToken) + { + var existingPermissions = await _dbContext.AuthClientUsagePermissions + .Where(permission => permission.Usage == usage) + .ToDictionaryAsync(permission => permission.Scope, StringComparer.Ordinal, cancellationToken); + + foreach (var scope in scopes.Distinct(StringComparer.Ordinal)) + { + if (existingPermissions.TryGetValue(scope, out var existing)) + { + existing.IsEnabled = true; + continue; + } + + _dbContext.AuthClientUsagePermissions.Add(new AuthClientUsagePermission + { + Id = Guid.NewGuid(), + Usage = usage, + Scope = scope, + IsEnabled = true + }); + } + } +} diff --git a/src/MemberCenter.Installer/Program.cs b/src/MemberCenter.Installer/Program.cs index 44865d7..e1192f3 100644 --- a/src/MemberCenter.Installer/Program.cs +++ b/src/MemberCenter.Installer/Program.cs @@ -1,7 +1,9 @@ +using MemberCenter.Application.Abstractions; using MemberCenter.Domain.Entities; using MemberCenter.Infrastructure.Configuration; using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Persistence; +using MemberCenter.Infrastructure.Services; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Migrations; @@ -99,6 +101,8 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo } await db.Database.MigrateAsync(); + var registry = scope.ServiceProvider.GetRequiredService(); + await registry.EnsureDefaultsAsync(); await EnsureRoleAsync(roleManager, "superuser"); await EnsureRoleAsync(roleManager, "admin"); @@ -267,6 +271,8 @@ migrateCommand.SetHandler(async (string? connectionString, string? appsettings, if (string.IsNullOrWhiteSpace(target)) { await db.Database.MigrateAsync(); + var registry = scope.ServiceProvider.GetRequiredService(); + await registry.EnsureDefaultsAsync(); } else { @@ -288,6 +294,9 @@ static IServiceProvider BuildServices(string connectionString) { var services = new ServiceCollection(); services.AddLogging(builder => builder.AddConsole()); + services.AddSingleton(new ConfigurationBuilder() + .AddEnvironmentVariables() + .Build()); services.AddDbContext(options => { options.UseNpgsql(connectionString); @@ -306,6 +315,8 @@ static IServiceProvider BuildServices(string connectionString) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); + services.AddScoped(); + return services.BuildServiceProvider(); } diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs index dae0f26..42f4927 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs @@ -13,13 +13,16 @@ public class OAuthClientsController : Controller { private readonly IOpenIddictApplicationManager _applicationManager; private readonly ITenantService _tenantService; + private readonly IAuthResourceRegistryService _authResourceRegistry; public OAuthClientsController( IOpenIddictApplicationManager applicationManager, - ITenantService tenantService) + ITenantService tenantService, + IAuthResourceRegistryService authResourceRegistry) { _applicationManager = applicationManager; _tenantService = tenantService; + _authResourceRegistry = authResourceRegistry; } [HttpGet("")] @@ -62,7 +65,7 @@ public class OAuthClientsController : Controller { if (!IsValidUsage(model.Usage)) { - ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service."); + ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api."); } if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty)) @@ -98,7 +101,7 @@ public class OAuthClientsController : Controller ? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) : null; - var descriptor = BuildDescriptor(clientId, model.Name, model.ClientType, model.Usage); + var descriptor = await BuildDescriptorAsync(clientId, model.Name, model.ClientType, model.Usage); if (!string.IsNullOrWhiteSpace(clientSecret)) { descriptor.ClientSecret = clientSecret; @@ -159,7 +162,7 @@ public class OAuthClientsController : Controller { if (!IsValidUsage(model.Usage)) { - ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service."); + ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api."); } if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty)) @@ -201,7 +204,7 @@ public class OAuthClientsController : Controller descriptor.DisplayName = model.Name; descriptor.ClientType = model.ClientType; - ApplyPermissions(descriptor, model.Usage); + await ApplyPermissionsAsync(descriptor, model.Usage); descriptor.RedirectUris.Clear(); foreach (var uri in redirectUris) { @@ -266,7 +269,7 @@ public class OAuthClientsController : Controller } private static bool IsValidUsage(string usage) => - usage is "tenant_api" or "send_api" or "web_login" or "webhook_outbound" or "platform_service"; + usage is "tenant_api" or "send_api" or "web_login" or "webhook_outbound" or "platform_service" or "file_api"; private static bool IsTenantOptionalUsage(string usage) => usage is "platform_service" or "web_login"; @@ -275,7 +278,7 @@ public class OAuthClientsController : Controller usage is "web_login" or "webhook_outbound"; private static bool RequiresClientCredentials(string usage) => - usage is "tenant_api" or "send_api" or "platform_service"; + usage is "tenant_api" or "send_api" or "platform_service" or "file_api"; private static List NormalizeRedirectUris(string? value, out string? error) { @@ -297,7 +300,7 @@ public class OAuthClientsController : Controller return items; } - private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage) + private async Task BuildDescriptorAsync(string clientId, string name, string clientType, string usage) { var descriptor = new OpenIddictApplicationDescriptor { @@ -306,11 +309,11 @@ public class OAuthClientsController : Controller ClientType = clientType }; - ApplyPermissions(descriptor, usage); + await ApplyPermissionsAsync(descriptor, usage); return descriptor; } - private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage) + private async Task ApplyPermissionsAsync(OpenIddictApplicationDescriptor descriptor, string usage) { descriptor.Permissions.Clear(); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token); @@ -321,24 +324,16 @@ public class OAuthClientsController : Controller 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.Prefixes.Scope + OpenIddictConstants.Scopes.OpenId); - descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.Email); - descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.Profile); - if (usage == "web_login") - { - descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "profile:basic.read"); - } - else - { - descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write"); - } } else { descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials); - descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write"); - descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write.global"); - descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:list.read"); + } + + var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted); + foreach (var scope in scopes) + { + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope); } } } diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml index 183673a..7112b51 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml @@ -30,6 +30,7 @@ + @@ -52,7 +53,8 @@ const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound"; const requiresConfidential = usageValue === "tenant_api" || usageValue === "send_api" - || usageValue === "platform_service"; + || usageValue === "platform_service" + || usageValue === "file_api"; redirect.disabled = !needsRedirect; if (!needsRedirect) { diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml index 468b168..d0e7f42 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml @@ -30,6 +30,7 @@ + @@ -52,7 +53,8 @@ const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound"; const requiresConfidential = usageValue === "tenant_api" || usageValue === "send_api" - || usageValue === "platform_service"; + || usageValue === "platform_service" + || usageValue === "file_api"; redirect.disabled = !needsRedirect; if (!needsRedirect) { diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 6de46d8..805793a 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -127,6 +127,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); builder.Services.AddScoped(); @@ -143,6 +144,8 @@ builder.Services.AddHttpContextAccessor(); var app = builder.Build(); +await EnsureAuthRegistryDefaultsAsync(app.Services); + if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Home/Error"); @@ -217,3 +220,10 @@ static RateLimitPartition CreateFixedWindowLimiter( AutoReplenishment = true }); } + +static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services) +{ + await using var scope = services.CreateAsyncScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + await registry.EnsureDefaultsAsync(); +}