From d54bdd9847c480f489b931f8c383d41dda6afe09 Mon Sep 17 00:00:00 2001 From: k2safe Date: Sun, 27 Sep 2026 13:48:26 +0800 Subject: [PATCH] feat: let administrators view and copy encrypted integration credentials --- AGENTS.md | 2 + README.md | 2 +- cmd/orbit-admin/credentials.go | 13 +- cmd/orbit-admin/main.go | 9 +- cmd/orbit/main.go | 10 +- deploy/docker-compose.yml | 1 + deploy/local/README.md | 8 +- deploy/local/compose.yml | 15 ++ docs/07-data-model.md | 4 + docs/08-development-plan.md | 10 +- docs/21-container-deployment.md | 4 + docs/22-compose-deployment.md | 2 + docs/api/authentication.md | 4 +- docs/api/console-applications.md | 60 ++++- docs/console.md | 4 +- docs/reports/credential-token-vault.md | 38 ++++ internal/config/config.go | 2 + internal/consoleauth/applications.go | 14 +- internal/consoleauth/credential_tokens.go | 33 +++ internal/credentialvault/vault.go | 111 ++++++++++ internal/credentialvault/vault_test.go | 80 +++++++ ...tion_resources_upgrade_integration_test.go | 1 + internal/storage/mysql/baseline.sql | 7 +- internal/storage/mysql/bootstrap.go | 13 +- .../storage/mysql/console_applications.go | 25 ++- .../mysql/console_credential_tokens.go | 85 +++++++ .../credential_vault_integration_test.go | 207 ++++++++++++++++++ internal/storage/mysql/credentials.go | 24 +- internal/storage/mysql/migrate.go | 2 +- .../000033_credential_token_vault.up.sql | 6 + .../httpserver/console/applications.js | 73 ++++-- .../transport/httpserver/console/index.html | 32 ++- .../httpserver/console_applications.go | 21 ++ platform/auth/token.go | 2 +- platform/model/credential.go | 8 +- scripts/orbit-local.py | 13 +- scripts/test-console-applications.py | 20 +- scripts/test-console-e2e.sh | 11 + 38 files changed, 905 insertions(+), 71 deletions(-) create mode 100644 docs/reports/credential-token-vault.md create mode 100644 internal/consoleauth/credential_tokens.go create mode 100644 internal/credentialvault/vault.go create mode 100644 internal/credentialvault/vault_test.go create mode 100644 internal/storage/mysql/console_credential_tokens.go create mode 100644 internal/storage/mysql/credential_vault_integration_test.go create mode 100644 internal/storage/mysql/migrations/000033_credential_token_vault.up.sql diff --git a/AGENTS.md b/AGENTS.md index 066f92b..fe49929 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,3 +93,5 @@ - `sdk/go/cmd/orbit-schedule-load` 使用同时起止的有限FIXED_RATE计划测量集中触发;固定版本/时区快照、触发时间槽、任务/尝试/来源、本地调用逐项核对。配置总数、已观察、已触发和已核验分开计数;截止保留原操作身份并有界暂停,不取消已生成任务;暂停只在明确版本冲突后换键,传输不明不得新键重试。样本不代表长期积压容量或生产HA。 - 流程测量核验必须绑定本次发布的不可变版本ID及完整规范,步骤精确为pair/left/right且任务/激活唯一;按任务和尝试关联本地调用,不能用每实例总次数抵充缺失分支。零序号也要求精确必填字段,拒绝重复键/别名/null;报告保留逐请求原键与ID和未确认调用分母。源码更新后重新构建并运行真实样本,原二进制结果不得配上更新后的源码摘要。 + +- 管理员接入信息按用户要求支持随时查看/复制当前 Token。000033 的 secret_key_id / secret_ciphertext 仅存 AES-256-GCM 密文,主密钥来自 ORBIT_CREDENTIAL_KEY_FILE,所有节点和管理 CLI 共用;不得记录秘密到日志/审计/幂等回执。查看须管理员会话、同源 POST、核对当前版本和摘要,并确认审计提交后才交付。历史导入只接受当前摘要,不接受过渡期旧代;不轮换身份、不改认证 revision/有效期/过渡截止。轮换原子替换密文,撤销清除密文;没有主密钥的旧 CLI 轮换必须清除过期密文。普通列表仅返回 token_stored,不自动读取秘密。 diff --git a/README.md b/README.md index b35a90e..8c82cae 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,换证时新旧共存 7 天),并复制或下载 SDK 配置。设置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一接入管理员;本地 Compose 已启用。[应用接入使用与接口文档](docs/api/console-applications.md)。 +管理员可在「应用接入」页面新建应用和初始队列、查看和调整有效期、平滑换证(默认长期 Token,换证时新旧共存 7 天),随时查看、复制包含当前 Token 的完整接入配置,或下载 orbit.env。设置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一接入管理员,并通过 `ORBIT_CREDENTIAL_KEY_FILE` 配置 Token 加密保管密钥;本地 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 1bb99bb..cba1fe0 100644 --- a/cmd/orbit-admin/credentials.go +++ b/cmd/orbit-admin/credentials.go @@ -13,6 +13,7 @@ import ( "time" "git.yuntuo.hk/k2safe/orbit/internal/config" + "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" core "git.yuntuo.hk/k2safe/orbit/model" ) @@ -112,7 +113,11 @@ func runCredentialChange(args []string, action string) error { if err := writePrivateOperation(output, op); err != nil { return err } - receipt, err := mysql.ApplyCredentialOperation(ctx, db, op) + vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE")) + if err != nil { + return err + } + receipt, err := mysql.ApplyCredentialOperation(ctx, db, op, vault) if err != nil { return errors.New("credential operation was not confirmed; the private operation file was retained for reconciliation") } @@ -138,7 +143,11 @@ func runCredentialApply(args []string) error { return err } defer closeDB() - receipt, err := mysql.ApplyCredentialOperation(ctx, db, op) + vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE")) + if err != nil { + return err + } + receipt, err := mysql.ApplyCredentialOperation(ctx, db, op, vault) if err != nil { return errors.New("credential operation was not confirmed; retry with the same private operation file") } diff --git a/cmd/orbit-admin/main.go b/cmd/orbit-admin/main.go index e69190f..636e701 100644 --- a/cmd/orbit-admin/main.go +++ b/cmd/orbit-admin/main.go @@ -15,6 +15,7 @@ import ( "git.yuntuo.hk/k2safe/orbit/internal/buildinfo" "git.yuntuo.hk/k2safe/orbit/internal/config" + "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" core "git.yuntuo.hk/k2safe/orbit/model" ) @@ -306,6 +307,10 @@ func runBootstrap(args []string) error { if err != nil { return err } + vault, err := credentialvault.LoadFile(cfg.CredentialKeyFile) + if err != nil { + return err + } file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { return errors.New("cannot create new private output file (existing files are never overwritten)") @@ -345,14 +350,14 @@ func runBootstrap(args []string) error { } delivered = true return nil - }) + }, vault) if err != nil { if delivered { return errors.New("bootstrap commit could not be confirmed; private output was retained; verify its credential before retrying") } return errors.New("bootstrap failed before credential delivery; check schema, names and database access") } - fmt.Println("Bootstrap committed. Credentials saved in the requested private file; valid for 30 days.") + fmt.Println("Bootstrap committed. Credentials saved in the requested private file; expiry follows --valid-for (default: permanent).") return nil } diff --git a/cmd/orbit/main.go b/cmd/orbit/main.go index 4c2e349..52891df 100644 --- a/cmd/orbit/main.go +++ b/cmd/orbit/main.go @@ -12,6 +12,7 @@ import ( "time" "git.yuntuo.hk/k2safe/orbit/internal/config" + "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" "git.yuntuo.hk/k2safe/orbit/internal/observability" "git.yuntuo.hk/k2safe/orbit/internal/retention" "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" @@ -41,6 +42,13 @@ func run() error { if err != nil { return err } + vault, err := credentialvault.LoadFile(cfg.CredentialKeyFile) + if err != nil { + return err + } + if cfg.ConsoleApplicationAdmin != "" && vault == nil { + return errors.New("ORBIT_CREDENTIAL_KEY_FILE is required for console application administration") + } root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() startup, cancel := context.WithTimeout(root, 10*time.Second) @@ -125,7 +133,7 @@ func run() error { httpserver.RegisterWorkflows(server, instance.New(mysql.NewWorkflowInstanceStore(db)), control.New(mysql.NewWorkflowControlStore(db)), weavesignal.New(mysql.NewWorkflowSignalStore(db)), recovery.New(mysql.NewWorkflowRecoveryStore(db))) httpserver.RegisterConsoleRetention(server, consoleAuth, retentionStore, retentionStore) httpserver.RegisterConsoleArchiveJobs(server, consoleAuth, retentionStore, cfg.ArchiveStorageID) - consoleApplications := mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin) + consoleApplications := mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin, vault) httpserver.RegisterConsoleApplications(server, consoleAuth, consoleApplications) httpserver.RegisterConsoleScopes(server, consoleApplications) httpserver.WrapConsoleAuth(server, consoleAuth, consoleApplications) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 5fb9e49..2627fa6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -93,6 +93,7 @@ services: ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90} ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555} ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-} + ORBIT_CREDENTIAL_KEY_FILE: ${ORBIT_CREDENTIAL_KEY_FILE:-} ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-} ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-} ports: diff --git a/deploy/local/README.md b/deploy/local/README.md index b7eefe7..3c5269f 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -38,7 +38,7 @@ ## 为其他应用分配独立配置 -登录后打开侧栏「应用接入」,新建应用并选择能力,成功后复制/下载该应用的 `.env`。详情可以随时查看队列、权限和有效期。页面平滑换证默认保留旧 Token 7 天,过渡期内新旧 Token 均按各自有效期核验,原任务和订阅保持不变;全部服务切换并收尾后才可提前结束过渡。完整 Token 仅在生成时显示,未保存时可点「Token 在哪里查看?」;过渡期不能再次换证,点击「查看原因」可打开说明。 +登录后打开侧栏「应用接入」,新建应用并选择能力,成功后复制/下载该应用的 `.env`。详情可以随时查看队列、权限和有效期。页面平滑换证默认保留旧 Token 7 天,过渡期内新旧 Token 均按各自有效期核验,原任务和订阅保持不变;全部服务切换并收尾后才可提前结束过渡。管理员可以随时「查看接入信息」「复制接入配置」或下载 orbit.env,过渡期也可以获取当前新 Token。旧凭据仅有摘要时先导入已保存的当前 Token,保留现有有效期和业务身份。 `host.env` / `docker.env` 是初始 local-testing 应用的联调身份;独立应用应使用页面分配的自己的配置。应用接入管理只授权 `admin`,无需创建多角色。[详细操作和恢复手册](../../docs/api/console-applications.md)。 @@ -138,3 +138,9 @@ Worker 返回三条 `SUCCEEDED`;计划示例实际执行后暂停;流程示 新建 SDK 凭据默认长期有效(省略 `expires_at`);旧环境保留原策略,可在应用接入页「调整有效期」且不改变 Token。启动输出读取数据库当前有效期。凭据拥有此测试命名空间的任务、Worker、调度、订阅、流程及恢复权限;这是联调身份,正式环境按角色收窄。凭据到期/撤销后不会自动重新初始化或重置历史,按[轮换手册](../../docs/api/authentication.md)处理。 管理员密码、数据库密码、SDK Token 均保存在 `.local/compose-test` 下的私密文件;目录权限 0700、文件权限 0600,Git 和 Docker 构建上下文都排除这些文件。需要其他服务读取时分发 `host.env` / `docker.env`,不要给业务服务数据库管理员配置。 + +### Token 保管密钥 + +`up` 自动生成 `.local/compose-test/credential-keys.json`(0600),重复部署复用它。初始化容器将文件放入 `orbit-keys` 卷,供 Orbit 的 65532 用户只读访问;bootstrap/凭据管理 CLI 使用同一源文件。已经启用后发现文件丢失会停止部署,要求恢复原文件,不能生成替代密钥。 + +备份数据库时单独备份该文件,不提交 Git;不要删除 `orbit-keys` 卷。密钥环、生产多节点部署、旧凭据导入和主密钥变更见[应用接入接口文档](../../docs/api/console-applications.md#8-部署-token-保管)。本地升级会保留原管理员和所有应用身份,历史 Token 需要经管理员导入补齐。 diff --git a/deploy/local/compose.yml b/deploy/local/compose.yml index 25860bb..9c7fdad 100644 --- a/deploy/local/compose.yml +++ b/deploy/local/compose.yml @@ -1,5 +1,14 @@ # 叠加 deploy/docker-compose.yml;使用 scripts/orbit-local.py 固定项目和私密配置。 services: + credential-key-init: + image: mysql:8.4 + restart: "no" + user: "0:0" + entrypoint: ["/bin/sh", "-ec"] + command: ["cp /source/credential-keys.json /keys/credential-keys.json.tmp && chown 65532:65532 /keys /keys/credential-keys.json.tmp && chmod 700 /keys && chmod 600 /keys/credential-keys.json.tmp && mv /keys/credential-keys.json.tmp /keys/credential-keys.json"] + volumes: + - ${ORBIT_LOCAL_DIRECTORY}/credential-keys.json:/source/credential-keys.json:ro + - orbit-keys:/keys archive-init: image: mysql:8.4 restart: "no" @@ -17,6 +26,7 @@ services: command: ["verify"] environment: ORBIT_MYSQL_DSN: "root:${ORBIT_DB_ROOT_PASSWORD}@tcp(mysql:3306)/${ORBIT_DB_NAME}?parseTime=true&multiStatements=true" + ORBIT_CREDENTIAL_KEY_FILE: /run/local/credential-keys.json volumes: - ${ORBIT_LOCAL_DIRECTORY}:/run/local @@ -29,16 +39,20 @@ services: REVISION: ${ORBIT_BUILD_REVISION} BUILD_TIME: ${ORBIT_BUILD_TIME} depends_on: + credential-key-init: + condition: service_completed_successfully archive-init: condition: service_completed_successfully environment: ORBIT_CONSOLE_APPLICATION_ADMIN: admin + ORBIT_CREDENTIAL_KEY_FILE: /run/orbit-keys/credential-keys.json ORBIT_ARCHIVE_ROOT: /var/lib/orbit/archives ORBIT_ARCHIVE_STORAGE_ID: ${ORBIT_ARCHIVE_STORAGE_ID} ORBIT_ARCHIVE_MAX_BYTES: 1073741824 ORBIT_ARCHIVE_TICK_INTERVAL: 10s volumes: - orbit-archives:/var/lib/orbit/archives + - orbit-keys:/run/orbit-keys:ro networks: default: services: @@ -56,4 +70,5 @@ networks: - subnet: ${ORBIT_TEST_SERVICES_SUBNET:-10.253.241.0/24} volumes: + orbit-keys: orbit-archives: diff --git a/docs/07-data-model.md b/docs/07-data-model.md index f634562..cdc0cf4 100644 --- a/docs/07-data-model.md +++ b/docs/07-data-model.md @@ -274,3 +274,7 @@ Event.WorkflowID为只读STORED生成列,从原不可变事件派生流程归 `orbit_archive_jobs` 固定范围和卷,保存 PENDING/RUNNING/SUCCEEDED/FAILED/ABANDONED 状态、版本、领取次数、已确认的批数/根数/字节/链摘要,以及随机租约代次和截止。租约确认必须匹配代次与版本且未过期;人工操作需要原版本、幂等键与审计原因。两个表均无外键,关联在同一事务按命名空间核验。 数据库与文件不做跨资源事务。持久文件是恢复依据,数据库链前缀是可信核验锚点;文件发布后确认丢失,由原作业重新读文件校验后收敛,缺失文件不自动重建。见[接口与部署说明](api/archive-scheduler.md)。 + +### 当前 Token 保管(000033) + +`orbit_credentials.secret_key_id` 指向独立文件中的主密钥;`secret_ciphertext` 保存 AES-256-GCM 的 nonce 与认证密文,两字段同时为空或非空。原文不进入持久化模型、日志和审计。既有摘要保留用于认证,旧记录通过管理员导入当前原文补存,不改变身份、版本、有效期或过渡截止。撤销清空密文,轮换原子替换当前摘要与密文;无外键。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 412db2a..6e1cdaa 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -278,7 +278,7 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专 ### 单管理员应用接入(2026-09-27) - 新增独立应用接入页:应用/环境分页、队列及凭据详情、选择业务能力新建应用、重新生成 Token、复制/下载 SDK 配置;沿用单管理员,不引入角色系统。 -- 新增 Cookie 专用管理接口,部署显式指定唯一登录名;同事务校验管理员与有效会话,写入要求同源与专用请求头。Token 只在生成时交付,数据库只存摘要。 +- 新增 Cookie 专用管理接口,部署显式指定唯一登录名;同事务校验管理员与有效会话,写入要求同源与专用请求头。最初采用摘要模式;后续管理员接入信息功能已改为当前 Token 加密保管,普通列表仍无秘密。 - 新建资源/审计/幂等回执同事务,轮换复用现有凭据 ID 与轮换核心。请求不明保留原操作,原样重报;原有任务、订阅和交付身份不变。无新表、无外键、无 SDK 协议修改。 - 已通过定向 MySQL 并发创建、幂等冲突、重名回滚、轮换/旧 Token 拒绝、退出失效与审计脱敏;真实 HTTP 已验证创建、轮换后原任务和订阅可读。标准/全量集成和页面验收记录见[本批报告](reports/console-applications.md)。 - [页面使用、HTTP 接口及故障恢复](api/console-applications.md)。 @@ -304,3 +304,11 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专 - 回归范围:标准竞态测试、vet/build、JavaScript 语法和文档链接;本地页面核对过渡原因可点击、凭据获取说明、正常换证入口与结束过渡确认弹窗。说明同步到[应用接入手册](api/console-applications.md)。 - 已完成验收:基础回归通过;浏览器验证过渡原因点击、说明展开/关闭/再次打开、普通换证与新建保存提醒、结束过渡确认。验收没有轮换或停用现有应用凭据。 - 本地接入命令已实跑:仓库外独立模块安装 SDK v0.1.1、读取队列及校验和通过;真实 Worker 三项成功且排空、结果订阅跨连接 ACK 去重、共享 Docker 网络就绪检查均通过。本地部署手册同步修正旧 Token 过渡语义。 + +### 管理员接入信息与 Token 保管(本批) + +- [x] 当前 Token 加密保存;查看/导入同事务审计,单管理员 Cookie 与同源校验,列表脱敏。 +- [x] 「查看接入信息」「复制接入配置」「下载 orbit.env」,包含地址、应用/命名空间/队列与当前 Token。 +- [x] 新建/换证/撤销密文与摘要同步,原有效期和过渡策略保持;历史凭据导入校验当前摘要。 +- [x] 前向迁移 000033、中文模型注释、无外键;本地 Compose 持久主密钥与丢失保护。 +- [x] 标准回归、完整 MySQL/SDK/进程恢复、控制台端到端和浏览器验证通过;本地数据库升级为 33,原身份保持。见[验收报告](reports/credential-token-vault.md)。历史凭据仅有摘要时仍需导入当前原文,不会自动重置。 diff --git a/docs/21-container-deployment.md b/docs/21-container-deployment.md index 609809e..0fcd676 100644 --- a/docs/21-container-deployment.md +++ b/docs/21-container-deployment.md @@ -101,3 +101,7 @@ GET /version 版本、提交和协议身份 ## 4. 发布验收 镜像构建通过只证明发布物可复现,不等于生产高可用。发布候选仍需按[交付核对清单](release-checklist.md)完成真实 MySQL 回归、滚动替换、复制切换、容量、备份恢复和 RPO/RTO 验收。业务 Worker 继续独立部署,通过 HTTP/WebSocket SDK 接入。 + +### 管理员 Token 保管文件 + +启用 `ORBIT_CONSOLE_APPLICATION_ADMIN` 的部署必须通过 `ORBIT_CREDENTIAL_KEY_FILE` 指定 JSON 密钥环。将文件以只读卷或编排器 secret 挂载,并赋予容器 UID 65532 读取权限;不要写入镜像。所有节点与管理 CLI 共用密钥环。字段、升级顺序、备份和主密钥轮替见[接入信息部署](api/console-applications.md#8-部署-token-保管)。本地 Compose 自动创建和复用该文件。 diff --git a/docs/22-compose-deployment.md b/docs/22-compose-deployment.md index c30de03..6c8d7e8 100644 --- a/docs/22-compose-deployment.md +++ b/docs/22-compose-deployment.md @@ -152,3 +152,5 @@ docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.ha.yml up - ## 6. 当前边界 该文件是可重复的单节点启动基线,不代表自动数据库故障切换、跨故障域多副本、备份恢复或容量目标已经验收。生产上线还需要真实 MySQL 拓扑、滚动替换、凭据轮换、备份恢复、压测和 RPO/RTO 证据;对应门槛以[交付核对清单](release-checklist.md)为准。 + +启用应用接入管理员时,还须配置 `ORBIT_CREDENTIAL_KEY_FILE` 并在 Compose 覆盖文件中只读挂载对应主密钥文件(容器 UID 65532 可读)。基础 Compose 只传递路径,不生成生产秘密;[本地 Compose](../deploy/local/README.md)提供自动初始化。参见[Token 保管配置](api/console-applications.md#8-部署-token-保管)。 diff --git a/docs/api/authentication.md b/docs/api/authentication.md index eaca933..3307666 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -69,7 +69,7 @@ go run ./cmd/orbit-admin credential rotate \ --output .local/rotated.credentials.json ``` -命令先读取并核对版本,生成一个固定 `operation_id`、令牌、可选绝对到期时间和固定过渡截止,写入新的 `0600` 私有操作文件,然后提交数据库事务。文件不得已存在;提交结果不明、网络断开或数据库返回未知错误时必须保留文件,并使用同一个文件执行 `credential apply --file`,不能重新执行 `rotate` 生成第二个秘密。`--valid-for` 默认 0(长期),也可配置 1 小时到 365 天;`--grace` 默认 168h(7 天),范围 0–720h,0 表示立即停用旧 Token。旧 Token 的实际截止取过渡截止与原到期时间的较早者,不复活已过期秘密。数据库只保存秘密摘要、请求摘要和脱敏回执。 +命令先读取并核对版本,生成一个固定 `operation_id`、令牌、可选绝对到期时间和固定过渡截止,写入新的 `0600` 私有操作文件,然后提交数据库事务。文件不得已存在;提交结果不明、网络断开或数据库返回未知错误时必须保留文件,并使用同一个文件执行 `credential apply --file`,不能重新执行 `rotate` 生成第二个秘密。`--valid-for` 默认 0(长期),也可配置 1 小时到 365 天;`--grace` 默认 168h(7 天),范围 0–720h,0 表示立即停用旧 Token。旧 Token 的实际截止取过渡截止与原到期时间的较早者,不复活已过期秘密。数据库保存认证摘要、请求摘要和脱敏回执;配置 ORBIT_CREDENTIAL_KEY_FILE 时还加密保存当前 Token,管理员可查看和复制。[管理员接入信息与密钥配置](console-applications.md#8-部署-token-保管)。 ```sh go run ./cmd/orbit-admin credential apply \ @@ -164,4 +164,4 @@ P4/P5实例公共接入增加`workflows:start/read/control/signal/recover`及`wo ## 部署升级 -迁移 `000032` 为凭据表增加上一代摘要与 UTC 截止,并以 CHECK 约束成对为空;不新增外键、不改变已有秘密或有效期。新安装的基线同步到 32。按部署流程应用迁移并更新全部 Orbit 节点后再使用平滑换证;旧版本节点只认识当前摘要,不能在混合版本期间承诺旧 Token 可用。既有凭据不会被迁移自动改为长期,须通过有审计的 expiry 操作显式调整。 +迁移 `000032` 为凭据表增加上一代摘要与 UTC 截止,并以 CHECK 约束成对为空;不新增外键、不改变已有秘密或有效期。该次基线同步到 32;当前基线还包含后续 000033 Token 加密保管字段。按部署流程应用迁移并更新全部 Orbit 节点后再使用平滑换证;旧版本节点只认识当前摘要,不能在混合版本期间承诺旧 Token 可用。既有凭据不会被迁移自动改为长期,须通过有审计的 expiry 操作显式调整。 diff --git a/docs/api/console-applications.md b/docs/api/console-applications.md index bcbe5d2..b53c304 100644 --- a/docs/api/console-applications.md +++ b/docs/api/console-applications.md @@ -1,6 +1,6 @@ # 控制台应用接入 -单管理员在「应用接入」页面创建应用、初始队列与 SDK 凭据,查看环境、队列、权限和有效期,平滑换证,复制或下载 `.env` 配置。数据库只保存 Token 摘要:完整 Token 仅在新建或轮换时交付,列表和详情不能反查旧秘密。 +单管理员在「应用接入」页面创建应用、初始队列与 SDK 凭据,查看环境、队列、权限和有效期,平滑换证,复制或下载 `.env` 配置。业务认证使用 Token 摘要;当前 Token 使用 AES-256-GCM 加密保管。管理员可以随时查看、复制完整接入配置和下载 orbit.env,列表仅返回脱敏元数据。 ## 1. 启用和使用 @@ -8,6 +8,7 @@ ```sh export ORBIT_CONSOLE_APPLICATION_ADMIN=admin +export ORBIT_CREDENTIAL_KEY_FILE=/run/orbit-secrets/credential-keys.json ``` 该账号必须已经通过 bootstrap 或 console-user create 创建。配置为空时所有应用接入管理请求返回 403,不会自动提升已有账号;多节点使用相同配置。继续使用单管理员,不引入角色管理系统。现有控制台账号与会话的绑定凭据仍须有效;到期前可在本页轮换。轮换 SDK Token 不改变浏览器独立会话秘密。 @@ -17,11 +18,11 @@ export ORBIT_CONSOLE_APPLICATION_ADMIN=admin 1. 点击「新建应用」,填写应用名称、环境、命名空间、初始队列和操作原因。 2. 选择 Worker、结果订阅、定时、流程等需要的能力及有效期。基础权限包含任务提交/查询/取消、队列读取/管理。 3. 创建成功后复制或下载完整 `.env`,交给该应用的服务端配置。默认地址使用当前页面来源,详情中可改为容器地址或部署域名。 -4. 已接入应用可以随时查看元数据和配置模板。模板中的 Token 是占位值,必须替换成已经保存的原 Token。 +4. 已接入应用点击「查看接入信息」或「复制接入配置」,获取包含当前 Token 的完整配置;也可下载 orbit.env。 5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。 6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。 -凭据卡片将「凭据 ID」与「当前 Token」分开展示:ID 只是标识,不能填入 ORBIT_TOKEN。点击「Token 在哪里查看?」可查看获取位置和未保存时的处理说明。完整 Token 请从创建或换证时下载的 `orbit.env`、已保存的 SDK 配置或业务服务配置中读取;处于过渡期时,业务配置可能仍是旧 Token,新 Token 要以本次换证保存的配置为准。页面里的「SDK 配置模板」不含完整 Token。 +凭据卡片提供「查看接入信息」「复制接入配置」。配置包含 ORBIT_URL、ORBIT_APPLICATION_ID、ORBIT_NAMESPACE_ID、ORBIT_QUEUE_ID、ORBIT_TOKEN;可直接粘贴为环境配置。先选择业务服务实际可访问的地址和队列,再复制;应用 ID 用于识别,真正认证身份来自 Token。过渡期始终返回当前新 Token,调整有效期或结束旧代过渡后仍可查看。历史凭据尚无密文时提供「导入当前 Token」,不能把仍可用的旧代 Token 导入为当前代次。 环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面支持新建应用及其初始队列;业务列表可通过应用筛选栏切换范围,分别展示各应用数据。未提供应用删除、身份迁移或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。 @@ -35,6 +36,8 @@ export ORBIT_CONSOLE_APPLICATION_ADMIN=admin | `GET /console/applications/{namespace_id}` | 应用/环境详情、最多 100 个队列和 100 张脱敏凭据;超出返回 `truncated=true` | | `POST /console/applications/prepare` | 校验意图,生成固定操作 ID、资源 ID、Token、绝对到期时间;不创建资源、不轮换秘密 | | `POST /console/applications/apply` | 提交准备结果,原样重报返回原脱敏回执 | +| `POST /console/applications/{namespace_id}/credentials/{credential_id}/token/reveal` | 管理员读取当前 Token;审计成功后才返回 | +| `POST /console/applications/{namespace_id}/credentials/{credential_id}/token/import` | 校验并加密补存当前 Token,不轮换身份 | POST 必须有 `Content-Type: application/json` 和 `X-Orbit-Console: 1`;浏览器 Origin 必须匹配当前主机,拒绝跨站 Fetch Metadata,不提供跨域授权。响应禁止缓存,成功信封为 `{"data": ...}`。未知字段、重复 JSON 键、显式 null 和错误类型被拒绝,正文上限 512 KiB。 @@ -73,7 +76,7 @@ prepare 响应 data 为固定操作对象: } ``` -以上是结构示意,ID、令牌及时间以服务端返回为准。将 **data 整体原样** POST 到 apply,不包 `data` 外层。应用、额度、命名空间、队列、凭据摘要、审计和幂等回执在同一事务提交。初始队列并发 4、开始速率 10/秒,沿用 bootstrap 默认策略;凭据队列范围为新命名空间的全部队列。重名应用返回 409,不覆盖原资源。 +以上是结构示意,ID、令牌及时间以服务端返回为准。将 **data 整体原样** POST 到 apply,不包 `data` 外层。应用、额度、命名空间、队列、凭据摘要、Token 密文、审计和幂等回执在同一事务提交。初始队列并发 4、开始速率 10/秒,沿用 bootstrap 默认策略;凭据队列范围为新命名空间的全部队列。重名应用返回 409,不覆盖原资源。 ## 4. 平滑换证 @@ -91,7 +94,7 @@ prepare 请求: } ``` -expected_revision 为读取到的字符串版本。只允许上述轮换字段,不混入新应用名称或能力组。准备对象包含原 application_id / namespace_id / credential_id,没有 queue_id。apply 复用原有凭据轮换事务:将原摘要保留到固定过渡截止,替换当前摘要与可选到期时间,版本加一,保存脱敏回执及人类账号审计;权限组不改变。已撤销的凭据禁止变更。ROTATE 的 grace_days 范围 0–30,API 省略/0 保持立即停用旧 Token 的兼容语义,页面显式提交 7;实际截止不超过旧 Token 原到期时间。过渡未结束时再次轮换返回 409。 +expected_revision 为读取到的字符串版本。只允许上述轮换字段,不混入新应用名称或能力组。准备对象包含原 application_id / namespace_id / credential_id,没有 queue_id。apply 复用原有凭据轮换事务:将原摘要保留到固定过渡截止,原子替换当前摘要、密文与可选到期时间,版本加一,保存脱敏回执及人类账号审计;权限组不改变。已撤销的凭据禁止变更。ROTATE 的 grace_days 范围 0–30,API 省略/0 保持立即停用旧 Token 的兼容语义,页面显式提交 7;实际截止不超过旧 Token 原到期时间。过渡未结束时再次轮换返回 409。 页面在过渡期将入口标为「暂不可换证 · 查看原因」,同时显示实际截止和切换步骤。该入口可以点击,打开只读说明弹窗,不会提交换证或结束过渡。这表示上一轮换证已生效;再次轮换会挤掉仍在使用的旧 Token,所以暂时禁止。将已保存的新 Token 配置到所有服务、Worker 和结果消费者,并收尾在途任务。到期后页面刷新会恢复换证入口;只有确认全部切换完成,才可提前点「结束过渡,停用旧 Token」。结束过渡会立即停用旧 Token,不能仅为解锁按钮而操作。已撤销凭据单独显示「已撤销 · 查看原因」,同样只打开说明。 @@ -133,8 +136,7 @@ SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长 - 页面提交前把整个固定操作保留在当前内存;请求超时或结果未知时,按钮变成「重试确认」,重用同一份内容。不能再次 prepare 来代替核对。 - 页面允许「保存待核对操作」下载私密 JSON;含真实 Token,应放到受限目录。恢复时登录同一管理员后,向 apply 发送该文件正文。不要把 Cookie 或操作文件提交 Git,也不要放到 URL。 -- 关闭/刷新页面会丢失内存中的 Token,页面会提醒先保存。若新建已经成功、交付内容未保存,可从应用列表重新生成同一凭据的 Token;不要再次创建同名应用。 -- 创建/换证前和成功交付时明确提示「只显示一次」及过渡期限制;未复制/下载时,关闭确认也提示原文不可回看。若换证后的新 Token 未保存,先查下载文件或已更新的部署配置。确实丢失且仍在过渡期时,需要安排维护窗口暂停接收新任务并收尾在途执行,再由管理员结束过渡、重新换证,保存和更新配置后恢复运行;平台不自动解除保护,也不能从摘要还原原文。 +- 关闭窗口后清除浏览器中的明文,管理员可以重新查看,无需再次换证。 - 原操作返回的回执是历史快照,可能已被后续轮换替代。页面会重新读取当前凭据版本后才展示配置。 - 管理审计主体是 `console:`;正文只有权限、资源 ID、到期时间等元数据。列表、审计、幂等回执和业务记录都不保存秘密原文。 - 当前管理员退出、会话过期或账号停用后不能重放管理操作。SDK Token 撤销/到期与控制台登录资格沿用既有账号绑定约束。 @@ -152,3 +154,47 @@ SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长 可重复运行的真实 HTTP 场景见 [test-console-applications.py](../../scripts/test-console-applications.py),标准容器入口 `make console-e2e` 同时验证此链路。基础 SDK 使用方式见 [SDK 接入](../sdk/README.md)。 业务数据的应用筛选、队列分页及会话隔离见[应用范围接口](console-scope.md)。 + +## 7. 当前 Token 与历史导入 + +详情返回 `token_vault_enabled`;每张凭据的 `token_stored` 表示当前 Token 是否已保管。列表、详情、审计和幂等回执都不包含原文。 + +查看请求: + +```json +{"expected_revision":"3"} +``` + +导入请求: + +```json +{"expected_revision":"3","token":"已保存的当前完整 Token"} +``` + +成功响应: + +```json +{"data":{"namespace_id":"33333333333333333333333333333333","credential_id":"55555555555555555555555555555555","revision":"3","token":"当前完整 Token"}} +``` + +只接受当前凭据版本。版本冲突、撤销或旧记录未保管返回 409;导入的身份或秘密与当前摘要不符返回 400(旧代处于过渡期也不例外);密钥缺失/解密失败返回 503。读取需要 Cookie、同源和 X-Orbit-Console,与其他管理 POST 相同。响应 `Cache-Control: no-store`;审计动作分别是 `console.credential.token.view`、`console.credential.token.import`,不含 Token、摘要或密文。 + +导入可以重复提交同一当前 Token,每次成功操作记录审计;不修改认证 revision、到期时间、权限或旧代截止。网络结果不明时可重新读取元数据或重报同一导入。读取/导入遇到审计写入失败或提交结果不明,不返回秘密。读取与换证对同一凭据加锁,返回的是该事务看到的当前代次;之后其他管理员的新轮换仍以实际数据库状态为准。 + +## 8. 部署 Token 保管 + +启用 `ORBIT_CONSOLE_APPLICATION_ADMIN` 时必须配置 `ORBIT_CREDENTIAL_KEY_FILE`,文件格式: + +```json +{"active":"key-v1","keys":{"key-v1":"32 字节随机密钥的标准 Base64 编码"}} +``` + +主密钥由部署方生成,权限限定运行用户读取;不要使用示例文字作为密钥。所有服务节点和管理 CLI 使用同一份密钥环,通过只读文件挂载部署,不写进镜像、Git 或数据库。参考[本地部署](../../deploy/local/README.md)自动生成方案。最多 16 把密钥,文件最多 64 KiB。密文绑定 namespace_id / credential_id,不能跨凭据替换。 + +升级先停止旧节点、应用迁移 000033、配置密钥再启动全部新节点;旧写节点不认识密文列,不能混合版本承诺回看一致性。迁移仅增加两个可空字段和成对 CHECK,无外键,不改变已有 Token、版本、有效期或过渡时间。 + +数据库和密钥文件分别备份。更换主密钥时使用新 key ID,将其设为 active,同时保留仍有密文引用的旧 key;在更新所有节点之前不写新 key ID 的数据。历史密文可以先查看、再导入同一 Token 以重新加密,不需要更换业务 Token。不得将旧 ID 映射到不同密钥,也不得在密文尚未迁移时删除旧 key。 + +管理 CLI 的 bootstrap / credential rotate / apply 读取同一环境变量。未设置时保持摘要模式,轮换会清除旧密文;之后必须导入该次新 Token 才能回看。控制台启用应用管理员时不会允许遗漏主密钥配置。 + +历史库仅有 SHA-256 摘要,无法凭摘要重建原文。请从既有业务配置或下载文件导入当前 Token;不要为补存秘密提前结束仍在使用的旧 Token 过渡。 diff --git a/docs/console.md b/docs/console.md index 95d48c2..6c29584 100644 --- a/docs/console.md +++ b/docs/console.md @@ -9,7 +9,7 @@ http://127.0.0.1:8080/console/ ## 当前能力 - 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。 -- 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,浏览器不接触业务 Token。 +- 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,普通业务页面无需业务 Token;应用接入页仅在管理员明确查看时读取当前 Token。 - 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;登录和权限信息不会占用统计区域。 - 页顶选择应用、环境与队列;任务、计划、流程、订阅、归档列表和详情显示应用归属。筛选保存在 URL,刷新、复制链接及后退可恢复。 - 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。 @@ -80,7 +80,7 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx' ## 应用接入 -侧栏「应用接入」提供应用创建、队列与凭据查看、Token 重新生成和完整 SDK 配置交付。配置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一管理登录名;本地 Compose 已配置。业务页面支持该管理员选择应用范围,订阅原 SDK 凭据绑定不变。[功能、接口、权限和恢复说明](api/console-applications.md)。 +侧栏「应用接入」提供应用创建、队列与凭据查看、Token 重新生成和完整 SDK 配置交付。配置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一管理登录名,并配置 `ORBIT_CREDENTIAL_KEY_FILE` 保管密钥;本地 Compose 自动配置。可随时查看、复制当前完整接入信息。业务页面支持该管理员选择应用范围,订阅原 SDK 凭据绑定不变。[功能、接口、权限和恢复说明](api/console-applications.md)。 ## 按应用查看数据 diff --git a/docs/reports/credential-token-vault.md b/docs/reports/credential-token-vault.md new file mode 100644 index 0000000..5bdb235 --- /dev/null +++ b/docs/reports/credential-token-vault.md @@ -0,0 +1,38 @@ +# 管理员接入信息与 Token 保管验收 + +日期:2026-09-27。范围:当前 Token 的管理员查看、完整配置复制、历史凭据导入与本地部署。 + +## 交付行为 + +- 应用凭据卡片提供「查看接入信息」「复制接入配置」,弹窗可下载 `orbit.env`。 +- 完整配置包含服务地址、应用 ID、命名空间 ID、授权队列 ID 和当前 Token;可指定主机、容器或域名地址。 +- 关闭弹窗会清除页面中的明文,管理员可重新读取。业务列表不会自动拉取秘密。 +- 新建、换证在原事务内保存 AES-256-GCM 密文;到期时间变更、结束过渡不影响回看;撤销清空密文。 +- 过渡期可以查看当前新 Token。历史导入必须匹配当前摘要,旧代仍有效也不能冒充当前代次。 +- 查看、导入均校验有效管理员会话、请求同源和版本;审计确认提交后才返回秘密。 + +## 验证 + +| 层次 | 验证内容 | 结果 | +| --- | --- | --- | +| 加密单测 | 随机 nonce、往返、密文篡改、跨凭据/命名空间、未知 key、保留旧 key、配置边界和格式脱敏 | 通过 | +| MySQL 专项 | 历史导入、重复导入、SDK/错误管理员拒绝、审计失败回滚与禁止交付秘密 | 通过 | +| MySQL 凭据生命周期 | 新建密文、读取/轮换并发、过渡期当前代次、拒绝旧代导入、expiry/finalize 保留、旧操作重报、旧 CLI 轮换清理、撤销清理 | 通过 | +| 历史升级 | 从历史 v9 重建并迁移到当前版本、原在途任务和额度继续结清;旧记录可空字段兼容 | 通过 | +| 标准回归 | `make test vet build`,包含根模块、SDK、示例与 Outbox 适配器 | 通过 | +| MySQL 全量存储 | `internal/storage/mysql` 的完整真实事务回归,含 `-race` | 通过 | +| HTTP/容器端到端 | `make console-e2e`:Cookie/CSRF/no-store、查看/导入、长期凭据、平滑换证及结束过渡、Worker/重试/计划/归档 | 通过 | +| 浏览器 | 完整配置字段与当前应用/授权队列一致、一键复制成功、Docker 地址切换、关闭清空、历史导入入口 | 通过 | +| 文档与格式 | 本地 Markdown 链接、JS 语法、Go 格式及 diff 检查 | 通过 | + +`make integration` 已完整通过:独立 SDK、任务/计划/流程/结果订阅示例、事务 Outbox 与嵌入 HTTP 服务、跨节点重连和 Worker/Event/Workflow 全进程恢复均完成;退出码为 0。 + +本次回归曾发现历史升级测试还原 v9 时未移除新增密文列,以及空身份在数据库层被拒绝而非权限层拒绝;已修正并通过专项及后续全量存储回归。 + +## 部署与兼容边界 + +迁移 `000033` 增加两个有中文注释的可空字段和成对 CHECK;实际本地数据库版本 33,外键数量 0。主密钥在独立受限文件与只读卷中保存,重复部署保留,不修改应用 Token、权限、有效期或过渡截止。 + +历史摘要不能反推出原文。具有当前配置的应用可一次导入补齐;只有过渡期旧配置的应用仍需取得已保存的新 Token,不会自动结束过渡或重置身份。源码仓库不包含真实业务 Token、管理员密码或主密钥。 + +接口、密钥配置、多节点升级与备份说明见[应用接入](../api/console-applications.md)。该结果验证本次功能和本地环境,不替代生产数据库高可用及容量验收。 diff --git a/internal/config/config.go b/internal/config/config.go index 1ddabf7..a6cad41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ import ( type Config struct { ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。 + CredentialKeyFile string // CredentialKeyFile 管理员 Token 保管密钥文件路径;所有服务节点共用同一密钥环。 ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。 ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。 ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。 @@ -40,6 +41,7 @@ func Load() (Config, error) { return FromEnv(os.Getenv) } func FromEnv(get func(string) string) (Config, error) { c := Config{ ConsoleApplicationAdmin: get("ORBIT_CONSOLE_APPLICATION_ADMIN"), + CredentialKeyFile: get("ORBIT_CREDENTIAL_KEY_FILE"), ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second, HTTPAddr: get("ORBIT_HTTP_ADDR"), MySQLDSN: get("ORBIT_MYSQL_DSN"), diff --git a/internal/consoleauth/applications.go b/internal/consoleauth/applications.go index aaa9ec8..2dd3e3c 100644 --- a/internal/consoleauth/applications.go +++ b/internal/consoleauth/applications.go @@ -32,6 +32,7 @@ type AccessQueue struct { // AccessCredential 是脱敏的凭据元数据,绝不返回秘密或秘密摘要。 type AccessCredential struct { + TokenStored bool `json:"token_stored"` // TokenStored 当前 Token 是否已加密保存;列表不会返回秘密。 PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil 上一代令牌实际截止,空表示无旧令牌。 ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。 Name string `json:"name"` // Name 凭据名称。 @@ -43,10 +44,11 @@ type AccessCredential struct { // ApplicationDetail 展示一个命名空间的队列和凭据,读取量有界。 type ApplicationDetail struct { - Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。 - Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。 - Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。 - Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。 + TokenVaultEnabled bool `json:"token_vault_enabled"` // TokenVaultEnabled 当前节点是否配置了凭据保管密钥。 + Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。 + Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。 + Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。 + Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。 } // ApplicationInput 描述创建应用或变更凭据的管理员意图。 @@ -65,7 +67,7 @@ type ApplicationInput struct { Reason string `json:"reason"` // Reason 写入审计的变更原因。 } -// ApplicationOperation 是浏览器在提交前保存的固定操作;Token 不写入数据库。 +// ApplicationOperation 是浏览器在提交前保存的固定操作;启用保管时仅保存 Token 密文。 // 网络结果未知时重放同一份内容,禁止重新生成操作身份或秘密。 type ApplicationOperation struct { Version int `json:"version,omitempty"` // Version v2 支持长期令牌与过渡;旧固定操作未设置时沿用 v1。 @@ -75,7 +77,7 @@ type ApplicationOperation struct { 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 一次性交付的秘密,只留在调用方内存或私密下载文件。 + Token string `json:"token,omitempty"` // Token 当前秘密;服务端启用保管后加密保存,不能进入日志。 ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 固定到期时间,空表示长期或不适用。 PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil ROTATE 固定过渡截止。 } diff --git a/internal/consoleauth/credential_tokens.go b/internal/consoleauth/credential_tokens.go new file mode 100644 index 0000000..2755f44 --- /dev/null +++ b/internal/consoleauth/credential_tokens.go @@ -0,0 +1,33 @@ +package consoleauth + +import ( + "context" + + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" +) + +// CredentialTokenInput 用于管理员查看或补存已有 Token,不改变业务认证身份。 +type CredentialTokenInput struct { + ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 页面读取到的凭据版本;并发换证时拒绝返回混合版本。 + Token string `json:"token,omitempty"` // Token 仅导入时填写;必须与当前摘要一致,旧代令牌不可导入。 +} + +func (CredentialTokenInput) String() string { return "CredentialTokenInput{REDACTED}" } +func (v CredentialTokenInput) GoString() string { return v.String() } + +// CredentialToken 只在已认证且已提交查看审计的管理员专用请求中返回。 +type CredentialToken struct { + NamespaceID core.ID `json:"namespace_id"` // NamespaceID 当前凭据的命名空间。 + CredentialID core.ID `json:"credential_id"` // CredentialID 当前凭据标识。 + Revision uint64 `json:"revision,string"` // Revision 读取时的当前凭据版本。 + Token string `json:"token"` // Token 当前代次原文,禁止进入日志、列表或浏览器持久存储。 +} + +func (CredentialToken) String() string { return "CredentialToken{REDACTED}" } +func (v CredentialToken) GoString() string { return v.String() } + +// CredentialTokens 的 importCurrent 为真时补存原文;为假时只读取,两个操作均留审计。 +type CredentialTokens interface { + AccessCredentialToken(context.Context, auth.Token, core.ID, core.ID, CredentialTokenInput, bool) (CredentialToken, error) +} diff --git a/internal/credentialvault/vault.go b/internal/credentialvault/vault.go new file mode 100644 index 0000000..2ee519e --- /dev/null +++ b/internal/credentialvault/vault.go @@ -0,0 +1,111 @@ +// Package credentialvault 加密保存管理员可重新查看的当前业务 Token。 +// 主密钥来自独立文件,不进入数据库、日志或 API 响应。 +package credentialvault + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "regexp" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +// Keyring 为不可变密钥环;active 用于新写入,历史密钥用于解密既有记录。 +type Keyring struct { + active string + keys map[string]cipher.AEAD +} + +func (*Keyring) String() string { return "credentialvault.Keyring{REDACTED}" } +func (k *Keyring) GoString() string { return k.String() } + +// LoadFile 读取最多 64 KiB 的 JSON 密钥文件。空路径表示未启用密文保管。 +func LoadFile(path string) (*Keyring, error) { + if path == "" { + return nil, nil + } + f, err := os.Open(path) + if err != nil { + return nil, errors.New("cannot open credential key file") + } + defer f.Close() + return Read(io.LimitReader(f, 65537)) +} + +// Read 校验 active 和 keys;每个 Base64 密钥必须恰好为 32 字节。 +func Read(r io.Reader) (*Keyring, error) { + var config struct { + Active string `json:"active"` + Keys map[string]string `json:"keys"` + } + raw, err := io.ReadAll(io.LimitReader(r, 65537)) + if err != nil || len(raw) > 65536 { + return nil, errors.New("invalid credential key file") + } + if err = json.Unmarshal(raw, &config); err != nil || len(config.Keys) == 0 || len(config.Keys) > 16 { + return nil, errors.New("invalid credential key file") + } + out := &Keyring{active: config.Active, keys: make(map[string]cipher.AEAD)} + for id, encoded := range config.Keys { + key, err := base64.StdEncoding.Strict().DecodeString(encoded) + if err != nil || len(key) != 32 || !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`).MatchString(id) { + return nil, errors.New("invalid credential encryption key") + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, errors.New("invalid credential encryption key") + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, errors.New("cannot initialize credential encryption") + } + out.keys[id] = aead + } + if out.keys[out.active] == nil { + return nil, errors.New("active credential encryption key is missing") + } + return out, nil +} + +func aad(namespace, credential core.ID) []byte { + return []byte("orbit-credential-v1:" + string(namespace) + ":" + string(credential)) +} + +// Seal 返回密钥标识和 nonce+ciphertext。未启用时两者均为空,兼容只存摘要的管理 CLI。 +func (k *Keyring) Seal(namespace, credential core.ID, token string) (*string, []byte, error) { + if k == nil { + return nil, nil, nil + } + if namespace.Validate() != nil || credential.Validate() != nil || len(token) == 0 || len(token) > 512 { + return nil, nil, errors.New("invalid credential encryption input") + } + aead := k.keys[k.active] + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, nil, errors.New("cannot generate credential encryption nonce") + } + id := k.active + return &id, aead.Seal(nonce, nonce, []byte(token), aad(namespace, credential)), nil +} + +// Open 只解密指定凭据;调用方必须先认证管理员、校验版本,再核对当前摘要并记录审计。 +func (k *Keyring) Open(namespace, credential core.ID, keyID string, ciphertext []byte) (string, error) { + if k == nil { + return "", errors.New("credential encryption is not configured") + } + aead := k.keys[keyID] + if aead == nil || len(ciphertext) < aead.NonceSize()+aead.Overhead() || len(ciphertext) > 1024 { + return "", errors.New("credential ciphertext cannot be decrypted") + } + raw, err := aead.Open(nil, ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():], aad(namespace, credential)) + if err != nil { + return "", errors.New("credential ciphertext cannot be decrypted") + } + return string(raw), nil +} diff --git a/internal/credentialvault/vault_test.go b/internal/credentialvault/vault_test.go new file mode 100644 index 0000000..d54d55d --- /dev/null +++ b/internal/credentialvault/vault_test.go @@ -0,0 +1,80 @@ +package credentialvault + +import ( + "bytes" + "encoding/base64" + "fmt" + "strings" + "testing" + + core "git.yuntuo.hk/k2safe/orbit/model" +) + +func TestCredentialEncryption(t *testing.T) { + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)) + ring, err := Read(strings.NewReader(`{"active":"one","keys":{"one":"` + key + `"}}`)) + if err != nil { + t.Fatal(err) + } + ns, _ := core.NewID() + id, _ := core.NewID() + other, _ := core.NewID() + k, a, err := ring.Seal(ns, id, "test-only-secret") + if err != nil { + t.Fatal(err) + } + _, b, _ := ring.Seal(ns, id, "test-only-secret") + if bytes.Equal(a, b) || bytes.Contains(a, []byte("test-only-secret")) { + t.Fatal("nonce reused or plaintext stored") + } + got, err := ring.Open(ns, id, *k, a) + if err != nil || got != "test-only-secret" { + t.Fatal("round trip failed") + } + if _, err = ring.Open(ns, other, *k, a); err == nil { + t.Fatal("different credential accepted") + } + if _, err = ring.Open(other, id, *k, a); err == nil { + t.Fatal("different namespace accepted") + } + if _, err = ring.Open(ns, id, "unknown", a); err == nil { + t.Fatal("unknown key accepted") + } + a[len(a)-1] ^= 1 + if _, err = ring.Open(ns, id, *k, a); err == nil { + t.Fatal("tampered ciphertext accepted") + } + for _, v := range []string{fmt.Sprint(ring), fmt.Sprintf("%#v", ring)} { + if strings.Contains(v, key) || !strings.Contains(v, "REDACTED") { + t.Fatal("keyring formatting not redacted") + } + } + old, _ := Read(strings.NewReader(`{"active":"one","keys":{"one":"` + key + `"}}`)) + k, c, _ := old.Seal(ns, id, "old-secret") + newKey := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32)) + rotated, err := Read(strings.NewReader(`{"active":"two","keys":{"one":"` + key + `","two":"` + newKey + `"}}`)) + if err != nil { + t.Fatal(err) + } + if v, err := rotated.Open(ns, id, *k, c); err != nil || v != "old-secret" { + t.Fatal("retained key cannot read old data") + } + if k, _, _ := rotated.Seal(ns, id, "new-secret"); *k != "two" { + t.Fatal("active key ignored") + } +} + +func TestCredentialKeyValidation(t *testing.T) { + for _, raw := range []string{`{}`, `{"active":"x","keys":{"x":"bad"}}`, `{"active":"missing","keys":{"x":"` + base64.StdEncoding.EncodeToString(make([]byte, 32)) + `"}}`, strings.Repeat(" ", 65537)} { + if _, err := Read(strings.NewReader(raw)); err == nil { + t.Fatal("invalid key configuration accepted") + } + } + k, err := LoadFile("") + if err != nil || k != nil { + t.Fatal("empty path must disable vault") + } + if id, c, err := k.Seal("", "", ""); err != nil || id != nil || c != nil { + t.Fatal("disabled vault must use SQL NULL pair") + } +} diff --git a/internal/storage/mysql/application_resources_upgrade_integration_test.go b/internal/storage/mysql/application_resources_upgrade_integration_test.go index 8cf428f..e79e45f 100644 --- a/internal/storage/mysql/application_resources_upgrade_integration_test.go +++ b/internal/storage/mysql/application_resources_upgrade_integration_test.go @@ -96,6 +96,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) { // 已经存在的 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(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_ciphertext, DROP COLUMN secret_key_id, DROP COLUMN secret_ciphertext`) 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 4c01993..6fea4ed 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 32. +-- Orbit fresh-install baseline, generated from migration version 33. -- This file intentionally contains no database-level relation constraints. CREATE TABLE `orbit_application_resources` ( `application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度', @@ -59,6 +59,8 @@ 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 摘要,不保存或输出原文', + `secret_key_id` varchar(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库', + `secret_ciphertext` varbinary(1024) DEFAULT NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要', `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 快照,必须由服务端解释和校验', @@ -67,8 +69,9 @@ CREATE TABLE `orbit_credentials` ( PRIMARY KEY (`namespace_id`,`id`), UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`), CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0)), + CONSTRAINT `ck_credential_ciphertext` CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)), 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='命名空间凭据,只保存秘密摘要和授权范围'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文'; CREATE TABLE `orbit_deliveries` ( `namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验', `id` binary(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源', diff --git a/internal/storage/mysql/bootstrap.go b/internal/storage/mysql/bootstrap.go index fd37121..75dba8b 100644 --- a/internal/storage/mysql/bootstrap.go +++ b/internal/storage/mysql/bootstrap.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" "git.yuntuo.hk/k2safe/orbit/pulse/service" @@ -49,7 +50,11 @@ type BootstrapResult struct { ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。 } -func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error { +func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error, vault ...*credentialvault.Keyring) error { + var ring *credentialvault.Keyring + if len(vault) > 0 { + ring = vault[0] + } 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") } @@ -96,6 +101,10 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func( return err } out.Token = secret + keyID, ciphertext, err := ring.Seal(out.NamespaceID, out.CredentialID, secret) + if err != nil { + return err + } permissions := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}} if in.WorkerAccess { permissions.Actions = append(permissions.Actions, "workers:connect", "workers:consume") @@ -180,7 +189,7 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func( if _, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", out.NamespaceID, out.QueueID, in.Queue); err != nil { return err } - if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'bootstrap',?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt); err != nil { + if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'bootstrap',?,?,?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt, keyID, ciphertext); err != nil { return err } actor, err := databaseActor(ctx, tx) diff --git a/internal/storage/mysql/console_applications.go b/internal/storage/mysql/console_applications.go index b7acbcd..130c7d0 100644 --- a/internal/storage/mysql/console_applications.go +++ b/internal/storage/mysql/console_applications.go @@ -9,6 +9,7 @@ import ( "time" "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" "git.yuntuo.hk/k2safe/orbit/internal/fault" core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" @@ -21,10 +22,15 @@ import ( type ConsoleApplicationStore struct { db *sql.DB username string + vault *credentialvault.Keyring } -func NewConsoleApplicationStore(db *sql.DB, username string) *ConsoleApplicationStore { - return &ConsoleApplicationStore{db: db, username: username} +func NewConsoleApplicationStore(db *sql.DB, username string, vault ...*credentialvault.Keyring) *ConsoleApplicationStore { + s := &ConsoleApplicationStore{db: db, username: username} + if len(vault) > 0 { + s.vault = vault[0] + } + return s } var _ consoleauth.Applications = (*ConsoleApplicationStore)(nil) @@ -32,7 +38,7 @@ var _ consoleauth.Applications = (*ConsoleApplicationStore)(nil) // admin 在实际读写事务中锁定人类账号及会话;普通 SDK Token 无对应会话,无法通过。 // 不锁业务凭据,轮换当前账号所绑定的 SDK 凭据也不会产生共享锁升级。 func (s *ConsoleApplicationStore) admin(ctx context.Context, tx *sql.Tx, token auth.Token) (string, error) { - if s.username == "" { + if s.username == "" || token.NamespaceID.Validate() != nil || token.CredentialID.Validate() != nil { return "", fault.Forbidden } var user core.ID @@ -117,7 +123,8 @@ 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,previous_valid_until,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace) + out.TokenVaultEnabled = s.vault != nil + rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions,secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace) if err != nil { return err } @@ -126,7 +133,7 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth var c consoleauth.AccessCredential var expires, revoked, previous sql.NullTime var raw []byte - if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw); err != nil { + if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw, &c.TokenStored); err != nil { return err } if err := json.Unmarshal(raw, &c.Permissions); err != nil { @@ -299,7 +306,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au return "", fault.Invalid("application identity mismatch") } return actor, nil - }) + }, s.vault) 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) @@ -352,6 +359,10 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au 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") } + keyID, ciphertext, err := s.vault.Seal(op.NamespaceID, op.CredentialID, op.Token) + if err != nil { + return err + } statements := []struct { query string args []any @@ -360,7 +371,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}}, + {`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'console-issued',?,?,?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt, keyID, ciphertext}}, } for _, st := range statements { if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil { diff --git a/internal/storage/mysql/console_credential_tokens.go b/internal/storage/mysql/console_credential_tokens.go new file mode 100644 index 0000000..edc8a2f --- /dev/null +++ b/internal/storage/mysql/console_credential_tokens.go @@ -0,0 +1,85 @@ +package mysql + +import ( + "context" + "database/sql" + + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "git.yuntuo.hk/k2safe/orbit/internal/fault" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" +) + +var _ consoleauth.CredentialTokens = (*ConsoleApplicationStore)(nil) + +// AccessCredentialToken 认证、版本核对、密文读取/补存和审计在同一事务完成。 +// 导入相同当前秘密可重复调用,不变更认证 revision、到期时间或旧代过渡截止。 +func (s *ConsoleApplicationStore) AccessCredentialToken(ctx context.Context, session auth.Token, namespace, credential core.ID, in consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) { + var out consoleauth.CredentialToken + if namespace.Validate() != nil || credential.Validate() != nil || in.ExpectedRevision == 0 || (!importCurrent && in.Token != "") { + return out, fault.Invalid("invalid credential token request") + } + err := WithTx(ctx, s.db, func(tx *sql.Tx) error { + actor, err := s.admin(ctx, tx, session) + if err != nil { + return err + } + if s.vault == nil { + return fault.New("UNAVAILABLE", "Token 保管密钥尚未配置,请联系部署管理员") + } + var revision uint64 + var revoked sql.NullTime + var digest core.Digest + var keyID sql.NullString + var ciphertext []byte + err = tx.QueryRowContext(ctx, `SELECT revision,revoked_at,secret_digest,secret_key_id,secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=? FOR UPDATE`, namespace, credential).Scan(&revision, &revoked, &digest, &keyID, &ciphertext) + if err == sql.ErrNoRows { + return fault.NotFound + } + if err != nil { + return err + } + if revision != in.ExpectedRevision || revoked.Valid { + return fault.New("STATE_CONFLICT", "凭据已变更或撤销,请刷新后重试") + } + raw := in.Token + action := "console.credential.token.view" + if !importCurrent { + if !keyID.Valid { + return fault.New("STATE_CONFLICT", "此历史凭据尚未保管 Token,请先导入当前 Token") + } + raw, err = s.vault.Open(namespace, credential, keyID.String, ciphertext) + if err != nil { + return fault.New("UNAVAILABLE", "Token 解密失败,请检查保管密钥文件") + } + } + parsed, err := auth.Parse(raw) + if err != nil || parsed.NamespaceID != namespace || parsed.CredentialID != credential || subtleCompareDigest(parsed.Digest, digest) != 1 { + return fault.Invalid("Token 与当前凭据不匹配;不能导入过渡期旧 Token") + } + if importCurrent { + action = "console.credential.token.import" + id, encrypted, err := s.vault.Seal(namespace, credential, raw) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET secret_key_id=?,secret_ciphertext=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, id, encrypted, namespace, credential); err != nil { + return err + } + } + operation, err := core.NewID() + if err != nil { + return err + } + if err := appendAuditActor(ctx, tx, namespace, actor, operation, action, credential, "administrator access to current integration configuration", nil, map[string]any{"revision": revision}); err != nil { + return err + } + out = consoleauth.CredentialToken{NamespaceID: namespace, CredentialID: credential, Revision: revision, Token: raw} + return nil + }) + // 包括提交结果未知:只有确认审计提交成功,才允许向调用方交付秘密。 + if err != nil { + return consoleauth.CredentialToken{}, err + } + return out, nil +} diff --git a/internal/storage/mysql/credential_vault_integration_test.go b/internal/storage/mysql/credential_vault_integration_test.go new file mode 100644 index 0000000..01c6072 --- /dev/null +++ b/internal/storage/mysql/credential_vault_integration_test.go @@ -0,0 +1,207 @@ +package mysql + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" + "git.yuntuo.hk/k2safe/orbit/internal/fault" + "git.yuntuo.hk/k2safe/orbit/platform/auth" +) + +func TestIntegrationCredentialVault(t *testing.T) { + ctx, db := overlapDB(t) + ring, err := credentialvault.Read(strings.NewReader(`{"active":"test","keys":{"test":"` + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)) + `"}}`)) + if err != nil { + t.Fatal(err) + } + name := "vault-" + string(testID(t)) + var initial BootstrapResult + if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: name, ConsolePassword: "vault-test-password-12345"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil { + t.Fatal(err) + } + login, err := NewConsoleAuthStore(db).Login(ctx, name, "vault-test-password-12345", "test", "127.0.0.1") + if err != nil { + t.Fatal(err) + } + admin, _ := auth.Parse(login.Token) + sdk, _ := auth.Parse(initial.Token) + store := NewConsoleApplicationStore(db, name, ring) + in := consoleauth.CredentialTokenInput{ExpectedRevision: 1} + access := func(input consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) { + return store.AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, input, importCurrent) + } + for _, unauthorized := range []auth.Token{sdk, {}} { + if _, err := store.AccessCredentialToken(ctx, unauthorized, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("SDK or missing session accepted") + } + } + if _, err := NewConsoleApplicationStore(db, "other", ring).AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("wrong administrator accepted") + } + if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" { + t.Fatal("legacy token unexpectedly revealed") + } + in.Token = initial.Token + // 审计失败时导入事务回滚,查看也不能把秘密交付给调用方。 + trigger := "vault_audit_" + string(testID(t)) + if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action LIKE 'console.credential.token.%' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil { + t.Fatal(err) + } + defer db.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger) + if got, err := access(in, true); err == nil || got.Token != "" { + t.Fatal("audit failure returned secret") + } + var imported bool + if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&imported); err != nil || imported { + t.Fatal("failed import did not roll back") + } + if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil { + t.Fatal(err) + } + if _, err := access(in, true); err != nil { + t.Fatal(err) + } + if _, err := access(in, true); err != nil { + t.Fatal("repeat import failed", err) + } + in.Token = "" + if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action='console.credential.token.view' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil { + t.Fatal(err) + } + if got, err := access(in, false); err == nil || got.Token != "" { + t.Fatal("read without audit returned secret") + } + if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil { + t.Fatal(err) + } + if got, err := access(in, false); err != nil || got.Token != initial.Token || got.Revision != 1 { + t.Fatal("import changed identity or cannot reveal") + } + var cipher []byte + if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&cipher); err != nil || bytes.Contains(cipher, []byte(initial.Token)) { + t.Fatal("plaintext persisted") + } + detail, err := store.GetApplication(ctx, admin, initial.NamespaceID) + raw, _ := json.Marshal(detail) + if err != nil || !detail.Credentials[0].TokenStored || bytes.Contains(raw, []byte(initial.Token)) { + t.Fatal("metadata leaked or failed") + } + // 已启用密文保管的 CREATE、ROTATE、过渡期读取以及旧操作重放。 + create, err := store.PrepareApplication(ctx, admin, consoleauth.ApplicationInput{Kind: "CREATE", Application: "vault-new-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Reason: "vault integration"}) + if err != nil { + t.Fatal(err) + } + if _, err := store.ApplyApplication(ctx, admin, create); err != nil { + t.Fatal(err) + } + if got, err := store.AccessCredentialToken(ctx, admin, create.NamespaceID, create.CredentialID, consoleauth.CredentialTokenInput{ExpectedRevision: 1}, false); err != nil || got.Token != create.Token { + t.Fatal("new app not encrypted") + } + rotate, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: "ROTATE", GraceFor: time.Hour, Reason: "vault rotate"}) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + wg.Add(2) + var readErr, rotateErr error + var read consoleauth.CredentialToken + go func() { defer wg.Done(); read, readErr = access(in, false) }() + go func() { defer wg.Done(); _, rotateErr = ApplyCredentialOperation(ctx, db, rotate, ring) }() + wg.Wait() + if rotateErr != nil || (readErr != nil && fault.Code(readErr) != "STATE_CONFLICT") || (readErr == nil && read.Token != initial.Token) { + t.Fatal("concurrent read/rotation inconsistent", rotateErr, readErr) + } + in.ExpectedRevision = 2 + if got, err := access(in, false); err != nil || got.Token != rotate.Token { + t.Fatal("current token unavailable during grace") + } + in.Token = initial.Token + if _, err := access(in, true); fault.Code(err) != "INVALID_ARGUMENT" { + t.Fatal("old generation imported") + } + in.Token = "" + status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID) + if err != nil || status.PreviousValidUntil == nil { + t.Fatal("grace lost") + } + for _, action := range []string{"SET_EXPIRY", "FINALIZE"} { + op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: action, Reason: "preserve vault"}) + if err != nil { + t.Fatal(err) + } + if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil { + t.Fatal(err) + } + in.ExpectedRevision++ + if got, err := access(in, false); err != nil || got.Token != rotate.Token { + t.Fatal("non-secret change invalidated encryption") + } + } + if _, err := ApplyCredentialOperation(ctx, db, rotate, ring); err != nil { + t.Fatal(err) + } + if got, err := access(in, false); err != nil || got.Token != rotate.Token { + t.Fatal("replay changed ciphertext") + } + // 撤销会清除密文;历史 CLI 不配置密钥时轮换也不能遗留过期密文。 + op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "ROTATE", Reason: "legacy CLI"}) + if err != nil { + t.Fatal(err) + } + if _, err = ApplyCredentialOperation(ctx, db, op); err != nil { + t.Fatal(err) + } + in.ExpectedRevision++ + if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" { + t.Fatal("stale ciphertext remained after CLI rotation") + } + in.Token = op.Token + if _, err := access(in, true); err != nil { + t.Fatal(err) + } + in.Token = "" + op, err = PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "REVOKE", Reason: "revoke vault"}) + if err != nil { + t.Fatal(err) + } + if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil { + t.Fatal(err) + } + in.ExpectedRevision++ + if _, err := access(in, false); err == nil { + t.Fatal("revoked credential revealed") + } + var stored bool + if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&stored); err != nil || stored { + t.Fatal("revoked ciphertext remains") + } + // 审计只包含版本与操作身份,不包含 Token 或密文。 + rows, err := db.QueryContext(ctx, "SELECT CONCAT_WS(' ',action,`before`,`after`) FROM orbit_audit_events WHERE namespace_id=?", initial.NamespaceID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + views := 0 + for rows.Next() { + var text string + if err := rows.Scan(&text); err != nil { + t.Fatal(err) + } + if strings.Contains(text, initial.Token) || strings.Contains(text, rotate.Token) { + t.Fatal("secret leaked to audit") + } + if strings.Contains(text, "console.credential.token.view") { + views++ + } + } + if rows.Err() != nil || views < 3 { + t.Fatal("view audit missing", rows.Err()) + } +} diff --git a/internal/storage/mysql/credentials.go b/internal/storage/mysql/credentials.go index c7d1404..e0770d8 100644 --- a/internal/storage/mysql/credentials.go +++ b/internal/storage/mysql/credentials.go @@ -10,6 +10,7 @@ import ( "time" "unicode/utf8" + "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" "git.yuntuo.hk/k2safe/orbit/internal/fault" core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" @@ -67,7 +68,7 @@ type CredentialChangeInput struct { } // CredentialOperation 是可安全重放的私有操作文件内容。 -// 令牌只存在于 ROTATE 文件中;服务端只保存其摘要和操作回执。 +// ROTATE 文件包含新令牌;服务端保存摘要、可选密文和不含秘密的操作回执。 type CredentialOperation struct { // Version 操作文件格式版本,升级时不得静默改变语义。 Version int `json:"version"` @@ -225,12 +226,16 @@ func ValidateCredentialOperation(op CredentialOperation) error { // ApplyCredentialOperation applies one frozen operation or returns its original // receipt. No callback or file I/O occurs inside the transaction. A commit error // is intentionally returned unchanged; replaying the same operation reconciles it. -func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation) (CredentialChangeReceipt, error) { - return applyCredentialOperation(ctx, db, op, nil) +func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) { + return applyCredentialOperation(ctx, db, op, nil, vault...) } // applyCredentialOperation 允许控制台在同一事务先核验管理员,并使用真实人类审计主体。 -func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error)) (CredentialChangeReceipt, error) { +func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error), vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) { + var ring *credentialvault.Keyring + if len(vault) > 0 { + ring = vault[0] + } var out CredentialChangeReceipt if err := ValidateCredentialOperation(op); err != nil { return out, err @@ -320,9 +325,14 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper out.PreviousValidUntil = timePtr(until) } } + keyID, ciphertext, err := ring.Seal(op.NamespaceID, op.CredentialID, op.Token) + if err != nil { + return err + } + // 未配置保管密钥的旧 CLI 轮换也必须清除旧密文,不能回看过时代次。 if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials - 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 { + SET secret_digest=?,expires_at=?,previous_secret_digest=?,previous_valid_until=?,secret_key_id=?,secret_ciphertext=?,revision=revision+1,updated_at=? + WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, keyID, ciphertext, now, op.NamespaceID, op.CredentialID, revision); err != nil { return err } out.ExpiresAt = op.ExpiresAt @@ -341,7 +351,7 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper } 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 { + if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,secret_key_id=NULL,secret_ciphertext=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) diff --git a/internal/storage/mysql/migrate.go b/internal/storage/mysql/migrate.go index 946a9e5..bf89bb0 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 = 32 +const freshInstallBaselineVersion uint64 = 33 type Migration struct { Version uint64 diff --git a/internal/storage/mysql/migrations/000033_credential_token_vault.up.sql b/internal/storage/mysql/migrations/000033_credential_token_vault.up.sql new file mode 100644 index 0000000..99625e4 --- /dev/null +++ b/internal/storage/mysql/migrations/000033_credential_token_vault.up.sql @@ -0,0 +1,6 @@ +-- 管理员可回看当前 Token;旧记录保留为空,导入只补密文,不轮换秘密。 +ALTER TABLE orbit_credentials + ADD COLUMN secret_key_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库', + ADD COLUMN secret_ciphertext VARBINARY(1024) NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要', + ADD CONSTRAINT ck_credential_ciphertext CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)), + COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文'; diff --git a/internal/transport/httpserver/console/applications.js b/internal/transport/httpserver/console/applications.js index ac7feea..14f83c9 100644 --- a/internal/transport/httpserver/console/applications.js +++ b/internal/transport/httpserver/console/applications.js @@ -3,6 +3,7 @@ const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '' }; const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效'; + let tokenRequest = 0, tokenTarget = null; function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; } async function api(path = '', body) { const response = await fetch(`/console/applications${path}`, { method: body ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json', 'X-Orbit-Console': '1' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); @@ -11,11 +12,11 @@ if (!response.ok) { const error = new Error(value.error?.message || `请求失败 (${response.status})`); error.status = response.status; throw error; } return value.data; } - function config(token = '<填入已保存的 Token>', queue = $('access-queue').value, namespace = ui.selected) { + function config(token = '••••••••', queue = $('access-queue').value, namespace = ui.selected) { const url = $('access-url').value.trim() || location.origin; let parsed; try { parsed = new URL(url); } catch { throw new Error('请输入完整的 HTTP / HTTPS 服务地址'); } - if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址'); - return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_TOKEN=${token}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\n`; + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\;&|<>(){}]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址'); + return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_APPLICATION_ID=${ui.detail?.entry.application_id || ''}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\nORBIT_TOKEN=${token}\n`; } async function load() { if (ui.loading || ui.busy) return; @@ -24,7 +25,7 @@ const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : ''); ui.next = page.next || ''; $('access-next').disabled = !ui.next; $('access-list').innerHTML = page.items.length ? page.items.map(entry => ``).join('') : '

