diff --git a/AGENTS.md b/AGENTS.md index 43955aa..066f92b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ - P6进程观测使用internal/observability固定指标目录,拒绝未知名称、类型和动态标签,不能引入任务/租户/业务键/凭据维度。ORBIT_METRICS_ADDR默认空(关闭),显式启用独立采集监听;业务端口不得暴露全局指标。渲染和网络I/O不持有全局记录锁,每个直方图局部快照保持桶/count/sum一致,最多2个并发抓取。启用指标后的5秒后台采样使用000026–000028固定索引读取任务、交付、流程状态、Weave Inbox状态、固定事件类别、终态耗时和最近窗口内固定事件阶段耗时,每个状态或事件最多10000条、事件窗口5分钟、900ms数据库截止;采样失败保留最后成功状态并独立记录错误/超时,不能影响业务事务或把有限样本当全局精确数。 - HTTP成功升级在Hijack完成时记录握手,WS处理器存续计入独立连接指标;保持1xx临时响应、Flush、Hijack与原panic行为。DB池取本地Stats,抓取不查询业务库;后台批次success不是业务任务成功量。观测丢样或失败不得改变权威事务及恢复决定。 - 进程指标、固定状态采样、格式验证、告警规则和标准回归证据见docs/reports/phase-6-observability.md及docs/reports/phase-6-business-observability.md。仍须补受理/授权/完成与流程推进延迟、持久吞吐/积压的全局聚合、重试/核查/恢复量、发送队列、实际采集告警及负载开销,不能把进程观测通过写成P6已完成。普通清理仍须保护有效幂等、未确认事件、未知结果和未终结流程。 -- 凭据轮换保留credential_id及订阅/Worker授权绑定,在同一事务替换摘要、固定到期时间、幂等回执和管理审计;新令牌必须重新建立会话,认证路径每次重新核对凭据状态。撤销必须让认证失效并保留原任务、订阅、交付、会话和审计证据,允许在应用或命名空间停用后执行;重复应用同一固定操作幂等返回。管理CLI先把固定操作文件写成0600,再提交;提交不明时保留文件并用原文件重放,不能重新生成秘密。日志、审计摘要、指标和业务表不得保存秘密原文或摘要。不要把轮换同一凭据ID解释成历史令牌仍有效,也不得级联删除订阅、任务或事件。 +- 凭据轮换保留credential_id及订阅/Worker授权绑定,在同一事务替换摘要、固定到期时间、幂等回执和管理审计;新令牌必须重新建立会话,认证路径每次重新核对凭据状态。撤销必须让认证失效并保留原任务、订阅、交付、会话和审计证据,允许在应用或命名空间停用后执行;重复应用同一固定操作幂等返回。管理CLI先把固定操作文件写成0600,再提交;提交不明时保留文件并用原文件重放,不能重新生成秘密。日志、审计摘要、指标和业务表不得保存秘密原文或摘要。000032 后仅在 previous_valid_until 之前接受上一代摘要(最长 30 天,不超过原到期时间);默认控制台/CLI过渡7天,旧固定v1操作仍立即轮换。默认新凭据长期有效;SET_EXPIRY 保持秘密不变,FINALIZE 停用旧代,REVOKE 同时停用两代;有效过渡期禁止再次轮换挤掉旧代。不要把稳定凭据ID解释成所有历史令牌仍有效,也不得级联删除订阅、任务或事件。 - `sdk/go/cmd/orbit-load` 只用于隔离环境受理测量:每项使用稳定独立幂等键,固定请求形状和并发上限,按请求超时,不自动重试,不启动Worker或执行外部副作用;输出延迟百分位与有限错误分类,失败必须以非零状态返回。其结果不能替代真实Worker、流程推进、数据库拓扑或生产容量验收。 - `sdk/go/cmd/orbit-exec-load` 独立测量真实Worker执行:每次运行固定专属处理器/业务键/序号/模拟时长,最多一次尝试且无外部副作用;任务成功须核对持久化结果、唯一尝试、时间顺序及单次本地调用。队列额度只读,提交不重试;整体截止和有界排空后输出实际分母,未完成/未知结果必须非零退出。数据库阶段耗时不得混用客户端轮询观察时间,不将隔离样本解释为生产容量。 - `sdk/go/cmd/orbit-workflow-load` 独立测量真实Weave流程:每次运行创建固定版本的双分支并行定义,启动实例不自动重试,必须核对流程/步骤/任务/尝试/来源/输出及两倍分支调用;队列只读,处理器无外部副作用,截止后保留实际分母并以非零状态退出。隔离样本不代表生产容量、高可用或跨故障域验收。 diff --git a/README.md b/README.md index ddc3f51..aac44c7 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ python3 -c 'import getpass; print(getpass.getpass("设置管理员密码(至 --console-password-stdin --output .local/development.credentials.json ``` -管理员可在「应用接入」页面新建应用和初始队列、查看凭据有效期、重新生成 Token,并复制或下载 SDK 配置。设置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一接入管理员;本地 Compose 已启用。[应用接入使用与接口文档](docs/api/console-applications.md)。 +管理员可在「应用接入」页面新建应用和初始队列、查看和调整有效期、平滑换证(默认长期 Token,换证时新旧共存 7 天),并复制或下载 SDK 配置。设置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一接入管理员;本地 Compose 已启用。[应用接入使用与接口文档](docs/api/console-applications.md)。 初始化只运行一次,后续重启复用数据库、账号和 SDK 凭据。凭据 JSON 是服务端程序访问 API 的秘密文件,**控制台不需要选择或上传它**。已有资源增加管理员的方法见 [Console 手册](docs/console.md);真实接入按实际角色缩小权限。 diff --git a/cmd/orbit-admin/credentials.go b/cmd/orbit-admin/credentials.go index 6f318e6..1bb99bb 100644 --- a/cmd/orbit-admin/credentials.go +++ b/cmd/orbit-admin/credentials.go @@ -19,19 +19,23 @@ import ( func runCredential(args []string) error { if len(args) == 0 { - return errors.New("usage: orbit-admin credential status|rotate|revoke|apply") + return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply") } switch args[0] { case "status": return runCredentialStatus(args[1:]) case "rotate": return runCredentialChange(args[1:], "ROTATE") + case "expiry": + return runCredentialChange(args[1:], "SET_EXPIRY") + case "finalize": + return runCredentialChange(args[1:], "FINALIZE") case "revoke": return runCredentialChange(args[1:], "REVOKE") case "apply": return runCredentialApply(args[1:]) default: - return errors.New("usage: orbit-admin credential status|rotate|revoke|apply") + return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply") } } @@ -64,14 +68,19 @@ func runCredentialStatus(args []string) error { func runCredentialChange(args []string, action string) error { flags := flag.NewFlagSet("credential "+strings.ToLower(action), flag.ContinueOnError) - namespace, credential, output, reason, validFor := "", "", "", "", "720h" + namespace, credential, output, reason, validFor := "", "", "", "", "0" + grace := time.Duration(0) + if action == "ROTATE" { + grace = 7 * 24 * time.Hour + } var expected uint64 flags.StringVar(&namespace, "namespace-id", "", "credential namespace id") flags.StringVar(&credential, "credential-id", "", "credential id") flags.Uint64Var(&expected, "expected-revision", 0, "current credential revision required for first apply") flags.StringVar(&reason, "reason", "", "required audit reason") flags.StringVar(&output, "output", "", "new private operation file; an existing file is never overwritten") - flags.StringVar(&validFor, "valid-for", "720h", "new credential validity, from 1h to 8760h") + flags.StringVar(&validFor, "valid-for", "0", "credential validity: 0 permanent, or 1h to 8760h") + flags.DurationVar(&grace, "grace", grace, "rotation overlap: 0 immediate, up to 720h; default 168h") if err := flags.Parse(args); err != nil { return err } @@ -83,7 +92,7 @@ func runCredentialChange(args []string, action string) error { return errors.New("credential change requires valid ids and a nonzero --expected-revision") } duration := time.Duration(0) - if action == "ROTATE" { + if action == "ROTATE" || action == "SET_EXPIRY" { duration, err = time.ParseDuration(validFor) if err != nil { return errors.New("invalid --valid-for duration") @@ -95,7 +104,7 @@ func runCredentialChange(args []string, action string) error { } defer closeDB() op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{ - NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, Reason: reason, + NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason, }) if err != nil { return errors.New("credential operation could not be prepared") diff --git a/cmd/orbit-admin/main.go b/cmd/orbit-admin/main.go index 8ac5d2a..e69190f 100644 --- a/cmd/orbit-admin/main.go +++ b/cmd/orbit-admin/main.go @@ -257,6 +257,7 @@ func runBootstrap(args []string) error { var in mysql.BootstrapInput var output string flags.StringVar(&in.Application, "application", "", "new application name") + flags.DurationVar(&in.ValidFor, "valid-for", 0, "initial credential validity: 0 permanent, or 1h to 8760h") flags.Func("application-id", "existing application id; mutually exclusive with --application", func(v string) error { in.ExistingApplicationID = core.ID(v) return in.ExistingApplicationID.Validate() diff --git a/deploy/local/README.md b/deploy/local/README.md index ab81f3a..83d5387 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -135,6 +135,6 @@ Worker 返回三条 `SUCCEEDED`;计划示例实际执行后暂停;流程示 ## 凭据和权限 -初始 SDK 凭据有效期 30 天,具体 `expires_at` 在私密 `sdk.credentials.json` 和启动输出中。凭据拥有此测试命名空间的任务、Worker、调度、订阅、流程及恢复权限;这是联调身份,正式环境按角色收窄。凭据到期/撤销后不会自动重新初始化或重置历史,按[轮换手册](../../docs/api/authentication.md)处理。 +新建 SDK 凭据默认长期有效(省略 `expires_at`);旧环境保留原策略,可在应用接入页「调整有效期」且不改变 Token。启动输出读取数据库当前有效期。凭据拥有此测试命名空间的任务、Worker、调度、订阅、流程及恢复权限;这是联调身份,正式环境按角色收窄。凭据到期/撤销后不会自动重新初始化或重置历史,按[轮换手册](../../docs/api/authentication.md)处理。 管理员密码、数据库密码、SDK Token 均保存在 `.local/compose-test` 下的私密文件;目录权限 0700、文件权限 0600,Git 和 Docker 构建上下文都排除这些文件。需要其他服务读取时分发 `host.env` / `docker.env`,不要给业务服务数据库管理员配置。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 1f1138c..1d5a919 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -282,3 +282,10 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专 - 新建资源/审计/幂等回执同事务,轮换复用现有凭据 ID 与轮换核心。请求不明保留原操作,原样重报;原有任务、订阅和交付身份不变。无新表、无外键、无 SDK 协议修改。 - 已通过定向 MySQL 并发创建、幂等冲突、重名回滚、轮换/旧 Token 拒绝、退出失效与审计脱敏;真实 HTTP 已验证创建、轮换后原任务和订阅可读。标准/全量集成和页面验收记录见[本批报告](reports/console-applications.md)。 - [页面使用、HTTP 接口及故障恢复](api/console-applications.md)。 + +### 长期凭据与平滑换证(2026-09-27) + +- 新建应用和 bootstrap 默认长期 Token,可选定期;既有 Token 可通过管理端/CLI 原地调整有效期,无需改业务配置。 +- 000032 与基线增加旧摘要/固定截止,无外键。控制台/CLI 默认 7 天过渡;同一身份的 API、WS 控制命令和跨节点重连均核验两代有效性,权限与任务归属保持不变。 +- 结束过渡、两代同时撤销、过渡期防重复换证、旧 v1 固定操作兼容、幂等审计及中文模型注释随功能交付。 +- 验证结果见[长期凭据与平滑换证](reports/credential-overlap.md)。SDK 无接口修改;调用方须在截止前滚动更新配置并收尾在途执行。 diff --git a/docs/api/authentication.md b/docs/api/authentication.md index e1f51a0..eaca933 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -2,7 +2,7 @@ 状态:P1 已实现。业务接口每次在数据库事务内校验凭据摘要、撤销/到期、应用与命名空间启停,以及动作和队列范围。授权与写入使用同一事务;管理变更与已经获得授权锁的请求按事务先后次序生效。 -已提供单管理员[应用接入页面](console-applications.md):新建应用、查看队列与凭据、重新生成 Token、复制 SDK 配置。下方 CLI 继续用于首次安装及运维。 +已提供单管理员[应用接入页面](console-applications.md):新建应用、查看队列与凭据、长期凭据、调整有效期、平滑换证、复制 SDK 配置。下方 CLI 继续用于首次安装及运维。 ## 初始化一个新的接入应用 @@ -21,7 +21,7 @@ go run ./cmd/orbit-admin bootstrap \ ORBIT_MYSQL_DSN 通过环境注入,命令不读取 .env。所有名称必填,以字母/数字开头,后续允许字母、数字、`_ . : -`;environment 最长 32,其余最长 128。输出文件必须不存在,以 0600 创建;原始凭据不会打印到 stdout/stderr。`.local/` 和 `*.credentials.json` 已被 Git 忽略。 -输出 JSON 包含 application_id / namespace_id / queue_id / credential_id / token / expires_at。初始凭据有效期为 30 天,权限包括当前命名空间内的五个 P1 动作和全部队列。初始队列 max_concurrency=4、rate_per_second=10、max_unfinished_tasks=100000;并发、开始限速与积压背压已生效,语义及管理方式见 [队列接口](queues.md)。这些默认值是开发基线,不是容量承诺。 +输出 JSON 包含 application_id / namespace_id / queue_id / credential_id / token,可选 expires_at。初始凭据默认长期有效(expires_at 省略),可用 `--valid-for 720h` 显式选择 30 天;0 表示长期,其他值为 1 小时至 365 天。权限包括当前命名空间内的五个 P1 动作和全部队列。初始队列 max_concurrency=4、rate_per_second=10、max_unfinished_tasks=100000;并发、开始限速与积压背压已生效,语义及管理方式见 [队列接口](queues.md)。这些默认值是开发基线,不是容量承诺。 如果希望在第一次初始化时同时创建控制台管理员,可在同一条 bootstrap 事务中提供账号,并从标准输入交付密码: @@ -46,7 +46,7 @@ secret-manager read orbit/console-admin-password | \ ## 轮换与撤销 -管理 CLI 在受保护的数据库管理环境执行;控制台也可由部署指定的单管理员轮换凭据,见[应用接入](console-applications.md)。业务 SDK 不开放凭据管理,撤销仍由 CLI 执行。轮换保留 `credential_id`,因此订阅和 Worker 实例的授权绑定仍指向同一记录;新的 Worker 进程必须按现有进程身份规则重新连接,旧令牌不能再通过任何 API 或 WSS 认证。平台不会批量改写在途任务、交付记录或历史会话证据。 +管理 CLI 在受保护的数据库管理环境执行;控制台也可由部署指定的单管理员轮换凭据,见[应用接入](console-applications.md)。业务 SDK 不开放凭据管理,撤销仍由 CLI 执行。轮换保留 `credential_id`,因此订阅和 Worker 实例的授权绑定仍指向同一记录;新进程仍遵循原 Worker 身份规则;过渡期内旧 Token 的 API 请求、原 WS 控制命令及跨节点重连都继续有效。新 Token 立即可用,结束过渡或超过截止后旧 Token 才被拒绝。平台不会批量改写在途任务、交付记录或历史会话证据。 先读取脱敏状态和当前版本: @@ -63,12 +63,13 @@ go run ./cmd/orbit-admin credential rotate \ --namespace-id \ --credential-id \ --expected-revision 1 \ - --valid-for 720h \ + --valid-for 0 \ + --grace 168h \ --reason "scheduled credential rotation" \ --output .local/rotated.credentials.json ``` -命令先读取并核对版本,生成一个固定 `operation_id`、令牌和绝对到期时间,写入新的 `0600` 私有操作文件,然后提交数据库事务。文件不得已存在;提交结果不明、网络断开或数据库返回未知错误时必须保留文件,并使用同一个文件执行 `credential apply --file`,不能重新执行 `rotate` 生成第二个秘密。`--valid-for` 限定在 1 小时到 1 年。数据库只保存秘密摘要、请求摘要和脱敏回执。 +命令先读取并核对版本,生成一个固定 `operation_id`、令牌、可选绝对到期时间和固定过渡截止,写入新的 `0600` 私有操作文件,然后提交数据库事务。文件不得已存在;提交结果不明、网络断开或数据库返回未知错误时必须保留文件,并使用同一个文件执行 `credential apply --file`,不能重新执行 `rotate` 生成第二个秘密。`--valid-for` 默认 0(长期),也可配置 1 小时到 365 天;`--grace` 默认 168h(7 天),范围 0–720h,0 表示立即停用旧 Token。旧 Token 的实际截止取过渡截止与原到期时间的较早者,不复活已过期秘密。数据库只保存秘密摘要、请求摘要和脱敏回执。 ```sh go run ./cmd/orbit-admin credential apply \ @@ -77,6 +78,36 @@ go run ./cmd/orbit-admin credential apply \ `apply` 会严格拒绝软链接、非 0600 文件、未知 JSON 字段、重复 JSON 对象和修改过的操作内容。重复应用已提交的文件只返回原回执,回执中的 `deduplicated` 为 `true`,不会重新轮换或新增审计。 +### 原 Token 调整有效期 + +无需换 Token:读取当前 revision 后执行以下命令,业务侧继续使用原配置。缩短有效期同样会生效,应按预期截止配置。 + +```sh +go run ./cmd/orbit-admin credential expiry \ + --namespace-id --credential-id \ + --expected-revision 1 --valid-for 0 \ + --reason "service credential remains valid until revoked" \ + --output .local/expiry.operation.json +``` + +### 平滑切换的完整顺序 + +1. 生成新 Token,保留默认 7 天过渡。凭据 ID、权限、任务、订阅和交付身份不变。 +2. 把新 Token 发布到调用方配置,逐台更新 API 服务、Worker 和结果消费者。SDK v0.1.1 的 `Options.Token` 在创建客户端时读取,修改 `.env` 不会自动刷新正在运行的客户端;新建客户端或滚动重启服务,按 [HTTP 宿主示例](../examples/http-service.md) 正常排空在途工作。 +3. 在截止前确认所有旧进程已完成任务回报和 ACK,并已经退出或用新 Token 重新连接。HTTP 调用无须整体停机;持续执行能力依赖业务侧保留足够副本。单实例重启本身会产生短暂停顿。 +4. 全部切换后可提前结束过渡;否则旧 Token 到时自动失效,认证使用数据库时间,不依赖定时清理。 + +```sh +go run ./cmd/orbit-admin credential finalize \ + --namespace-id --credential-id \ + --expected-revision 2 --reason "all service instances switched and drained" \ + --output .local/finalize.operation.json +``` + +结束过渡立即停用旧 Token,当前 Token 不变。长期 Token 仍受主动撤销、应用/命名空间停用约束。旧代仍在有效过渡期时再次轮换返回 409,避免误伤未切换服务;先完成本次切换。修改当前有效期不延长旧代截止。若旧 Token 本身即将到期而需要更长切换窗口,应先调整当前有效期,再发起换证。 + +新固定操作文件使用 version=2;历史 version=1 仍按原来的固定到期和立即轮换语义重放,不自动延长旧秘密。 + 发生泄露或 Worker 必须立即停用时执行撤销: ```sh @@ -88,7 +119,7 @@ go run ./cmd/orbit-admin credential revoke \ --reason "incident response" ``` -撤销同样先写入固定操作文件,再提交事务;提交不明时用 `credential apply --file .local/revoke.operation.json` 重试。撤销只标记凭据失效,不删除订阅、任务、交付或历史记录。即使应用或命名空间已停用,管理撤销仍可执行。重复应用同一撤销文件只返回原回执,不追加重复审计。审计主体取自 MySQL 管理连接的 `CURRENT_USER()`,审计摘要不包含秘密原文或秘密摘要。 +撤销同样先写入固定操作文件,再提交事务;提交不明时用 `credential apply --file .local/revoke.operation.json` 重试。撤销使当前和过渡中的上一代同时失效,不删除订阅、任务、交付或历史记录。即使应用或命名空间已停用,管理撤销仍可执行。重复应用同一撤销文件只返回原回执,不追加重复审计。审计主体取自 MySQL 管理连接的 `CURRENT_USER()`,审计摘要不包含秘密原文或秘密摘要。 ## 凭据格式与作用域 @@ -130,3 +161,7 @@ P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume` P4定义发布新增`workflows:definitions:read`与`workflows:definitions:publish`,仅显式`--workflow-access`授予。创建/读取元数据作用于命名空间,发布及读取完整版本额外校验全部引用队列;默认提交/Worker凭据不获此权限。发布不创建任务,不要求tasks:submit,详见[定义API](workflow-definitions.md)。 P4/P5实例公共接入增加`workflows:start/read/control/signal/recover`及`workflows:audit:read`。五个独立CLI开关`--workflow-run-access`、`--workflow-control-access`、`--workflow-signal-access`、`--workflow-recovery-access`、`--workflow-audit-access`分别明确授予。前四个同时包含实例读取;audit只授予流程操作审计读取。原`--workflow-access`仍仅授予定义读/发布,任务`--recovery-access`不授予流程恢复。全部固定队列仍按当前凭据校验,详见[实例权限表](workflows.md#1-路由权限与-sdk)。初始化权限集合在交付凭据前校验。 + +## 部署升级 + +迁移 `000032` 为凭据表增加上一代摘要与 UTC 截止,并以 CHECK 约束成对为空;不新增外键、不改变已有秘密或有效期。新安装的基线同步到 32。按部署流程应用迁移并更新全部 Orbit 节点后再使用平滑换证;旧版本节点只认识当前摘要,不能在混合版本期间承诺旧 Token 可用。既有凭据不会被迁移自动改为长期,须通过有审计的 expiry 操作显式调整。 diff --git a/docs/api/console-applications.md b/docs/api/console-applications.md index 9920217..3550af7 100644 --- a/docs/api/console-applications.md +++ b/docs/api/console-applications.md @@ -1,6 +1,6 @@ # 控制台应用接入 -单管理员在「应用接入」页面创建应用、初始队列与 SDK 凭据,查看环境、队列、权限和有效期,重新生成 Token,复制或下载 `.env` 配置。数据库只保存 Token 摘要:完整 Token 仅在新建或轮换时交付,列表和详情不能反查旧秘密。 +单管理员在「应用接入」页面创建应用、初始队列与 SDK 凭据,查看环境、队列、权限和有效期,平滑换证,复制或下载 `.env` 配置。数据库只保存 Token 摘要:完整 Token 仅在新建或轮换时交付,列表和详情不能反查旧秘密。 ## 1. 启用和使用 @@ -18,7 +18,8 @@ export ORBIT_CONSOLE_APPLICATION_ADMIN=admin 2. 选择 Worker、结果订阅、定时、流程等需要的能力及有效期。基础权限包含任务提交/查询/取消、队列读取/管理。 3. 创建成功后复制或下载完整 `.env`,交给该应用的服务端配置。默认地址使用当前页面来源,详情中可改为容器地址或部署域名。 4. 已接入应用可以随时查看元数据和配置模板。模板中的 Token 是占位值,必须替换成已经保存的原 Token。 -5. 「重新生成 Token」保留 application_id、namespace_id、credential_id、队列、任务和订阅;旧 Token 在轮换事务提交后不能再通过业务认证,服务端配置需要同步更新。 +5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。 +6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。 环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面目前支持新建应用及其初始队列,未提供应用删除、身份迁移、跨应用任务列表或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。接入管理页不会改变其他业务页面的账号默认范围。 @@ -48,31 +49,31 @@ prepare 请求: "queue": "orders", "capabilities": ["worker", "events", "schedules", "workflows"], "expected_revision": "0", - "valid_days": 30, + "valid_days": 0, "reason": "接入订单服务" } ``` -名称以字母或数字开头,后续只允许字母、数字、`_ . : -`,environment 最长 32,其余最长 128。valid_days 为 1–365 的整数;reason 非空、最多 1024 个 Unicode 字符,不含换行或 NUL。capabilities 可选 `worker`、`events`、`schedules`、`workflows`、`recovery`、`resources`,不可重复。新建不传 namespace_id / credential_id,expected_revision 省略或为字符串 `"0"`。 +名称以字母或数字开头,后续只允许字母、数字、`_ . : -`,environment 最长 32,其余最长 128。valid_days 为 0–365 的整数,默认 0 表示长期有效;reason 非空、最多 1024 个 Unicode 字符,不含换行或 NUL。capabilities 可选 `worker`、`events`、`schedules`、`workflows`、`recovery`、`resources`,不可重复。新建不传 namespace_id / credential_id,expected_revision 省略或为字符串 `"0"`。 prepare 响应 data 为固定操作对象: ```json { - "input": {"kind":"CREATE","application":"order-service","namespace":"default","environment":"test","queue":"orders","capabilities":["worker","events"],"expected_revision":"0","valid_days":30,"reason":"接入订单服务"}, + "version": 2, + "input": {"kind":"CREATE","application":"order-service","namespace":"default","environment":"test","queue":"orders","capabilities":["worker","events"],"expected_revision":"0","valid_days":0,"reason":"接入订单服务"}, "operation_id": "11111111111111111111111111111111", "application_id": "22222222222222222222222222222222", "namespace_id": "33333333333333333333333333333333", "queue_id": "44444444444444444444444444444444", "credential_id": "55555555555555555555555555555555", - "token": "服务端生成的完整 Token", - "expires_at": "2026-10-27T02:00:00Z" + "token": "服务端生成的完整 Token" } ``` 以上是结构示意,ID、令牌及时间以服务端返回为准。将 **data 整体原样** POST 到 apply,不包 `data` 外层。应用、额度、命名空间、队列、凭据摘要、审计和幂等回执在同一事务提交。初始队列并发 4、开始速率 10/秒,沿用 bootstrap 默认策略;凭据队列范围为新命名空间的全部队列。重名应用返回 409,不覆盖原资源。 -## 4. 重新生成 Token +## 4. 平滑换证 prepare 请求: @@ -82,12 +83,13 @@ prepare 请求: "namespace_id": "33333333333333333333333333333333", "credential_id": "55555555555555555555555555555555", "expected_revision": "1", - "valid_days": 30, + "valid_days": 0, + "grace_days": 7, "reason": "更新接入凭据" } ``` -expected_revision 为读取到的字符串版本。只允许上述轮换字段,不混入新应用名称或能力组。准备对象包含原 application_id / namespace_id / credential_id,没有 queue_id。apply 复用原有凭据轮换事务:替换秘密摘要与到期时间,版本加一,保存脱敏回执及人类账号审计;权限组不改变。已撤销的凭据禁止轮换。 +expected_revision 为读取到的字符串版本。只允许上述轮换字段,不混入新应用名称或能力组。准备对象包含原 application_id / namespace_id / credential_id,没有 queue_id。apply 复用原有凭据轮换事务:将原摘要保留到固定过渡截止,替换当前摘要与可选到期时间,版本加一,保存脱敏回执及人类账号审计;权限组不改变。已撤销的凭据禁止变更。ROTATE 的 grace_days 范围 0–30,API 省略/0 保持立即停用旧 Token 的兼容语义,页面显式提交 7;实际截止不超过旧 Token 原到期时间。过渡未结束时再次轮换返回 409。 apply 成功 data: @@ -102,7 +104,28 @@ apply 成功 data: } ``` -## 5. 超时、重复和审计 +## 5. 调整有效期与结束过渡 + +prepare 输入示例: + +```json +{ + "kind": "SET_EXPIRY", + "namespace_id": "33333333333333333333333333333333", + "credential_id": "55555555555555555555555555555555", + "expected_revision": "2", + "valid_days": 0, + "reason": "服务凭据设置为长期有效" +} +``` + +SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长期,1–365 为从准备时间计算的天数。FINALIZE 使用相同身份/版本字段,kind 改为 FINALIZE,valid_days 必须为 0,用于提前停用旧 Token。两者均禁止 grace_days 和新建字段,prepare 不返回 token;FINALIZE 也不返回 expires_at。apply 使用完整原对象,具有相同的并发版本、幂等和审计约束。 + +新版 prepare 对象带 version=2;expires_at 为空时**省略字段**,不要传显式 null。ROTATE 有过渡期时另外包含固定 previous_valid_until;详情中的 previous_valid_until 是受旧到期时间约束后的实际截止,未设置时省略。详情/回执不会返回旧秘密。历史不带 version 的固定操作仍按原语义重放。 + +完整滚动更新及 SDK 客户端生命周期说明见[凭据手册](authentication.md)。更换配置不会自动刷新已创建的客户端,须在过渡截止前完成重建或逐台重启并排空在途任务。 + +## 6. 超时、重复和审计 - 页面提交前把整个固定操作保留在当前内存;请求超时或结果未知时,按钮变成「重试确认」,重用同一份内容。不能再次 prepare 来代替核对。 - 页面允许「保存待核对操作」下载私密 JSON;含真实 Token,应放到受限目录。恢复时登录同一管理员后,向 apply 发送该文件正文。不要把 Cookie 或操作文件提交 Git,也不要放到 URL。 diff --git a/docs/reports/console-applications.md b/docs/reports/console-applications.md index dff5c12..2545ef2 100644 --- a/docs/reports/console-applications.md +++ b/docs/reports/console-applications.md @@ -1,5 +1,7 @@ # 单管理员应用接入验收(2026-09-27) +本报告记录当时的立即换证语义;后续长期凭据和双 Token 过渡已在[增量报告](credential-overlap.md)单独说明。 + ## 交付范围 - 独立应用接入页:按应用/环境查看接入范围、队列与凭据,创建应用/命名空间/初始队列,选择业务能力,生成或轮换 Token,复制/下载 SDK 环境配置。 diff --git a/docs/reports/credential-overlap.md b/docs/reports/credential-overlap.md new file mode 100644 index 0000000..12cc20b --- /dev/null +++ b/docs/reports/credential-overlap.md @@ -0,0 +1,29 @@ +# 长期凭据与平滑换证(2026-09-27) + +新建应用与 bootstrap 默认长期 Token,可选有限有效期。已有 Token 支持 SET_EXPIRY 原地修改有效期,原秘密和接入配置保持不变;默认换证保留 7 天过渡,支持按配置调整,确认全部服务切换后可提前 FINALIZE。REVOKE 同时拒绝两代秘密。 + +## 实现与兼容 + +- 000032 在 orbit_credentials 增加 previous_secret_digest、previous_valid_until,CHECK 要求成对为空/非空。中文模型与 SQL 注释齐全,当前空库基线为 32,无外键。历史迁移不修改。 +- 认证在原事务共享锁下读取两代摘要及各自截止;HTTP 和 Worker/结果消费复用原认证入口。截止使用数据库时间,不依赖内存、清理任务或节点时钟。 +- 旧代实际截止不晚于其原有效期;重复轮换不会挤掉尚未切换的旧代,调整当前有效期也不会顺带延长旧代。凭据 ID、权限、任务、订阅与交付身份不变。 +- 管理操作、版本、固定过渡截止、审计及幂等回执同事务。格式 v2 新增长期和过渡语义,历史 v1 仍按原固定到期/立即轮换恢复;重放历史轮换不复活已经停用的旧代。 +- 管理端增加长期选项、调整有效期、平滑换证及结束过渡;CLI 增加 expiry/finalize、默认 --valid-for 0 和轮换 --grace 168h。没有修改 SDK 接口或 Token 格式。 + +## 验证 + +| 验证 | 证据 | +| --- | --- | +| 标准回归 | `make test vet build` 七模块竞态测试、静态检查和构建通过 | +| 原 Token 改长期与在途执行 | `TestIntegrationCredentialOverlap`:运行中的任务原凭据改长期后继续授权;换证期间旧 Token 续租、完成结果、释放许可;两代认证、并发原操作去重、重复轮换冲突、结束过渡、历史重报、两代撤销及审计脱敏通过 | +| 期限与旧代隔离 | `TestIntegrationCredentialOverlapDeadline`:不超过旧有效期、两代独立到期、到期无需清理即可拒绝、下一轮不复活更旧秘密通过;时间边界由隔离测试库明确设置,不把测试耗时当作长期运行证据 | +| 双节点真实 WS | `TestIntegrationCredentialOverlapWebSocket`:两个 HTTP/WS 节点共享真实 MySQL;原长连接在轮换后可发控制命令,旧 Token 可跨节点重连,新 Token 同时连接;结束过渡后旧连接命令和新握手被拒绝,新连接继续可用 | +| 提交结果未知 | `TestIntegrationCredentialCommitFaults`:COMMIT 前截断及数据库提交成功后丢确认;旧 Token 始终可用,新 Token 只在实际提交后生效,原操作核对恢复 | +| 管理 HTTP | `scripts/test-console-applications.py`:真实 Cookie 管理、CSRF、定期改长期且原 Token 200、双 Token 过渡、提前结束、旧 Token 401、新 Token 200、历史回执不能复活旧秘密、原任务/订阅读取和退出失效通过 | +| 容器端到端 | `make console-e2e` 全新 MySQL/Orbit、上述管理链路、原登录/Worker/重试/定时/归档验收通过 | + +## 使用边界 + +调用方在截止前完成滚动更新并正常收尾在途任务。SDK 在创建客户端时读取 Token,改 `.env` 不会热更新现有客户端;须重建客户端或逐台重启服务。单实例重启仍有短暂停顿,平台不能替业务服务保证副本容量。提前结束过渡或到期会拒绝尚未切换的旧 Token,不承诺强行停用旧身份后其连接还能继续工作。 + +这次数据库升级完成且所有 Orbit 节点更新后才能启用双 Token 换证;旧版本节点不认识上一代摘要。只修改有授权的本地测试凭据为长期,迁移不会自动延长其他已有凭据。完整操作顺序见[凭据手册](../api/authentication.md)和[管理接口](../api/console-applications.md)。 diff --git a/docs/reports/phase-6-credentials.md b/docs/reports/phase-6-credentials.md index 712e5de..2b0768f 100644 --- a/docs/reports/phase-6-credentials.md +++ b/docs/reports/phase-6-credentials.md @@ -1,5 +1,7 @@ # P6:凭据操作与安全轮换 +本报告记录当时的立即换证语义;后续长期凭据和双 Token 过渡已在[增量报告](credential-overlap.md)单独说明。 + 日期:2026-09-19。本文只记录管理凭据操作的已实现边界,不把凭据功能等同于 P6 全部生产验收。 ## 已实现行为 diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 24f248c..4c7bd17 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -50,7 +50,7 @@ task=<32位任务ID> duplicate=true listed=1 final_state=CANCELLED revision=2 - [任务示例源码](../../examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消,再核对直接来源。 - 任务示例也接受 ORBIT_TOKEN 和 ORBIT_QUEUE_ID,环境值优先于凭据文件。 - 示例发生错误会打印错误并非零退出;打印内容不包含原始 token。 -- 初始凭据 30 天有效。初始化行为和错误核对方式见 [凭据文档](../api/authentication.md)。 +- 初始凭据默认长期有效,可配置固定到期时间;支持新旧 Token 过渡换证。初始化行为和错误核对方式见 [凭据文档](../api/authentication.md)。 - `make integration` 会在一次性数据库中自动完成上面的真实服务与 SDK 示例验证。 ## 2. 创建客户端与提交任务 diff --git a/internal/consoleauth/applications.go b/internal/consoleauth/applications.go index a59af75..aaa9ec8 100644 --- a/internal/consoleauth/applications.go +++ b/internal/consoleauth/applications.go @@ -32,12 +32,13 @@ type AccessQueue struct { // AccessCredential 是脱敏的凭据元数据,绝不返回秘密或秘密摘要。 type AccessCredential struct { - ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。 - Name string `json:"name"` // Name 凭据名称。 - Revision uint64 `json:"revision,string"` // Revision 轮换的并发校验版本。 - ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 到期时间,空表示无固定到期时间。 - RevokedAt *time.Time `json:"revoked_at,omitempty"` // RevokedAt 撤销时间,非空时禁止轮换。 - Permissions auth.Permissions `json:"permissions"` // Permissions 动作和队列授权范围。 + PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil 上一代令牌实际截止,空表示无旧令牌。 + ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。 + Name string `json:"name"` // Name 凭据名称。 + Revision uint64 `json:"revision,string"` // Revision 轮换的并发校验版本。 + ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 到期时间,空表示无固定到期时间。 + RevokedAt *time.Time `json:"revoked_at,omitempty"` // RevokedAt 撤销时间,非空时禁止轮换。 + Permissions auth.Permissions `json:"permissions"` // Permissions 动作和队列授权范围。 } // ApplicationDetail 展示一个命名空间的队列和凭据,读取量有界。 @@ -48,9 +49,9 @@ type ApplicationDetail struct { Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。 } -// ApplicationInput 描述创建应用或轮换凭据的管理员意图。 +// ApplicationInput 描述创建应用或变更凭据的管理员意图。 type ApplicationInput struct { - Kind string `json:"kind"` // Kind CREATE 或 ROTATE。 + Kind string `json:"kind"` // Kind CREATE、ROTATE、SET_EXPIRY 或 FINALIZE。 Application string `json:"application"` // Application 新应用名称,仅 CREATE 使用。 Namespace string `json:"namespace"` // Namespace 新命名空间名称,仅 CREATE 使用。 Environment string `json:"environment"` // Environment 新环境名称,仅 CREATE 使用。 @@ -59,21 +60,24 @@ type ApplicationInput struct { NamespaceID core.ID `json:"namespace_id,omitempty"` // NamespaceID 轮换目标所属范围。 CredentialID core.ID `json:"credential_id,omitempty"` // CredentialID 轮换目标凭据。 ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 轮换时读取到的版本。 - ValidDays int `json:"valid_days"` // ValidDays 新令牌有效天数,1–365。 + ValidDays int `json:"valid_days"` // ValidDays 有效天数;0 为长期,1–365 为定期。 + GraceDays int `json:"grace_days,omitempty"` // GraceDays ROTATE 的旧令牌过渡天数,0–30;0 显式立即失效。 Reason string `json:"reason"` // Reason 写入审计的变更原因。 } // ApplicationOperation 是浏览器在提交前保存的固定操作;Token 不写入数据库。 // 网络结果未知时重放同一份内容,禁止重新生成操作身份或秘密。 type ApplicationOperation struct { - Input ApplicationInput `json:"input"` // Input 固定的管理员意图。 - OperationID core.ID `json:"operation_id"` // OperationID 稳定幂等身份。 - ApplicationID core.ID `json:"application_id"` // ApplicationID 新建或已核验的应用标识。 - NamespaceID core.ID `json:"namespace_id"` // NamespaceID 固定命名空间标识。 - QueueID core.ID `json:"queue_id,omitempty"` // QueueID 新建应用的初始队列。 - CredentialID core.ID `json:"credential_id"` // CredentialID 固定凭据身份。 - Token string `json:"token"` // Token 一次性交付的秘密,只留在调用方内存或私密下载文件。 - ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 首次准备时冻结的绝对到期时间。 + Version int `json:"version,omitempty"` // Version v2 支持长期令牌与过渡;旧固定操作未设置时沿用 v1。 + Input ApplicationInput `json:"input"` // Input 固定的管理员意图。 + OperationID core.ID `json:"operation_id"` // OperationID 稳定幂等身份。 + ApplicationID core.ID `json:"application_id"` // ApplicationID 新建或已核验的应用标识。 + NamespaceID core.ID `json:"namespace_id"` // NamespaceID 固定命名空间标识。 + QueueID core.ID `json:"queue_id,omitempty"` // QueueID 新建应用的初始队列。 + CredentialID core.ID `json:"credential_id"` // CredentialID 固定凭据身份。 + Token string `json:"token,omitempty"` // Token 一次性交付的秘密,只留在调用方内存或私密下载文件。 + ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 固定到期时间,空表示长期或不适用。 + PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil ROTATE 固定过渡截止。 } // ApplicationReceipt 是数据库持久化的脱敏回执。 diff --git a/internal/storage/mysql/application_resources_upgrade_integration_test.go b/internal/storage/mysql/application_resources_upgrade_integration_test.go index 339201c..8cf428f 100644 --- a/internal/storage/mysql/application_resources_upgrade_integration_test.go +++ b/internal/storage/mysql/application_resources_upgrade_integration_test.go @@ -95,6 +95,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) { // 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把 // 已经存在的 v29/v30 表误当成迁移重复执行。 f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`) + f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`) f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`) if err := Migrate(ctx, db); err != nil { t.Fatal(err) diff --git a/internal/storage/mysql/baseline.sql b/internal/storage/mysql/baseline.sql index 661ebde..4c01993 100644 --- a/internal/storage/mysql/baseline.sql +++ b/internal/storage/mysql/baseline.sql @@ -1,4 +1,4 @@ --- Orbit fresh-install baseline, generated from migration version 31. +-- Orbit fresh-install baseline, generated from migration version 32. -- This file intentionally contains no database-level relation constraints. CREATE TABLE `orbit_application_resources` ( `application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度', @@ -59,12 +59,15 @@ CREATE TABLE `orbit_credentials` ( `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本', `name` varchar(128) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束', `secret_digest` binary(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文', + `previous_secret_digest` binary(32) DEFAULT NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文', + `previous_valid_until` datetime(6) DEFAULT NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空', `permissions` json NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验', `expires_at` datetime(6) DEFAULT NULL COMMENT '凭据过期时间,统一 UTC;为空表示没有配置到期时间', `revoked_at` datetime(6) DEFAULT NULL COMMENT '撤销时间;为空表示尚未撤销', PRIMARY KEY (`namespace_id`,`id`), UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`), - CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0)) + CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0)), + CONSTRAINT `ck_credential_overlap` CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围'; CREATE TABLE `orbit_deliveries` ( `namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', diff --git a/internal/storage/mysql/bootstrap.go b/internal/storage/mysql/bootstrap.go index 762b8c5..fd37121 100644 --- a/internal/storage/mysql/bootstrap.go +++ b/internal/storage/mysql/bootstrap.go @@ -16,39 +16,43 @@ import ( // BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。 type BootstrapInput struct { - WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。 - WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。 - WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。 - WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。 - WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。 - WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。 - RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。 - EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。 - Application string // Application 新应用名称,与 ExistingApplicationID 二选一。 - Namespace string // Namespace 命名空间名称。 - Environment string // Environment 环境名称,必须显式指定。 - Queue string // Queue 初始队列名称。 - WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。 - ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。 - ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。 - ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。 - ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。 - ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。 - ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。 + ValidFor time.Duration // ValidFor 初始令牌有效时长;0 为长期,定期范围 1 小时至 365 天。 + WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。 + WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。 + WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。 + WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。 + WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。 + WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。 + RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。 + EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。 + Application string // Application 新应用名称,与 ExistingApplicationID 二选一。 + Namespace string // Namespace 命名空间名称。 + Environment string // Environment 环境名称,必须显式指定。 + Queue string // Queue 初始队列名称。 + WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。 + ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。 + ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。 + ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。 + ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。 + ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。 + ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。 } // BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。 type BootstrapResult struct { - ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。 - NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。 - QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。 - CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。 - Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。 - ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。 - ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。 + ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。 + NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。 + QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。 + CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。 + Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。 + ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 初始凭据到期时间,UTC;空为长期。 + ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。 } func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error { + if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) { + return fmt.Errorf("validity must be zero or between one hour and one year") + } for _, v := range []string{in.Namespace, in.Queue} { if !service.ValidName(v, 128) { return fmt.Errorf("invalid bootstrap resource name") @@ -154,7 +158,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func( if err != nil { return err } - out.ExpiresAt = now.Add(30 * 24 * time.Hour) + if in.ValidFor > 0 { + out.ExpiresAt = timePtr(now.Add(in.ValidFor)) + } if in.ExistingApplicationID == "" { if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil { return err diff --git a/internal/storage/mysql/console_applications.go b/internal/storage/mysql/console_applications.go index 840be2f..b7acbcd 100644 --- a/internal/storage/mysql/console_applications.go +++ b/internal/storage/mysql/console_applications.go @@ -117,16 +117,16 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth if err != nil { return err } - rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace) + rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace) if err != nil { return err } defer rows.Close() for rows.Next() { var c consoleauth.AccessCredential - var expires, revoked sql.NullTime + var expires, revoked, previous sql.NullTime var raw []byte - if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &raw); err != nil { + if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw); err != nil { return err } if err := json.Unmarshal(raw, &c.Permissions); err != nil { @@ -134,6 +134,7 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth } c.ExpiresAt = nullableTime(expires) c.RevokedAt = nullableTime(revoked) + c.PreviousValidUntil = nullableTime(previous) out.Credentials = append(out.Credentials, c) } if len(out.Queues) > 100 { @@ -181,8 +182,14 @@ func applicationPermissions(in consoleauth.ApplicationInput) (auth.Permissions, return p, p.Validate() } func validateApplicationInput(in consoleauth.ApplicationInput) error { - if in.ValidDays < 1 || in.ValidDays > 365 { - return fault.Invalid("valid_days must be 1-365") + if in.ValidDays < 0 || in.ValidDays > 365 { + return fault.Invalid("valid_days must be 0 (permanent) or 1-365") + } + if in.GraceDays < 0 || in.GraceDays > 30 || (in.Kind != "ROTATE" && in.GraceDays != 0) { + return fault.Invalid("grace_days must be 0-30 for rotation only") + } + if in.Kind == "FINALIZE" && in.ValidDays != 0 { + return fault.Invalid("finalize cannot set validity") } if err := validCredentialReason(in.Reason); err != nil { return err @@ -194,17 +201,17 @@ func validateApplicationInput(in consoleauth.ApplicationInput) error { } _, err := applicationPermissions(in) return err - case "ROTATE": + case "ROTATE", "SET_EXPIRY", "FINALIZE": if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil || in.ExpectedRevision == 0 || in.Application != "" || in.Namespace != "" || in.Environment != "" || in.Queue != "" || len(in.Capabilities) != 0 { return fault.Invalid("invalid rotation fields") } default: - return fault.Invalid("kind must be CREATE or ROTATE") + return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY or FINALIZE") } return nil } func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) { - out := consoleauth.ApplicationOperation{Input: in} + out := consoleauth.ApplicationOperation{Version: 2, Input: in} if err := validateApplicationInput(in); err != nil { return out, err } @@ -216,7 +223,12 @@ func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token if err != nil { return err } - out.ExpiresAt = now.Add(time.Duration(in.ValidDays) * 24 * time.Hour) + if in.ValidDays > 0 { + out.ExpiresAt = timePtr(now.Add(time.Duration(in.ValidDays) * 24 * time.Hour)) + } + if in.GraceDays > 0 { + out.PreviousValidUntil = timePtr(now.Add(time.Duration(in.GraceDays) * 24 * time.Hour)) + } out.OperationID, err = core.NewID() if err != nil { return err @@ -243,7 +255,9 @@ func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token return fault.Conflict } } - out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID) + if in.Kind == "CREATE" || in.Kind == "ROTATE" { + out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID) + } return err }) return out, err @@ -253,15 +267,21 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au if err := validateApplicationInput(op.Input); err != nil { return out, err } - parsed, err := auth.Parse(op.Token) - if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.ExpiresAt.IsZero() { - return out, fault.Invalid("invalid fixed operation") + if (op.Version != 0 && op.Version != 2) || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil { + return out, fault.Invalid("invalid fixed operation identity") } - if op.Input.Kind == "ROTATE" { + if (op.Input.ValidDays == 0) != (op.ExpiresAt == nil) || (op.Input.GraceDays == 0) != (op.PreviousValidUntil == nil) { + return out, fault.Invalid("fixed lifetime does not match input") + } + version := op.Version + if version == 0 { + version = 1 + } + if op.Input.Kind != "CREATE" { if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID { - return out, fault.Invalid("rotation identity mismatch") + return out, fault.Invalid("credential identity mismatch") } - change := CredentialOperation{Version: 1, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: "ROTATE", Reason: op.Input.Reason, Token: op.Token, ExpiresAt: &op.ExpiresAt} + change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil} receipt, err := applyCredentialOperation(ctx, s.db, change, func(tx *sql.Tx) (string, error) { actor, err := s.admin(ctx, tx, token) if err != nil { @@ -282,6 +302,10 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au }) return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err } + parsed, err := auth.Parse(op.Token) + if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || (version == 1 && op.ExpiresAt == nil) { + return out, fault.Invalid("invalid new credential") + } if op.QueueID.Validate() != nil { return out, fault.Invalid("invalid queue identity") } @@ -325,7 +349,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au if err != nil { return err } - if !op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) { + if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) { return fault.Invalid("expired or excessive credential lifetime") } statements := []struct { @@ -336,7 +360,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au {`INSERT INTO orbit_application_resources(application_id) VALUES (?)`, []any{op.ApplicationID}}, {`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)`, []any{op.NamespaceID, op.ApplicationID, op.Input.Namespace, op.Input.Environment}}, {`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, []any{op.NamespaceID, op.QueueID, op.Input.Queue}}, - {`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt.UTC()}}, + {`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt}}, } for _, st := range statements { if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil { diff --git a/internal/storage/mysql/credential_integration_test.go b/internal/storage/mysql/credential_integration_test.go index 288731b..784fc5e 100644 --- a/internal/storage/mysql/credential_integration_test.go +++ b/internal/storage/mysql/credential_integration_test.go @@ -150,7 +150,7 @@ func TestIntegrationCredentialCommitFaults(t *testing.T) { if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil { t.Fatal(err) } - op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, Reason: "commit boundary"}) + op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, GraceFor: time.Hour, Reason: "commit boundary"}) if err != nil { t.Fatal(err) } @@ -175,6 +175,14 @@ func TestIntegrationCredentialCommitFaults(t *testing.T) { if after { wantRevision = 2 } + oldToken, _ := auth.Parse(initial.Token) + newToken, _ := auth.Parse(op.Token) + if err := NewTaskStore(db).AuthorizeSubmit(ctx, oldToken); err != nil { + t.Fatal("commit ambiguity invalidated old token", err) + } + if accepted := NewTaskStore(db).AuthorizeSubmit(ctx, newToken) == nil; accepted != after { + t.Fatal("commit boundary accepted wrong generation") + } if status.Revision != wantRevision { t.Fatalf("commit %v status revision=%d want=%d", after, status.Revision, wantRevision) } diff --git a/internal/storage/mysql/credential_overlap_integration_test.go b/internal/storage/mysql/credential_overlap_integration_test.go new file mode 100644 index 0000000..9e36955 --- /dev/null +++ b/internal/storage/mysql/credential_overlap_integration_test.go @@ -0,0 +1,283 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "git.yuntuo.hk/k2safe/orbit/internal/fault" + "git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + pulse "git.yuntuo.hk/k2safe/orbit/pulse/model" + "git.yuntuo.hk/k2safe/orbit/pulse/worker" + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +func overlapDB(t *testing.T) (context.Context, *sql.DB) { + t.Helper() + dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable MySQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + t.Cleanup(cancel) + db, err := Open(ctx, dsn, 12, 3) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if err = Migrate(ctx, db); err != nil { + t.Fatal(err) + } + return ctx, db +} + +func TestIntegrationCredentialOverlap(t *testing.T) { + ctx, db := overlapDB(t) + f := newExecutionFixture(t, ctx, db) + old := f.token + // 先使用有限有效期,再原地改长期;在途授权、秘密及绑定均不改变。 + change := func(action string, revision uint64, grace time.Duration) CredentialOperation { + t.Helper() + op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: revision, Action: action, GraceFor: grace, Reason: "credential overlap regression"}) + if err != nil { + t.Fatal(err) + } + return op + } + f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 30 DAY) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID) + task := f.submit(1) + _, ref := f.claim() + if _, err := f.a.Start(ctx, old, f.binding, testID(t), ref); err != nil { + t.Fatal(err) + } + expiry := change(credentialExpiryAction, 1, 0) + receipt, err := ApplyCredentialOperation(ctx, db, expiry) + if err != nil || receipt.ExpiresAt != nil || expiry.Token != "" { + t.Fatalf("expiry update: %v", err) + } + if err := f.a.Authorize(ctx, old); err != nil { + t.Fatal("original token lost after expiry update", err) + } + rotation := change(credentialRotateAction, 2, 7*24*time.Hour) + var wg sync.WaitGroup + receipts := make([]CredentialChangeReceipt, 2) + errs := make([]error, 2) + for i := range receipts { + wg.Add(1) + go func(i int) { defer wg.Done(); receipts[i], errs[i] = ApplyCredentialOperation(ctx, db, rotation) }(i) + } + wg.Wait() + if errs[0] != nil || errs[1] != nil || receipts[0].Deduplicated == receipts[1].Deduplicated { + t.Fatalf("concurrent replay: %v", errs) + } + current, _ := auth.Parse(rotation.Token) + for _, token := range []auth.Token{old, current} { + if err := f.a.Authorize(ctx, token); err != nil { + t.Fatal("overlap auth rejected", err) + } + } + status, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID) + if err != nil || status.ExpiresAt != nil || status.PreviousValidUntil == nil { + t.Fatal("missing permanent/overlap status", err) + } + originalUntil := *status.PreviousValidUntil + // 过渡期间第二次轮换必须冲突,而不能挤掉仍在工作的上一代。 + blocked := change(credentialRotateAction, 3, time.Hour) + if _, err := ApplyCredentialOperation(ctx, db, blocked); fault.Code(err) != "STATE_CONFLICT" { + t.Fatal("active predecessor overwritten", err) + } + renewed, err := f.a.Renew(ctx, old, f.binding, testID(t), ref) + if err != nil || !renewed.Lease.Active { + t.Fatal("in-flight old-token renew failed", err) + } + if _, err := f.a.Finish(ctx, old, f.binding, testID(t), worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"ok":true}`)}); err != nil { + t.Fatal("in-flight finish rejected", err) + } + if f.get(task.ID).State != pulse.TaskSucceeded || f.used() != 0 { + t.Fatal("task/permit changed by rotation") + } + final := change(credentialFinalizeAction, 3, 0) + if _, err := ApplyCredentialOperation(ctx, db, final); err != nil { + t.Fatal(err) + } + if err := f.a.Authorize(ctx, old); fault.Code(err) != "UNAUTHENTICATED" { + t.Fatal("retired token accepted", err) + } + if err := f.a.Authorize(ctx, current); err != nil { + t.Fatal("current token rejected", err) + } + replay, err := ApplyCredentialOperation(ctx, db, rotation) + if err != nil || !replay.Deduplicated || !replay.PreviousValidUntil.Equal(originalUntil) { + t.Fatal("old receipt drifted", err) + } + if err := f.a.Authorize(ctx, old); err == nil { + t.Fatal("replay resurrected predecessor") + } + // 整张凭据撤销必须同时使两代令牌失效。 + next := change(credentialRotateAction, 4, time.Hour) + if _, err := ApplyCredentialOperation(ctx, db, next); err != nil { + t.Fatal(err) + } + newest, _ := auth.Parse(next.Token) + revoke := change(credentialRevokeAction, 5, 0) + if _, err := ApplyCredentialOperation(ctx, db, revoke); err != nil { + t.Fatal(err) + } + for _, token := range []auth.Token{old, current, newest} { + if err := f.a.Authorize(ctx, token); fault.Code(err) != "UNAUTHENTICATED" { + t.Fatal("revocation missed a generation", err) + } + } + var audits string + if err := db.QueryRowContext(ctx, `SELECT CAST(JSON_ARRAYAGG(JSON_OBJECT('before',`+"`before`"+`,'after',`+"`after`"+`)) AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='credential'`, old.NamespaceID).Scan(&audits); err != nil { + t.Fatal(err) + } + if strings.Contains(audits, rotation.Token) || strings.Contains(audits, "secret_digest") { + t.Fatal("audit leaked secret") + } +} + +func TestIntegrationCredentialOverlapDeadline(t *testing.T) { + ctx, db := overlapDB(t) + f := newExecutionFixture(t, ctx, db) + old := f.token + f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID) + before, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID) + if err != nil { + t.Fatal(err) + } + op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: 24 * time.Hour, Reason: "bounded grace"}) + if err != nil { + t.Fatal(err) + } + out, err := ApplyCredentialOperation(ctx, db, op) + if err != nil || !out.PreviousValidUntil.Equal(*before.ExpiresAt) { + t.Fatal("grace extended original expiry", err) + } + current, _ := auth.Parse(op.Token) + // 新令牌过期不会改变独立的旧令牌截止;旧令牌也不能越过自己的截止。 + f.exec(`UPDATE orbit_credentials SET expires_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID) + if f.a.Authorize(ctx, old) != nil || fault.Code(f.a.Authorize(ctx, current)) != "UNAUTHENTICATED" { + t.Fatal("expiry of two generations conflated") + } + f.exec(`UPDATE orbit_credentials SET expires_at=NULL,previous_valid_until=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 MICROSECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID) + if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" { + t.Fatal("expired old token remained usable") + } + // 过期的旧令牌不需定时清理才能失效;下一轮可以重用单一旧代槽。 + next, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 2, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "next rotation"}) + if err != nil { + t.Fatal(err) + } + if _, err := ApplyCredentialOperation(ctx, db, next); err != nil { + t.Fatal(err) + } + if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" { + t.Fatal("wrong predecessor reused") + } +} + +// 两个独立 HTTP/WS 节点共享真实 MySQL,验证旧长连接命令和跨节点重连。 +func TestIntegrationCredentialOverlapWebSocket(t *testing.T) { + ctx, db := overlapDB(t) + f := newExecutionFixture(t, ctx, db) + serve := func(service *worker.Service) *httptest.Server { + hub := httpserver.NewWorkerHub(ctx, service) + server := httpserver.New("", nil, f.tasks) + httpserver.RegisterWorkers(server, hub) + ts := httptest.NewServer(server.Handler) + t.Cleanup(func() { + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := hub.Shutdown(shutdown); err != nil { + t.Error(err) + } + ts.Close() + }) + return ts + } + a, b := serve(f.a), serve(f.b) + hello := f.hello + hello.InstanceID = testID(t) + hello.Capabilities = []worker.Capability{{Handler: "rotation.idle", HandlerVersion: "v1", PayloadVersion: "v1"}} + type frame struct { + Type string `json:"type"` + SessionID core.ID `json:"session_id"` + Error *fault.Error `json:"error"` + } + connect := func(url, token string) (*websocket.Conn, core.ID) { + t.Helper() + c, _, err := websocket.Dial(ctx, url+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + token}}, Subprotocols: []string{"pulse.worker.v1"}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.CloseNow() }) + if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.hello", "message_id": testID(t), "body": hello}); err != nil { + t.Fatal(err) + } + var out frame + if err := wsjson.Read(ctx, c, &out); err != nil || out.Type != "worker.welcome" { + t.Fatalf("WS welcome: %v, %s", err, out.Type) + } + return c, out.SessionID + } + capacity := func(c *websocket.Conn, session core.ID, seq int, want string) { + t.Helper() + body := worker.CapacityInput{Sequence: uint64(seq), Capacity: 0} + if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.capacity", "message_id": testID(t), "session_id": session, "body": body}); err != nil { + t.Fatal(err) + } + var out frame + if err := wsjson.Read(ctx, c, &out); err != nil { + t.Fatal(err) + } + code := "" + if out.Error != nil { + code = out.Error.Code + } + if code != want { + t.Fatalf("WS auth: %s, want %s", code, want) + } + } + oldConn, session := connect(a.URL, f.resources.Token) + op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "rolling switch"}) + if err != nil { + t.Fatal(err) + } + if _, err := ApplyCredentialOperation(ctx, db, op); err != nil { + t.Fatal(err) + } + capacity(oldConn, session, 1, "") + oldConn.CloseNow() + reconnected, oldSession := connect(b.URL, f.resources.Token) + capacity(reconnected, oldSession, 1, "") + hello.InstanceID = testID(t) + current, newSession := connect(a.URL, op.Token) + capacity(current, newSession, 1, "") + final, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 2, Action: credentialFinalizeAction, Reason: "all services switched"}) + if err != nil { + t.Fatal(err) + } + if _, err := ApplyCredentialOperation(ctx, db, final); err != nil { + t.Fatal(err) + } + capacity(reconnected, oldSession, 2, "UNAUTHENTICATED") + capacity(current, newSession, 2, "") + c, response, err := websocket.Dial(ctx, b.URL+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + f.resources.Token}}, Subprotocols: []string{"pulse.worker.v1"}}) + if c != nil { + c.CloseNow() + } + if err == nil || response == nil || response.StatusCode != 401 { + t.Fatal("retired WS handshake accepted") + } +} diff --git a/internal/storage/mysql/credentials.go b/internal/storage/mysql/credentials.go index 628ec66..c7d1404 100644 --- a/internal/storage/mysql/credentials.go +++ b/internal/storage/mysql/credentials.go @@ -16,9 +16,11 @@ import ( ) const ( - credentialOperationVersion = 1 + credentialOperationVersion = 2 credentialRotateAction = "ROTATE" credentialRevokeAction = "REVOKE" + credentialExpiryAction = "SET_EXPIRY" + credentialFinalizeAction = "FINALIZE" credentialOperationName = "credential.change.v1" ) @@ -33,6 +35,8 @@ type CredentialStatus struct { Name string `json:"name"` // Revision 凭据的乐观并发版本,所有变更都必须核对它。 Revision uint64 `json:"revision"` + // PreviousValidUntil 上一代令牌可用的 UTC 截止;空表示无旧令牌。 + PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // ExpiresAt 当前秘密的到期时间;空值表示未设置到期时间。 ExpiresAt *time.Time `json:"expires_at,omitempty"` // RevokedAt 撤销时间;空值表示尚未撤销。 @@ -52,9 +56,11 @@ type CredentialChangeInput struct { CredentialID core.ID // ExpectedRevision 准备操作时观察到的版本。 ExpectedRevision uint64 - // Action 只能是 ROTATE 或 REVOKE。 + // Action 为 ROTATE、REVOKE、SET_EXPIRY 或 FINALIZE。 Action string - // ValidFor 轮换后的有效时长;撤销时必须为零。 + // GraceFor 轮换时旧令牌的共存时长;零为立即失效,最多 30 天。 + GraceFor time.Duration + // ValidFor 新令牌或原令牌的新有效时长;零为长期,撤销/结束过渡时必须为零。 ValidFor time.Duration // Reason 必填审计原因。 Reason string @@ -73,13 +79,15 @@ type CredentialOperation struct { CredentialID core.ID `json:"credential_id"` // ExpectedRevision 只允许在该版本上首次应用。 ExpectedRevision uint64 `json:"expected_revision"` - // Action 固定为 ROTATE 或 REVOKE。 + // Action 固定变更类型;格式 v1 仅支持立即 ROTATE 和 REVOKE。 Action string `json:"action"` // Reason 写入管理审计,不能包含控制字符或秘密。 Reason string `json:"reason"` // Token 轮换产生的一次性新令牌;撤销操作为空。 Token string `json:"token,omitempty"` - // ExpiresAt 轮换后固定的绝对到期时间;重放不能重新计算。 + // PreviousValidUntil ROTATE 请求的固定过渡截止;实际不超过旧令牌原到期时间。 + PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` + // ExpiresAt 轮换/调整有效期后的固定绝对时间;v2 为空表示长期,重放不能重新计算。 ExpiresAt *time.Time `json:"expires_at,omitempty"` } @@ -98,7 +106,9 @@ type CredentialChangeReceipt struct { Revision uint64 `json:"revision"` // AppliedAt 操作提交事务使用的数据库时间。 AppliedAt time.Time `json:"applied_at"` - // ExpiresAt 轮换后的固定到期时间。 + // PreviousValidUntil 操作后的旧令牌截止;重报返回首次提交时的值。 + PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` + // ExpiresAt 当前令牌的固定到期时间,空为长期。 ExpiresAt *time.Time `json:"expires_at,omitempty"` // RevokedAt 撤销提交时间。 RevokedAt *time.Time `json:"revoked_at,omitempty"` @@ -115,13 +125,13 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential if err := CheckSchema(ctx, db); err != nil { return out, err } - var expires, revoked sql.NullTime - err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,n.enabled,a.enabled + var expires, revoked, previousUntil sql.NullTime + err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled FROM orbit_credentials c JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id WHERE c.namespace_id=? AND c.id=?`, namespace, credential). - Scan(&out.Name, &out.Revision, &expires, &revoked, &out.NamespaceEnabled, &out.ApplicationEnabled) + Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled) if err == sql.ErrNoRows { return out, fault.NotFound } @@ -130,6 +140,7 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential } out.NamespaceID, out.CredentialID = namespace, credential out.ExpiresAt = nullableTime(expires) + out.PreviousValidUntil = nullableTime(previousUntil) out.RevokedAt = nullableTime(revoked) return out, nil } @@ -162,9 +173,12 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh return out, err } out.Token = secret + if in.GraceFor > 0 { + out.PreviousValidUntil = timePtr(now.Add(in.GraceFor)) + } + } + if in.ValidFor > 0 { out.ExpiresAt = timePtr(now.Add(in.ValidFor)) - } else if in.ValidFor != 0 { - return out, fault.Invalid("revoke operation cannot set validity") } out.Version = credentialOperationVersion out.NamespaceID = in.NamespaceID @@ -182,25 +196,28 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh // ValidateCredentialOperation validates the exact private operation shape. // It is exported so the admin CLI can reject tampered files before opening a DB. func ValidateCredentialOperation(op CredentialOperation) error { - if op.Version != credentialOperationVersion || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil { + if (op.Version != 1 && op.Version != credentialOperationVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil { return fault.Invalid("credential operation identity or version is invalid") } - if op.ExpectedRevision == 0 || (op.Action != credentialRotateAction && op.Action != credentialRevokeAction) { + if op.ExpectedRevision == 0 || !validCredentialAction(op.Action) { return fault.Invalid("credential operation action or revision is invalid") } if err := validCredentialReason(op.Reason); err != nil { return err } + if op.Version == 1 && ((op.Action != credentialRotateAction && op.Action != credentialRevokeAction) || op.PreviousValidUntil != nil || (op.Action == credentialRotateAction && op.ExpiresAt == nil)) { + return fault.Invalid("legacy operation requires its original expiry and immediate rotation") + } + if op.ExpiresAt != nil && op.ExpiresAt.IsZero() || op.PreviousValidUntil != nil && op.PreviousValidUntil.IsZero() { + return fault.Invalid("fixed times must not be zero") + } if op.Action == credentialRotateAction { - if op.ExpiresAt == nil || op.ExpiresAt.IsZero() || op.Token == "" { - return fault.Invalid("rotation operation must contain a token and fixed expiry") - } parsed, err := auth.Parse(op.Token) if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID { return fault.Invalid("rotation token does not match credential identity") } - } else if op.Token != "" || op.ExpiresAt != nil { - return fault.Invalid("revoke operation must not contain a token or expiry") + } else if op.Token != "" || op.PreviousValidUntil != nil || (op.Action != credentialExpiryAction && op.ExpiresAt != nil) { + return fault.Invalid("invalid fields for credential change") } return nil } @@ -232,14 +249,14 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper } var name string var revision uint64 - var expires, revoked sql.NullTime + var expires, revoked, previousUntil sql.NullTime var namespaceEnabled, applicationEnabled bool - err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,n.enabled,a.enabled + err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled FROM orbit_credentials c JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID). - Scan(&name, &revision, &expires, &revoked, &namespaceEnabled, &applicationEnabled) + Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled) if err == sql.ErrNoRows { return fault.NotFound } @@ -267,32 +284,72 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper if err != nil { return err } - before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled} + before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "previous_valid_until": nullableTime(previousUntil), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled} after := map[string]any{"name": name, "revision": revision + 1, "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled} - if op.Action == credentialRotateAction { - parsed, err := auth.Parse(op.Token) - if err != nil { - return fault.Invalid("rotation token is invalid") - } - if !op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) { + if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) { + return fault.Conflict + } + out.ExpiresAt = nullableTime(expires) + out.PreviousValidUntil = nullableTime(previousUntil) + switch op.Action { + case credentialRotateAction: + parsed, _ := auth.Parse(op.Token) + // 仍在过渡期时不覆盖上一代,避免把尚未切换的服务突然踢下线。 + if previousUntil.Valid && previousUntil.Time.After(now) { return fault.Conflict } + var previousDigest any + out.PreviousValidUntil = nil + if op.PreviousValidUntil != nil { + if !op.PreviousValidUntil.After(now) || op.PreviousValidUntil.After(now.Add(30*24*time.Hour)) { + return fault.Conflict + } + until := *op.PreviousValidUntil + if expires.Valid && expires.Time.Before(until) { + until = expires.Time + } + if until.After(now) { + var old core.Digest + if err := tx.QueryRowContext(ctx, `SELECT secret_digest FROM orbit_credentials WHERE namespace_id=? AND id=?`, op.NamespaceID, op.CredentialID).Scan(&old); err != nil { + return err + } + if subtleCompareDigest(old, parsed.Digest) == 1 { + return fault.Invalid("rotation must use a new secret") + } + previousDigest = old + out.PreviousValidUntil = timePtr(until) + } + } if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials - SET secret_digest=?,expires_at=?,revoked_at=NULL,revision=revision+1,updated_at=? - WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt.UTC(), now, op.NamespaceID, op.CredentialID, revision); err != nil { + SET secret_digest=?,expires_at=?,previous_secret_digest=?,previous_valid_until=?,revision=revision+1,updated_at=? + WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, now, op.NamespaceID, op.CredentialID, revision); err != nil { return err } - out.ExpiresAt = timePtr(op.ExpiresAt.UTC()) - after["expires_at"] = out.ExpiresAt - } else { - if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials - SET revoked_at=?,revision=revision+1,updated_at=? - WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil { + out.ExpiresAt = op.ExpiresAt + case credentialExpiryAction: + // 只变更当前令牌有效期;不能延长上一代截止或改变任何秘密。 + if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET expires_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, op.ExpiresAt, now, op.NamespaceID, op.CredentialID, revision); err != nil { + return err + } + out.ExpiresAt = op.ExpiresAt + case credentialFinalizeAction: + if !previousUntil.Valid { + return fault.Conflict + } + if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, op.NamespaceID, op.CredentialID, revision); err != nil { + return err + } + out.PreviousValidUntil = nil + case credentialRevokeAction: + if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil { return err } out.RevokedAt = timePtr(now) - after["revoked_at"] = now + out.PreviousValidUntil = nil } + after["expires_at"] = out.ExpiresAt + after["previous_valid_until"] = out.PreviousValidUntil + after["revoked_at"] = out.RevokedAt out.OperationID = op.OperationID out.NamespaceID = op.NamespaceID out.CredentialID = op.CredentialID @@ -410,17 +467,20 @@ func validateCredentialChangeInput(in CredentialChangeInput) error { if err := validateCredentialIDs(in.NamespaceID, in.CredentialID); err != nil { return err } - if in.ExpectedRevision == 0 || (in.Action != credentialRotateAction && in.Action != credentialRevokeAction) { + if in.ExpectedRevision == 0 || !validCredentialAction(in.Action) { return fault.Invalid("credential action and expected revision are required") } if err := validCredentialReason(in.Reason); err != nil { return err } - if in.Action == credentialRotateAction && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) { - return fault.Invalid("credential validity must be between one hour and one year") + if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) { + return fault.Invalid("validity must be zero (permanent) or between one hour and one year") } - if in.Action == credentialRevokeAction && in.ValidFor != 0 { - return fault.Invalid("revoke operation cannot set validity") + if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction) && in.ValidFor != 0 { + return fault.Invalid("this operation cannot set validity") + } + if in.GraceFor < 0 || in.GraceFor > 30*24*time.Hour || (in.Action != credentialRotateAction && in.GraceFor != 0) { + return fault.Invalid("rotation grace must be between zero and 30 days") } return nil } @@ -444,3 +504,7 @@ func timePtr(v time.Time) *time.Time { t := v.UTC() return &t } + +func validCredentialAction(action string) bool { + return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction +} diff --git a/internal/storage/mysql/migrate.go b/internal/storage/mysql/migrate.go index be1111e..946a9e5 100644 --- a/internal/storage/mysql/migrate.go +++ b/internal/storage/mysql/migrate.go @@ -26,7 +26,7 @@ var migrationFiles embed.FS //go:embed baseline.sql var freshInstallBaseline string -const freshInstallBaselineVersion uint64 = 31 +const freshInstallBaselineVersion uint64 = 32 type Migration struct { Version uint64 diff --git a/internal/storage/mysql/migrations/000032_credential_overlap.up.sql b/internal/storage/mysql/migrations/000032_credential_overlap.up.sql new file mode 100644 index 0000000..b9a7d18 --- /dev/null +++ b/internal/storage/mysql/migrations/000032_credential_overlap.up.sql @@ -0,0 +1,5 @@ +-- 同一凭据最多保留一代旧秘密;截止时间由轮换事务固定,不依赖节点内存。 +ALTER TABLE orbit_credentials + ADD COLUMN previous_secret_digest BINARY(32) NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文', + ADD COLUMN previous_valid_until DATETIME(6) NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空', + ADD CONSTRAINT ck_credential_overlap CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL)); diff --git a/internal/storage/mysql/task_store.go b/internal/storage/mysql/task_store.go index aad4d1d..8203388 100644 --- a/internal/storage/mysql/task_store.go +++ b/internal/storage/mysql/task_store.go @@ -46,18 +46,23 @@ func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action st } func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) { var digest core.Digest - var raw []byte - err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions FROM orbit_credentials c + var raw, previous []byte + var currentValid, previousValid bool + err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions,c.previous_secret_digest, + (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)), + COALESCE(c.previous_valid_until>UTC_TIMESTAMP(6),FALSE) FROM orbit_credentials c JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL - AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw) + AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw, &previous, ¤tValid, &previousValid) if errors.Is(err, sql.ErrNoRows) { return auth.Permissions{}, fault.Unauthorized } if err != nil { return auth.Permissions{}, err } - if subtle.ConstantTimeCompare(digest[:], token.Digest[:]) != 1 { + currentMatch := subtle.ConstantTimeCompare(digest[:], token.Digest[:]) + previousMatch := subtle.ConstantTimeCompare(previous, token.Digest[:]) + if !(currentValid && currentMatch == 1) && !(previousValid && previousMatch == 1) { // 控制台会话使用独立的短期令牌摘要,不轮换也不覆盖业务 SDK // 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验, // 才允许沿用该业务凭据的权限快照。 diff --git a/internal/transport/httpserver/console/applications.js b/internal/transport/httpserver/console/applications.js index 2f8f6d7..e16e02b 100644 --- a/internal/transport/httpserver/console/applications.js +++ b/internal/transport/httpserver/console/applications.js @@ -1,6 +1,6 @@ (() => { const $ = id => document.getElementById(id); - const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true }; + const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE' }; const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效'; function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; } @@ -42,22 +42,30 @@ $('access-credentials').innerHTML = detail.credentials.map(c => { const status = c.revoked_at ? '已撤销' : c.expires_at && new Date(c.expires_at) <= new Date() ? '已到期' : '有效'; const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name); - return `
${escape(c.name)} · ${status}

凭据 ID:${escape(c.id)}
有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}
Token 原文不保存在数据库;已有服务继续使用原配置。

