Runtime License 决定一个部署实例是否有权运行某个产品版本、租户模式和商业 Feature。它不负责下载包,也不等同于用户 RBAC 或租户 Feature Flag。强制执行是自动的:三个源生成的 Mediator 管线行为在每个 command、query、stream 和 notification 进入 Handler 之前求值。开发者无需在端点散落手动检查(如 if (!license.IsFeatureAllowed(featureId)))。
三层授权不要混用
| 层 | 控制什么 | 例子 |
|---|---|---|
| Package Entitlement | 客户能下载哪些包和版本 | 私有 Feed 短期 token |
| Runtime License | 部署能运行哪些产品能力 | edition、versionRange、features |
| Tenant Feature Entitlement | 某租户能用哪些业务能力 | 套餐权益、Feature Flag |
签名与部署身份
License 使用 ES256 签名。运行时只持有按 keyId 选择的公钥集合,私钥留在厂商签发系统或 HSM/KMS。验证通过后,还要匹配产品、版本、环境、tenancy mode 和持久 DeploymentId。
DeploymentId 应保存在 K8s PV、Secret 或等价持久存储中。它不能绑定 Pod 名、MAC 或 CPU,因为这些值会随扩缩容和故障迁移变化。
状态与行为
| 状态 | Readiness | 普通业务写入 | 数据可携带操作 |
|---|---|---|---|
Valid | Healthy | 允许 | 允许 |
Grace | Degraded | 在合同定义的有限期内运行 | 允许 |
Expired | Unhealthy;符合社区策略时 Healthy | 拒绝;符合社区策略时回退 | Backup/Export/Migration 保留 |
Revoked | Unhealthy | 拒绝 | 拒绝,fail-closed |
Invalid | Unhealthy | 拒绝 | 拒绝,fail-closed |
Unavailable | Unhealthy;符合社区策略时 Healthy | 无有效快照时默认拒绝;符合社区策略时放行 | 按受控策略处理 |
该矩阵由管线自动强制执行(见下一节)。一个请求被分类为某个 LicensedOperation——查询为 BusinessRead,其余为 BusinessWrite,或带数据携带标记的请求为 Backup/Export/Migration——然后由上面的状态×操作矩阵决定是否放行。
状态机优先级是 Revoked、Invalid、Expired、Grace、Valid。撤销和无效优先于时间窗口,避免一个仍在日期范围内但已撤销的快照被误判为可运行。
Host 注册与默认行为
AddBitzOrcasLicensing() 默认注册 UnavailableLicenseAdapter。这是安全默认值:Host 可以组合,但所有 License 决策为 Unavailable 且 IsOperational=false。传入配置并设置 Licensing:Runtime:Enabled=true 后,才注册持久部署身份、签名验证、Envelope 缓存、正式 Adapter 和后台刷新服务。
{ "Licensing": { "Runtime": { "Enabled": true, "ProductId": "bitzorcas-modern", "ProductVersion": "1.0.0", "Environment": "production", "TenancyMode": "multi-tenant", "DeploymentIdentityPath": "/var/lib/bitzorcas/license/deployment-id", "CachePath": "/var/lib/bitzorcas/license/runtime-license.json", "OfflineLicensePath": "/run/secrets/bitzorcas-license.json", "RefreshInterval": "00:15:00", "ClockSkewTolerance": "00:05:00", "PolicyId": "", "TrustedPublicKeys": { "prod-2026-01": "<PEM public key from secret/config provider>" } } }}示例展示键结构,不应把真实 License Envelope 或密钥直接写进 appsettings.json。TrustedPublicKeys 只包含验证公钥;签发私钥绝不能进入 Host。专属商业部署可把 PolicyId 留空;精确 Web 免费组合使用 community.web.v1;任意静态组合要启用 30 人以内内建权益时使用 community.small.v1。后者不需要 License 文件或验证公钥,但仍要求持久 DeploymentId、Identity 权威计量和数据库原子容量保护。API 与 JobHost 的 Development 配置以及 AppHost 已默认选择该策略,不需要在 Program.cs 硬编码 License。
通过 Mediator 管线自动强制执行
每个模板 Host 在启动时把三个许可行为接入 Mediator 组合,启动期缝校验(EnsureSourceGeneratedMediatorLicenseSeams)在组合不完整时硬失败。没有退出路径,也无法意外绕过许可。
| 管线面 | 行为 | 拒绝/失败时 |
|---|---|---|
| 请求(非流式) | RuntimeLicenseResultPipelineBehavior 包裹 RuntimeLicensePipelineBehavior | IResult<T> 响应时适配器返回 TResponse.Failure(error);否则 RuntimeLicenseExecutionDeniedException 传播并映射为 403/503 |
| 流 | RuntimeLicenseStreamPipelineBehavior | 在 Handler 枚举之前抛 RuntimeLicenseExecutionDeniedException;异常处理器在响应开始前映射为 403 |
| 通知 | RuntimeLicenseNotificationPublisher | 在向任何 Handler 发布前评估 gate |
Gate 在每一层都是失败关闭:null decision、来自 gate 或 provider 的任何非取消异常、或 Unavailable 状态,都抛 RuntimeLicenseExecutionDeniedException(LicensingErrors.RuntimeUnavailable, Unavailable)。当 gate 无法给出肯定决策时,Host 绝不落入 Handler。
请求: Logging → RuntimeLicenseResult → RuntimeLicense → Authorization → Validation → … → Transaction流: LoggingStream → RuntimeLicenseStream → AuthorizationStream → handler只有 IOuterPipelineObservability 行为(日志/指标)可在许可 gate 之前。启动缝校验强制此顺序、每管线一行为不变量、Result 适配器邻接(Result 适配器必须紧贴强制行为之外),以及 IMediator/ISender/IPublisher 解析到同一个源生成 facade。
首张许可证的静态控制面边界
产品主 Host 必须先登录,才能从 LicenseManagement 控制面签出首张 Runtime License。为避免“没有 License 不能登录、不能登录又不能签发”的循环依赖,产品 Host 的生成式组合根向许可管线传入一个精确、封闭的消息类型列表:
- 最低身份会话:登录、验证码刷新、MFA、Token 刷新、登出和当前用户;
- 首次登录强制改密所需的密码策略查询和已认证修改密码;
- 忘记密码时的重置申请与一次性 Token 重置,使管理员无需先取得 Runtime License 也能恢复控制面访问;
- 按当前用户权限过滤的应用导航查询,使操作者可以进入许可证页面;
- 厂商 LicenseManagement:创建、审批、驳回、触发签发/吊销、后台签名操作、查询、下载和 signer readiness。
- 超额部署恢复合规所需的用户搜索、用户详情、禁用和软删除。
这些消息只跳过客户商业运行权检查,认证、授权、校验、事务、幂等和审计仍照常执行。用户恢复面不包含创建、邀请、注册、激活、重新启用或资料修改,因此不能借恢复路径扩容。普通业务消息仍按 License 状态失败关闭。列表来自编译期 Pipeline capability manifest,不能通过 appsettings、环境变量、命名空间、程序集、接口或通配符扩张;Consumer 模板的列表固定为空。
操作分类与状态×操作矩阵
RuntimeLicenseExecutionPolicy.ResolveOperation 对每条消息分类:
| 消息形状 | LicensedOperation |
|---|---|
IBaseQuery / IBaseStreamQuery | BusinessRead |
ILicensedBackupRequest | Backup |
ILicensedExportRequest | Export |
ILicensedMigrationRequest | Migration |
| 其余 | BusinessWrite(默认) |
一个请求类型不能声明多个数据携带标记——守卫会抛错。LicenseOperationStatusPolicy.AllowsExecution 随后应用状态表的矩阵:Valid/Grace 允许全部;Expired 允许读 + backup + export + migration 但阻断业务写入;Revoked/Invalid/Unavailable 阻断一切。
稳定错误面
| 状态 | 错误码 | HTTP | RFC 9457 |
|---|---|---|---|
Unavailable(或 gate 异常/null decision) | Licensing.Runtime.Unavailable | 503 | service-unavailable |
Expired / Revoked / Invalid | Licensing.Runtime.Denied | 403 | forbidden |
IResult<T> 响应时,拒绝是携带同一错误码的类型化 Result.Failure;Handler 从不运行。非 Result 响应与流由 RuntimeLicenseExecutionDeniedExceptionHandler 投影为带 errorCode 的 ProblemDetails,不含签名/payload 细节。
注册许可应用
AddBitzOrcasLicensedApplication(features) 把 Profile 的静态特性闭包冻结进一个 RuntimeLicenseExecutionPolicy 单例。产品 Host 的生成式三参数重载还接收上节所述的精确控制面消息类型;Consumer 仍调用默认严格重载。第二次调用抛 "Runtime License application execution policy is already registered."。特性字符串(如 framework.core、framework.aspnetcore、workflow.runtime)成为需要 License 的消息所校验的不可变要求。
builder.Services.AddBitzOrcasLicensing(builder.Configuration);builder.Services.AddBitzOrcasLicensedApplication( ["framework.core", "framework.aspnetcore", "framework.infrastructure", "workflow.runtime"]);
// 商业许可使用独立标签,不进入基础 readiness。builder.Services.AddHealthChecks().AddBitzOrcasLicenseReadiness( tags: [ServiceDefaultsExtensions.LicenseTag, "runtime"]);var app = builder.Build();// 端点只筛选 LicenseTag,避免退化成聚合健康检查。app.MapHealthChecks("/health/license", new HealthCheckOptions{ Predicate = check => check.Tags.Contains(ServiceDefaultsExtensions.LicenseTag)}).AllowAnonymous();Program.cs 负责注册服务,不应硬编码许可证或私钥。Licensing:Runtime:* 应来自 appsettings、user-secrets、环境变量或生产 Secret Provider;本地 AppHost 已统一注入产品、版本、development 环境、multi-tenant、共享 DeploymentId/离线信封路径和可信公钥。完整首张签发步骤见在线许可证签发与分发。
用例何时需要显式 gate
管线已对每条消息强制许可,因此大多数用例从不直接调 ILicenseGate。显式缝用于细粒度、非消息作用域的决策(例如工作流引擎在每次后台作业前评估特性,或模块在运行时按 Feature 分支)。WorkflowRuntimeLicenseGuard 是典型示例:它在每次工作流写入和每次后台作业前用 workflow.runtime 特性调 ILicenseGate.EvaluateAsync。
// 由组合根注入统一许可决策器。public sealed class ReportingLicensePolicy(ILicenseGate licenseGate){ public Task<LicenseAccessDecision> CanGenerateAsync( CancellationToken cancellationToken) { // 用稳定 Feature 和操作类别表达精确授权意图。 var requirement = new LicenseRequirement( Features: ["reporting.generate"], Operation: LicensedOperation.BusinessWrite); return licenseGate.EvaluateAsync(requirement, cancellationToken); }}Repository 层不应决定商业授权,因为它无法理解业务操作。
策略准入与静态组合
共享 Runtime License 策略由 RuntimeLicensePolicyEvaluator 在注册期准入。RuntimeLicenseOptions.PolicyId 选择策略;Composition 声明策略钉定的静态部署边界。
| 策略 | 状态 | 准入组合 |
|---|---|---|
community.web.v1 | 活跃 | Profile: mini-api、CombinationId: mini-api-single、TenancyMode: single-tenant、PlatformModule: none、IndustryExtension: none、Features: [framework.core, framework.aspnetcore] |
community.small.v1 | 活跃 | 任意静态组合;同一 Deployment 跨租户汇总的权威活跃自然人用户不超过 30;无需客户专属 License、信封或公钥 |
| 其他 | — | RuntimeLicensePolicyAdmissionException(PolicyNotActive) |
空 PolicyId 保留 protocol-v1 专属部署兼容路径。未知 PolicyId 在启动时抛错。community.web.v1 的 protocol-v2 envelope 仍必须精确匹配 edition(Community)、profile(mini-api)、DeploymentId(bitzorcas-shared-policy)、租户模式与完整特性集——多或少任一特性都被拒绝。community.small.v1 不接收或生成共享信封;它是 Host 显式选择、由 Identity 持久计量证明的内建容量权益。
“小型应用 / SaaS”是产品适用分类,不是客户端可填写的授权证据。Runtime 不接受 ApplicationKind=small/saas 之类的自报配置;客观门禁只使用显式 PolicyId、持久 DeploymentId 和 Identity 权威人数。若某个生产部署按合同无论人数都必须持证,就不要选择 community.small.v1,继续使用专属签名模式。
有效或宽限期内的商业 License 始终优先,并解除 30 人上限;计量仍持续维护,以便 License 移除或过期后立即回到正确边界。Unavailable 或 Expired 可在人数不超过 30 时回退到社区权益;Invalid 或 Revoked 属于安全事件,绝不回退。若商业 License 失效时部署已有 31 人以上,启动和基础控制面仍可用,用户搜索、详情、禁用和删除保持可用以恢复到 30 人;新增、邀请、注册、激活和重新启用继续拒绝。
许可就绪健康检查
每个模板 Host 映射 /health/license(匿名)并注册 AddBitzOrcasLicenseReadiness()。LicenseReadinessHealthCheck 把 LicenseReadinessLevel 映射为 HealthStatus;内建社区权益生效时返回 Healthy、状态码 LIC.COMMUNITY_SMALL,并提供当前人数、上限和计量序列。Invalid/Revoked,以及不满足社区边界的 Unavailable/Expired 映射为 Unhealthy。描述只暴露安全状态码与脱敏原因——绝不暴露签名或 payload。默认检查名 license。
Kubernetes 流量探针使用 /health/ready:它只反映数据库、消息系统等基础运行依赖,License 未签发时仍允许登录、密码恢复、导航和签发控制面进入实例。商业许可状态由 /health/license 单独监控和告警;/health 是包含 License 在内的聚合诊断,因此在 License 不可用时仍可返回 503。License 不可用不会让整个程序无法接流量,但普通业务消息依旧由 Mediator gate 返回 503/403。
在线与离线生命周期
后台刷新服务从 ILicenseLeaseSource 获取新租约;请求路径只读最近一次原子发布的决策,不访问网络或文件。缓存保存完整签名 Envelope,读取后必须重新验签。
在线服务不可用时,运行时根据 expiresAt、offlineUntil 和 graceUntil 进入 Valid → Grace → Expired。离线介质导入也走同一验签、上下文匹配和原子缓存路径。
为防重放:
- 新租约的
issuedAt必须单调前进; - 同一时刻但内容不同的租约视为异常;
- 缓存损坏后不能跳过验签;
- 超出容差的系统时钟回拨返回
Invalid。
一个断网场景
假设生产部署昨天拿到有效租约,今天 License Server 暂时不可达:
- 运行时重新验证本地签名快照;
- 尚在
offlineUntil内,状态保持Valid; - 超过离线窗口但仍在
graceUntil内,进入Grace并告警; - 超过宽限期,进入
Expired,停止普通写入但保留导出和迁移; - 网络恢复后,只接受更新且通过验签的租约。
这比“License Server 一断网就停机”更可运维,也比“缓存一份结果永久运行”更安全。
公钥轮换与撤销
安全轮换需要一段双公钥窗口:先向所有目标部署发布新公钥,再用新 keyId 签发 Lease,观察刷新成功后才移除旧公钥。提前移除会让尚持有旧签名缓存的实例进入 Invalid/Unavailable。
撤销不是删除本地文件。签发侧必须发布可验证的 Revoked 状态或等价的受控撤销结果,运行时将 readiness 置为 Unhealthy 并 fail closed。撤销事件、状态转换和管理员操作要写入 ILicenseAuditSink,但审计中不能记录完整 Envelope。
Readiness 与告警
| 信号 | 建议级别 | 运维动作 |
|---|---|---|
| Valid 且刷新成功 | 正常 | 观察剩余租期 |
| Valid 但连续刷新失败 | Warning | 在 offlineUntil 前恢复来源 |
| Grace | Critical/Degraded | 立即续租并准备受控降级 |
| Expired | Unhealthy | 停止普通写入,执行合同允许的数据携带操作 |
| Revoked/Invalid | Security incident | 隔离实例并调查签名/上下文 |
LIC.COMMUNITY_SMALL | 正常 | 观察人数;达到 30 前规划商业 License |
| Unavailable 且无社区权益 | Unhealthy | 检查策略、计量、配置、文件权限和 Lease Source |
请求路径只读取原子快照,因此 License Server 延迟不应直接增加业务请求延迟。相反,刷新失败次数、当前状态、剩余离线/宽限时间和缓存读取失败应进入监控。
测试矩阵
# 状态机、签名、缓存、重放、DI 和 Gate 行为。dotnet test tests/BitzOrcas.Licensing.Tests --configuration Release
# 商业模块是否绕过统一 License seam。dotnet test tests/BitzOrcas.Architecture.Tests \ --configuration Release \ --filter 'FullyQualifiedName~RuntimeLicenseArchitectureTests'至少覆盖 Valid/Grace/Expired/Revoked/Invalid/Unavailable、系统时钟回拨、同 issuedAt 不同 payload、缓存损坏、Lease Source 中断、公钥轮换以及 DeploymentId 重启保持。
配置原则
Production/Staging 必须显式配置产品、版本、环境、tenancy、DeploymentId/缓存持久路径。签名策略还必须提供至少一个验证公钥;显式选择 community.small.v1 时不需要公钥,因为它不是签名 License,但缺失 Identity 持久化适配器或权威计量时仍快速失败。Development/Test 可通过同一社区策略运行,也可用 AddTestLicense 注入测试快照;后者在注册期拒绝 Test/Testing 以外的环境,生产绝不能附带万能 License。
上线验收
- DeploymentId 与缓存路径跨 Pod/进程重启持久;
- API 与 JobHost 使用相同产品、版本、环境和租户模式语义;
- 签名部署至少一个受信公钥通过安全配置提供;社区小型部署不要求公钥,Host 中始终没有签发私钥;
- 社区小型部署验证 29/30/31、并发激活、重复/并发释放幂等、禁用/删除释放席位和启动重建;
- 在线或离线 Lease 经过 ES256 验签与上下文匹配;
- readiness、状态转换和刷新失败已有告警;
- Grace/Expired/Revoked 的业务与数据携带行为经过测试;
- 密钥轮换、License Server 故障和恢复流程完成演练;
- 日志和 Problem Details 不泄露 KeyId、签名或 payload。