XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

租户解析与隔离 ​

这一页讲框架怎样在一次调用里确定租户、租户信息存在哪里、怎样启用多租户,以及租户确定之后持久化层怎样把数据隔开。读完你能让 SimpleApp 这样的应用按调用方身份识别出租户,知道解析在管道的哪一步发生、失败时拿到什么错误码,并让自己的实体只对本租户可见。阅读前先了解执行管道与调用上下文;租户来自主体的声明,认证一章可以之后再读。

为什么需要 ​

一个 SaaS 应用要同时服务多家客户,每家客户的数据不能被别家看见。如果把「当前租户」交给业务代码自己传,就会在每个仓储方法、每条查询上重复一遍,漏掉一处就是越权;如果用环境态的全局访问器,后台任务、消息处理与并发调用又容易拿到错的值。框架把租户当成调用上下文的一部分:管道的 Tenant 阶段在每次调用里解析一次、核对一次、写进安全上下文,之后的阶段只读它。持久化 Provider 据此自动过滤多租户实体,业务代码不需要再写一次 WHERE TenantId = ...。

租户在调用里的位置 ​

管道的阶段顺序是 Authenticate → Tenant → Authorize → ... → Transaction → Handle。Tenant 阶段的 TenantResolutionMiddleware 解析并核对租户后写入 SecurityContext.Tenant;它在认证之后,所以能读到已认证主体的租户声明;在授权之前,所以租户专属的授予在判权时已经生效。Transaction 阶段用这个租户开工作单元,幂等键与审计记录也带上它。

框架没有环境态的「当前租户」访问器,读租户只有一个入口:

csharp
var tenant = context.GetSecurity().Tenant;   // TenantContext?,没有租户时为 null

TenantContext 只有 Id 与 DisplayName 两个成员。

多租户分成两个能力,由同一个 Provider 提供:

能力 ID作用这一章的位置
multitenancy.context@1解析、校验与存储租户本页
multitenancy.sharding@1按租户选数据连接(分库)租户分片与分库
包清单角色租户存在哪里
XiHan.Framework.MultiTenancy.InMemorydefault启动时从配置 XiHan:MultiTenancy:Tenants 装载,运行时可增删,有容量上限
XiHan.Framework.MultiTenancy.Databaseprovider数据库表 TenantRecord,经装配的持久化 Provider 读写,见数据库租户目录

组合包 Profiles.Standard 与 Profiles.Full 已包含 MultiTenancy.InMemory;再 UseFeature<DatabaseMultiTenancyFeature>() 即由数据库目录顶替。

租户信息与 ITenantStore ​

一个租户是一条 TenantInfo:

成员类型说明
Idstring租户 ID,赋值时按文法校验
DisplayNamestring?显示名,解析后随租户进入安全上下文
IsEnabledbool缺省 true;禁用的租户在 Tenant 阶段被拒绝
PropertiesIReadOnlyDictionary<string, string>租户专属属性(连接串、配额等),键区分大小写

租户 ID 的文法是 ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$:字母或数字开头,之后是字母、数字、.、_、-,最长 64 个字符(TenantIdentifier.MaxLength),区分大小写。TenantIdentifier.IsValid 判断是否合法,TenantIdentifier.Validate 不合法时抛出 ArgumentException。

存储契约 ITenantStore 只有两个方法:FindAsync(tenantId) 按 ID 查找,不存在时返回无值的 Maybe<TenantInfo>;ListAsync() 枚举全部租户。存储连不上这类基础设施故障以异常抛出,不伪装成「租户不存在」,Tenant 阶段据此让调用失败而不是放行。

最小示例 ​

内存实现从配置装载租户。下面是 framework/samples/SimpleApp 的配置:acme 启用,globex 已禁用。每个子节的节名就是租户 ID,IsEnabled 缺省视为启用,Properties 下的键值进 TenantInfo.Properties。

json
{
  "XiHan": {
    "MultiTenancy": {
      "Tenants": {
        "acme": { "DisplayName": "Acme Corp" },
        "globex": { "DisplayName": "Globex", "IsEnabled": false }
      }
    },
    "Security": {
      "InMemory": {
        "Principals": {
          "alice": { "ApiKey": "dev-alice-key", "DisplayName": "Alice", "Roles": "editor", "Tenant": "acme" }
        }
      }
    }
  }
}
csharp
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);

// Standard 组合包已含内存多租户与内存安全
builder.AddXiHan(xihan => xihan.UseStandardProfile());

var app = builder.Build();
app.UseXiHan();

// 端点在管道的 Handle 阶段内执行,租户已由 Tenant 阶段写进安全上下文
app.MapGet("/tenant", (HttpContext http) => http.GetXiHanContext().ValueOrThrow().GetSecurity().Tenant?.Id ?? "-");

app.Run();