还没有接入应用,点击右上角新建。

'; if (!ui.selected && page.items.length) await select(page.items[0].namespace_id); - else if (ui.selected && !$('access-dialog').open) await select(ui.selected); + else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected); } catch (error) { $('access-error').textContent = message(error); } finally { ui.loading = false; } } @@ -46,12 +47,13 @@ const blocked = !!c.revoked_at || transitioning; const helpID = `access-rotation-help-${c.id}`; const reason = c.revoked_at ? '凭据已撤销,不能再次换证。' : '旧 Token 仍在过渡期,暂不能再次换证,以免中断尚未切换的服务。'; - const rotationHelp = transitioning ? `` : c.revoked_at ? `

${escape(reason)}

` : c.previous_valid_until ? `

旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。

` : ''; + const rotationHelp = transitioning ? `` : c.revoked_at ? `

${escape(reason)}

` : c.previous_valid_until ? `

旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。

` : ''; 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原文仅在生成时显示,无法回看
+
接入配置 · 当前 Token${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}
+
${rotationHelp}
${c.previous_valid_until && !c.revoked_at ? `` : ''}
${groups.map(a => `${escape(a)}`).join('')}
查看具体权限

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

@@ -62,6 +64,43 @@ } catch (error) { $('access-error').textContent = message(error); } } function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } } + function closeToken() { + tokenRequest++; tokenTarget = null; + $('access-token-import-submit').disabled = false; + $('access-token-config').value = ''; $('access-token-input').value = ''; + $('access-token-dialog').close(); + } + async function showToken(credential, copyImmediately = false) { + closeToken(); + tokenTarget = { namespace: ui.selected, credential, queue: $('access-queue').value }; + // 只导出该凭据已授权的队列,不能拼出无法使用的配置。 + if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) tokenTarget.queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || ''; + $('access-token-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · 凭据版本 ${credential.revision}`; + $('access-token-error').textContent = ''; + $('access-token-import').hidden = credential.token_stored; + $('access-token-output').hidden = true; + $('access-token-dialog').showModal(); + if (!ui.detail.token_vault_enabled) { $('access-token-error').textContent = '服务端尚未配置 Token 保管密钥,请联系部署管理员。'; return; } + if (credential.token_stored) await readToken(false, copyImmediately); + else $('access-token-input').focus(); + } + async function readToken(importCurrent, copyImmediately = false) { + const target = tokenTarget; if (!target) return; + const request = ++tokenRequest; + $('access-token-error').textContent = '正在读取接入配置…'; $('access-token-import-submit').disabled = true; + try { + if (!target.queue) throw new Error('没有可用的授权队列,请先配置队列权限。'); + const value = await api(`/${encodeURIComponent(target.namespace)}/credentials/${encodeURIComponent(target.credential.id)}/token/${importCurrent ? 'import' : 'reveal'}`, { expected_revision: String(target.credential.revision), ...(importCurrent ? { token: $('access-token-input').value.trim() } : {}) }); + if (request !== tokenRequest || ui.selected !== target.namespace || !$('access-token-dialog').open) return; + if (value.namespace_id !== target.namespace || value.credential_id !== target.credential.id || String(value.revision) !== String(target.credential.revision)) throw new Error('凭据已更新,请刷新后重试。'); + $('access-token-config').value = config(value.token, target.queue, target.namespace); + $('access-token-import').hidden = true; $('access-token-input').value = ''; + $('access-token-output').hidden = false; $('access-token-error').textContent = ''; + if (importCurrent) target.credential.token_stored = true; + if (copyImmediately) await copy($('access-token-config').value, $('access-token-copy')); + } catch (error) { if (request === tokenRequest) $('access-token-error').textContent = message(error); } + finally { if (request === tokenRequest) $('access-token-import-submit').disabled = false; } + } function renderCredentialHelp() { const credential = ui.detail?.credentials.find(c => c.id === ui.helpCredential); if (!credential) { $('access-help-dialog').close(); return; } @@ -96,7 +135,7 @@ } function close() { if (ui.busy) return; - if ((ui.pending || !ui.saved) && !window.confirm(ui.pending ? '操作结果尚未确认,请先保存待核对操作。仍要关闭吗?' : '请确认已保存完整 SDK 配置。关闭后无法再次查看 Token;如果还在换证过渡期,也不能立即再次换证。仍要关闭吗?')) return; + if (ui.pending && !window.confirm('操作结果尚未确认,请先保存待核对操作。仍要关闭吗?')) return; $('access-dialog').close(); ui.pending = null; ui.result = null; ui.saved = true; $('access-result-config').value = ''; load(); } function download(name, content, type = 'text/plain') { @@ -104,7 +143,7 @@ } async function copy(value, button) { try { await navigator.clipboard.writeText(value); const old = button.textContent; button.textContent = '已复制 ✓'; setTimeout(() => { button.textContent = old; }, 2000); return true; } - catch { ($('access-dialog').open ? $('access-clipboard-status') : $('access-error')).textContent = '浏览器未允许复制,请选中配置后手动复制,或下载 .env 文件。'; return false; } + catch { ($('access-token-dialog').open ? $('access-token-error') : $('access-dialog').open ? $('access-clipboard-status') : $('access-error')).textContent = '浏览器未允许复制,请选中配置后手动复制,或下载 .env 文件。'; return false; } } async function submit(event) { event.preventDefault(); if (ui.busy) return; ui.busy = true; $('access-form-error').textContent = ''; @@ -121,7 +160,7 @@ form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; }); const op = ui.pending; await api('/apply', op); - ui.saved = !op.token; + ui.saved = true; let queue = op.queue_id || $('access-queue').value; // 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。 await select(op.namespace_id); @@ -146,21 +185,29 @@ } } $('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-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) { closeToken(); select(el.dataset.accessId); } }); $('access-credentials').addEventListener('click', e => { - const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize]'); if (!el || el.disabled) return; - const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize; + const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return; + const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessToken || el.dataset.accessCopyConfig; const credential = ui.detail?.credentials.find(c => c.id === id); + if (credential && (el.dataset.accessToken || el.dataset.accessCopyConfig)) { showToken(credential, !!el.dataset.accessCopyConfig); return; } if (credential && el.dataset.accessHelp) { showCredentialHelp(credential); return; } if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE'); }); $('access-help-close').addEventListener('click', () => $('access-help-dialog').close()); $('access-help-done').addEventListener('click', () => $('access-help-dialog').close()); + $('access-token-close').addEventListener('click', () => { closeToken(); load(); }); + $('access-token-dialog').addEventListener('cancel', e => { e.preventDefault(); closeToken(); load(); }); + $('access-token-import').addEventListener('submit', e => { e.preventDefault(); readToken(true); }); + $('access-token-copy').addEventListener('click', () => copy($('access-token-config').value, $('access-token-copy'))); + $('access-token-download').addEventListener('click', () => download('orbit.env', $('access-token-config').value)); + window.addEventListener('hashchange', closeToken); + window.addEventListener('pagehide', closeToken); $('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(); }); $('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', renderConfig); - $('access-copy-template').addEventListener('click', async () => { try { await copy(config(), $('access-copy-template')); } catch (error) { $('access-error').textContent = error.message; } }); + $('access-copy-template').addEventListener('click', () => { const credential = ui.detail?.credentials.find(c => !c.revoked_at); if (credential) showToken(credential); }); $('access-form').addEventListener('submit', submit); $('access-copy').addEventListener('click', async () => { if (await copy($('access-result-config').value, $('access-copy'))) ui.saved = true; }); $('access-download').addEventListener('click', () => { download('orbit.env', $('access-result-config').value); ui.saved = true; }); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 5f564d8..0c11a8a 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -224,8 +224,8 @@

