Skip to content
bitzorcas
中EN

Concept

Runtime License

理解 BitzOrcas Runtime License 的自动管线强制、签名验证,以及 30 人以内内建社区权益的计量、配置与恢复边界。

Last updated

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普通业务写入数据可携带操作
ValidHealthy允许允许
GraceDegraded在合同定义的有限期内运行允许
ExpiredUnhealthy;符合社区策略时 Healthy拒绝;符合社区策略时回退Backup/Export/Migration 保留
RevokedUnhealthy拒绝拒绝,fail-closed
InvalidUnhealthy拒绝拒绝,fail-closed
UnavailableUnhealthy;符合社区策略时 Healthy无有效快照时默认拒绝;符合社区策略时放行按受控策略处理

该矩阵由管线自动强制执行(见下一节)。一个请求被分类为某个 LicensedOperation——查询为 BusinessRead,其余为 BusinessWrite,或带数据携带标记的请求为 Backup/Export/Migration——然后由上面的状态×操作矩阵决定是否放行。

"尚无已验证快照""获得并验签新 Lease""刷新为更新 Lease""超过有效/离线窗口""续租成功""超过 graceUntil""收到撤销状态""收到撤销状态""上下文/时钟/内容校验失败""上下文/时钟/内容校验失败""接受更新且有效的 Lease"

Unavailable

Valid

Grace

Expired

Revoked

Invalid

状态机优先级是 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 包裹 RuntimeLicensePipelineBehaviorIResult<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 / IBaseStreamQueryBusinessRead
ILicensedBackupRequestBackup
ILicensedExportRequestExport
ILicensedMigrationRequestMigration
其余BusinessWrite(默认)

一个请求类型不能声明多个数据携带标记——守卫会抛错。LicenseOperationStatusPolicy.AllowsExecution 随后应用状态表的矩阵:Valid/Grace 允许全部;Expired 允许读 + backup + export + migration 但阻断业务写入;Revoked/Invalid/Unavailable 阻断一切。

稳定错误面

状态错误码HTTPRFC 9457
Unavailable(或 gate 异常/null decision)Licensing.Runtime.Unavailable503service-unavailable
Expired / Revoked / InvalidLicensing.Runtime.Denied403forbidden

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 的消息所校验的不可变要求。

Host 组合(模板 BitzConsumer.Api/Program.cs)
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 暂时不可达:

  1. 运行时重新验证本地签名快照;
  2. 尚在 offlineUntil 内,状态保持 Valid;
  3. 超过离线窗口但仍在 graceUntil 内,进入 Grace 并告警;
  4. 超过宽限期,进入 Expired,停止普通写入但保留导出和迁移;
  5. 网络恢复后,只接受更新且通过验签的租约。

这比“License Server 一断网就停机”更可运维,也比“缓存一份结果永久运行”更安全。

公钥轮换与撤销

安全轮换需要一段双公钥窗口:先向所有目标部署发布新公钥,再用新 keyId 签发 Lease,观察刷新成功后才移除旧公钥。提前移除会让尚持有旧签名缓存的实例进入 Invalid/Unavailable。

撤销不是删除本地文件。签发侧必须发布可验证的 Revoked 状态或等价的受控撤销结果,运行时将 readiness 置为 Unhealthy 并 fail closed。撤销事件、状态转换和管理员操作要写入 ILicenseAuditSink,但审计中不能记录完整 Envelope。

Readiness 与告警

信号建议级别运维动作
Valid 且刷新成功正常观察剩余租期
Valid 但连续刷新失败Warning在 offlineUntil 前恢复来源
GraceCritical/Degraded立即续租并准备受控降级
ExpiredUnhealthy停止普通写入,执行合同允许的数据携带操作
Revoked/InvalidSecurity incident隔离实例并调查签名/上下文
LIC.COMMUNITY_SMALL正常观察人数;达到 30 前规划商业 License
Unavailable 且无社区权益Unhealthy检查策略、计量、配置、文件权限和 Lease Source

请求路径只读取原子快照,因此 License Server 延迟不应直接增加业务请求延迟。相反,刷新失败次数、当前状态、剩余离线/宽限时间和缓存读取失败应进入监控。

测试矩阵

Terminal window
# 状态机、签名、缓存、重放、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。

另见

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%