feat: let administrators view and copy encrypted integration credentials
This commit is contained in:
@@ -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,不自动读取秘密。
|
||||
|
||||
@@ -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);真实接入按实际角色缩小权限。
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 需要经管理员导入补齐。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 与认证密文,两字段同时为空或非空。原文不进入持久化模型、日志和审计。既有摘要保留用于认证,旧记录通过管理员导入当前原文补存,不改变身份、版本、有效期或过渡截止。撤销清空密文,轮换原子替换当前摘要与密文;无外键。
|
||||
|
||||
@@ -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)。历史凭据仅有摘要时仍需导入当前原文,不会自动重置。
|
||||
|
||||
@@ -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 自动创建和复用该文件。
|
||||
|
||||
@@ -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-保管)。
|
||||
|
||||
@@ -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 操作显式调整。
|
||||
|
||||
@@ -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:<user_id>`;正文只有权限、资源 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 过渡。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 按应用查看数据
|
||||
|
||||
|
||||
38
docs/reports/credential-token-vault.md
Normal file
38
docs/reports/credential-token-vault.md
Normal file
@@ -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)。该结果验证本次功能和本地环境,不替代生产数据库高可用及容量验收。
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 固定过渡截止。
|
||||
}
|
||||
|
||||
33
internal/consoleauth/credential_tokens.go
Normal file
33
internal/consoleauth/credential_tokens.go
Normal file
@@ -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)
|
||||
}
|
||||
111
internal/credentialvault/vault.go
Normal file
111
internal/credentialvault/vault.go
Normal file
@@ -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
|
||||
}
|
||||
80
internal/credentialvault/vault_test.go
Normal file
80
internal/credentialvault/vault_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 '记录标识;与命名空间一起定位运行资源',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
85
internal/storage/mysql/console_credential_tokens.go
Normal file
85
internal/storage/mysql/console_credential_tokens.go
Normal file
@@ -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
|
||||
}
|
||||
207
internal/storage/mysql/credential_vault_integration_test.go
Normal file
207
internal/storage/mysql/credential_vault_integration_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 密文';
|
||||
@@ -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 => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
|
||||
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 ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>请用已保存的新 Token 更新服务、Worker 和结果消费者配置。确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
|
||||
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>请在上方复制当前接入配置,更新服务、Worker 和结果消费者。确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
|
||||
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 `<section class="access-credential">
|
||||
<div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button ${blocked ? 'access-rotation-blocked' : ''}" ${blocked ? `data-access-help="${escape(c.id)}" title="${escape(reason)}" aria-describedby="${escape(helpID)}"` : `data-access-rotate="${escape(c.id)}"`} aria-haspopup="dialog">${c.revoked_at ? '已撤销 · 查看原因 ⓘ' : transitioning ? '暂不可换证 · 查看原因 ⓘ' : '平滑换证 ↗'}</button></div>
|
||||
<p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 凭据版本 ${escape(c.revision)}</p>
|
||||
<div class="access-token-summary"><div><strong>当前 Token</strong><span>原文仅在生成时显示,无法回看</span></div><button type="button" class="text-button" data-access-help="${escape(c.id)}" aria-haspopup="dialog">Token 在哪里查看?</button></div>
|
||||
<div class="access-token-summary"><div><strong>接入配置 · 当前 Token</strong><span>${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}</span></div></div>
|
||||
<div class="access-form-actions"><button type="button" class="primary-button compact" data-access-token="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>${c.token_stored ? '查看接入信息' : '导入当前 Token'}</button><button type="button" class="secondary-button compact" data-access-copy-config="${escape(c.id)}" ${c.revoked_at || !c.token_stored ? 'disabled' : ''}>复制接入配置 ↗</button></div>
|
||||
${rotationHelp}
|
||||
<div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
|
||||
<div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details>
|
||||
@@ -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; });
|
||||
|
||||
@@ -224,8 +224,8 @@
|
||||
<p class="panel-description">本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。</p>
|
||||
<div class="access-credentials" id="access-credentials"></div>
|
||||
<p class="panel-description" id="access-truncated" hidden>队列或凭据超过 100 项,此页只展示前 100 项。</p>
|
||||
<div class="access-config-heading"><h3>SDK 配置模板</h3><button class="text-button" id="access-copy-template">复制配置模板 ↗</button></div>
|
||||
<p class="panel-description">此模板不含完整 Token。请将 ORBIT_TOKEN 替换为生成凭据时保存的值;上面的凭据 ID 不能作为 Token 使用。</p>
|
||||
<div class="access-config-heading"><h3>接入配置预览</h3><button class="text-button" id="access-copy-template">查看完整接入配置 ↗</button></div>
|
||||
<p class="panel-description">预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。</p>
|
||||
<pre class="access-code" id="access-config"></pre>
|
||||
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
|
||||
</div>
|
||||
@@ -397,7 +397,7 @@
|
||||
<dialog id="access-dialog" class="access-dialog">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CONNECT</span><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
|
||||
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>
|
||||
<p class="access-token-once-note" id="access-token-once-note">完整 Token 只在生成成功后显示一次,请当场复制或下载保存。关闭后无法回看;换证过渡期内也不能立即再次换证。</p>
|
||||
<p class="access-token-once-note" id="access-token-once-note">Token 会加密保存。管理员可随时查看接入信息、复制完整配置或下载 orbit.env。</p>
|
||||
<form id="access-form">
|
||||
<div id="access-create-fields">
|
||||
<label>应用名称<input id="access-name" maxlength="128" placeholder="例如 order-service" autocomplete="off" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||
@@ -411,21 +411,35 @@
|
||||
<div class="access-form-actions"><button type="submit" class="primary-button compact" id="access-submit">创建并生成凭据 →</button><button type="button" class="text-button" id="access-backup" hidden>保存待核对操作</button></div>
|
||||
</form>
|
||||
<p id="access-clipboard-status" class="panel-description" role="status"></p>
|
||||
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="access-token-once-note" id="access-result-note">请现在复制或下载下面的完整 SDK 配置。Token 只在本次操作中显示,关闭或刷新后无法回看;旧 Token 仍在过渡期时,不能立即再次换证。</p><label id="access-result-config-field">完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions" id="access-result-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="access-token-once-note" id="access-result-note">复制下面的完整接入配置即可用于业务服务。关闭后仍可从凭据卡片的「查看接入信息」重新获取当前 Token。</p><label id="access-result-config-field">完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions" id="access-result-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||
</dialog>
|
||||
<dialog id="access-help-dialog" class="access-dialog access-credential-help" aria-labelledby="access-help-title">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CREDENTIAL</span><button type="button" class="icon-button" id="access-help-close" aria-label="关闭凭据说明">×</button></div>
|
||||
<h2 id="access-help-title">凭据与换证说明</h2>
|
||||
<p class="panel-description" id="access-help-scope"></p>
|
||||
<div class="access-rotation-notice"><strong>当前换证状态</strong><p id="access-help-state"></p></div>
|
||||
<h3>当前 Token 在哪里查看?</h3>
|
||||
<p>请打开生成凭据时下载的 <code>orbit.env</code>、已保存的 SDK 配置,或业务服务的配置,找到 <code>ORBIT_TOKEN</code>。凭据 ID 只是标识,不能代替 Token。</p>
|
||||
<p>控制台只能查看凭据状态、有效期、版本和权限。完整 Token 仅在创建或换证成功时显示,关闭后无法从平台还原。</p>
|
||||
<div id="access-help-transition" hidden><p>过渡期内,业务配置可能仍是旧 Token;新 Token 请以本次换证时保存的配置为准。</p><p>确认所有服务已切换新 Token、在途任务已收尾后,才可以提前结束过渡。该操作会立即停用旧 Token。</p></div>
|
||||
<details class="access-token-recovery"><summary>如果新 Token 没有保存怎么办?</summary><p>先检查下载文件、已保存的配置或已更新的服务配置。若都没有保存,平台无法找回原文,需要重新生成并更新服务配置。</p><p>仍在过渡期时,不要为了解锁按钮直接结束过渡,这会立即停用旧 Token。确需重置,应先安排维护窗口,暂停接收新任务并收尾在途执行,再由管理员结束过渡、重新换证,保存新配置并更新服务后恢复运行。</p></details>
|
||||
<h3>查看和复制当前接入配置</h3>
|
||||
<p>在凭据卡片点击「查看接入信息」或「复制接入配置」。配置包含服务地址、应用 ID、命名空间、队列和当前 Token;换证过渡期也可以获取。</p>
|
||||
<div id="access-help-transition" hidden><p>当前新 Token 可以正常查看和复制。确认所有服务已切换、在途任务已收尾后,再结束旧 Token 过渡。</p></div>
|
||||
<div class="access-form-actions"><button type="button" class="primary-button compact" id="access-help-done">我知道了</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="access-token-dialog" class="access-dialog" aria-labelledby="access-token-title">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / INTEGRATION</span><button type="button" class="icon-button" id="access-token-close" aria-label="关闭接入信息">×</button></div>
|
||||
<h2 id="access-token-title">接入信息</h2><p class="panel-description" id="access-token-scope"></p>
|
||||
<form id="access-token-import" hidden>
|
||||
<p class="panel-description">此凭据创建于 Token 保管功能启用之前。导入已保存的当前 Token 后,管理员即可随时查看和复制完整配置。导入不会换证,也不会改变有效期或过渡时间。</p>
|
||||
<label>当前 Token<input id="access-token-input" type="password" autocomplete="off" spellcheck="false" maxlength="512" required placeholder="粘贴当前 ORBIT_TOKEN"></label>
|
||||
<button type="submit" class="primary-button compact" id="access-token-import-submit">导入并查看接入配置 →</button>
|
||||
</form>
|
||||
<section id="access-token-output" hidden>
|
||||
<label>完整接入配置<textarea id="access-token-config" readonly rows="8" spellcheck="false" autocomplete="off"></textarea></label>
|
||||
<p class="panel-description">按所选服务地址和授权队列生成,包含当前 Token。可以直接粘贴到业务服务的环境配置中。</p>
|
||||
<div class="access-form-actions"><button type="button" class="primary-button compact" id="access-token-copy">复制接入配置 ↗</button><button type="button" class="secondary-button compact" id="access-token-download">下载 orbit.env ↓</button></div>
|
||||
</section>
|
||||
<p class="action-error" id="access-token-error" role="status"></p>
|
||||
</dialog>
|
||||
|
||||
<script src="/console/applications.js"></script>
|
||||
<script src="/console/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package auth 定义凭据格式与权限快照;凭据原文仅在签发时交付。
|
||||
// Package auth 定义凭据格式与权限快照;凭据原文通过签发或管理员审计读取接口交付。
|
||||
package auth
|
||||
|
||||
import (
|
||||
|
||||
@@ -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 截止;不晚于原到期时间,与旧摘要同时为空或非空。
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user