diff --git a/Directory.Packages.props b/Directory.Packages.props
index f6274f2..444bacb 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,8 @@
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -24,6 +26,7 @@
+
diff --git a/README.md b/README.md
index 98b2253..70aa76e 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@
阶段设计文档:
+- [`docs/architecture/authentication-authorization-security.md`](docs/architecture/authentication-authorization-security.md)(当前认证、RBAC、DataScope、MFA、Session 与 Host 安全策略)
- [`docs/migration/phase-1-repository-baseline.md`](docs/migration/phase-1-repository-baseline.md)
- [`docs/migration/phase-2-engineering-foundation.md`](docs/migration/phase-2-engineering-foundation.md)
- [`docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md`](docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md)
@@ -59,7 +60,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试
- ASP.NET Authorization 负责权限策略。
- JWT + 数据库 `auth_sessions` 负责 access/refresh/session 闭环。
- `ICurrentUser` / 只读 `ITenantContext` 统一当前用户和请求租户上下文。
-- Refresh Token 采用 `v1.{tenantId}.{sessionId}.{secret}` 结构,刷新和退出先解析租户再按 `TenantId + SessionId + TokenHash` 定位。
+- Refresh Token 采用 `v2.{t|p}.{tenantId|-}.{sessionId}.{secret}` 结构,刷新和退出先验证 realm/Host/tenant,再通过统一 Session Store 定位并撤销 token family。
- EF Core Query Filter、写入拦截器和 PostgreSQL 组合约束共同阻断跨租户读写。
- PostgreSQL FK / unique / check / index 负责数据完整性底线。
- 审计事件表记录关键行为。
@@ -147,7 +148,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试
数据库模型迁移已经完成到 greenfield 初始 schema:
```text
-Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
+Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs
```
当前 EF 模型覆盖:
@@ -171,12 +172,14 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
已完成:
-- JWT Bearer 认证。
-- 数据库 Session 校验;登出/撤销后旧 token 会被拒绝。
-- 手机号 + 密码登录。
-- 短信验证码登录。
-- 微信网页 OAuth 登录。
-- 微信小程序登录。
+- ASP.NET Core Identity 密码、锁定、SecurityStamp、强制改密、TOTP 和恢复码。
+- 带 `kid` 的 RSA JWT Bearer 认证和旧公钥轮换验证。
+- tenant/platform 双 realm 与 Host、tenant claim、数据库 Session 联合校验。
+- Session family 原子 refresh、重放撤销、logout 和 logout-all。
+- 手机号/邮箱/用户名 + 密码登录、短信验证码登录和安全短信发送入口。
+- 微信网页 OAuth 和微信小程序登录,外部身份不保存 `session_key`。
+- 数据库 tenant/platform RBAC、MFA policy、资源型授权和 DataScope SQL。
+- 按有效权限生成 tenant/platform UI 菜单 bootstrap;菜单不作为 API 授权依据。
- 租户级身份 Provider 配置解析。
- 当前用户 `/api/me`。
- 当前租户 `/api/tenants/current`。
@@ -184,6 +187,8 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
- Host 解析的前端运行时配置 `/api/runtime/bootstrap`。
- 统一异常响应和请求日志。
+完整安全策略、Host 判定矩阵和登录/Session 文字流程图见 [`docs/architecture/authentication-authorization-security.md`](docs/architecture/authentication-authorization-security.md)。
+
### 已迁移 API
2026-07-27 运行时 OpenAPI 基线包含 192 个路径、237 个操作。已完成的业务/API 闭环包括:
@@ -250,3 +255,14 @@ dotnet ef migrations script \
--project Tiku.Infrastructure \
--startup-project Tiku.DbMigrator
```
+
+首次部署可在迁移完成后一次性创建平台超级管理员:
+
+```bash
+export TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL='admin@example.com'
+export TIKU_BOOTSTRAP_PLATFORM_ADMIN_PASSWORD='replace-with-a-strong-temporary-password'
+export TIKU_BOOTSTRAP_PLATFORM_ADMIN_NAME='Platform Administrator'
+dotnet run --project Tiku.DbMigrator -- --bootstrap-platform-admin
+```
+
+该命令只允许在不存在任何平台角色用户绑定时执行。创建的账号必须在首次登录时修改临时密码并完成 TOTP MFA 注册;检测到已有平台管理员时命令会拒绝重复引导。不要把临时密码写入仓库配置或命令行参数。
diff --git a/Tiku.Api/Contracts/AuthDtos.cs b/Tiku.Api/Contracts/AuthDtos.cs
index 32f1d4e..22cca7a 100644
--- a/Tiku.Api/Contracts/AuthDtos.cs
+++ b/Tiku.Api/Contracts/AuthDtos.cs
@@ -1,6 +1,7 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Tiku.Application.Auth;
+using Tiku.Domain.Tenancy;
namespace Tiku.Api.Contracts;
@@ -9,6 +10,8 @@ namespace Tiku.Api.Contracts;
///
public sealed class PasswordLoginDto
{
+ [Required]
+ public AuthRealm? Realm { get; set; }
///
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
///
@@ -17,18 +20,21 @@ public sealed class PasswordLoginDto
public string? TenantCode { get; set; }
///
- /// 手机号,建议前端提交规范化后的中国大陆手机号。
+ /// 账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。
///
- [Required]
+ [StringLength(320)]
+ [Description("账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。")]
+ public string? Identifier { get; set; }
+
[StringLength(32)]
- [Description("手机号,建议前端提交规范化后的中国大陆手机号。")]
- public string Phone { get; set; } = string.Empty;
+ [Description("兼容手机号字段;新客户端应使用 identifier。")]
+ public string? Phone { get; set; }
///
/// 用户密码。
///
[Required]
- [StringLength(128, MinimumLength = 6)]
+ [StringLength(128, MinimumLength = 10)]
[Description("用户密码。")]
public string Password { get; set; } = string.Empty;
}
@@ -38,6 +44,8 @@ public sealed class PasswordLoginDto
///
public sealed class SmsLoginDto
{
+ [Required]
+ public AuthRealm? Realm { get; set; }
///
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
///
@@ -62,11 +70,29 @@ public sealed class SmsLoginDto
public string Code { get; set; } = string.Empty;
}
+public sealed class SendSmsCodeDto
+{
+ [Required]
+ public AuthRealm? Realm { get; set; }
+
+ [StringLength(100)]
+ public string? TenantCode { get; set; }
+
+ [Required]
+ [StringLength(32)]
+ public string Phone { get; set; } = string.Empty;
+
+ [StringLength(256)]
+ public string? DeviceId { get; set; }
+}
+
///
/// OAuth code 登录请求。
///
public sealed class OAuthCodeDto
{
+ [Required]
+ public AuthRealm? Realm { get; set; }
///
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
///
@@ -135,10 +161,12 @@ public sealed class AuthenticatedUserDto
///
public string? Name { get; init; }
+ public AuthRealm Realm { get; init; }
+
///
/// 当前登录租户成员摘要。
///
- public TenantMembershipSummary Tenant { get; init; } = default!;
+ public TenantMembershipSummary? Tenant { get; init; }
///
/// access token 和 refresh token。
@@ -153,8 +181,52 @@ public sealed class AuthenticatedUserDto
Phone = user.Phone,
Email = user.Email,
Name = user.Name,
+ Realm = user.Realm,
Tenant = user.Tenant,
Tokens = user.Tokens
};
}
}
+
+public sealed class AuthenticationResultDto
+{
+ public AuthenticationStatus Status { get; init; }
+ public AuthenticatedUserDto? User { get; init; }
+ public string? ChallengeToken { get; init; }
+ public DateTimeOffset? ChallengeExpiresAt { get; init; }
+
+ public static AuthenticationResultDto FromApplication(AuthenticationResult result) => new()
+ {
+ Status = result.Status,
+ User = result.User is null ? null : AuthenticatedUserDto.FromApplication(result.User),
+ ChallengeToken = result.ChallengeToken,
+ ChallengeExpiresAt = result.ChallengeExpiresAt
+ };
+}
+
+public sealed class MfaChallengeDto
+{
+ [Required]
+ [StringLength(2048)]
+ public string ChallengeToken { get; set; } = string.Empty;
+
+ [StringLength(64)]
+ public string? Code { get; set; }
+}
+
+public sealed class MfaConfirmDto
+{
+ public AuthenticationResultDto Authentication { get; init; } = default!;
+ public IReadOnlyList RecoveryCodes { get; init; } = [];
+}
+
+public sealed class RequiredPasswordChangeDto
+{
+ [Required]
+ [StringLength(2048)]
+ public string ChallengeToken { get; set; } = string.Empty;
+
+ [Required]
+ [StringLength(128, MinimumLength = 10)]
+ public string NewPassword { get; set; } = string.Empty;
+}
diff --git a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs
index e7370c2..df808f3 100644
--- a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs
+++ b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs
@@ -97,19 +97,6 @@ public sealed class TenantAdminAuditLogQueryDto
}
}
-public sealed class TenantAdminRoleTemplateQueryDto
-{
- public string? Status { get; set; }
-
- [Range(1, 500)]
- public int? Limit { get; set; }
-
- public TenantAdminRoleTemplateFilter ToFilter()
- {
- return new TenantAdminRoleTemplateFilter(Status, Limit);
- }
-}
-
public sealed class TenantAdminBadgeQueryDto
{
public string? Category { get; set; }
@@ -324,13 +311,11 @@ public sealed class UpsertTenantAdminMemberDto
public TenantAdminUserLookupDto User { get; set; } = new();
public string? Role { get; set; }
public string? Status { get; set; }
- public Guid? RoleTemplateId { get; set; }
- public JsonElement Permissions { get; set; } = JsonDefaults.Object();
public string? PrimaryRole { get; set; }
public UpsertTenantAdminMemberCommand ToCommand()
{
- return new UpsertTenantAdminMemberCommand(MembershipId, User.ToCommand(), Role, Status, RoleTemplateId, Permissions, PrimaryRole);
+ return new UpsertTenantAdminMemberCommand(MembershipId, User.ToCommand(), Role, Status, PrimaryRole);
}
}
@@ -340,45 +325,6 @@ public sealed class DisableTenantAdminMemberDto
public Guid MembershipId { get; set; }
}
-public sealed class UpsertTenantAdminRoleTemplateDto
-{
- public Guid? Id { get; set; }
- public string? Code { get; set; }
- public required string Name { get; set; }
- public string? Description { get; set; }
- public string? BaseRole { get; set; }
- public string? Status { get; set; }
- public JsonElement Permissions { get; set; } = JsonDefaults.Object();
- public JsonElement MenuPermissions { get; set; } = JsonDefaults.Object();
- public JsonElement ModulePermissions { get; set; } = JsonDefaults.Object();
- public JsonElement FieldPermissions { get; set; } = JsonDefaults.Object();
- public JsonElement DataScope { get; set; } = JsonDefaults.Object();
- public int? Order { get; set; }
-
- public UpsertTenantAdminRoleTemplateCommand ToCommand()
- {
- return new UpsertTenantAdminRoleTemplateCommand(
- Id,
- Code,
- Name,
- Description,
- BaseRole,
- Status,
- Permissions,
- MenuPermissions,
- ModulePermissions,
- FieldPermissions,
- DataScope,
- Order);
- }
-}
-
-public sealed class DisableTenantAdminRoleTemplateDto
-{
- [Required]
- public Guid RoleTemplateId { get; set; }
-}
-
public sealed class UpsertTenantBrandingDto
{
public required string BrandName { get; set; }
diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs
index fd158c5..0b22e0d 100644
--- a/Tiku.Api/Controllers/AuthController.cs
+++ b/Tiku.Api/Controllers/AuthController.cs
@@ -1,10 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Api.Contracts;
+using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Content;
+using Tiku.Domain.Tenancy;
namespace Tiku.Api.Controllers;
@@ -13,97 +17,144 @@ namespace Tiku.Api.Controllers;
[Produces("application/json")]
public sealed class AuthController(
IAuthService authService,
- ISessionService sessionService,
+ ISmsVerificationService smsVerificationService,
+ IAuthSessionStore sessionStore,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
- ITenantDirectory tenantDirectory) : ControllerBase
+ ITenantDirectory tenantDirectory,
+ ICurrentUser currentUser,
+ IOptions tenantResolutionOptions) : ControllerBase
{
[AllowAnonymous]
+ [EnableRateLimiting(AuthRateLimitPolicies.Sms)]
+ [HttpPost("sms/send")]
+ [ProducesResponseType(StatusCodes.Status202Accepted)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status429TooManyRequests)]
+ public async Task> SendSmsCode(
+ [FromBody] SendSmsCodeDto request,
+ CancellationToken cancellationToken)
+ {
+ var realm = request.Realm!.Value;
+ if (realm != AuthRealm.Tenant)
+ {
+ throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
+ }
+
+ var tenantId = await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken)
+ ?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
+ var result = await smsVerificationService.CreateCodeAsync(
+ new SendSmsCodeRequest(
+ tenantId,
+ request.Phone,
+ SmsPurpose.Login,
+ GetIpAddress(),
+ Request.Headers.UserAgent.ToString(),
+ request.DeviceId),
+ cancellationToken);
+ return Accepted(result);
+ }
+
+ [AllowAnonymous]
+ [EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("login/password")]
[EndpointSummary("手机号密码登录")]
[EndpointDescription("使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
- public async Task> LoginWithPassword(
+ public async Task> LoginWithPassword(
[FromBody] PasswordLoginDto request,
CancellationToken cancellationToken)
{
+ var realm = request.Realm!.Value;
+ var identifier = request.Identifier ?? request.Phone;
+ if (string.IsNullOrWhiteSpace(identifier))
+ {
+ throw new RequiredFieldException("identifier is required.");
+ }
var result = await authService.LoginWithPasswordAsync(
new PasswordLoginRequest(
- await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
- request.Phone,
+ realm,
+ await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
+ identifier,
request.Password,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
- return Ok(AuthenticatedUserDto.FromApplication(result));
+ return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("login/sms")]
[EndpointSummary("短信验证码登录")]
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
- public async Task> LoginWithSms(
+ public async Task> LoginWithSms(
[FromBody] SmsLoginDto request,
CancellationToken cancellationToken)
{
+ var realm = request.Realm!.Value;
var result = await authService.LoginWithSmsAsync(
new SmsLoginRequest(
- await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
+ realm,
+ await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Phone,
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
- return Ok(AuthenticatedUserDto.FromApplication(result));
+ return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat")]
[EndpointSummary("微信网页 OAuth 登录")]
[EndpointDescription("使用微信网页授权 code 换取 openid/unionid,upsert 用户身份并创建应用会话。")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
- public async Task> LoginWithWechatWeb(
+ public async Task> LoginWithWechatWeb(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
+ var realm = request.Realm!.Value;
var result = await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
- await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
+ realm,
+ await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
- return Ok(AuthenticatedUserDto.FromApplication(result));
+ return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat-miniapp")]
[EndpointSummary("微信小程序登录")]
[EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_key,upsert 用户身份并创建应用会话。")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
- public async Task> LoginWithWechatMiniApp(
+ public async Task> LoginWithWechatMiniApp(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
+ var realm = request.Realm!.Value;
var result = await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
- await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
+ realm,
+ await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
- return Ok(AuthenticatedUserDto.FromApplication(result));
+ return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
@@ -142,14 +193,135 @@ public sealed class AuthController(
return NoContent();
}
+ [HttpPost("logout-all")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task LogoutAll(CancellationToken cancellationToken)
+ {
+ if (currentUser.UserId is not { } userId)
+ {
+ return Unauthorized();
+ }
+
+ await authService.LogoutAllAsync(userId, cancellationToken);
+ return NoContent();
+ }
+
+ [AllowAnonymous]
+ [EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
+ [HttpPost("mfa/totp/setup")]
+ public async Task> SetupTotp(
+ [FromBody] MfaChallengeDto request,
+ CancellationToken cancellationToken)
+ {
+ ResolveAuthChallengeTenant(request.ChallengeToken);
+ var result = await authService.SetupTotpAsync(
+ new MfaChallengeRequest(
+ request.ChallengeToken, null, GetIpAddress(), Request.Headers.UserAgent.ToString()),
+ cancellationToken);
+ return Ok(result);
+ }
+
+ [AllowAnonymous]
+ [EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
+ [HttpPost("mfa/totp/confirm")]
+ public async Task> ConfirmTotp(
+ [FromBody] MfaChallengeDto request,
+ CancellationToken cancellationToken)
+ {
+ ResolveAuthChallengeTenant(request.ChallengeToken);
+ var result = await authService.ConfirmTotpAsync(
+ new MfaChallengeRequest(
+ request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()),
+ cancellationToken);
+ return Ok(new MfaConfirmDto
+ {
+ Authentication = AuthenticationResultDto.FromApplication(result.Authentication),
+ RecoveryCodes = result.RecoveryCodes
+ });
+ }
+
+ [AllowAnonymous]
+ [EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
+ [HttpPost("mfa/totp/verify")]
+ public async Task> VerifyTotp(
+ [FromBody] MfaChallengeDto request,
+ CancellationToken cancellationToken)
+ {
+ ResolveAuthChallengeTenant(request.ChallengeToken);
+ var result = await authService.VerifyTotpAsync(
+ new MfaChallengeRequest(
+ request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()),
+ cancellationToken);
+ return Ok(AuthenticationResultDto.FromApplication(result));
+ }
+
+ [AllowAnonymous]
+ [HttpPost("password/change-required")]
+ [EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
+ public async Task> ChangeRequiredPassword(
+ [FromBody] RequiredPasswordChangeDto request,
+ CancellationToken cancellationToken)
+ {
+ ResolveAuthChallengeTenant(request.ChallengeToken);
+ var result = await authService.ChangeRequiredPasswordAsync(
+ new PasswordChangeChallengeRequest(
+ request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()),
+ cancellationToken);
+ return Ok(AuthenticationResultDto.FromApplication(result));
+ }
+
private void ResolveRefreshTokenTenant(string refreshToken)
{
- if (!sessionService.TryParseRefreshToken(refreshToken, out var locator))
+ if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator))
{
return;
}
- tenantContextInitializer.Initialize(locator.TenantId, null, TenantResolutionSource.RefreshToken);
+ if (locator.Realm == AuthRealm.Platform)
+ {
+ EnsurePlatformHost();
+ if (tenantContext.IsResolved)
+ {
+ throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
+ }
+ return;
+ }
+
+ if (!tenantContext.IsResolved)
+ {
+ throw new RequiredFieldException(
+ "tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token.");
+ }
+
+ tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken);
+ }
+
+ private void ResolveAuthChallengeTenant(string challengeToken)
+ {
+ var parts = challengeToken.Split('.', 4, StringSplitOptions.None);
+ if (parts.Length != 4 || parts[0] != "c1")
+ {
+ return;
+ }
+
+ if (parts[1] == "p" && parts[2] == "-")
+ {
+ EnsurePlatformHost();
+ if (tenantContext.IsResolved)
+ {
+ throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
+ }
+ return;
+ }
+
+ if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved)
+ {
+ throw new RequiredFieldException(
+ "tenant authentication challenge requires a tenant host or x-tenant-code.");
+ }
+
+ tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken);
}
private string? GetIpAddress()
@@ -157,8 +329,22 @@ public sealed class AuthController(
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
- private async Task ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
+ private async Task ResolveRealmTenantIdAsync(
+ AuthRealm realm,
+ string? tenantCode,
+ CancellationToken cancellationToken)
{
+ if (realm == AuthRealm.Platform)
+ {
+ EnsurePlatformHost();
+ if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode))
+ {
+ throw new RequiredFieldException("platform realm does not accept tenantCode and must use a platform host.");
+ }
+
+ return null;
+ }
+
if (tenantContext.TenantId.HasValue)
{
if (!string.IsNullOrWhiteSpace(tenantCode) &&
@@ -189,4 +375,17 @@ public sealed class AuthController(
TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
+
+ private void EnsurePlatformHost()
+ {
+ var requestHost = Request.Host.Host.Trim().TrimEnd('.');
+ if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
+ string.Equals(
+ host.Trim().TrimEnd('.'),
+ requestHost,
+ StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new RequiredFieldException("platform realm is only available on a configured platform host.");
+ }
+ }
}
diff --git a/Tiku.Api/Controllers/BackgroundJobsController.cs b/Tiku.Api/Controllers/BackgroundJobsController.cs
index 3a99de0..e377ff3 100644
--- a/Tiku.Api/Controllers/BackgroundJobsController.cs
+++ b/Tiku.Api/Controllers/BackgroundJobsController.cs
@@ -8,7 +8,7 @@ namespace Tiku.Api.Controllers;
[ApiController]
[Route("api/backoffice/tenant/jobs")]
-[Authorize(Policy = TikuPolicies.TenantAdmin)]
+[Authorize(Policy = BackendPermissions.TenantJobManage)]
public sealed class BackgroundJobsController(
IBackgroundJobService backgroundJobService,
ITenantContext tenantContext) : ControllerBase
diff --git a/Tiku.Api/Controllers/BackofficeController.cs b/Tiku.Api/Controllers/BackofficeController.cs
index 0864303..3999832 100644
--- a/Tiku.Api/Controllers/BackofficeController.cs
+++ b/Tiku.Api/Controllers/BackofficeController.cs
@@ -10,113 +10,117 @@ namespace Tiku.Api.Controllers;
[Route("api/backoffice")]
public sealed class BackofficeController(
IBackofficeService backofficeService,
- ICurrentUser currentUser,
- ITenantContext tenantContext) : ControllerBase
+ ICurrentAccessContext currentAccessContext) : ControllerBase
{
+ [HttpGet("tenant/ui-bootstrap")]
+ [Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> GetTenantUiBootstrap(CancellationToken cancellationToken)
+ {
+ return Ok(await backofficeService.GetTenantUiBootstrapAsync(
+ await currentAccessContext.GetAsync(cancellationToken),
+ cancellationToken));
+ }
+
[HttpGet("tenant/bootstrap")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> GetTenantBootstrap(CancellationToken cancellationToken)
{
- return Ok(await backofficeService.GetTenantBootstrapAsync(ResolveTenantActor(), cancellationToken));
+ return Ok(await backofficeService.GetTenantBootstrapAsync(await ResolveTenantActorAsync(cancellationToken), cancellationToken));
+ }
+
+ [HttpGet("platform/ui-bootstrap")]
+ [Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> GetPlatformUiBootstrap(CancellationToken cancellationToken)
+ {
+ return Ok(await backofficeService.GetPlatformUiBootstrapAsync(
+ await currentAccessContext.GetAsync(cancellationToken),
+ cancellationToken));
}
[HttpPost("tenant/roles")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> UpsertTenantRole(
UpsertBackofficeRoleDto request,
CancellationToken cancellationToken)
{
- return Ok(await backofficeService.UpsertTenantRoleAsync(ResolveTenantActor(), request.ToCommand(), cancellationToken));
+ return Ok(await backofficeService.UpsertTenantRoleAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(), cancellationToken));
}
[HttpPut("tenant/roles/{roleId:guid}/bindings")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> ReplaceTenantRoleBindings(
Guid roleId,
ReplaceRoleBindingsDto request,
CancellationToken cancellationToken)
{
- return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(ResolveTenantActor(), request.ToCommand(roleId), cancellationToken));
+ return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken));
}
[HttpPut("tenant/users/{userId:guid}/roles")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task ReplaceTenantUserRoles(
Guid userId,
ReplaceUserRolesDto request,
CancellationToken cancellationToken)
{
- await backofficeService.ReplaceTenantUserRolesAsync(ResolveTenantActor(), request.ToCommand(userId), cancellationToken);
+ await backofficeService.ReplaceTenantUserRolesAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken);
return NoContent();
}
[HttpGet("platform/bootstrap")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> GetPlatformBootstrap(CancellationToken cancellationToken)
{
- return Ok(await backofficeService.GetPlatformBootstrapAsync(ResolvePlatformActor(), cancellationToken));
+ return Ok(await backofficeService.GetPlatformBootstrapAsync(await ResolvePlatformActorAsync(cancellationToken), cancellationToken));
}
[HttpPost("platform/roles")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> UpsertPlatformRole(
UpsertBackofficeRoleDto request,
CancellationToken cancellationToken)
{
- return Ok(await backofficeService.UpsertPlatformRoleAsync(ResolvePlatformActor(), request.ToCommand(), cancellationToken));
+ return Ok(await backofficeService.UpsertPlatformRoleAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(), cancellationToken));
}
[HttpPut("platform/roles/{roleId:guid}/bindings")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> ReplacePlatformRoleBindings(
Guid roleId,
ReplaceRoleBindingsDto request,
CancellationToken cancellationToken)
{
- return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(ResolvePlatformActor(), request.ToCommand(roleId), cancellationToken));
+ return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken));
}
[HttpPut("platform/users/{userId:guid}/roles")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task ReplacePlatformUserRoles(
Guid userId,
ReplaceUserRolesDto request,
CancellationToken cancellationToken)
{
- await backofficeService.ReplacePlatformUserRolesAsync(ResolvePlatformActor(), request.ToCommand(userId), cancellationToken);
+ await backofficeService.ReplacePlatformUserRolesAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken);
return NoContent();
}
- private BackofficeActor ResolveTenantActor()
+ private async Task ResolveTenantActorAsync(CancellationToken cancellationToken)
{
- if (currentUser.UserId is not { } userId || tenantContext.TenantId is not { } tenantId)
- {
- throw new InvalidOperationException("Tenant backoffice actor was not resolved.");
- }
-
- return new BackofficeActor(userId, tenantId, IsPlatformAdmin());
+ return BackofficeActor.FromTenantAccess(await currentAccessContext.GetAsync(cancellationToken));
}
- private BackofficeActor ResolvePlatformActor()
+ private async Task ResolvePlatformActorAsync(CancellationToken cancellationToken)
{
- if (currentUser.UserId is not { } userId)
- {
- throw new InvalidOperationException("Platform backoffice actor was not resolved.");
- }
-
- return new BackofficeActor(userId, tenantContext.TenantId, IsPlatformAdmin());
- }
-
- private bool IsPlatformAdmin()
- {
- return string.Equals(currentUser.TenantRole, "PlatformAdmin", StringComparison.OrdinalIgnoreCase);
+ return BackofficeActor.FromPlatformAccess(await currentAccessContext.GetAsync(cancellationToken));
}
}
diff --git a/Tiku.Api/Controllers/CommissionController.cs b/Tiku.Api/Controllers/CommissionController.cs
index 1dea7c9..a78902b 100644
--- a/Tiku.Api/Controllers/CommissionController.cs
+++ b/Tiku.Api/Controllers/CommissionController.cs
@@ -7,7 +7,7 @@ using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
-[Authorize(Policy = TikuPolicies.TenantAdmin)]
+[Authorize(Policy = BackendPermissions.TenantCommissionManage)]
[Produces("application/json")]
[Route("api/commission")]
public sealed class CommissionController(
diff --git a/Tiku.Api/Controllers/CrmController.cs b/Tiku.Api/Controllers/CrmController.cs
index e8bca9c..d2197f1 100644
--- a/Tiku.Api/Controllers/CrmController.cs
+++ b/Tiku.Api/Controllers/CrmController.cs
@@ -7,7 +7,7 @@ using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
-[Authorize(Policy = TikuPolicies.TenantAdmin)]
+[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[Produces("application/json")]
[Route("api/crm")]
public sealed class CrmController(
diff --git a/Tiku.Api/Controllers/ReferralController.cs b/Tiku.Api/Controllers/ReferralController.cs
index 316642a..8cdcc8b 100644
--- a/Tiku.Api/Controllers/ReferralController.cs
+++ b/Tiku.Api/Controllers/ReferralController.cs
@@ -95,7 +95,7 @@ public sealed class ReferralController(
}
[HttpGet("stats")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐人个人统计")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> Stats(
@@ -109,7 +109,7 @@ public sealed class ReferralController(
}
[HttpGet("sales-stats")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询销售推荐统计排行")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> SalesStats(
@@ -123,7 +123,7 @@ public sealed class ReferralController(
}
[HttpGet("conversion-report")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐转化报告")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> ConversionReport(
@@ -137,7 +137,7 @@ public sealed class ReferralController(
}
[HttpGet("sales-clients")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐人名下客户")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> SalesClients(
@@ -151,7 +151,7 @@ public sealed class ReferralController(
}
[HttpPost("manual-bind")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("人工调整学生推荐归属")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> ManualBind(
@@ -165,7 +165,7 @@ public sealed class ReferralController(
}
[HttpGet("team")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐团队成员")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> Team(
@@ -179,7 +179,7 @@ public sealed class ReferralController(
}
[HttpPut("team")]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("新增或更新推荐团队关系")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> UpsertTeam(
diff --git a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs
index 9c32f1e..32fe4a5 100644
--- a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs
+++ b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs
@@ -28,8 +28,7 @@ public sealed class SecurityDiagnosticsController(
{
return Ok(new
{
- currentTenant.TenantId,
- currentUser.TenantRole
+ currentTenant.TenantId
});
}
@@ -39,8 +38,7 @@ public sealed class SecurityDiagnosticsController(
{
return Ok(new
{
- currentTenant.TenantId,
- currentUser.TenantRole
+ currentTenant.TenantId
});
}
}
diff --git a/Tiku.Api/Controllers/TaxonomyController.cs b/Tiku.Api/Controllers/TaxonomyController.cs
index a7206d5..7e3d77e 100644
--- a/Tiku.Api/Controllers/TaxonomyController.cs
+++ b/Tiku.Api/Controllers/TaxonomyController.cs
@@ -21,7 +21,7 @@ public sealed class TaxonomyController(
}
[HttpPost]
- [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [Authorize(Policy = BackendPermissions.TenantContentManage)]
public Task Create(
CreateTaxonomyNodeDto request,
CancellationToken cancellationToken)
diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs
index 34ee26e..55b073d 100644
--- a/Tiku.Api/Controllers/TenantAdminDirectController.cs
+++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs
@@ -10,7 +10,6 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
-[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Produces("application/json")]
[Route("api/tenant-admin")]
public sealed class TenantAdminDirectController(
@@ -19,6 +18,7 @@ public sealed class TenantAdminDirectController(
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("classes")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询租户班级")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> GetClasses(
@@ -29,6 +29,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("classes")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新租户班级")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertClass(
@@ -39,6 +40,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("classes/disable")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("停用租户班级")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> DisableClass(
@@ -49,6 +51,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("classes/members")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询班级成员")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetClassMembers(
@@ -59,6 +62,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("classes/members")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新班级成员")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertClassMember(
@@ -69,6 +73,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("classes/members/remove")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("移除班级成员")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> RemoveClassMember(
@@ -79,6 +84,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("students")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询租户学生")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> GetStudents(
@@ -89,6 +95,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("students")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新租户学生档案")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertStudent(
@@ -99,6 +106,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("students/status")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("更新租户学生状态")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpdateStudentStatus(
@@ -109,6 +117,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("student-notes")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询学生备注")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetStudentNotes(
@@ -119,6 +128,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("student-notes")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新学生备注")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertStudentNote(
@@ -129,6 +139,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("student-followups")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询学生跟进")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetStudentFollowups(
@@ -139,6 +150,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("student-followups")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新学生跟进")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertStudentFollowup(
@@ -149,6 +161,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("members")]
+ [Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("查询租户成员")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetMembers(
@@ -159,6 +172,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("members")]
+ [Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("新增或更新租户成员")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertMember(
@@ -169,6 +183,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("members/disable")]
+ [Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("停用租户成员并撤销会话")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> DisableMember(
@@ -179,6 +194,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("audit-logs")]
+ [Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("查询租户审计日志")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetAuditLogs(
@@ -188,45 +204,8 @@ public sealed class TenantAdminDirectController(
return Ok(await tenantAdminService.GetAuditLogsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
- [HttpGet("permissions")]
- [EndpointSummary("查询租户后台权限矩阵")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task> GetPermissions(CancellationToken cancellationToken)
- {
- return Ok(await tenantAdminService.GetPermissionMatrixAsync(ResolveActor(), cancellationToken));
- }
-
- [HttpGet("role-templates")]
- [EndpointSummary("查询租户角色模板")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> GetRoleTemplates(
- [FromQuery] TenantAdminRoleTemplateQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await tenantAdminService.GetRoleTemplatesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
- }
-
- [HttpPut("role-templates")]
- [EndpointSummary("新增或更新租户角色模板")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> UpsertRoleTemplate(
- UpsertTenantAdminRoleTemplateDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await tenantAdminService.UpsertRoleTemplateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
- }
-
- [HttpPost("role-templates/disable")]
- [EndpointSummary("停用租户角色模板")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> DisableRoleTemplate(
- DisableTenantAdminRoleTemplateDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await tenantAdminService.DisableRoleTemplateAsync(ResolveActor(), request.RoleTemplateId, cancellationToken));
- }
-
[HttpPut("branding")]
+ [Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("更新租户品牌信息")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertBranding(
@@ -237,6 +216,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("settings")]
+ [Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("更新租户公开设置与功能开关")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertSettings(
@@ -247,6 +227,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("theme-templates")]
+ [Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("查询可用租户主题模板")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetThemeTemplates(CancellationToken cancellationToken)
@@ -255,6 +236,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("theme")]
+ [Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("查询租户当前主题与草稿")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetTheme(CancellationToken cancellationToken)
@@ -263,6 +245,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("theme/preview")]
+ [Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("生成租户主题草稿")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> PreviewTheme(
@@ -273,6 +256,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("theme/publish")]
+ [Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("发布租户主题")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> PublishTheme(
@@ -283,6 +267,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("domains")]
+ [Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("查询租户域名")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetDomains(CancellationToken cancellationToken)
@@ -291,6 +276,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("domains")]
+ [Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("添加租户域名")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> CreateDomain(
@@ -301,6 +287,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("auth-providers")]
+ [Authorize(Policy = BackendPermissions.TenantProviderManage)]
[EndpointSummary("查询租户登录 Provider 公开配置")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetAuthProviders(CancellationToken cancellationToken)
@@ -309,6 +296,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("auth-providers")]
+ [Authorize(Policy = BackendPermissions.TenantProviderManage)]
[EndpointSummary("新增或更新租户登录 Provider")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertAuthProvider(
@@ -319,6 +307,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("badges")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询租户勋章")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetBadges(
@@ -329,6 +318,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("badges")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新租户勋章")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertBadge(
@@ -339,6 +329,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("badge-grants")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询勋章发放记录")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetBadgeGrants(
@@ -349,6 +340,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("badge-grants")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("向租户成员发放勋章")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GrantBadge(
@@ -359,6 +351,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("notifications")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询用户站内通知")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetNotifications(
@@ -369,6 +362,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("notifications")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新用户站内通知")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpsertNotification(
@@ -379,6 +373,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("feedbacks")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询用户反馈")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> GetFeedbacks(
@@ -389,6 +384,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("feedbacks/status")]
+ [Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("处理用户反馈")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> UpdateFeedback(
@@ -400,17 +396,13 @@ public sealed class TenantAdminDirectController(
private TenantAdminActor ResolveActor()
{
- if (currentTenant.TenantId is null || currentUser.UserId is null)
+ try
{
- throw new TenantAdminDirectException("Tenant admin actor was not resolved.", "tenant_admin_access_denied");
+ return TenantAdminActor.FromResolvedIdentity(currentTenant.TenantId, currentUser.UserId);
+ }
+ catch (InvalidOperationException exception)
+ {
+ throw new TenantAdminDirectException(exception.Message, "tenant_admin_access_denied");
}
-
- var role = Enum.TryParse(
- currentUser.TenantRole?.Replace("_", string.Empty, StringComparison.Ordinal),
- ignoreCase: true,
- out var parsedRole)
- ? parsedRole
- : TenantRole.TenantAdmin;
- return new TenantAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value, role);
}
}
diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs
index f5a88aa..c1c228c 100644
--- a/Tiku.Api/Controllers/TenantCommerceController.cs
+++ b/Tiku.Api/Controllers/TenantCommerceController.cs
@@ -8,7 +8,7 @@ using Tiku.Domain.Commerce;
namespace Tiku.Api.Controllers;
[ApiController]
-[Authorize(Policy = TikuPolicies.TenantAdmin)]
+[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
[Produces("application/json")]
[Route("api/tenant-commerce")]
public sealed class TenantCommerceController(
@@ -17,6 +17,7 @@ public sealed class TenantCommerceController(
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("payment-accounts")]
+ [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询租户支付账号")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> PaymentAccounts(
@@ -30,6 +31,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("payment-accounts")]
+ [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新租户支付账号")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> UpsertPaymentAccount(
@@ -43,6 +45,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("secrets")]
+ [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("写入或轮换租户密钥")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> UpsertSecret(
@@ -82,6 +85,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("code-batches")]
+ [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("创建兑换码批次")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> CreateCodeBatch(
@@ -95,6 +99,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("activation-codes")]
+ [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询兑换码")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> ActivationCodes(
@@ -108,6 +113,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("activation-codes/redeem")]
+ [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("后台核销兑换码")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> RedeemActivationCode(
@@ -121,6 +127,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("point-activity-tasks")]
+ [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询积分活动任务")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> PointTasks(
@@ -134,6 +141,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("point-activity-tasks")]
+ [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新积分活动任务")]
[ProducesResponseType