Add auth resource registry for audience and scope mapping

This commit is contained in:
Warren Chen 2026-04-23 00:50:11 +09:00
parent f1077d0801
commit 5f32452263
25 changed files with 2002 additions and 145 deletions

View File

@ -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 驗簽 JWTJWS
- 驗簽通過後將 `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

View File

@ -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 請求檔案

View File

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

View File

@ -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 serviceFile Access delegated token issuer 不屬於 Member Center。
- 新增會員基本資料與地址簿的資料模型與服務層。
### Installer

View File

@ -81,7 +81,7 @@ curl -s -X POST https://{send-engine}/api/send-jobs \
- Send Engine 以 Member Center 的 JWKS 驗簽 access tokenJWS
- 驗證重點:`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 來源
---

View File

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

View File

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

View File

@ -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");
}
}

View File

@ -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<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IAuthResourceRegistryService _authResourceRegistry;
public TokenController(
IConfiguration configuration,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> 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<string> ResolveResources(IEnumerable<string> scopes)
private async Task<IReadOnlyList<string>> ResolveResourcesAsync(IEnumerable<string> scopes)
{
var scopeSet = scopes as ISet<string> ?? new HashSet<string>(scopes, StringComparer.Ordinal);
if (scopeSet.Count == 0)
{
return [_memberCenterAudience];
}
var resources = new HashSet<string>(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);
}
}

View File

@ -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<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IAccountEmailService, AccountEmailService>();
builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
@ -198,6 +205,8 @@ builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublish
var app = builder.Build();
await EnsureAuthRegistryDefaultsAsync(app.Services);
app.UseForwardedHeaders();
if (!string.IsNullOrWhiteSpace(pathBase))
{
@ -319,3 +328,10 @@ static RateLimitPartition<string> CreateFixedWindowLimiter(
AutoReplenishment = true
});
}
static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services)
{
await using var scope = services.CreateAsyncScope();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
}

View File

@ -0,0 +1,8 @@
namespace MemberCenter.Application.Abstractions;
public interface IAuthResourceRegistryService
{
Task EnsureDefaultsAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> ResolveAudiencesAsync(IEnumerable<string> scopes, CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> GetAllowedScopesForUsageAsync(string usage, CancellationToken cancellationToken = default);
}

View File

@ -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;
}

View File

@ -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<AuthResourceScope> Scopes { get; set; } = new();
}

View File

@ -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; }
}

View File

@ -12,6 +12,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
</ItemGroup>

View File

@ -23,6 +23,9 @@ public class MemberCenterDbContext
public DbSet<UnsubscribeToken> UnsubscribeTokens => Set<UnsubscribeToken>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<SystemFlag> SystemFlags => Set<SystemFlag>();
public DbSet<AuthResource> AuthResources => Set<AuthResource>();
public DbSet<AuthResourceScope> AuthResourceScopes => Set<AuthResourceScope>();
public DbSet<AuthClientUsagePermission> AuthClientUsagePermissions => Set<AuthClientUsagePermission>();
protected override void OnModelCreating(ModelBuilder builder)
{
@ -199,6 +202,49 @@ public class MemberCenterDbContext
entity.HasIndex(x => x.Key).IsUnique();
});
builder.Entity<AuthResource>(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<AuthResourceScope>(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<AuthClientUsagePermission>(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<ApplicationUser>(entity =>
{
entity.ToTable("users");

View File

@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAuthResourceRegistry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "auth_client_usage_permissions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Usage = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Audience = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
RequireTenant = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
AllowDelegatedToken = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
UpdatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
ResourceId = table.Column<Guid>(type: "uuid", nullable: false),
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
CreatedAt = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "auth_client_usage_permissions");
migrationBuilder.DropTable(
name: "auth_resource_scopes");
migrationBuilder.DropTable(
name: "auth_resources");
}
}
}

View File

@ -54,6 +54,135 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.ToTable("audit_logs", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthClientUsagePermission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("Scope")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("AllowDelegatedToken")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Audience")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("RequireTenant")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<Guid>("ResourceId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("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");

View File

@ -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<IReadOnlyList<string>> ResolveAudiencesAsync(
IEnumerable<string> 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<IReadOnlyList<string>> 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<AuthResource> 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<string> 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<string> 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
});
}
}
}

View File

@ -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<IAuthResourceRegistryService>();
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<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
}
else
{
@ -288,6 +294,9 @@ static IServiceProvider BuildServices(string connectionString)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddConsole());
services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build());
services.AddDbContext<MemberCenterDbContext>(options =>
{
options.UseNpgsql(connectionString);
@ -306,6 +315,8 @@ static IServiceProvider BuildServices(string connectionString)
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
return services.BuildServiceProvider();
}

View File

@ -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<string> 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<OpenIddictApplicationDescriptor> 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);
}
}
}

View File

@ -30,6 +30,7 @@
<option value="web_login">web_login</option>
<option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option>
<option value="file_api">file_api</option>
</select>
<span asp-validation-for="Usage"></span>
@ -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) {

View File

@ -30,6 +30,7 @@
<option value="web_login">web_login</option>
<option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option>
<option value="file_api">file_api</option>
</select>
<span asp-validation-for="Usage"></span>
@ -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) {

View File

@ -127,6 +127,7 @@ builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
@ -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<string> CreateFixedWindowLimiter(
AutoReplenishment = true
});
}
static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services)
{
await using var scope = services.CreateAsyncScope();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
}