本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。

-

SDK 配置模板

-

此模板不含完整 Token。请将 ORBIT_TOKEN 替换为生成凭据时保存的值;上面的凭据 ID 不能作为 Token 使用。

+

接入配置预览

+

预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。


               
Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。
@@ -397,7 +397,7 @@
ORBIT / CONNECT

新建应用

为应用创建独立的环境、队列与调用凭据。

-

完整 Token 只在生成成功后显示一次,请当场复制或下载保存。关闭后无法回看;换证过渡期内也不能立即再次换证。

+

Token 会加密保存。管理员可随时查看接入信息、复制完整配置或下载 orbit.env。

@@ -411,21 +411,35 @@

- +
ORBIT / CREDENTIAL

凭据与换证说明

当前换证状态

-

当前 Token 在哪里查看?

-

请打开生成凭据时下载的 orbit.env、已保存的 SDK 配置,或业务服务的配置,找到 ORBIT_TOKEN。凭据 ID 只是标识,不能代替 Token。

-

控制台只能查看凭据状态、有效期、版本和权限。完整 Token 仅在创建或换证成功时显示,关闭后无法从平台还原。

- -
如果新 Token 没有保存怎么办?

先检查下载文件、已保存的配置或已更新的服务配置。若都没有保存,平台无法找回原文,需要重新生成并更新服务配置。

