diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6b34a2b..080b9e8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -43,7 +43,7 @@ - roles / user_roles (Identity) - id, name, created_at - OpenIddictApplications - - id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|webhook_outbound`) + - id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|webhook_outbound|platform_service`) - OpenIddictAuthorizations - id, application_id, status, subject, type, scopes - OpenIddictTokens @@ -110,6 +110,9 @@ 4) 呼叫 Member Center API 將 email 加入 `email_blacklist` 5) Member Center 對該 email 的訂閱事件全部忽略且不再推送 6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證 +7) 回寫授權可用: + - tenant client scope:`newsletter:events.write` + - platform client scope:`newsletter:events.write.global`(SES 聚合事件) ### 6.5 註冊後銜接 1) 使用者完成註冊 @@ -141,10 +144,11 @@ - `POST /subscriptions/disable`:Send Engine → Member Center 黑名單回寫 ### Auth / Scope -- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界 -- OAuth Client 需區分用途:`tenant_api` 與 `webhook_outbound`(禁止混用) +- `tenant_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界 +- OAuth Client 需區分用途:`tenant_api` / `webhook_outbound` / `platform_service`(禁止混用) - 新增 scope:`newsletter:list.read`、`newsletter:events.read` - 新增 scope:`newsletter:events.write` +- 新增 scope:`newsletter:events.write.global` ### 租戶端取 Token(Client Credentials) - 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 7425666..9630061 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -48,7 +48,10 @@ - [API] Send Engine 依事件規則處理: - [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫 - [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單 -- [API] 呼叫 `POST /subscriptions/disable`(scope: `newsletter:events.write`),body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at` +- [API] 呼叫 `POST /subscriptions/disable`: +- [API] tenant client 用 `newsletter:events.write` +- [API] 平台 client(SES 聚合事件)用 `newsletter:events.write.global` +- [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at` - [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送 ## F-07 會員資料查看 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index fb41009..8e1846e 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -63,13 +63,18 @@ ## OAuth Client 用途分離(強制) - `usage=tenant_api`: - 供租戶站台拿 token 呼叫 Member Center / Send Engine API - - scope 僅給業務所需(如 `newsletter:send.write`、`newsletter:list.read`) + - scope 僅給業務所需(如 `newsletter:events.write`) - `usage=webhook_outbound`: - 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途 - 不可用於租戶 API 呼叫 - `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準 +- `usage=platform_service`: + - 供平台級 S2S(例如 SES 聚合事件回寫) + - 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global` +- `tenant_api` / `platform_service` 建議(且實作要求)`client_type=confidential` - 管理規則: - 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`) + - 平台級流程另建 `platform_service` 憑證 - secret 分開輪替,禁止共用 ## 整合 API / Auth(狀態) @@ -80,13 +85,17 @@ - `POST /subscriptions/disable`:已實作(Send Engine 回寫黑名單) ### Auth / Scope -- OAuth Client 需綁定 `tenant_id` +- `tenant_api` / `webhook_outbound` 類型需綁定 `tenant_id` +- `platform_service` 可不綁定 `tenant_id` - 新增 scope: - `newsletter:list.read` - `newsletter:events.read` - `newsletter:events.write` + - `newsletter:events.write.global` - 發送引擎僅能用上述 scope,禁止 admin 權限 -- `POST /subscriptions/disable` 需 Bearer token 且包含 `newsletter:events.write` +- `POST /subscriptions/disable` 需 Bearer token 且包含下列其一: + - `newsletter:events.write`(tenant-scoped) + - `newsletter:events.write.global`(platform-scoped,SES 回寫用) - 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token ### 回寫原因碼(Send Engine -> Member Center) diff --git a/docs/UI.md b/docs/UI.md index cfafeee..edacea9 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -48,7 +48,7 @@ ### 管理者端(統一 UI) - UC-11 租戶管理: `/admin/tenants` -- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;需選 `usage=tenant_api` 或 `usage=webhook_outbound`) +- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;可選 `usage=tenant_api` / `webhook_outbound` / `platform_service`;`platform_service` 可不指定 tenant) - UC-13 電子報清單管理: `/admin/newsletter-lists` - UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export` - UC-15 審計紀錄查詢: `/admin/audit-logs` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3d94bcb..894ffa4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -440,7 +440,7 @@ paths: post: summary: Disable email (global blacklist) security: [{ BearerAuth: [] }] - description: Requires scope `newsletter:events.write`. Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`. + description: Requires scope `newsletter:events.write` (tenant client) or `newsletter:events.write.global` (platform client). Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`. requestBody: required: true content: @@ -722,6 +722,10 @@ components: openid: OpenID Connect email: Email profile: Basic profile + newsletter:list.read: Read newsletter subscriptions by list + newsletter:events.read: Read newsletter events + newsletter:events.write: Write newsletter events (tenant scoped) + newsletter:events.write.global: Write newsletter events (platform scoped) BearerAuth: type: http scheme: bearer @@ -907,8 +911,10 @@ components: type: object properties: id: { type: string } - tenant_id: { type: string } + tenant_id: + type: string + nullable: true name: { type: string } - usage: { type: string, enum: [tenant_api, webhook_outbound] } + usage: { type: string, enum: [tenant_api, webhook_outbound, platform_service] } redirect_uris: { type: array, items: { type: string } } client_type: { type: string, enum: [public, confidential] } diff --git a/src/MemberCenter.Api/Contracts/AdminRequests.cs b/src/MemberCenter.Api/Contracts/AdminRequests.cs index 29e54ed..6c55cfe 100644 --- a/src/MemberCenter.Api/Contracts/AdminRequests.cs +++ b/src/MemberCenter.Api/Contracts/AdminRequests.cs @@ -4,4 +4,4 @@ public sealed record TenantRequest(string Name, List Domains, string Sta public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status); -public sealed record OAuthClientRequest(Guid TenantId, string Name, List RedirectUris, string ClientType); +public sealed record OAuthClientRequest(Guid? TenantId, string Name, List RedirectUris, string ClientType, string Usage = "tenant_api"); diff --git a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs index 50e7653..786aa54 100644 --- a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs @@ -41,30 +41,40 @@ public class AdminOAuthClientsController : ControllerBase [HttpPost] public async Task Create([FromBody] OAuthClientRequest request) { + if (!IsValidUsage(request.Usage)) + { + return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service."); + } + + if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty)) + { + return BadRequest("tenant_id is required for this usage."); + } + + if (RequiresClientCredentials(request.Usage) + && !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("client_type must be confidential for this usage."); + } + var descriptor = new OpenIddictApplicationDescriptor { ClientId = Guid.NewGuid().ToString("N"), DisplayName = request.Name, - ClientType = request.ClientType, - Permissions = - { - OpenIddictConstants.Permissions.Endpoints.Authorization, - OpenIddictConstants.Permissions.Endpoints.Token, - OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, - OpenIddictConstants.Permissions.GrantTypes.RefreshToken, - OpenIddictConstants.Permissions.ResponseTypes.Code, - OpenIddictConstants.Permissions.Scopes.Email, - OpenIddictConstants.Permissions.Scopes.Profile, - "scp:openid" - } + ClientType = request.ClientType }; + ApplyPermissions(descriptor, request.Usage); foreach (var uri in request.RedirectUris) { descriptor.RedirectUris.Add(new Uri(uri)); } - descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString()); + if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue) + { + descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString()); + } + descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage); await _applicationManager.CreateAsync(descriptor); @@ -100,6 +110,22 @@ public class AdminOAuthClientsController : ControllerBase [HttpPut("{id}")] public async Task Update(string id, [FromBody] OAuthClientRequest request) { + if (!IsValidUsage(request.Usage)) + { + return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service."); + } + + if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty)) + { + return BadRequest("tenant_id is required for this usage."); + } + + if (RequiresClientCredentials(request.Usage) + && !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("client_type must be confidential for this usage."); + } + var app = await _applicationManager.FindByIdAsync(id); if (app is null) { @@ -111,12 +137,21 @@ public class AdminOAuthClientsController : ControllerBase descriptor.DisplayName = request.Name; descriptor.ClientType = request.ClientType; + ApplyPermissions(descriptor, request.Usage); descriptor.RedirectUris.Clear(); foreach (var uri in request.RedirectUris) { descriptor.RedirectUris.Add(new Uri(uri)); } - descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString()); + if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue) + { + descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString()); + } + else + { + descriptor.Properties.Remove("tenant_id"); + } + descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage); await _applicationManager.UpdateAsync(app, descriptor); @@ -141,4 +176,51 @@ public class AdminOAuthClientsController : ControllerBase await _applicationManager.DeleteAsync(app); return NoContent(); } + + private static bool IsValidUsage(string usage) + { + return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTenantOptionalUsage(string usage) + { + return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); + } + + private static bool RequiresClientCredentials(string usage) + { + return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase); + } + + private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage) + { + descriptor.Permissions.Clear(); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token); + + if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)) + { + descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials); + if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)) + { + descriptor.Permissions.Add("scp:newsletter:events.write.global"); + } + else + { + descriptor.Permissions.Add("scp:newsletter:events.write"); + } + return; + } + + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile); + descriptor.Permissions.Add("scp:openid"); + } } diff --git a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs index f98902d..cc0b7f4 100644 --- a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs +++ b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs @@ -35,7 +35,9 @@ public class SubscriptionsController : ControllerBase [HttpPost("disable")] public async Task Disable([FromBody] DisableSubscriptionRequest request) { - if (!HasScope(User, "newsletter:events.write")) + var hasTenantScope = HasScope(User, "newsletter:events.write"); + var hasGlobalScope = HasScope(User, "newsletter:events.write.global"); + if (!hasTenantScope && !hasGlobalScope) { return Forbid(); } @@ -54,6 +56,11 @@ public class SubscriptionsController : ControllerBase return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression."); } + if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId) + { + return BadRequest("tenant_id does not match token tenant scope."); + } + var target = await ( from subscription in _dbContext.NewsletterSubscriptions join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id @@ -90,4 +97,11 @@ public class SubscriptionsController : ControllerBase .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)); return values.Contains(scope, StringComparer.Ordinal); } + + private static bool TryGetTenantId(System.Security.Claims.ClaimsPrincipal user, out Guid tenantId) + { + tenantId = Guid.Empty; + var value = user.FindFirst("tenant_id")?.Value; + return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out tenantId); + } } diff --git a/src/MemberCenter.Api/Controllers/TokenController.cs b/src/MemberCenter.Api/Controllers/TokenController.cs index 717118b..868fb06 100644 --- a/src/MemberCenter.Api/Controllers/TokenController.cs +++ b/src/MemberCenter.Api/Controllers/TokenController.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; +using System.Security.Claims; +using System.Text.Json; namespace MemberCenter.Api.Controllers; @@ -13,11 +15,16 @@ public class TokenController : ControllerBase { private readonly UserManager _userManager; private readonly SignInManager _signInManager; + private readonly IOpenIddictApplicationManager _applicationManager; - public TokenController(UserManager userManager, SignInManager signInManager) + public TokenController( + UserManager userManager, + SignInManager signInManager, + IOpenIddictApplicationManager applicationManager) { _userManager = userManager; _signInManager = signInManager; + _applicationManager = applicationManager; } [HttpPost("/oauth/token")] @@ -69,6 +76,61 @@ public class TokenController : ControllerBase return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + if (request.IsClientCredentialsGrantType()) + { + if (string.IsNullOrWhiteSpace(request.ClientId)) + { + return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + var app = await _applicationManager.FindByClientIdAsync(request.ClientId); + if (app is null) + { + return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + var identity = new ClaimsIdentity( + authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + nameType: OpenIddictConstants.Claims.Name, + roleType: OpenIddictConstants.Claims.Role); + + identity.AddClaim(OpenIddictConstants.Claims.Subject, request.ClientId); + identity.AddClaim(OpenIddictConstants.Claims.ClientId, request.ClientId); + + var properties = await _applicationManager.GetPropertiesAsync(app); + if (properties.TryGetValue("tenant_id", out var tenantValue)) + { + var tenantId = tenantValue.ValueKind == JsonValueKind.String + ? tenantValue.GetString() + : tenantValue.ToString(); + if (!string.IsNullOrWhiteSpace(tenantId)) + { + identity.AddClaim("tenant_id", tenantId); + } + } + + if (properties.TryGetValue("usage", out var usageValue)) + { + var usage = usageValue.ValueKind == JsonValueKind.String + ? usageValue.GetString() + : usageValue.ToString(); + if (!string.IsNullOrWhiteSpace(usage)) + { + identity.AddClaim("client_usage", usage); + } + } + + var principal = new ClaimsPrincipal(identity); + principal.SetScopes(request.Scope.GetScopesOrDefault()); + + foreach (var claim in principal.Claims) + { + claim.SetDestinations(ClaimsExtensions.GetDestinations(claim)); + } + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + return BadRequest("Unsupported grant type."); } } diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 16c14ac..77bb353 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -56,13 +56,18 @@ builder.Services.AddOpenIddict() .RequireProofKeyForCodeExchange(); options.AllowRefreshTokenFlow(); options.AllowPasswordFlow(); + options.AllowClientCredentialsFlow(); options.AcceptAnonymousClients(); options.RegisterScopes( OpenIddictConstants.Scopes.OpenId, OpenIddictConstants.Scopes.Email, - OpenIddictConstants.Scopes.Profile); + OpenIddictConstants.Scopes.Profile, + "newsletter:list.read", + "newsletter:events.read", + "newsletter:events.write", + "newsletter:events.write.global"); options.AddDevelopmentEncryptionCertificate(); options.AddDevelopmentSigningCertificate(); diff --git a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs b/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs index fa0dbc0..9554ebd 100644 --- a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs @@ -61,7 +61,18 @@ public class OAuthClientsController : Controller { if (!IsValidUsage(model.Usage)) { - ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound."); + ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service."); + } + + if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty)) + { + ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage."); + } + + if (RequiresClientCredentials(model.Usage) + && !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) + { + ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage."); } if (!ModelState.IsValid) @@ -75,23 +86,7 @@ public class OAuthClientsController : Controller ? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) : null; - var descriptor = new OpenIddictApplicationDescriptor - { - ClientId = clientId, - DisplayName = model.Name, - ClientType = model.ClientType, - Permissions = - { - OpenIddictConstants.Permissions.Endpoints.Authorization, - OpenIddictConstants.Permissions.Endpoints.Token, - OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, - OpenIddictConstants.Permissions.GrantTypes.RefreshToken, - OpenIddictConstants.Permissions.ResponseTypes.Code, - OpenIddictConstants.Permissions.Scopes.Email, - OpenIddictConstants.Permissions.Scopes.Profile, - "scp:openid" - } - }; + var descriptor = BuildDescriptor(clientId, model.Name, model.ClientType, model.Usage); if (!string.IsNullOrWhiteSpace(clientSecret)) { descriptor.ClientSecret = clientSecret; @@ -102,7 +97,10 @@ public class OAuthClientsController : Controller descriptor.RedirectUris.Add(new Uri(uri)); } - descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString()); + if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue) + { + descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString()); + } descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage); await _applicationManager.CreateAsync(descriptor); @@ -134,7 +132,7 @@ public class OAuthClientsController : Controller return View(new OAuthClientFormViewModel { - TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : Guid.Empty, + TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : null, Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty, ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public", Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage, @@ -148,7 +146,18 @@ public class OAuthClientsController : Controller { if (!IsValidUsage(model.Usage)) { - ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound."); + ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service."); + } + + if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty)) + { + ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage."); + } + + if (RequiresClientCredentials(model.Usage) + && !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) + { + ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage."); } if (!ModelState.IsValid) @@ -168,12 +177,20 @@ public class OAuthClientsController : Controller descriptor.DisplayName = model.Name; descriptor.ClientType = model.ClientType; + ApplyPermissions(descriptor, model.Usage); descriptor.RedirectUris.Clear(); foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { descriptor.RedirectUris.Add(new Uri(uri)); } - descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString()); + if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue) + { + descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString()); + } + else + { + descriptor.Properties.Remove("tenant_id"); + } descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage); await _applicationManager.UpdateAsync(app, descriptor); @@ -225,6 +242,60 @@ public class OAuthClientsController : Controller private static bool IsValidUsage(string usage) { return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase) - || string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase); + || string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTenantOptionalUsage(string usage) + { + return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); + } + + private static bool RequiresClientCredentials(string usage) + { + return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase); + } + + private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage) + { + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = clientId, + DisplayName = name, + ClientType = clientType + }; + + ApplyPermissions(descriptor, usage); + return descriptor; + } + + private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage) + { + descriptor.Permissions.Clear(); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token); + + if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)) + { + descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials); + if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)) + { + descriptor.Permissions.Add("scp:newsletter:events.write.global"); + } + else + { + descriptor.Permissions.Add("scp:newsletter:events.write"); + } + return; + } + + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile); + descriptor.Permissions.Add("scp:openid"); } } diff --git a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs index 12c0c4d..ba07126 100644 --- a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs @@ -4,8 +4,7 @@ namespace MemberCenter.Web.Models.Admin; public sealed class OAuthClientFormViewModel { - [Required] - public Guid TenantId { get; set; } + public Guid? TenantId { get; set; } [Required] public string Name { get; set; } = string.Empty; diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml b/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml index a3a9e44..387581b 100644 --- a/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml +++ b/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml @@ -27,6 +27,7 @@ diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml b/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml index 16a1902..62af603 100644 --- a/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml +++ b/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml @@ -27,6 +27,7 @@