${groups.map(a => `${escape(a)}`).join('')}
查看具体权限

${c.permissions.actions.map(escape).join(' · ')}
授权队列:${escape(c.permissions.queues.join(', '))}

`; + return `
${escape(c.name)} · ${status}

凭据 ID:${escape(c.id)}
有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}
Token 原文不保存在数据库;已有服务继续使用原配置。

${c.previous_valid_until ? `

旧 Token ${new Date(c.previous_valid_until) > new Date() ? '过渡截止' : '已失效于'}:${escape(time(c.previous_valid_until))}

` : ''}
${c.previous_valid_until && !c.revoked_at ? `` : ''}
${groups.map(a => `${escape(a)}`).join('')}
查看具体权限

${c.permissions.actions.map(escape).join(' · ')}
授权队列:${escape(c.permissions.queues.join(', '))}

`; }).join('') || '

该环境暂无凭据。

'; document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig(); } catch (error) { $('access-error').textContent = message(error); } } function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } } - function open(credential = null) { + function open(credential = null, mode = 'ROTATE') { if (ui.busy) return; - ui.rotating = credential; ui.pending = null; ui.result = null; ui.saved = true; + ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.pending = null; ui.result = null; ui.saved = true; $('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = ''; $('access-result-config').value = ''; $('access-clipboard-status').textContent = ''; $('access-create-fields').hidden = !!credential; $('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; }); - $('access-form-title').textContent = credential ? '重新生成 Token' : '新建应用'; - $('access-form-note').textContent = credential ? `正在更新 ${ui.detail.entry.application} 的凭据。确认后旧 Token 立即失效,请同步更新业务服务配置;应用、队列、订阅和历史记录保持原有归属。` : '为应用创建独立的环境、队列与调用凭据。'; - $('access-submit').textContent = credential ? '确认重新生成 →' : '创建并生成凭据 →'; + const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡' }; + $('access-form-title').textContent = titles[ui.mode]; + $('access-valid-field').hidden = ui.mode === 'FINALIZE'; + $('access-grace-field').hidden = ui.mode !== 'ROTATE'; + $('access-form-note').textContent = { + CREATE: '为应用创建独立的环境、队列与调用凭据,默认长期有效。', + ROTATE: '生成新 Token 后,旧 Token 在过渡期内继续可用。逐台更新服务配置并正常收尾在途任务;确认全部切换后,再结束过渡。', + SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。', + FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。' + }[ui.mode]; + $('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`; $('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus(); } function close() { @@ -77,7 +85,8 @@ const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]); try { if (!ui.pending) { - const input = { kind: ui.rotating ? 'ROTATE' : 'CREATE', valid_days: Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' }; + const input = { kind: ui.mode, valid_days: ui.mode === 'FINALIZE' ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' }; + if (ui.mode === 'ROTATE') input.grace_days = Number($('access-grace-days').value); if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id }); else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) }); form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; }); @@ -86,7 +95,7 @@ form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; }); const op = ui.pending; await api('/apply', op); - ui.saved = false; + ui.saved = !op.token; let queue = op.queue_id || $('access-queue').value; // 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。 await select(op.namespace_id); @@ -94,12 +103,13 @@ const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n); if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。'); if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || ''; - const preparedConfig = config(op.token, queue, op.namespace_id); + const preparedConfig = op.token ? config(op.token, queue, op.namespace_id) : ''; ui.result = op; ui.pending = null; $('access-form').hidden = true; $('access-result').hidden = false; - $('access-result-title').textContent = op.input.kind === 'CREATE' ? '应用已创建,可以接入' : '新 Token 已生效'; + $('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用' }[op.input.kind]; + $('access-result-note').hidden = !op.token; $('access-result-config-field').hidden = !op.token; $('access-result-actions').hidden = !op.token; $('access-result-config').value = preparedConfig; - $('access-result-expiry').textContent = `有效期至 ${time(op.expires_at)}。业务服务读取这些配置后交给 SDK。`; + $('access-result-expiry').textContent = `当前 Token:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 截止:${time(credential.previous_valid_until)},请在此之前完成切换。` : '无旧 Token 过渡。'}`; } catch (error) { $('access-form-error').textContent = `${message(error)}${ui.pending ? '。保留当前窗口,点击“重试确认”会使用原操作核对结果。' : ''}`; } finally { @@ -110,7 +120,12 @@ } $('access-url').value = location.origin; $('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) select(el.dataset.accessId); }); - $('access-credentials').addEventListener('click', e => { const el = e.target.closest('[data-access-rotate]'); const credential = ui.detail?.credentials.find(c => c.id === el?.dataset.accessRotate); if (credential && !credential.revoked_at) open(credential); }); + $('access-credentials').addEventListener('click', e => { + const el = e.target.closest('[data-access-rotate],[data-access-expiry],[data-access-finalize]'); if (!el || el.disabled) return; + const id = el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize; + const credential = ui.detail?.credentials.find(c => c.id === id); + if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE'); + }); $('access-create').addEventListener('click', () => open()); $('access-close').addEventListener('click', close); $('access-dialog').addEventListener('cancel', e => { e.preventDefault(); close(); }); $('access-refresh').addEventListener('click', load); $('access-next').addEventListener('click', () => { ui.after = ui.next; load(); }); $('access-first').addEventListener('click', () => { ui.after = ''; load(); }); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 1fb2584..3695491 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -396,12 +396,13 @@
接入能力 默认包含任务提交、查询与队列管理
-
+
+

- + diff --git a/internal/transport/httpserver/console_applications_test.go b/internal/transport/httpserver/console_applications_test.go index 976951c..0e94980 100644 --- a/internal/transport/httpserver/console_applications_test.go +++ b/internal/transport/httpserver/console_applications_test.go @@ -91,8 +91,8 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) { queue, _ := core.NewID() opID, _ := core.NewID() token, _, _ := auth.Issue(ns, cred) - for _, kind := range []string{"CREATE", "ROTATE"} { - op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: time.Now().UTC()} + for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE"} { + op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: func() *time.Time { v := time.Now().UTC(); return &v }()} if kind == "CREATE" { op.QueueID = queue op.Input.Application = "sample" @@ -104,6 +104,12 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) { op.Input.CredentialID = cred op.Input.ExpectedRevision = 1 } + if kind == "SET_EXPIRY" || kind == "FINALIZE" { + op.Version = 2 + op.Token = "" + op.ExpiresAt = nil + op.Input.ValidDays = 0 + } raw, err := json.Marshal(op) if err != nil { t.Fatal(err) @@ -114,7 +120,7 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) { if err := decodeBody(req, &got); err != nil { t.Fatalf("%s prepared output rejected: %v", kind, err) } - if got.Token != token || got.Input.Kind != kind { + if got.Token != op.Token || got.Input.Kind != kind { t.Fatal("prepared operation changed") } } diff --git a/platform/model/credential.go b/platform/model/credential.go index fb6cde1..e60f7ff 100644 --- a/platform/model/credential.go +++ b/platform/model/credential.go @@ -15,6 +15,10 @@ type Credential struct { Name string `db:"name"` // SecretDigest 凭据秘密的 SHA-256 摘要,不保存或输出原文。 SecretDigest core.Digest `db:"secret_digest"` + // PreviousSecretDigest 上一代秘密摘要;仅过渡期使用,空表示未保留旧令牌。 + PreviousSecretDigest *core.Digest `db:"previous_secret_digest"` + // PreviousValidUntil 旧令牌的固定 UTC 截止;不晚于原到期时间,与旧摘要同时为空或非空。 + PreviousValidUntil *time.Time `db:"previous_valid_until"` // Permissions 授权范围的 JSON 快照,必须由服务端解释和校验。 Permissions core.JSON `db:"permissions"` // ExpiresAt 凭据过期时间,统一 UTC;为空表示没有配置到期时间。 diff --git a/scripts/orbit-local.py b/scripts/orbit-local.py index 612d36f..2dfa11c 100755 --- a/scripts/orbit-local.py +++ b/scripts/orbit-local.py @@ -137,7 +137,7 @@ def setup(): print('Docker network: orbit-test-services; API: http://orbit-test:8080') print('Admin: admin; password file: ' + str(PRIVATE / 'admin.password')) print('SDK settings: ' + str(PRIVATE / 'host.env') + ' / docker.env') - print('Credential expires_at: ' + credential['expires_at']) + print('Credential expiry: ' + (status.get('expires_at') or 'permanent')) def main(): diff --git a/scripts/test-console-applications.py b/scripts/test-console-applications.py index bf8570d..52cf0a9 100644 --- a/scripts/test-console-applications.py +++ b/scripts/test-console-applications.py @@ -69,13 +69,33 @@ subscription_id = sub['subscription']['id'] task = call('/v1/tasks', {'queue_id': queue, 'handler': 'onboarding.noop', 'handler_version': 'v1', 'payload_version': 'v1', 'payload': {}}, token=op['token'], headers={'Idempotency-Key': 'onboarding-task'}, expected=201, session=False) task_id = task['task']['id'] call('/v1/tasks/' + task_id + ':cancel', {'expected_revision': task['task']['revision'], 'reason': 'acceptance complete'}, token=op['token'], headers={'Idempotency-Key': 'onboarding-cancel'}, session=False) -rotate = call('/console/applications/prepare', {'kind': 'ROTATE', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '1', 'valid_days': 30, 'reason': 'rotation acceptance'}) +expiry = call('/console/applications/prepare', {'kind': 'SET_EXPIRY', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '1', 'valid_days': 0, 'reason': 'long-lived service credential'}) +assert 'token' not in expiry and 'expires_at' not in expiry +if private: + Path(private, 'expiry.operation.json').write_text(json.dumps(expiry)) +assert call('/console/applications/apply', expiry)['revision'] == '2' +assert call('/console/applications/apply', expiry)['deduplicated'] +assert 'expires_at' not in call('/console/applications/' + op['namespace_id'])['credentials'][0] +call('/v1/queues/' + queue, token=op['token'], session=False) +rotate = call('/console/applications/prepare', {'kind': 'ROTATE', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '2', 'valid_days': 0, 'grace_days': 7, 'reason': 'rotation acceptance'}) if private: Path(private, 'rotate.operation.json').write_text(json.dumps(rotate)) -assert call('/console/applications/apply', rotate)['revision'] == '2' +assert call('/console/applications/apply', rotate)['revision'] == '3' assert call('/console/applications/apply', rotate)['deduplicated'] +call('/v1/queues/' + queue, token=op['token'], session=False) +call('/v1/queues/' + queue, token=rotate['token'], session=False) +assert call('/console/applications/' + op['namespace_id'])['credentials'][0]['previous_valid_until'] +finalize = call('/console/applications/prepare', {'kind': 'FINALIZE', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '3', 'valid_days': 0, 'reason': 'all services switched'}) +if private: + Path(private, 'finalize.operation.json').write_text(json.dumps(finalize)) +assert 'token' not in finalize +assert call('/console/applications/apply', finalize)['revision'] == '4' +assert call('/console/applications/apply', finalize)['deduplicated'] call('/v1/queues/' + queue, token=op['token'], expected=401, session=False) call('/v1/queues/' + queue, token=rotate['token'], session=False) +# 重放旧轮换回执不能重新启用已经结束过渡的秘密。 +assert call('/console/applications/apply', rotate)['deduplicated'] +call('/v1/queues/' + queue, token=op['token'], expected=401, session=False) current_task = call('/v1/tasks/' + task_id, token=rotate['token'], session=False) assert current_task['state'] == 'CANCELLED' current_sub = call('/v1/subscriptions/' + subscription_id, token=rotate['token'], session=False) @@ -87,5 +107,5 @@ if private: Path(private, 'result.json').write_text(json.dumps({'application': name, 'namespace_id': op['namespace_id'], 'queue_id': queue, 'credential_id': op['credential_id'], 'task_id': task_id, 'subscription_id': subscription_id})) call('/console/auth/logout', {}) call('/console/applications', expected=401) -print('PASS: application create/replay, CSRF/session isolation, token rotation, original task/subscription preserved, logout') +print('PASS: application create/replay, CSRF/session isolation, permanent expiry, overlapping rotation/finalization, original task/subscription preserved, logout') print('Test application: ' + name)