仍在过渡期时,不要为了解锁按钮直接结束过渡,这会立即停用旧 Token。确需重置,应先安排维护窗口,暂停接收新任务并收尾在途执行,再由管理员结束过渡、重新换证,保存新配置并更新服务后恢复运行。

+

查看和复制当前接入配置

+

在凭据卡片点击「查看接入信息」或「复制接入配置」。配置包含服务地址、应用 ID、命名空间、队列和当前 Token;换证过渡期也可以获取。

+
+ +
ORBIT / INTEGRATION
+

接入信息

+ + +

+
+ diff --git a/internal/transport/httpserver/console_applications.go b/internal/transport/httpserver/console_applications.go index 3d66ff4..f0e55a9 100644 --- a/internal/transport/httpserver/console_applications.go +++ b/internal/transport/httpserver/console_applications.go @@ -102,4 +102,25 @@ func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.St } writeConsoleJSON(w, 200, map[string]any{"data": out}) }) + if secrets, ok := store.(consoleauth.CredentialTokens); ok { + for _, action := range []string{"reveal", "import"} { + mux.HandleFunc("POST /console/applications/{namespace}/credentials/{credential}/token/"+action, func(w http.ResponseWriter, r *http.Request) { + token, ok := check(w, r) + if !ok { + return + } + var in consoleauth.CredentialTokenInput + if err := decodeBody(r, &in); err != nil { + writeError(w, "", err) + return + } + out, err := secrets.AccessCredentialToken(r.Context(), token, core.ID(r.PathValue("namespace")), core.ID(r.PathValue("credential")), in, action == "import") + if err != nil { + writeError(w, "", err) + return + } + writeConsoleJSON(w, 200, map[string]any{"data": out}) + }) + } + } } diff --git a/platform/auth/token.go b/platform/auth/token.go index 7ca5365..7a6e29d 100644 --- a/platform/auth/token.go +++ b/platform/auth/token.go @@ -1,4 +1,4 @@ -// Package auth 定义凭据格式与权限快照;凭据原文仅在签发时交付。 +// Package auth 定义凭据格式与权限快照;凭据原文通过签发或管理员审计读取接口交付。 package auth import ( diff --git a/platform/model/credential.go b/platform/model/credential.go index e60f7ff..d36576a 100644 --- a/platform/model/credential.go +++ b/platform/model/credential.go @@ -6,15 +6,19 @@ import ( core "git.yuntuo.hk/k2safe/orbit/model" ) -// Credential 表示命名空间凭据,只保存秘密摘要和授权范围。 +// Credential 表示命名空间凭据,保存认证摘要、授权范围及可选的当前 Token 密文。 // 对应表 orbit_credentials;这是服务端持久化模型,不直接作为 API 响应。 type Credential struct { // Record 包含命名空间、记录标识、并发版本和 UTC 时间。 core.Record // Name 可读名称,唯一性范围由对应数据表约束。 Name string `db:"name"` - // SecretDigest 凭据秘密的 SHA-256 摘要,不保存或输出原文。 + // SecretDigest 凭据秘密的 SHA-256 摘要,用于业务认证,不输出原文。 SecretDigest core.Digest `db:"secret_digest"` + // SecretKeyID 当前 Token 密文使用的主密钥标识;空表示尚未保管,与密文同时为空。 + SecretKeyID *string `db:"secret_key_id"` + // SecretCiphertext AES-256-GCM nonce 与认证密文,最多 1024 字节;仅管理员接口可解密。 + SecretCiphertext []byte `db:"secret_ciphertext"` // PreviousSecretDigest 上一代秘密摘要;仅过渡期使用,空表示未保留旧令牌。 PreviousSecretDigest *core.Digest `db:"previous_secret_digest"` // PreviousValidUntil 旧令牌的固定 UTC 截止;不晚于原到期时间,与旧摘要同时为空或非空。 diff --git a/scripts/orbit-local.py b/scripts/orbit-local.py index 2dfa11c..7e794aa 100755 --- a/scripts/orbit-local.py +++ b/scripts/orbit-local.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """部署持久化的本地联调环境;私密配置只保存在 .local/compose-test。""" import datetime +import base64 import json import os from pathlib import Path @@ -86,12 +87,22 @@ def setup(): 'ORBIT_BUILD_TIME': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}) private_write(PRIVATE / 'admin.password', secrets.token_urlsafe(24) + '\n') values = settings() + key_file = PRIVATE / 'credential-keys.json' + if not key_file.exists(): + # 已启用后丢失主密钥,必须恢复备份,不能生成替代密钥使已有密文不可读。 + key_volumes = subprocess.check_output(['docker', 'volume', 'ls', '--filter', 'name=orbit-test_orbit-keys', '-q'], text=True).strip() + if values.get('ORBIT_CREDENTIAL_VAULT_INITIALIZED') or key_volumes: + raise RuntimeError('Token 保管密钥缺失,请恢复 credential-keys.json;不会替换既有密钥。') + private_write(key_file, json.dumps({'active': 'local-v1', 'keys': {'local-v1': base64.b64encode(secrets.token_bytes(32)).decode()}}) + '\n') + values['ORBIT_CREDENTIAL_VAULT_INITIALIZED'] = '1' values['ORBIT_BUILD_REVISION'] = subprocess.check_output( ['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() save_settings(values) compose('config', '--quiet') compose('build', 'orbit') - compose('up', '-d', 'mysql', 'verify', 'archive-init') + # 完成构建后再优雅停止旧写节点,避免迁移期间混用凭据存储版本。 + compose('stop', 'orbit') + compose('up', '-d', 'mysql', 'verify', 'archive-init', 'credential-key-init') # 显式再核验一次,失败不初始化身份、不启动流量。 compose('run', '--rm', '-T', '--no-deps', 'verify') if not CREDENTIALS.exists(): diff --git a/scripts/test-console-applications.py b/scripts/test-console-applications.py index 43a5da7..bd932de 100644 --- a/scripts/test-console-applications.py +++ b/scripts/test-console-applications.py @@ -32,6 +32,8 @@ def call(path, body=None, token=None, headers=None, expected=200, session=True): response = error with response: status = response.status + if path.startswith("/console/applications"): + assert response.headers.get("Cache-Control") == "no-store" value = json.load(response) assert status == expected, f'{path}: HTTP {status}, expected {expected}; code={value.get("error", {}).get("code")}' return value.get('data', value) @@ -60,6 +62,16 @@ page = call('/console/applications') assert any(x['namespace_id'] == op['namespace_id'] for x in page['items']) detail = call('/console/applications/' + op['namespace_id']) assert op['token'] not in json.dumps(detail) +assert detail['token_vault_enabled'] and detail['credentials'][0]['token_stored'] +token_path = '/console/applications/' + op['namespace_id'] + '/credentials/' + op['credential_id'] + '/token/' +assert call(token_path + 'reveal', {'expected_revision':'1'})['token'] == op['token'] +call(token_path + 'reveal', {'expected_revision':'1'}, token=op['token'], session=False, expected=401) +call(token_path + 'reveal', {'expected_revision':'1'}, headers={'Origin':'https://other.invalid'}, expected=403) +call(token_path + 'reveal', {'expected_revision':'1'}, headers={'X-Orbit-Console':''}, expected=403) +call(token_path + 'reveal', {'expected_revision':'1'}, headers={'Sec-Fetch-Site':'cross-site'}, expected=403) +call(token_path + 'reveal', {'expected_revision':'2'}, expected=409) +assert call(token_path + 'import', {'expected_revision':'1', 'token':op['token']})['token'] == op['token'] + queue = detail['queues'][0]['id'] assert queue == op['queue_id'] call('/console/applications', token=op['token'], expected=401, session=False) @@ -85,12 +97,17 @@ 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'] +assert call(token_path + 'reveal', {'expected_revision':'3'})['token'] == rotate['token'] +call(token_path + 'import', {'expected_revision':'3', 'token':op['token']}, expected=400) +call(token_path + 'reveal', {'expected_revision':'2'}, expected=409) + 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'] +assert call(token_path + 'reveal', {'expected_revision':'4'})['token'] == rotate['token'] call('/v1/queues/' + queue, token=op['token'], expected=401, session=False) call('/v1/queues/' + queue, token=rotate['token'], session=False) # 重放旧轮换回执不能重新启用已经结束过渡的秘密。 @@ -135,7 +152,8 @@ assert call('/v1/subscriptions/' + subscription_id, token=rotate['token'], sessi assert call('/console/scope')['entry']['namespace_id'] == original_scope['entry']['namespace_id'] print('PASS: scoped task/schedule/workflow/subscription/retention/archive reads, subscription management/replay, SDK ownership, CSRF and cross-application isolation') call('/console/auth/logout', {}) +call(token_path + 'reveal', {'expected_revision':'4'}, expected=401) call('/console/applications', expected=401) call('/v1/tasks/' + task_id, headers=scope_headers, expected=401) -print('PASS: application create/replay, CSRF/session isolation, permanent expiry, overlapping rotation/finalization, original task/subscription preserved, logout') +print('PASS: encrypted Token reveal/import, session/CSRF/no-store/current-generation checks, application create/replay, CSRF/session isolation, permanent expiry, overlapping rotation/finalization, original task/subscription preserved, logout') print('Test application: ' + name) diff --git a/scripts/test-console-e2e.sh b/scripts/test-console-e2e.sh index 03b958c..f3164a1 100755 --- a/scripts/test-console-e2e.sh +++ b/scripts/test-console-e2e.sh @@ -46,6 +46,13 @@ E2E_APPLICATION="console-e2e-$E2E_SUFFIX" E2E_ADMIN="orbit-admin-$E2E_SUFFIX" printf '%s\n' "$E2E_CONSOLE_PASSWORD" > "$E2E_PASSWORD_FILE" +python3 - "$E2E_DIR/credential-keys.json" <<'PY_KEY' +import base64, json, pathlib, secrets, sys +path = pathlib.Path(sys.argv[1]) +path.write_text(json.dumps({'active':'test-v1', 'keys':{'test-v1':base64.b64encode(secrets.token_bytes(32)).decode()}})) +path.chmod(0o600) +PY_KEY + printf '%s\n' "[1/8] build image" "$E2E_DOCKER" build --tag "$E2E_IMAGE" . >/dev/null "$E2E_DOCKER" run -d --name "$E2E_MYSQL" --network bridge \ @@ -76,6 +83,7 @@ printf '%s\n' "[3/8] bootstrap resources and console administrator" "$E2E_DOCKER" run --rm -i --network bridge --link "$E2E_MYSQL:mysql" \ --env ORBIT_MYSQL_DSN="$E2E_DSN" \ --volume "$E2E_DIR:/run/orbit" \ + --env ORBIT_CREDENTIAL_KEY_FILE=/run/orbit/credential-keys.json \ --entrypoint /usr/local/bin/orbit-admin "$E2E_IMAGE" bootstrap \ --application "$E2E_APPLICATION" \ --namespace default \ @@ -87,6 +95,7 @@ printf '%s\n' "[3/8] bootstrap resources and console administrator" --console-display-name 'Orbit E2E 管理员' \ --console-password-stdin \ --output /run/orbit/credentials.json < "$E2E_PASSWORD_FILE" >/dev/null +"$E2E_DOCKER" run --rm --user 0:0 --volume "$E2E_DIR:/run/orbit" --entrypoint /bin/sh mysql:8.4 -c 'chown 65532:65532 /run/orbit/credential-keys.json' E2E_QUEUE=$(python3 - "$E2E_CREDENTIALS_FILE" <<'PY' import json import sys @@ -107,6 +116,8 @@ printf '%s\n' "[4/8] start Orbit container" --env ORBIT_HTTP_ADDR=0.0.0.0:8080 \ --env ORBIT_CONSOLE_QUEUE_ID="$E2E_QUEUE" \ --env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \ + --env ORBIT_CREDENTIAL_KEY_FILE=/run/credential-keys.json \ + --volume "$E2E_DIR/credential-keys.json:/run/credential-keys.json:ro" \ --env ORBIT_ARCHIVE_ROOT=/var/lib/orbit/archive \ --env ORBIT_ARCHIVE_STORAGE_ID="$E2E_STORAGE_ID" \ --env ORBIT_ARCHIVE_TICK_INTERVAL=1s \