运行后用 alice 的 ApiKey 调用,响应是 acme;匿名调用响应 -,因为匿名主体没有租户声明:

bash
curl -H "Authorization: ApiKey dev-alice-key" http://localhost:5000/tenant   # acme
curl http://localhost:5000/tenant                                            # -

操作契约的处理器里同样从传进来的调用上下文读:context.GetSecurity().Tenant。

解析流程 ​

Tenant 阶段按下面的顺序处理,任一步失败即以结果结束调用,后面的阶段不再执行:

  1. 按注册顺序调用全部 ITenantResolver,第一个给出值的生效。
  2. 都没有值时用 DefaultTenant;仍没有时,RequireTenant 为真返回 MULTITENANCY_VALIDATION_002,否则以无租户继续。
  3. 校验租户 ID 文法,不合法返回 MULTITENANCY_VALIDATION_001。先校验再查存储,未经校验的外部输入不会交给 Provider。
  4. ValidateAgainstStore 为真(缺省)时查 ITenantStore:不存在返回 MULTITENANCY_NOTFOUND_001,已禁用返回 MULTITENANCY_DENIED_001;存在且启用时以存储里的记录为准,显示名随之带入。关闭存储核对时只校验文法,安全上下文里没有显示名。
  5. 写入 SecurityContext.Tenant。
  6. 容器里有 ITenantShardResolver 时解析分片并写进调用上下文,见租户分片与分库。

多租户 Provider 注册两个解析器,注册顺序就是优先级:

顺序解析器读什么
1ClaimTenantResolver已认证主体的租户声明(类型由 TenantClaim 决定,缺省 tenant),同类声明有多个时取第一个;未认证主体直接跳过
2BaggageTenantResolver调用上下文的 Baggage xihan.tenant,由进程内建上下文的地方放进去

应用登记自己的 ITenantResolver 会排在这两个之后。解析器只负责给出候选 ID,校验都在中间件里。

网络入站不接受自报租户 ​

Baggage 里的租户只来自进程内:消息派发时取信封的扩展属性 xihantenant,作业与工作流带上发起时的租户,命令行宿主用 --tenant。

HTTP、gRPC、MCP 的网络入站不收 xihan.tenant 与 xihan.principal:baggage 头是调用方自己写的,解析器只核对租户存在且启用,不核对调用方属不属于它。所以这些调用方的租户只来自认证(令牌或 ApiKey 的租户声明)。

CloudEvents 入站端点是例外:事件的 xihantenant 由发送方给,经消息派发进 Baggage,不和请求的身份核对。这个端点的权限要由宿主自己加,SimpleApp 把它授给专门的服务账号,见 CloudEvents。

自定义解析器 ​

要按主机名或请求头选租户时,登记自己的解析器,由它决定信什么、核对什么;中间件接着核对租户存在且启用:

csharp
using Microsoft.AspNetCore.Http;
using XiHan.Framework.MultiTenancy.Abstractions.Resolution;
using XiHan.Framework.Primitives;

public sealed class SubdomainTenantResolver(IHttpContextAccessor http) : ITenantResolver
{
    public ValueTask<Maybe<string>> ResolveAsync(InvocationContext context, CancellationToken cancellationToken)
    {
        // acme.example.com → acme
        var host = http.HttpContext?.Request.Host.Host;
        var tenant = host?.Split('.')[0];
        return ValueTask.FromResult(string.IsNullOrEmpty(tenant) ? default : Maybe.Some(tenant));
    }
}

在特性的 Configure 里按单例登记:context.Services.AddSingleton<ITenantResolver, SubdomainTenantResolver>()。按主机名选租户时要自己判断「这个调用方能不能进这个租户」,否则任何人都能用子域名进任何租户;一种做法是再配一个要求主体租户声明与解析结果一致的策略,见授权与权限。

配置 ​

MultiTenancyOptions 从配置节 XiHan:MultiTenancy 读取:

键类型缺省说明
DefaultTenantstring无解析器都没有给出值时使用;同样经过文法与存储校验;空字符串表示清除
RequireTenantboolfalse为真时没有租户的调用返回 MULTITENANCY_VALIDATION_002
ValidateAgainstStorebooltrue为真时租户必须在存储中存在且启用;为假时只校验文法
TenantClaimstringtenantClaimTenantResolver 读取的声明类型,不能为空
ShardCacheSizeint10000分片解析器按租户缓存结果的条目上限,满时整体清空;必须是正整数

布尔值只接受 true / false,ShardCacheSize 只接受正整数,其他写法在启动时抛出 ArgumentException。

json
{
  "XiHan": {
    "MultiTenancy": {
      "RequireTenant": true,
      "TenantClaim": "tenant"
    }
  }
}

租户与主体声明 ​

认证 Provider 把租户写成主体的声明,ClaimTenantResolver 再从声明里取出来:

认证方式租户声明从哪来
Security.InMemory 的 ApiKey主体记录的 Tenant,写成类型为 TenantClaim 的声明(读同一个配置键 XiHan:MultiTenancy:TenantClaim)
Security.Jwt / Security.OpenIdConnect令牌里类型等于 TenantClaim 的声明,声明按原始类型名保留

HTTP 调用方的租户只来自声明。以 SimpleApp 为例(alice 的记录是 Tenant: acme):

调用结果
alice 的 ApiKey租户 acme
alice 的 ApiKey,请求头 baggage: xihan.tenant=globex租户仍是 acme,这个键入站不收
匿名,请求头 baggage: xihan.tenant=acme无租户,这个键入站不收
匿名,RequireTenant 为真MULTITENANCY_VALIDATION_002

主体没有租户声明时(匿名调用、平台级账号)就没有租户。平台账号要进入某个租户,由应用换发带租户声明的令牌,并在换发时核对成员关系。

持久化层的租户过滤 ​

实体实现 IMultiTenant(XiHan.Framework.Domain.Entities)就成为租户数据:

csharp
using XiHan.Framework.Domain.Entities;

public sealed class Invoice : IEntity<Guid>, IMultiTenant
{
    public Guid Id { get; set; }

    // 新增时由持久化 Provider 写入工作单元的租户,调用方不用赋值
    public string TenantId { get; set; } = string.Empty;

    public decimal Amount { get; set; }
}

Transaction 阶段的 UnitOfWorkMiddleware 用安全上下文里的租户开工作单元(UnitOfWorkOptions.TenantId),持久化 Provider 据此施加规则:

  • 新增多租户实体时写入工作单元的租户;工作单元没有租户时返回 PERSISTENCE_VALIDATION_001。
  • 查询只返回 TenantId 等于当前租户的行;没有租户的调用看不到任何多租户实体。
  • 更新、删除不属于当前租户的实体,与实体不存在一样返回 PERSISTENCE_NOTFOUND_001。
  • 更新不能把实体改到别的租户:租户列由 Provider 写,调用方实例上的值不生效。

租户目录(TenantRecord)、权限授予(PermissionGrantRecord)、审计记录这类平台数据不实现 IMultiTenant,不受当前租户过滤,租户在它们自己的列里。仓储与工作单元的完整用法见仓储读写与工作单元与事务。

错误码 ​

错误码含义HTTP
MULTITENANCY_VALIDATION_001租户 ID 不合法400
MULTITENANCY_VALIDATION_002调用必须带租户但没有解析到400
MULTITENANCY_NOTFOUND_001租户不存在404
MULTITENANCY_DENIED_001租户已禁用403
MULTITENANCY_UNAVAILABLE_001内存租户存储容量已满(InMemoryTenantStore.Set 与健康项 multitenancy.inmemory.capacity)503
MULTITENANCY_UNAVAILABLE_002租户分片无法解析(租户存储不可达)503
PERSISTENCE_VALIDATION_001多租户实体在没有租户的调用中新增400

HTTP 状态码按错误码的种类段映射,与其他子系统一致,见结果与错误处理。

常见问题 ​

为什么请求头里带了 baggage: xihan.tenant=acme 却没有租户 ​

HTTP、gRPC、MCP 入站不收这个键。网络调用方的租户只来自认证:ApiKey 主体记录的 Tenant,或令牌里类型为 TenantClaim 的声明。要按主机名、请求头选租户,登记自己的 ITenantResolver,并自己核对调用方属于该租户。

平台管理员怎样进入某个租户 ​

平台账号没有租户声明,解析不出租户。由应用的登录服务换发一张带目标租户声明的令牌(换发时核对成员关系或管理员身份),再用新令牌调用。不要通过 DefaultTenant 或关闭 ValidateAgainstStore 绕过。

禁用一个租户后它的调用会怎样 ​

Tenant 阶段核对存储时发现 IsEnabled 为假,调用以 MULTITENANCY_DENIED_001(HTTP 403)结束,不进入授权与业务代码。内存实现运行时用 InMemoryTenantStore.Set 改写记录即可生效;数据库目录的生效时机受缓存存活时间影响,见数据库租户目录。

不需要多租户的应用要关掉它吗 ​

不用。没有租户声明的调用就是无租户调用,RequireTenant 缺省为假,不实现 IMultiTenant 的实体不受过滤。不想装配时用 Profiles.Minimal 再按需 UseFeature,Tenant 阶段就是空的。

后台作业里怎样拿到租户 ​

作业没有网络调用方,租户随作业在发起时记下并放进 Baggage,BaggageTenantResolver 在作业的调用里读出来;见作业与计划。自己建服务作用域、不经管道的代码没有安全上下文,租户与分片都要显式给出,见租户分片与分库的「管道外绑定分片」。

相关包与下一步 ​

Released under The MIT License