From eb2b911379a0ab2d9dd11c9f50ea6b5126030bf7 Mon Sep 17 00:00:00 2001 From: k2safe Date: Sat, 19 Sep 2026 23:56:33 +0800 Subject: [PATCH] test: verify worker rebind after credential rotation --- docs/08-development-plan.md | 4 +- docs/reports/phase-6-credentials.md | 5 ++- .../storage/mysql/worker_integration_test.go | 44 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index abada2c..86033d8 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -1,6 +1,6 @@ # Orbit 持续开发与回归计划 -更新:2026-09-18。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。 +更新:2026-09-19。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。 ## 1. 最终交付目标 @@ -60,7 +60,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告]( 1. 已补[首批可复现故障工具](reports/phase-6-fault-boundaries.md):A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终回执/旧PENDING结果重放/下一轮竞争。每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。 2. 已实现[基础运行观测](api/observability.md)及[有界业务状态与事件采样](reports/phase-6-business-observability.md):HTTP/WS、数据库连接池、后台批次、固定任务/交付/流程/Inbox状态计数、最近事件类别、终态耗时样本、固定事件阶段耗时、最早年龄、截断/新鲜度指标和可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签;发布前只读结构核验由[`orbit-admin verify`](api/operations.md)提供。仍需端到端外部效果延迟、全局持久吞吐、Inbox从事件产生到推进完成的精确阶段延迟、全局发送队列、多节点聚合及生产采集/通知验证。 -3. 已补齐受保护管理环境中的固定凭据操作文件、轮换、撤销、幂等回执和脱敏管理审计;轮换保留授权绑定并要求新令牌重新建立会话,撤销允许在停用范围内执行且不改写历史记录。仍需补齐提交不明故障切点、多节点安全重绑演练、凭据最小权限配置核对和生产密钥托管/审计演练。 +3. 已补齐受保护管理环境中的固定凭据操作文件、轮换、撤销、幂等回执和脱敏管理审计;轮换保留授权绑定并要求新令牌重新建立会话,旧 Worker 会话被排空且立即过期,撤销允许在停用范围内执行且不改写历史记录。已用一次性 MySQL 定向验证旧令牌失效、新令牌重绑和零外键;仍需补齐提交不明故障切点、多节点安全重绑演练、凭据最小权限配置核对和生产密钥托管/审计演练。 4. 已补[`orbit-admin retention inventory`](api/retention.md)只读盘点和[阶段报告](reports/phase-6-retention-inventory.md),能区分过期幂等、确定终态、未知效果、未终结流程、已确认/未确认交付、已消费/未消费Inbox、事件和审计规模;它不执行归档或删除。后续仍需评审各类保留周期、归档格式、受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。 5. 已补[`orbit-load`](api/load.md)有界受理负载工具:固定请求形状、并发上限、每请求超时、无自动重试、延迟百分位和有限错误分类;已完成一次隔离 MySQL + HTTP 小样本实测,证据见[负载报告](reports/phase-6-load.md)。标准 Go 1.27.1 隔离回归仍需在工具链恢复后复跑;该工具只提供受理层测量,真实 Worker/流程/数据库拓扑容量仍需独立报告。 6. 已补[隔离备份恢复演练](api/backup-restore.md):源库和恢复库均为一次性 MySQL 容器,逻辑备份恢复后核对迁移账本、核心记录数量、固定索引和零外键;证据见[恢复报告](reports/phase-6-backup-restore.md)。该演练不替代生产备份介质、复制切换、跨版本恢复或 RPO/RTO 验收。 diff --git a/docs/reports/phase-6-credentials.md b/docs/reports/phase-6-credentials.md index 95079dc..712e5de 100644 --- a/docs/reports/phase-6-credentials.md +++ b/docs/reports/phase-6-credentials.md @@ -1,6 +1,6 @@ # P6:凭据操作与安全轮换 -日期:2026-09-18。本文只记录管理凭据操作的已实现边界,不把凭据功能等同于 P6 全部生产验收。 +日期:2026-09-19。本文只记录管理凭据操作的已实现边界,不把凭据功能等同于 P6 全部生产验收。 ## 已实现行为 @@ -9,6 +9,7 @@ - `credential apply --file` 严格读取同一个私有操作文件。操作身份、请求摘要、凭据摘要更新、脱敏回执和管理审计在同一事务中提交;提交结果不明时重复应用同一文件只返回原回执,不重新生成秘密、不重置到期时间、不新增审计。 - 操作文件被修改、替换为不同令牌或不同原因时返回 `IDEMPOTENCY_CONFLICT`;已有成功回执不受后续凭据轮换或撤销影响。 - `credential revoke` 也使用固定操作文件,允许在应用或命名空间已停用时关闭凭据。撤销只改变凭据授权状态,不批量改写 Worker、订阅、任务、交付或历史会话记录;后续认证和控制操作会重新核对凭据状态。 +- 凭据轮换不会把旧 Worker 会话直接改成新凭据;旧令牌在每条控制命令上重新鉴权并立即失效,Worker 必须用新令牌重新 `Connect`。重新连接会建立新会话、递增会话代次,并将旧会话标记为排空且立即过期。 - 审计主体来自提交事务所在 MySQL 连接的 `CURRENT_USER()`,不是凭据 ID 或请求自报字段。审计前后摘要不包含令牌或秘密摘要。 - 没有公网管理路由,也没有新增运行时依赖;管理 CLI 使用现有 MySQL 连接和迁移账本。 @@ -25,6 +26,8 @@ GO="$(cat /tmp/orbit-toolchain-path)/go" DOCKER=/usr/local/bin/docker \ `TestIntegrationCredentialCommitFaults` 同样退出 0:使用测试专用 MySQL 包代理分别在 COMMIT 发出前断线和收到成功包后丢确认,独立连接回读版本,并用同一操作文件完成核对。BEFORE 情况回滚后重试首次提交,AFTER 情况直接返回原回执;两种情况都没有生成第二个令牌或第二条审计。该故障代理只存在于测试二进制,不进入生产进程。 +补充定向真实 MySQL 验证使用 Docker MySQL 8.4 和 Docker Go 1.25 临时模块文件运行 `TestIntegrationWorkerExecution/credential_rotation_requires_worker_session_rebind`,退出 0(含 `-race`,耗时 1.091 秒)。该用例确认旧令牌的 `Capacity` 返回 `UNAUTHENTICATED`,新令牌重新连接后获得不同会话,旧会话 `draining=TRUE` 且已过期,新会话可以继续提交容量;同次 `orbit-admin verify` 输出 `schema_version=28`、`migration_count=28`、`foreign_key_count=0`、`complete=true`。 + 随后执行标准隔离回归 `GO="$(cat /tmp/orbit-toolchain-path)/go" DOCKER=/usr/local/bin/docker sh scripts/test-mysql.sh`,退出 0。该次 root MySQL 集成组耗时 264.138 秒,包含上述两个凭据场景;流程运行器、SDK、Worker/事件进程、业务 Inbox/Outbox 和 23 个 SDK 示例均通过。另一次 CLI 专项实际构建 `orbit-admin`,在一次性 MySQL 上跑通 status、rotate、apply 首次提交、apply 重放和 revoke,且新操作文件权限为 0600。 以上证据仍不替代密钥托管演练、多节点重连演练、保留/归档、备份恢复和容量验收。 diff --git a/internal/storage/mysql/worker_integration_test.go b/internal/storage/mysql/worker_integration_test.go index b24de9b..57bc0ce 100644 --- a/internal/storage/mysql/worker_integration_test.go +++ b/internal/storage/mysql/worker_integration_test.go @@ -593,6 +593,50 @@ func TestIntegrationWorkerExecution(t *testing.T) { t.Fatal(fmt.Sprint(err)) } }) + t.Run("credential_rotation_requires_worker_session_rebind", func(t *testing.T) { + f := newExecutionFixture(t, ctx, db) + op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{ + NamespaceID: f.resources.NamespaceID, + CredentialID: f.resources.CredentialID, + ExpectedRevision: 1, + Action: credentialRotateAction, + ValidFor: time.Hour, + Reason: "worker session rotation", + }) + if err != nil { + t.Fatal(err) + } + if _, err := ApplyCredentialOperation(ctx, db, op); err != nil { + t.Fatal(err) + } + rotated, err := auth.Parse(op.Token) + if err != nil { + t.Fatal(err) + } + _, err = f.a.Capacity(ctx, f.token, f.binding, worker.CapacityInput{Sequence: 1, Capacity: f.hello.Capacity}) + expectWorkerCode(t, err, "UNAUTHENTICATED") + + newBinding, welcome, err := f.a.Connect(ctx, rotated, f.hello) + if err != nil { + t.Fatal(err) + } + if newBinding.SessionID == f.binding.SessionID || welcome.SessionID != newBinding.SessionID { + t.Fatal("credential rotation did not create a replacement worker session") + } + var drained bool + var expiredAt time.Time + if err := db.QueryRowContext(ctx, `SELECT draining,expires_at FROM pulse_worker_sessions WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.binding.SessionID).Scan(&drained, &expiredAt); err != nil { + t.Fatal(err) + } + if !drained || expiredAt.After(time.Now().UTC()) { + t.Fatalf("old worker session was not drained and expired: draining=%v expires_at=%s", drained, expiredAt.UTC().Format(time.RFC3339Nano)) + } + _, err = f.a.Capacity(ctx, f.token, f.binding, worker.CapacityInput{Sequence: 1, Capacity: f.hello.Capacity}) + expectWorkerCode(t, err, "UNAUTHENTICATED") + if _, err := f.a.Capacity(ctx, rotated, newBinding, worker.CapacityInput{Sequence: 1, Capacity: f.hello.Capacity}); err != nil { + t.Fatal(err) + } + }) t.Run("claim_event_failure_does_not_leave_an_invisible_lease", func(t *testing.T) { f := newExecutionFixture(t, ctx, db) task := f.submit(3)