Compare commits
11 Commits
adapters/o
...
5555a7b342
| Author | SHA1 | Date | |
|---|---|---|---|
| 5555a7b342 | |||
| 0782b0488b | |||
| e3d8b16844 | |||
| c2ca5e987f | |||
| 1f2e7230dc | |||
| ed17713ea1 | |||
| 4eeafdef41 | |||
| 385a618154 | |||
| 87aa710013 | |||
| 98ff028228 | |||
| 2ec13bf662 |
@@ -7,3 +7,8 @@ sdk
|
|||||||
examples
|
examples
|
||||||
deploy
|
deploy
|
||||||
*.log
|
*.log
|
||||||
|
.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/*.credentials.json
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
- P6进程观测使用internal/observability固定指标目录,拒绝未知名称、类型和动态标签,不能引入任务/租户/业务键/凭据维度。ORBIT_METRICS_ADDR默认空(关闭),显式启用独立采集监听;业务端口不得暴露全局指标。渲染和网络I/O不持有全局记录锁,每个直方图局部快照保持桶/count/sum一致,最多2个并发抓取。启用指标后的5秒后台采样使用000026–000028固定索引读取任务、交付、流程状态、Weave Inbox状态、固定事件类别、终态耗时和最近窗口内固定事件阶段耗时,每个状态或事件最多10000条、事件窗口5分钟、900ms数据库截止;采样失败保留最后成功状态并独立记录错误/超时,不能影响业务事务或把有限样本当全局精确数。
|
- P6进程观测使用internal/observability固定指标目录,拒绝未知名称、类型和动态标签,不能引入任务/租户/业务键/凭据维度。ORBIT_METRICS_ADDR默认空(关闭),显式启用独立采集监听;业务端口不得暴露全局指标。渲染和网络I/O不持有全局记录锁,每个直方图局部快照保持桶/count/sum一致,最多2个并发抓取。启用指标后的5秒后台采样使用000026–000028固定索引读取任务、交付、流程状态、Weave Inbox状态、固定事件类别、终态耗时和最近窗口内固定事件阶段耗时,每个状态或事件最多10000条、事件窗口5分钟、900ms数据库截止;采样失败保留最后成功状态并独立记录错误/超时,不能影响业务事务或把有限样本当全局精确数。
|
||||||
- HTTP成功升级在Hijack完成时记录握手,WS处理器存续计入独立连接指标;保持1xx临时响应、Flush、Hijack与原panic行为。DB池取本地Stats,抓取不查询业务库;后台批次success不是业务任务成功量。观测丢样或失败不得改变权威事务及恢复决定。
|
- HTTP成功升级在Hijack完成时记录握手,WS处理器存续计入独立连接指标;保持1xx临时响应、Flush、Hijack与原panic行为。DB池取本地Stats,抓取不查询业务库;后台批次success不是业务任务成功量。观测丢样或失败不得改变权威事务及恢复决定。
|
||||||
- 进程指标、固定状态采样、格式验证、告警规则和标准回归证据见docs/reports/phase-6-observability.md及docs/reports/phase-6-business-observability.md。仍须补受理/授权/完成与流程推进延迟、持久吞吐/积压的全局聚合、重试/核查/恢复量、发送队列、实际采集告警及负载开销,不能把进程观测通过写成P6已完成。普通清理仍须保护有效幂等、未确认事件、未知结果和未终结流程。
|
- 进程指标、固定状态采样、格式验证、告警规则和标准回归证据见docs/reports/phase-6-observability.md及docs/reports/phase-6-business-observability.md。仍须补受理/授权/完成与流程推进延迟、持久吞吐/积压的全局聚合、重试/核查/恢复量、发送队列、实际采集告警及负载开销,不能把进程观测通过写成P6已完成。普通清理仍须保护有效幂等、未确认事件、未知结果和未终结流程。
|
||||||
- 凭据轮换保留credential_id及订阅/Worker授权绑定,在同一事务替换摘要、固定到期时间、幂等回执和管理审计;新令牌必须重新建立会话,认证路径每次重新核对凭据状态。撤销必须让认证失效并保留原任务、订阅、交付、会话和审计证据,允许在应用或命名空间停用后执行;重复应用同一固定操作幂等返回。管理CLI先把固定操作文件写成0600,再提交;提交不明时保留文件并用原文件重放,不能重新生成秘密。日志、审计摘要、指标和业务表不得保存秘密原文或摘要。不要把轮换同一凭据ID解释成历史令牌仍有效,也不得级联删除订阅、任务或事件。
|
- 凭据轮换保留credential_id及订阅/Worker授权绑定,在同一事务替换摘要、固定到期时间、幂等回执和管理审计;新令牌必须重新建立会话,认证路径每次重新核对凭据状态。撤销必须让认证失效并保留原任务、订阅、交付、会话和审计证据,允许在应用或命名空间停用后执行;重复应用同一固定操作幂等返回。管理CLI先把固定操作文件写成0600,再提交;提交不明时保留文件并用原文件重放,不能重新生成秘密。日志、审计摘要、指标和业务表不得保存秘密原文或摘要。000032 后仅在 previous_valid_until 之前接受上一代摘要(最长 30 天,不超过原到期时间);默认控制台/CLI过渡7天,旧固定v1操作仍立即轮换。默认新凭据长期有效;SET_EXPIRY 保持秘密不变,FINALIZE 停用旧代,REVOKE 同时停用两代;有效过渡期禁止再次轮换挤掉旧代。不要把稳定凭据ID解释成所有历史令牌仍有效,也不得级联删除订阅、任务或事件。
|
||||||
- `sdk/go/cmd/orbit-load` 只用于隔离环境受理测量:每项使用稳定独立幂等键,固定请求形状和并发上限,按请求超时,不自动重试,不启动Worker或执行外部副作用;输出延迟百分位与有限错误分类,失败必须以非零状态返回。其结果不能替代真实Worker、流程推进、数据库拓扑或生产容量验收。
|
- `sdk/go/cmd/orbit-load` 只用于隔离环境受理测量:每项使用稳定独立幂等键,固定请求形状和并发上限,按请求超时,不自动重试,不启动Worker或执行外部副作用;输出延迟百分位与有限错误分类,失败必须以非零状态返回。其结果不能替代真实Worker、流程推进、数据库拓扑或生产容量验收。
|
||||||
- `sdk/go/cmd/orbit-exec-load` 独立测量真实Worker执行:每次运行固定专属处理器/业务键/序号/模拟时长,最多一次尝试且无外部副作用;任务成功须核对持久化结果、唯一尝试、时间顺序及单次本地调用。队列额度只读,提交不重试;整体截止和有界排空后输出实际分母,未完成/未知结果必须非零退出。数据库阶段耗时不得混用客户端轮询观察时间,不将隔离样本解释为生产容量。
|
- `sdk/go/cmd/orbit-exec-load` 独立测量真实Worker执行:每次运行固定专属处理器/业务键/序号/模拟时长,最多一次尝试且无外部副作用;任务成功须核对持久化结果、唯一尝试、时间顺序及单次本地调用。队列额度只读,提交不重试;整体截止和有界排空后输出实际分母,未完成/未知结果必须非零退出。数据库阶段耗时不得混用客户端轮询观察时间,不将隔离样本解释为生产容量。
|
||||||
- `sdk/go/cmd/orbit-workflow-load` 独立测量真实Weave流程:每次运行创建固定版本的双分支并行定义,启动实例不自动重试,必须核对流程/步骤/任务/尝试/来源/输出及两倍分支调用;队列只读,处理器无外部副作用,截止后保留实际分母并以非零状态退出。隔离样本不代表生产容量、高可用或跨故障域验收。
|
- `sdk/go/cmd/orbit-workflow-load` 独立测量真实Weave流程:每次运行创建固定版本的双分支并行定义,启动实例不自动重试,必须核对流程/步骤/任务/尝试/来源/输出及两倍分支调用;队列只读,处理器无外部副作用,截止后保留实际分母并以非零状态退出。隔离样本不代表生产容量、高可用或跨故障域验收。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- **Pulse · 脉动**:异步任务、延迟执行、定时计划、执行授权、重试与结果订阅。
|
- **Pulse · 脉动**:异步任务、延迟执行、定时计划、执行授权、重试与结果订阅。
|
||||||
- **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。
|
- **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。
|
||||||
- **Console**:账号密码登录的管理页面,查看任务、计划、流程、结果交付及归档作业。
|
- **Console**:账号密码登录的管理页面,按应用与环境查看任务、计划、流程、结果交付及归档作业。
|
||||||
|
|
||||||
主分支:`main`。当前版本:**`v0.1.1`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。
|
主分支:`main`。当前版本:**`v0.1.1`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
| 业务结果 Inbox | 事件去重与业务更新同事务,提交成功后确认交付 | [result-inbox](examples/result-inbox/README.md) |
|
| 业务结果 Inbox | 事件去重与业务更新同事务,提交成功后确认交付 | [result-inbox](examples/result-inbox/README.md) |
|
||||||
| HTTP 服务完整生命周期 | 业务接口、Outbox、Worker、结果消费、就绪检查和有界关闭放进同一业务服务 | [http-service](examples/http-service/README.md) |
|
| HTTP 服务完整生命周期 | 业务接口、Outbox、Worker、结果消费、就绪检查和有界关闭放进同一业务服务 | [http-service](examples/http-service/README.md) |
|
||||||
| 平台身份与凭据 | 应用/命名空间/队列授权范围、凭据轮换/撤销;SDK 不直连平台数据库 | [鉴权](docs/api/authentication.md) · [凭据管理](docs/api/authentication.md) |
|
| 平台身份与凭据 | 应用/命名空间/队列授权范围、凭据轮换/撤销;SDK 不直连平台数据库 | [鉴权](docs/api/authentication.md) · [凭据管理](docs/api/authentication.md) |
|
||||||
| 账号密码控制台 | 初始化管理员,登录查看统计、任务/计划/流程/交付详情和管理操作,未登录跳转登录页 | [Console 手册](docs/console.md) |
|
| 账号密码控制台 | 初始化管理员,按应用/环境/队列查看任务、计划、流程、交付与归档,列表和详情显示应用归属 | [Console 手册](docs/console.md) · [应用范围](docs/api/console-scope.md) |
|
||||||
| 保留策略与周期归档 | 配置各类数据保留天数,持久化归档计划/作业,分页、续跑、重试与文件核验 | [保留策略](docs/api/retention.md) · [周期归档](docs/api/archive-scheduler.md) |
|
| 保留策略与周期归档 | 配置各类数据保留天数,持久化归档计划/作业,分页、续跑、重试与文件核验 | [保留策略](docs/api/retention.md) · [周期归档](docs/api/archive-scheduler.md) |
|
||||||
| 运维与观测 | 健康/readiness、独立指标端口、迁移核验、负载工具、备份恢复与故障演练 | [观测](docs/api/observability.md) · [运维](docs/20-operations-runbook.md) |
|
| 运维与观测 | 健康/readiness、独立指标端口、迁移核验、负载工具、备份恢复与故障演练 | [观测](docs/api/observability.md) · [运维](docs/20-operations-runbook.md) |
|
||||||
|
|
||||||
@@ -103,6 +103,8 @@ python3 -c 'import getpass; print(getpass.getpass("设置管理员密码(至
|
|||||||
--console-password-stdin --output .local/development.credentials.json
|
--console-password-stdin --output .local/development.credentials.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
管理员可在「应用接入」页面新建应用和初始队列、查看和调整有效期、平滑换证(默认长期 Token,换证时新旧共存 7 天),并复制或下载 SDK 配置。设置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一接入管理员;本地 Compose 已启用。[应用接入使用与接口文档](docs/api/console-applications.md)。
|
||||||
|
|
||||||
初始化只运行一次,后续重启复用数据库、账号和 SDK 凭据。凭据 JSON 是服务端程序访问 API 的秘密文件,**控制台不需要选择或上传它**。已有资源增加管理员的方法见 [Console 手册](docs/console.md);真实接入按实际角色缩小权限。
|
初始化只运行一次,后续重启复用数据库、账号和 SDK 凭据。凭据 JSON 是服务端程序访问 API 的秘密文件,**控制台不需要选择或上传它**。已有资源增加管理员的方法见 [Console 手册](docs/console.md);真实接入按实际角色缩小权限。
|
||||||
|
|
||||||
### 3. 启动平台,进入控制台
|
### 3. 启动平台,进入控制台
|
||||||
@@ -203,6 +205,8 @@ fmt.Println(current.ID, current.State)
|
|||||||
|
|
||||||
## Docker 与域名部署
|
## Docker 与域名部署
|
||||||
|
|
||||||
|
本机给其他服务联调,可直接运行 `./scripts/orbit-local.py up`,自动部署到 `18080`,生成管理员和 SDK 配置;完整地址、Docker 网络与启停方法见[本地 Compose 测试环境](deploy/local/README.md)。
|
||||||
|
|
||||||
仓库提供 [Dockerfile](Dockerfile)、[Compose 启动链](deploy/docker-compose.yml)和 [Nginx 配置](deploy/nginx/orbit.conf.example)。按[完整 Compose 手册](docs/22-compose-deployment.md)注入数据库密码、构建镜像、启动 MySQL/迁移/核验、初始化管理员,最后启动 Orbit。手册也包括停止、更新与两节点覆盖配置。
|
仓库提供 [Dockerfile](Dockerfile)、[Compose 启动链](deploy/docker-compose.yml)和 [Nginx 配置](deploy/nginx/orbit.conf.example)。按[完整 Compose 手册](docs/22-compose-deployment.md)注入数据库密码、构建镜像、启动 MySQL/迁移/核验、初始化管理员,最后启动 Orbit。手册也包括停止、更新与两节点覆盖配置。
|
||||||
|
|
||||||
- Console 同源访问:域名反向代理到 Orbit 后,页面自动使用这个域名,无需在界面填写服务地址。
|
- Console 同源访问:域名反向代理到 Orbit 后,页面自动使用这个域名,无需在界面填写服务地址。
|
||||||
@@ -219,6 +223,7 @@ fmt.Println(current.ID, current.State)
|
|||||||
| `ORBIT_MYSQL_DSN` | 平台数据库连接,迁移账号与运行账号按部署手册分开 |
|
| `ORBIT_MYSQL_DSN` | 平台数据库连接,迁移账号与运行账号按部署手册分开 |
|
||||||
| `ORBIT_HTTP_ADDR` | 服务进程监听地址,例 `127.0.0.1:8080` |
|
| `ORBIT_HTTP_ADDR` | 服务进程监听地址,例 `127.0.0.1:8080` |
|
||||||
| `ORBIT_CONSOLE_QUEUE_ID` | 控制台默认队列;不授予账号额外权限 |
|
| `ORBIT_CONSOLE_QUEUE_ID` | 控制台默认队列;不授予账号额外权限 |
|
||||||
|
| `ORBIT_CONSOLE_APPLICATION_ADMIN` | 指定唯一可接入和管理各应用的控制台登录名;空值不开放跨应用权限,多节点保持一致 |
|
||||||
| `ORBIT_DB_MAX_OPEN` / `ORBIT_DB_MAX_IDLE` | 数据库连接池上限 |
|
| `ORBIT_DB_MAX_OPEN` / `ORBIT_DB_MAX_IDLE` | 数据库连接池上限 |
|
||||||
| `ORBIT_SHUTDOWN_TIMEOUT` | SIGTERM 关闭窗口,默认 10s |
|
| `ORBIT_SHUTDOWN_TIMEOUT` | SIGTERM 关闭窗口,默认 10s |
|
||||||
| `ORBIT_WORKFLOW_TICK_INTERVAL` / `ORBIT_SCHEDULE_TICK_INTERVAL` | 流程/计划后台扫描间隔,默认 1s |
|
| `ORBIT_WORKFLOW_TICK_INTERVAL` / `ORBIT_SCHEDULE_TICK_INTERVAL` | 流程/计划后台扫描间隔,默认 1s |
|
||||||
|
|||||||
@@ -19,19 +19,23 @@ import (
|
|||||||
|
|
||||||
func runCredential(args []string) error {
|
func runCredential(args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("usage: orbit-admin credential status|rotate|revoke|apply")
|
return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "status":
|
case "status":
|
||||||
return runCredentialStatus(args[1:])
|
return runCredentialStatus(args[1:])
|
||||||
case "rotate":
|
case "rotate":
|
||||||
return runCredentialChange(args[1:], "ROTATE")
|
return runCredentialChange(args[1:], "ROTATE")
|
||||||
|
case "expiry":
|
||||||
|
return runCredentialChange(args[1:], "SET_EXPIRY")
|
||||||
|
case "finalize":
|
||||||
|
return runCredentialChange(args[1:], "FINALIZE")
|
||||||
case "revoke":
|
case "revoke":
|
||||||
return runCredentialChange(args[1:], "REVOKE")
|
return runCredentialChange(args[1:], "REVOKE")
|
||||||
case "apply":
|
case "apply":
|
||||||
return runCredentialApply(args[1:])
|
return runCredentialApply(args[1:])
|
||||||
default:
|
default:
|
||||||
return errors.New("usage: orbit-admin credential status|rotate|revoke|apply")
|
return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +68,19 @@ func runCredentialStatus(args []string) error {
|
|||||||
|
|
||||||
func runCredentialChange(args []string, action string) error {
|
func runCredentialChange(args []string, action string) error {
|
||||||
flags := flag.NewFlagSet("credential "+strings.ToLower(action), flag.ContinueOnError)
|
flags := flag.NewFlagSet("credential "+strings.ToLower(action), flag.ContinueOnError)
|
||||||
namespace, credential, output, reason, validFor := "", "", "", "", "720h"
|
namespace, credential, output, reason, validFor := "", "", "", "", "0"
|
||||||
|
grace := time.Duration(0)
|
||||||
|
if action == "ROTATE" {
|
||||||
|
grace = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
var expected uint64
|
var expected uint64
|
||||||
flags.StringVar(&namespace, "namespace-id", "", "credential namespace id")
|
flags.StringVar(&namespace, "namespace-id", "", "credential namespace id")
|
||||||
flags.StringVar(&credential, "credential-id", "", "credential id")
|
flags.StringVar(&credential, "credential-id", "", "credential id")
|
||||||
flags.Uint64Var(&expected, "expected-revision", 0, "current credential revision required for first apply")
|
flags.Uint64Var(&expected, "expected-revision", 0, "current credential revision required for first apply")
|
||||||
flags.StringVar(&reason, "reason", "", "required audit reason")
|
flags.StringVar(&reason, "reason", "", "required audit reason")
|
||||||
flags.StringVar(&output, "output", "", "new private operation file; an existing file is never overwritten")
|
flags.StringVar(&output, "output", "", "new private operation file; an existing file is never overwritten")
|
||||||
flags.StringVar(&validFor, "valid-for", "720h", "new credential validity, from 1h to 8760h")
|
flags.StringVar(&validFor, "valid-for", "0", "credential validity: 0 permanent, or 1h to 8760h")
|
||||||
|
flags.DurationVar(&grace, "grace", grace, "rotation overlap: 0 immediate, up to 720h; default 168h")
|
||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -83,7 +92,7 @@ func runCredentialChange(args []string, action string) error {
|
|||||||
return errors.New("credential change requires valid ids and a nonzero --expected-revision")
|
return errors.New("credential change requires valid ids and a nonzero --expected-revision")
|
||||||
}
|
}
|
||||||
duration := time.Duration(0)
|
duration := time.Duration(0)
|
||||||
if action == "ROTATE" {
|
if action == "ROTATE" || action == "SET_EXPIRY" {
|
||||||
duration, err = time.ParseDuration(validFor)
|
duration, err = time.ParseDuration(validFor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("invalid --valid-for duration")
|
return errors.New("invalid --valid-for duration")
|
||||||
@@ -95,7 +104,7 @@ func runCredentialChange(args []string, action string) error {
|
|||||||
}
|
}
|
||||||
defer closeDB()
|
defer closeDB()
|
||||||
op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{
|
op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{
|
||||||
NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, Reason: reason,
|
NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("credential operation could not be prepared")
|
return errors.New("credential operation could not be prepared")
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ func runBootstrap(args []string) error {
|
|||||||
var in mysql.BootstrapInput
|
var in mysql.BootstrapInput
|
||||||
var output string
|
var output string
|
||||||
flags.StringVar(&in.Application, "application", "", "new application name")
|
flags.StringVar(&in.Application, "application", "", "new application name")
|
||||||
|
flags.DurationVar(&in.ValidFor, "valid-for", 0, "initial credential validity: 0 permanent, or 1h to 8760h")
|
||||||
flags.Func("application-id", "existing application id; mutually exclusive with --application", func(v string) error {
|
flags.Func("application-id", "existing application id; mutually exclusive with --application", func(v string) error {
|
||||||
in.ExistingApplicationID = core.ID(v)
|
in.ExistingApplicationID = core.ID(v)
|
||||||
return in.ExistingApplicationID.Validate()
|
return in.ExistingApplicationID.Validate()
|
||||||
|
|||||||
@@ -125,7 +125,10 @@ 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.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.RegisterConsoleRetention(server, consoleAuth, retentionStore, retentionStore)
|
||||||
httpserver.RegisterConsoleArchiveJobs(server, consoleAuth, retentionStore, cfg.ArchiveStorageID)
|
httpserver.RegisterConsoleArchiveJobs(server, consoleAuth, retentionStore, cfg.ArchiveStorageID)
|
||||||
httpserver.WrapConsoleAuth(server, consoleAuth)
|
consoleApplications := mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin)
|
||||||
|
httpserver.RegisterConsoleApplications(server, consoleAuth, consoleApplications)
|
||||||
|
httpserver.RegisterConsoleScopes(server, consoleApplications)
|
||||||
|
httpserver.WrapConsoleAuth(server, consoleAuth, consoleApplications)
|
||||||
httpserver.WrapMetrics(server, metrics)
|
httpserver.WrapMetrics(server, metrics)
|
||||||
|
|
||||||
if cfg.ArchiveRoot != "" {
|
if cfg.ArchiveRoot != "" {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ x-orbit-environment: &orbit-environment
|
|||||||
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
||||||
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
||||||
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
||||||
|
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
|
||||||
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ services:
|
|||||||
ORBIT_RETENTION_DELIVERY_DAYS: ${ORBIT_RETENTION_DELIVERY_DAYS:-90}
|
ORBIT_RETENTION_DELIVERY_DAYS: ${ORBIT_RETENTION_DELIVERY_DAYS:-90}
|
||||||
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
||||||
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
||||||
|
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
|
||||||
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
||||||
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
140
deploy/local/README.md
Normal file
140
deploy/local/README.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# 本地 Docker Compose 联调环境
|
||||||
|
|
||||||
|
一套长期保留数据的 Orbit + MySQL 测试环境。项目名固定为 `orbit-test`,本机端口 `18080`;复用[基础 Compose](../docker-compose.yml),本目录补充镜像构建、跨服务网络和持久归档卷。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
此部署工具随 `main` 分支交付;使用当前 main 代码,SDK 仍可固定 v0.1.1。需要 Docker Desktop / Docker Compose、Python 3;首次构建镜像需要访问构建依赖。从 Orbit 仓库根目录执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/orbit-local.py up
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本创建随机数据库密码和管理员密码,构建镜像,依次完成 MySQL 健康检查、前向迁移、运行账号权限收紧、结构核验、管理员/测试身份初始化及服务启动。成功后还检查 readiness 和实际 SDK 队列权限。
|
||||||
|
|
||||||
|
启动状态:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/orbit-local.py status
|
||||||
|
```
|
||||||
|
|
||||||
|
`mysql` 和 `orbit` 长期运行;`migrate`、`grant`、`verify`、`archive-init` 显示 `Exited (0)` 是一次性初始化成功。
|
||||||
|
|
||||||
|
## 控制台与接入地址
|
||||||
|
|
||||||
|
| 用途 | 地址/配置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 本机控制台 | <http://127.0.0.1:18080/console/> |
|
||||||
|
| 本机 API / SDK baseURL | `http://127.0.0.1:18080` |
|
||||||
|
| Docker 网络 | `orbit-test-services` |
|
||||||
|
| 同网络容器 API / SDK baseURL | `http://orbit-test:8080` |
|
||||||
|
| Worker WebSocket | 上述主机的 `/v1/worker/connect`;SDK 自动处理升级与鉴权 |
|
||||||
|
| 结果通知 | 使用 SDK 持久订阅 + EventConsumer,参见[消费手册](../../docs/sdk/event-consumer.md) |
|
||||||
|
| 就绪检查 | `/health/ready` |
|
||||||
|
| 管理员 | `admin` |
|
||||||
|
| 管理员密码 | 本机 `.local/compose-test/admin.password` |
|
||||||
|
|
||||||
|
浏览器直接账号密码登录,自动使用同源地址,不需要上传凭据 JSON。MySQL 仅在环境内部网络开放,业务服务通过 Orbit API 接入。
|
||||||
|
|
||||||
|
## 为其他应用分配独立配置
|
||||||
|
|
||||||
|
登录后打开侧栏「应用接入」,新建应用并选择能力,成功后复制/下载该应用的 `.env`。详情可以随时查看队列、权限和有效期。页面平滑换证默认保留旧 Token 7 天,过渡期内新旧 Token 均按各自有效期核验,原任务和订阅保持不变;全部服务切换并收尾后才可提前结束过渡。完整 Token 仅在生成时显示,未保存时可点「Token 在哪里查看?」;过渡期不能再次换证,点击「查看原因」可打开说明。
|
||||||
|
|
||||||
|
`host.env` / `docker.env` 是初始 local-testing 应用的联调身份;独立应用应使用页面分配的自己的配置。应用接入管理只授权 `admin`,无需创建多角色。[详细操作和恢复手册](../../docs/api/console-applications.md)。
|
||||||
|
|
||||||
|
## 本机业务服务
|
||||||
|
|
||||||
|
初始化生成 `.local/compose-test/host.env`,包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ORBIT_URL=http://127.0.0.1:18080
|
||||||
|
ORBIT_TOKEN=自动生成的真实令牌
|
||||||
|
ORBIT_NAMESPACE_ID=自动生成的命名空间ID
|
||||||
|
ORBIT_QUEUE_ID=自动生成的队列ID
|
||||||
|
```
|
||||||
|
|
||||||
|
在仓库根目录加载,再启动自己的服务:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -a
|
||||||
|
. ./.local/compose-test/host.env
|
||||||
|
set +a
|
||||||
|
# 在当前环境中启动你的业务进程
|
||||||
|
```
|
||||||
|
|
||||||
|
这些变量由业务配置代码读取,再传给 SDK;SDK 本身不自动加载 env 文件:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{
|
||||||
|
Token: os.Getenv("ORBIT_TOKEN"),
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
queueID := os.Getenv("ORBIT_QUEUE_ID")
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK 安装、任务处理器和完整服务见[根 README](../../README.md#sdk-怎么调用)、[HTTP 接入例子](../../examples/http-service/README.md)。业务处理器由接入服务注册运行;平台不会自动执行业务代码。
|
||||||
|
|
||||||
|
## 其他 Docker Compose 服务
|
||||||
|
|
||||||
|
在业务 Compose 中接入共享网络,并引用本机生成的 `docker.env`。将下方路径换为本机 Orbit 仓库的绝对路径;这份文件含真实令牌,不提交到业务 Git。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# 这里保留业务服务自己的 image/build、ports 等配置
|
||||||
|
env_file:
|
||||||
|
- /absolute/path/to/orbit/.local/compose-test/docker.env
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- orbit-test
|
||||||
|
|
||||||
|
networks:
|
||||||
|
orbit-test:
|
||||||
|
external: true
|
||||||
|
name: orbit-test-services
|
||||||
|
```
|
||||||
|
|
||||||
|
业务服务读取到的 `ORBIT_URL` 为 `http://orbit-test:8080`,HTTP 和 WebSocket 都通过共享网络访问;Docker 内不要把 `127.0.0.1:18080` 当成宿主机地址。先启动 Orbit 环境,再启动业务 Compose。
|
||||||
|
|
||||||
|
如果仅临时联调一个已运行容器,可以执行 `docker network connect orbit-test-services <业务容器名>`,并在该业务服务配置中设置上述 URL 和凭据;容器重建后应采用 Compose 声明网络。
|
||||||
|
|
||||||
|
## 运行示例与看数据
|
||||||
|
|
||||||
|
宿主机有 Go 1.27.1 时,从仓库根目录执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export ORBIT_URL=http://127.0.0.1:18080
|
||||||
|
export ORBIT_CREDENTIALS_FILE="$PWD/.local/compose-test/sdk.credentials.json"
|
||||||
|
cd examples
|
||||||
|
go run ./worker
|
||||||
|
go run ./schedules
|
||||||
|
go run ./workflow
|
||||||
|
go run ./event-protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
Worker 返回三条 `SUCCEEDED`;计划示例实际执行后暂停;流程示例完成并行、信号和审计核对;事件示例验证通知、ACK 与断线重报。刷新控制台即可查看真实数据。短示例每次产生新测试记录,不是长期业务消费者。
|
||||||
|
|
||||||
|
## 日常命令与持久化
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/orbit-local.py logs # 默认显示 Orbit 最近 100 行
|
||||||
|
./scripts/orbit-local.py logs mysql # 数据库日志
|
||||||
|
./scripts/orbit-local.py restart # 只重启 Orbit,等到 ready
|
||||||
|
./scripts/orbit-local.py stop # 停止本环境的 Orbit 和 MySQL
|
||||||
|
./scripts/orbit-local.py start # 启动已有容器,等到 ready
|
||||||
|
./scripts/orbit-local.py up # 初次部署/重建镜像与容器;复用原密码和身份
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库卷为 `orbit-test_orbit-mysql`,归档卷为 `orbit-test_orbit-archives`,容器配置 `restart: unless-stopped`。停止、重启、重新构建都保留数据。私密配置目录 `.local/compose-test` 也要保留;仅留下卷而丢失密码文件不能当成全新安装。
|
||||||
|
|
||||||
|
归档卷预算 1 GiB,归档计划和保留策略通过控制台配置,默认不自动开启计划;目前导出保留源数据。不要为腾空间删除数据库卷。
|
||||||
|
|
||||||
|
默认使用 `10.253.240.0/24` 内部网络和 `10.253.241.0/24` 服务网络,避免占用已耗尽的 Docker 默认子网池。如与本机已有网络冲突,在私密 `compose.env` 中设置 `ORBIT_TEST_BACKEND_SUBNET`、`ORBIT_TEST_SERVICES_SUBNET` 后首次启动;已有网络需要先协调连接服务后再调整。
|
||||||
|
|
||||||
|
配置统一从 `.local/compose-test/compose.env` 读取,脚本隔离其他项目的同名环境变量。可查看原始配置:[基础文件](../docker-compose.yml) + [本地覆盖](compose.yml)。脚本不提供删卷命令;需手工管理时务必固定 Compose 项目 `orbit-test` 和同一份配置。
|
||||||
|
|
||||||
|
## 凭据和权限
|
||||||
|
|
||||||
|
新建 SDK 凭据默认长期有效(省略 `expires_at`);旧环境保留原策略,可在应用接入页「调整有效期」且不改变 Token。启动输出读取数据库当前有效期。凭据拥有此测试命名空间的任务、Worker、调度、订阅、流程及恢复权限;这是联调身份,正式环境按角色收窄。凭据到期/撤销后不会自动重新初始化或重置历史,按[轮换手册](../../docs/api/authentication.md)处理。
|
||||||
|
|
||||||
|
管理员密码、数据库密码、SDK Token 均保存在 `.local/compose-test` 下的私密文件;目录权限 0700、文件权限 0600,Git 和 Docker 构建上下文都排除这些文件。需要其他服务读取时分发 `host.env` / `docker.env`,不要给业务服务数据库管理员配置。
|
||||||
59
deploy/local/compose.yml
Normal file
59
deploy/local/compose.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 叠加 deploy/docker-compose.yml;使用 scripts/orbit-local.py 固定项目和私密配置。
|
||||||
|
services:
|
||||||
|
archive-init:
|
||||||
|
image: mysql:8.4
|
||||||
|
restart: "no"
|
||||||
|
user: "0:0"
|
||||||
|
entrypoint: ["/bin/sh", "-ec"]
|
||||||
|
command: ["chown 65532:65532 /archives && chmod 700 /archives"]
|
||||||
|
volumes:
|
||||||
|
- orbit-archives:/archives
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
profiles: ["tools"]
|
||||||
|
image: ${ORBIT_IMAGE}
|
||||||
|
user: ${ORBIT_LOCAL_USER}
|
||||||
|
entrypoint: ["/usr/local/bin/orbit-admin"]
|
||||||
|
command: ["verify"]
|
||||||
|
environment:
|
||||||
|
ORBIT_MYSQL_DSN: "root:${ORBIT_DB_ROOT_PASSWORD}@tcp(mysql:3306)/${ORBIT_DB_NAME}?parseTime=true&multiStatements=true"
|
||||||
|
volumes:
|
||||||
|
- ${ORBIT_LOCAL_DIRECTORY}:/run/local
|
||||||
|
|
||||||
|
orbit:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: ${ORBIT_BUILD_VERSION}
|
||||||
|
REVISION: ${ORBIT_BUILD_REVISION}
|
||||||
|
BUILD_TIME: ${ORBIT_BUILD_TIME}
|
||||||
|
depends_on:
|
||||||
|
archive-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
ORBIT_CONSOLE_APPLICATION_ADMIN: admin
|
||||||
|
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
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
services:
|
||||||
|
aliases: [orbit-test]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: ${ORBIT_TEST_BACKEND_SUBNET:-10.253.240.0/24}
|
||||||
|
services:
|
||||||
|
name: orbit-test-services
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: ${ORBIT_TEST_SERVICES_SUBNET:-10.253.241.0/24}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
orbit-archives:
|
||||||
@@ -260,9 +260,47 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
|
|||||||
- 已通过:标准竞态测试、vet/build、SDK隔离和文档链接;定向真实MySQL/平台回归与平台全量 integration 均退出0。包括事务回滚/重复/旧快照、确认回滚、提交回执丢失、强杀恢复和SIGTERM收尾。
|
- 已通过:标准竞态测试、vet/build、SDK隔离和文档链接;定向真实MySQL/平台回归与平台全量 integration 均退出0。包括事务回滚/重复/旧快照、确认回滚、提交回执丢失、强杀恢复和SIGTERM收尾。
|
||||||
- [公共包接口](sdk/outbox-mysql.md)、[完整调用和故障示例](examples/http-service.md)、[验收报告](reports/outbox-http-integration.md)。本批不扩展到其他数据库/ORM、流程命令Outbox或源数据删除。
|
- [公共包接口](sdk/outbox-mysql.md)、[完整调用和故障示例](examples/http-service.md)、[验收报告](reports/outbox-http-integration.md)。本批不扩展到其他数据库/ORM、流程命令Outbox或源数据删除。
|
||||||
|
|
||||||
### main、正式版本与统一示例(本批)
|
### main、正式版本与统一示例(本批已完成)
|
||||||
|
|
||||||
- 根 examples 统一 27 个可运行场景,各目录 README 说明用途、权限、运行和结果;首页改为完整功能与接入入口。
|
- 根 examples 统一 27 个可运行场景,各目录 README 说明用途、权限、运行和结果;首页改为完整功能与接入入口。
|
||||||
- 开发工作区、构建、MySQL/控制台/历史 SDK 回归同步迁移路径;SDK 保持独立模块。
|
- 开发工作区、构建、MySQL/控制台/历史 SDK 回归同步迁移路径;SDK 保持独立模块。
|
||||||
- 使用 main 与 v0.1.1 / sdk/go/v0.1.1 / adapters/outbox/mysql/v0.1.1 交付,旧标签不变;业务固定版本,无需开发分支或提交号。
|
- 使用 main 与 v0.1.1 / sdk/go/v0.1.1 / adapters/outbox/mysql/v0.1.1 交付,旧标签不变;业务固定版本,无需开发分支或提交号。
|
||||||
- 标准 7 模块回归、SDK/协议/文档门禁、真实 MySQL 全量、控制台端到端及历史任务 API 双向 smoke 均通过;远端安装结果见[发布记录](reports/v0.1.1-release.md)。
|
- 标准 7 模块回归、SDK/协议/文档门禁、真实 MySQL 全量、控制台端到端及历史任务 API 双向 smoke 均通过;main 与三组 v0.1.1 标签已推送,仓库外空缓存安装两个模块、构建、HTTP smoke 及校验和验证通过,无 replace;证据见[发布记录](reports/v0.1.1-release.md)。
|
||||||
|
|
||||||
|
### 本地 Compose 联调环境(2026-09-27)
|
||||||
|
|
||||||
|
- 已提供 `scripts/orbit-local.py` 和本地 Compose 覆盖文件,部署在独立项目 orbit-test,主机端口 18080;MySQL/归档卷持久保存,迁移账号与运行账号分离。
|
||||||
|
- 初始化单管理员、完整测试身份和 host.env/docker.env,私密内容位于被 Git 与 Docker 构建排除的 .local/compose-test;重复启动复用原身份,不重置密码或数据。
|
||||||
|
- 提供 orbit-test-services 共享网络与 http://orbit-test:8080 地址,固定可配置子网,解决本机默认 Docker 地址池耗尽的问题。
|
||||||
|
- 已验证:真实容器构建/迁移/零外键/权限收紧;浏览器登录协议和未登录跳转;另一容器运行 Worker 完成三项任务及事件 ACK 重报;定时/流程示例;重复部署与 Orbit/MySQL 停止再启动后原任务、身份保持。
|
||||||
|
- 接入、账号位置、启动/停止及网络声明见[本地部署手册](../deploy/local/README.md)。这是本地联调环境,不改变平台或 SDK 协议。
|
||||||
|
|
||||||
|
### 单管理员应用接入(2026-09-27)
|
||||||
|
|
||||||
|
- 新增独立应用接入页:应用/环境分页、队列及凭据详情、选择业务能力新建应用、重新生成 Token、复制/下载 SDK 配置;沿用单管理员,不引入角色系统。
|
||||||
|
- 新增 Cookie 专用管理接口,部署显式指定唯一登录名;同事务校验管理员与有效会话,写入要求同源与专用请求头。Token 只在生成时交付,数据库只存摘要。
|
||||||
|
- 新建资源/审计/幂等回执同事务,轮换复用现有凭据 ID 与轮换核心。请求不明保留原操作,原样重报;原有任务、订阅和交付身份不变。无新表、无外键、无 SDK 协议修改。
|
||||||
|
- 已通过定向 MySQL 并发创建、幂等冲突、重名回滚、轮换/旧 Token 拒绝、退出失效与审计脱敏;真实 HTTP 已验证创建、轮换后原任务和订阅可读。标准/全量集成和页面验收记录见[本批报告](reports/console-applications.md)。
|
||||||
|
- [页面使用、HTTP 接口及故障恢复](api/console-applications.md)。
|
||||||
|
|
||||||
|
### 长期凭据与平滑换证(2026-09-27)
|
||||||
|
|
||||||
|
- 新建应用和 bootstrap 默认长期 Token,可选定期;既有 Token 可通过管理端/CLI 原地调整有效期,无需改业务配置。
|
||||||
|
- 000032 与基线增加旧摘要/固定截止,无外键。控制台/CLI 默认 7 天过渡;同一身份的 API、WS 控制命令和跨节点重连均核验两代有效性,权限与任务归属保持不变。
|
||||||
|
- 结束过渡、两代同时撤销、过渡期防重复换证、旧 v1 固定操作兼容、幂等审计及中文模型注释随功能交付。
|
||||||
|
- 验证结果见[长期凭据与平滑换证](reports/credential-overlap.md)。SDK 无接口修改;调用方须在截止前滚动更新配置并收尾在途执行。
|
||||||
|
|
||||||
|
### 控制台应用范围与列表归属(2026-09-27)
|
||||||
|
|
||||||
|
- 已实现:页顶应用/环境与队列筛选、列表和详情应用标签、URL 保持筛选;切换隔离旧请求和表单,错误范围不降级读取默认数据。
|
||||||
|
- 单管理员通过 Cookie 显式选择范围;业务事务重新核验会话与目标应用,SDK 不支持范围覆盖,订阅原凭据绑定不变。
|
||||||
|
- 同步提供[范围接口](api/console-scope.md)、真实 MySQL 授权/审计/退出后拒绝测试和 HTTP 列表及订阅管理回归。最终结果见[本批报告](reports/console-scope.md)。
|
||||||
|
|
||||||
|
### 换证过渡状态提示(2026-09-27)
|
||||||
|
|
||||||
|
- 凭据卡片明确区分过渡中与已撤销状态,展示旧 Token 实际截止及服务切换步骤;入口可点击「查看原因」打开只读说明,到期后页面刷新恢复换证入口。
|
||||||
|
- 单独说明当前 Token 的获取位置、凭据 ID 与 Token 的区别、过渡期配置可能仍是旧代,以及未保存时的处理方式;新建/换证前、成功交付与关闭确认均提示原文只显示一次。
|
||||||
|
- 保留过渡期禁止重复换证的保护;提前结束仍需管理员确认服务全部切换、在途任务收尾,不自动结束过渡或替换现有凭据。
|
||||||
|
- 回归范围:标准竞态测试、vet/build、JavaScript 语法和文档链接;本地页面核对过渡原因可点击、凭据获取说明、正常换证入口与结束过渡确认弹窗。说明同步到[应用接入手册](api/console-applications.md)。
|
||||||
|
- 已完成验收:基础回归通过;浏览器验证过渡原因点击、说明展开/关闭/再次打开、普通换证与新建保存提醒、结束过渡确认。验收没有轮换或停用现有应用凭据。
|
||||||
|
- 本地接入命令已实跑:仓库外独立模块安装 SDK v0.1.1、读取队列及校验和通过;真实 Worker 三项成功且排空、结果订阅跨连接 ACK 去重、共享 Docker 网络就绪检查均通过。本地部署手册同步修正旧 Token 过渡语义。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Orbit HTTP / WebSocket 接口手册
|
# Orbit HTTP / WebSocket 接口手册
|
||||||
|
|
||||||
更新:2026-09-18。接口实现状态随 [开发计划](../08-development-plan.md) 更新。
|
更新:2026-09-27。接口实现状态随 [开发计划](../08-development-plan.md) 更新。
|
||||||
|
|
||||||
## 当前接口状态
|
## 当前接口状态
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
| `GET /health/live` | 已实现 | [健康检查](health.md) |
|
| `GET /health/live` | 已实现 | [健康检查](health.md) |
|
||||||
| `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
|
| `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
|
||||||
| `GET /version` | 已实现,返回版本、提交、构建时间及协议版本,不包含数据库、凭据或租户信息 | [健康检查](health.md) |
|
| `GET /version` | 已实现,返回版本、提交、构建时间及协议版本,不包含数据库、凭据或租户信息 | [健康检查](health.md) |
|
||||||
|
| `GET /console/scope` 与控制台显式应用范围 | 已实现单管理员应用/环境切换、队列分页和列表归属;SDK 不可覆盖范围 | [应用范围](console-scope.md) |
|
||||||
| 独立监听的 `GET /metrics` | 已实现基础运行指标,默认关闭 | [运行观测](observability.md) |
|
| 独立监听的 `GET /metrics` | 已实现基础运行指标,默认关闭 | [运行观测](observability.md) |
|
||||||
| `/v1/tasks` 提交、查询、分页与待执行取消 | 已实现,P3扩展业务键与创建时间筛选 | [任务接口](tasks.md) / [查询契约](task-queries.md) |
|
| `/v1/tasks` 提交、查询、分页与待执行取消 | 已实现,P3扩展业务键与创建时间筛选 | [任务接口](tasks.md) / [查询契约](task-queries.md) |
|
||||||
| `GET /v1/tasks/{task_id}/source` | 已实现普通、人工重试、计划固定版本及流程历史激活/轮次来源 | [来源查询](task-source.md) |
|
| `GET /v1/tasks/{task_id}/source` | 已实现普通、人工重试、计划固定版本及流程历史激活/轮次来源 | [来源查询](task-source.md) |
|
||||||
@@ -66,3 +67,5 @@ P1 业务接口已遵守下述约定,后续接口继续沿用:
|
|||||||
[流程实例筛选分页与权限](workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。
|
[流程实例筛选分页与权限](workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。
|
||||||
|
|
||||||
- [周期归档与作业管理](archive-scheduler.md)
|
- [周期归档与作业管理](archive-scheduler.md)
|
||||||
|
|
||||||
|
- [控制台应用接入与 Token 管理](console-applications.md)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
状态:P1 已实现。业务接口每次在数据库事务内校验凭据摘要、撤销/到期、应用与命名空间启停,以及动作和队列范围。授权与写入使用同一事务;管理变更与已经获得授权锁的请求按事务先后次序生效。
|
状态:P1 已实现。业务接口每次在数据库事务内校验凭据摘要、撤销/到期、应用与命名空间启停,以及动作和队列范围。授权与写入使用同一事务;管理变更与已经获得授权锁的请求按事务先后次序生效。
|
||||||
|
|
||||||
|
已提供单管理员[应用接入页面](console-applications.md):新建应用、查看队列与凭据、长期凭据、调整有效期、平滑换证、复制 SDK 配置。下方 CLI 继续用于首次安装及运维。
|
||||||
|
|
||||||
## 初始化一个新的接入应用
|
## 初始化一个新的接入应用
|
||||||
|
|
||||||
前置:专用 MySQL 数据库已经执行 orbit-migrate up,使用持有初始化权限的数据库管理连接。此命令创建新应用、命名空间、队列和凭据,不会覆盖现有同名应用。
|
前置:专用 MySQL 数据库已经执行 orbit-migrate up,使用持有初始化权限的数据库管理连接。此命令创建新应用、命名空间、队列和凭据,不会覆盖现有同名应用。
|
||||||
@@ -19,7 +21,7 @@ go run ./cmd/orbit-admin bootstrap \
|
|||||||
|
|
||||||
ORBIT_MYSQL_DSN 通过环境注入,命令不读取 .env。所有名称必填,以字母/数字开头,后续允许字母、数字、`_ . : -`;environment 最长 32,其余最长 128。输出文件必须不存在,以 0600 创建;原始凭据不会打印到 stdout/stderr。`.local/` 和 `*.credentials.json` 已被 Git 忽略。
|
ORBIT_MYSQL_DSN 通过环境注入,命令不读取 .env。所有名称必填,以字母/数字开头,后续允许字母、数字、`_ . : -`;environment 最长 32,其余最长 128。输出文件必须不存在,以 0600 创建;原始凭据不会打印到 stdout/stderr。`.local/` 和 `*.credentials.json` 已被 Git 忽略。
|
||||||
|
|
||||||
输出 JSON 包含 application_id / namespace_id / queue_id / credential_id / token / expires_at。初始凭据有效期为 30 天,权限包括当前命名空间内的五个 P1 动作和全部队列。初始队列 max_concurrency=4、rate_per_second=10、max_unfinished_tasks=100000;并发、开始限速与积压背压已生效,语义及管理方式见 [队列接口](queues.md)。这些默认值是开发基线,不是容量承诺。
|
输出 JSON 包含 application_id / namespace_id / queue_id / credential_id / token,可选 expires_at。初始凭据默认长期有效(expires_at 省略),可用 `--valid-for 720h` 显式选择 30 天;0 表示长期,其他值为 1 小时至 365 天。权限包括当前命名空间内的五个 P1 动作和全部队列。初始队列 max_concurrency=4、rate_per_second=10、max_unfinished_tasks=100000;并发、开始限速与积压背压已生效,语义及管理方式见 [队列接口](queues.md)。这些默认值是开发基线,不是容量承诺。
|
||||||
|
|
||||||
如果希望在第一次初始化时同时创建控制台管理员,可在同一条 bootstrap 事务中提供账号,并从标准输入交付密码:
|
如果希望在第一次初始化时同时创建控制台管理员,可在同一条 bootstrap 事务中提供账号,并从标准输入交付密码:
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ secret-manager read orbit/console-admin-password | \
|
|||||||
|
|
||||||
## 轮换与撤销
|
## 轮换与撤销
|
||||||
|
|
||||||
凭据轮换和撤销只允许在受保护的数据库管理环境执行,不提供公网管理接口。轮换保留 `credential_id`,因此订阅和 Worker 实例的授权绑定仍指向同一记录;新的 Worker 进程必须按现有进程身份规则重新连接,旧令牌不能再通过任何 API 或 WSS 认证。平台不会批量改写在途任务、交付记录或历史会话证据。
|
管理 CLI 在受保护的数据库管理环境执行;控制台也可由部署指定的单管理员轮换凭据,见[应用接入](console-applications.md)。业务 SDK 不开放凭据管理,撤销仍由 CLI 执行。轮换保留 `credential_id`,因此订阅和 Worker 实例的授权绑定仍指向同一记录;新进程仍遵循原 Worker 身份规则;过渡期内旧 Token 的 API 请求、原 WS 控制命令及跨节点重连都继续有效。新 Token 立即可用,结束过渡或超过截止后旧 Token 才被拒绝。平台不会批量改写在途任务、交付记录或历史会话证据。
|
||||||
|
|
||||||
先读取脱敏状态和当前版本:
|
先读取脱敏状态和当前版本:
|
||||||
|
|
||||||
@@ -61,12 +63,13 @@ go run ./cmd/orbit-admin credential rotate \
|
|||||||
--namespace-id <namespace_id> \
|
--namespace-id <namespace_id> \
|
||||||
--credential-id <credential_id> \
|
--credential-id <credential_id> \
|
||||||
--expected-revision 1 \
|
--expected-revision 1 \
|
||||||
--valid-for 720h \
|
--valid-for 0 \
|
||||||
|
--grace 168h \
|
||||||
--reason "scheduled credential rotation" \
|
--reason "scheduled credential rotation" \
|
||||||
--output .local/rotated.credentials.json
|
--output .local/rotated.credentials.json
|
||||||
```
|
```
|
||||||
|
|
||||||
命令先读取并核对版本,生成一个固定 `operation_id`、令牌和绝对到期时间,写入新的 `0600` 私有操作文件,然后提交数据库事务。文件不得已存在;提交结果不明、网络断开或数据库返回未知错误时必须保留文件,并使用同一个文件执行 `credential apply --file`,不能重新执行 `rotate` 生成第二个秘密。`--valid-for` 限定在 1 小时到 1 年。数据库只保存秘密摘要、请求摘要和脱敏回执。
|
命令先读取并核对版本,生成一个固定 `operation_id`、令牌、可选绝对到期时间和固定过渡截止,写入新的 `0600` 私有操作文件,然后提交数据库事务。文件不得已存在;提交结果不明、网络断开或数据库返回未知错误时必须保留文件,并使用同一个文件执行 `credential apply --file`,不能重新执行 `rotate` 生成第二个秘密。`--valid-for` 默认 0(长期),也可配置 1 小时到 365 天;`--grace` 默认 168h(7 天),范围 0–720h,0 表示立即停用旧 Token。旧 Token 的实际截止取过渡截止与原到期时间的较早者,不复活已过期秘密。数据库只保存秘密摘要、请求摘要和脱敏回执。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/orbit-admin credential apply \
|
go run ./cmd/orbit-admin credential apply \
|
||||||
@@ -75,6 +78,36 @@ go run ./cmd/orbit-admin credential apply \
|
|||||||
|
|
||||||
`apply` 会严格拒绝软链接、非 0600 文件、未知 JSON 字段、重复 JSON 对象和修改过的操作内容。重复应用已提交的文件只返回原回执,回执中的 `deduplicated` 为 `true`,不会重新轮换或新增审计。
|
`apply` 会严格拒绝软链接、非 0600 文件、未知 JSON 字段、重复 JSON 对象和修改过的操作内容。重复应用已提交的文件只返回原回执,回执中的 `deduplicated` 为 `true`,不会重新轮换或新增审计。
|
||||||
|
|
||||||
|
### 原 Token 调整有效期
|
||||||
|
|
||||||
|
无需换 Token:读取当前 revision 后执行以下命令,业务侧继续使用原配置。缩短有效期同样会生效,应按预期截止配置。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/orbit-admin credential expiry \
|
||||||
|
--namespace-id <namespace_id> --credential-id <credential_id> \
|
||||||
|
--expected-revision 1 --valid-for 0 \
|
||||||
|
--reason "service credential remains valid until revoked" \
|
||||||
|
--output .local/expiry.operation.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 平滑切换的完整顺序
|
||||||
|
|
||||||
|
1. 生成新 Token,保留默认 7 天过渡。凭据 ID、权限、任务、订阅和交付身份不变。
|
||||||
|
2. 把新 Token 发布到调用方配置,逐台更新 API 服务、Worker 和结果消费者。SDK v0.1.1 的 `Options.Token` 在创建客户端时读取,修改 `.env` 不会自动刷新正在运行的客户端;新建客户端或滚动重启服务,按 [HTTP 宿主示例](../examples/http-service.md) 正常排空在途工作。
|
||||||
|
3. 在截止前确认所有旧进程已完成任务回报和 ACK,并已经退出或用新 Token 重新连接。HTTP 调用无须整体停机;持续执行能力依赖业务侧保留足够副本。单实例重启本身会产生短暂停顿。
|
||||||
|
4. 全部切换后可提前结束过渡;否则旧 Token 到时自动失效,认证使用数据库时间,不依赖定时清理。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/orbit-admin credential finalize \
|
||||||
|
--namespace-id <namespace_id> --credential-id <credential_id> \
|
||||||
|
--expected-revision 2 --reason "all service instances switched and drained" \
|
||||||
|
--output .local/finalize.operation.json
|
||||||
|
```
|
||||||
|
|
||||||
|
结束过渡立即停用旧 Token,当前 Token 不变。长期 Token 仍受主动撤销、应用/命名空间停用约束。旧代仍在有效过渡期时再次轮换返回 409,避免误伤未切换服务;先完成本次切换。修改当前有效期不延长旧代截止。若旧 Token 本身即将到期而需要更长切换窗口,应先调整当前有效期,再发起换证。
|
||||||
|
|
||||||
|
新固定操作文件使用 version=2;历史 version=1 仍按原来的固定到期和立即轮换语义重放,不自动延长旧秘密。
|
||||||
|
|
||||||
发生泄露或 Worker 必须立即停用时执行撤销:
|
发生泄露或 Worker 必须立即停用时执行撤销:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -86,7 +119,7 @@ go run ./cmd/orbit-admin credential revoke \
|
|||||||
--reason "incident response"
|
--reason "incident response"
|
||||||
```
|
```
|
||||||
|
|
||||||
撤销同样先写入固定操作文件,再提交事务;提交不明时用 `credential apply --file .local/revoke.operation.json` 重试。撤销只标记凭据失效,不删除订阅、任务、交付或历史记录。即使应用或命名空间已停用,管理撤销仍可执行。重复应用同一撤销文件只返回原回执,不追加重复审计。审计主体取自 MySQL 管理连接的 `CURRENT_USER()`,审计摘要不包含秘密原文或秘密摘要。
|
撤销同样先写入固定操作文件,再提交事务;提交不明时用 `credential apply --file .local/revoke.operation.json` 重试。撤销使当前和过渡中的上一代同时失效,不删除订阅、任务、交付或历史记录。即使应用或命名空间已停用,管理撤销仍可执行。重复应用同一撤销文件只返回原回执,不追加重复审计。审计主体取自 MySQL 管理连接的 `CURRENT_USER()`,审计摘要不包含秘密原文或秘密摘要。
|
||||||
|
|
||||||
## 凭据格式与作用域
|
## 凭据格式与作用域
|
||||||
|
|
||||||
@@ -128,3 +161,7 @@ P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`
|
|||||||
P4定义发布新增`workflows:definitions:read`与`workflows:definitions:publish`,仅显式`--workflow-access`授予。创建/读取元数据作用于命名空间,发布及读取完整版本额外校验全部引用队列;默认提交/Worker凭据不获此权限。发布不创建任务,不要求tasks:submit,详见[定义API](workflow-definitions.md)。
|
P4定义发布新增`workflows:definitions:read`与`workflows:definitions:publish`,仅显式`--workflow-access`授予。创建/读取元数据作用于命名空间,发布及读取完整版本额外校验全部引用队列;默认提交/Worker凭据不获此权限。发布不创建任务,不要求tasks:submit,详见[定义API](workflow-definitions.md)。
|
||||||
|
|
||||||
P4/P5实例公共接入增加`workflows:start/read/control/signal/recover`及`workflows:audit:read`。五个独立CLI开关`--workflow-run-access`、`--workflow-control-access`、`--workflow-signal-access`、`--workflow-recovery-access`、`--workflow-audit-access`分别明确授予。前四个同时包含实例读取;audit只授予流程操作审计读取。原`--workflow-access`仍仅授予定义读/发布,任务`--recovery-access`不授予流程恢复。全部固定队列仍按当前凭据校验,详见[实例权限表](workflows.md#1-路由权限与-sdk)。初始化权限集合在交付凭据前校验。
|
P4/P5实例公共接入增加`workflows:start/read/control/signal/recover`及`workflows:audit:read`。五个独立CLI开关`--workflow-run-access`、`--workflow-control-access`、`--workflow-signal-access`、`--workflow-recovery-access`、`--workflow-audit-access`分别明确授予。前四个同时包含实例读取;audit只授予流程操作审计读取。原`--workflow-access`仍仅授予定义读/发布,任务`--recovery-access`不授予流程恢复。全部固定队列仍按当前凭据校验,详见[实例权限表](workflows.md#1-路由权限与-sdk)。初始化权限集合在交付凭据前校验。
|
||||||
|
|
||||||
|
## 部署升级
|
||||||
|
|
||||||
|
迁移 `000032` 为凭据表增加上一代摘要与 UTC 截止,并以 CHECK 约束成对为空;不新增外键、不改变已有秘密或有效期。新安装的基线同步到 32。按部署流程应用迁移并更新全部 Orbit 节点后再使用平滑换证;旧版本节点只认识当前摘要,不能在混合版本期间承诺旧 Token 可用。既有凭据不会被迁移自动改为长期,须通过有审计的 expiry 操作显式调整。
|
||||||
|
|||||||
154
docs/api/console-applications.md
Normal file
154
docs/api/console-applications.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# 控制台应用接入
|
||||||
|
|
||||||
|
单管理员在「应用接入」页面创建应用、初始队列与 SDK 凭据,查看环境、队列、权限和有效期,平滑换证,复制或下载 `.env` 配置。数据库只保存 Token 摘要:完整 Token 仅在新建或轮换时交付,列表和详情不能反查旧秘密。
|
||||||
|
|
||||||
|
## 1. 启用和使用
|
||||||
|
|
||||||
|
部署配置指定唯一有跨应用接入管理权限的控制台登录名:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export ORBIT_CONSOLE_APPLICATION_ADMIN=admin
|
||||||
|
```
|
||||||
|
|
||||||
|
该账号必须已经通过 bootstrap 或 console-user create 创建。配置为空时所有应用接入管理请求返回 403,不会自动提升已有账号;多节点使用相同配置。继续使用单管理员,不引入角色管理系统。现有控制台账号与会话的绑定凭据仍须有效;到期前可在本页轮换。轮换 SDK Token 不改变浏览器独立会话秘密。
|
||||||
|
|
||||||
|
本地 Compose 已为 `admin` 配置该入口,访问 `http://127.0.0.1:18080/console/#/applications`。
|
||||||
|
|
||||||
|
1. 点击「新建应用」,填写应用名称、环境、命名空间、初始队列和操作原因。
|
||||||
|
2. 选择 Worker、结果订阅、定时、流程等需要的能力及有效期。基础权限包含任务提交/查询/取消、队列读取/管理。
|
||||||
|
3. 创建成功后复制或下载完整 `.env`,交给该应用的服务端配置。默认地址使用当前页面来源,详情中可改为容器地址或部署域名。
|
||||||
|
4. 已接入应用可以随时查看元数据和配置模板。模板中的 Token 是占位值,必须替换成已经保存的原 Token。
|
||||||
|
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。
|
||||||
|
|
||||||
|
环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面支持新建应用及其初始队列;业务列表可通过应用筛选栏切换范围,分别展示各应用数据。未提供应用删除、身份迁移或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。
|
||||||
|
|
||||||
|
## 2. 路由与认证
|
||||||
|
|
||||||
|
这些是浏览器管理端接口,不属于业务 SDK。只接受有效的 `orbit_console_session` HttpOnly Cookie,并在数据库事务中重新核验部署指定的管理员账号和会话;即使 SDK 凭据拥有全部业务权限也不能访问。
|
||||||
|
|
||||||
|
| 方法与路径 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /console/applications?after=<namespace_id>` | 按命名空间 ID 升序读取 50 条接入范围,返回 `items`、可选 `next`;`after` 为排他起点 |
|
||||||
|
| `GET /console/applications/{namespace_id}` | 应用/环境详情、最多 100 个队列和 100 张脱敏凭据;超出返回 `truncated=true` |
|
||||||
|
| `POST /console/applications/prepare` | 校验意图,生成固定操作 ID、资源 ID、Token、绝对到期时间;不创建资源、不轮换秘密 |
|
||||||
|
| `POST /console/applications/apply` | 提交准备结果,原样重报返回原脱敏回执 |
|
||||||
|
|
||||||
|
POST 必须有 `Content-Type: application/json` 和 `X-Orbit-Console: 1`;浏览器 Origin 必须匹配当前主机,拒绝跨站 Fetch Metadata,不提供跨域授权。响应禁止缓存,成功信封为 `{"data": ...}`。未知字段、重复 JSON 键、显式 null 和错误类型被拒绝,正文上限 512 KiB。
|
||||||
|
|
||||||
|
## 3. 新建应用
|
||||||
|
|
||||||
|
prepare 请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "CREATE",
|
||||||
|
"application": "order-service",
|
||||||
|
"namespace": "default",
|
||||||
|
"environment": "test",
|
||||||
|
"queue": "orders",
|
||||||
|
"capabilities": ["worker", "events", "schedules", "workflows"],
|
||||||
|
"expected_revision": "0",
|
||||||
|
"valid_days": 0,
|
||||||
|
"reason": "接入订单服务"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
名称以字母或数字开头,后续只允许字母、数字、`_ . : -`,environment 最长 32,其余最长 128。valid_days 为 0–365 的整数,默认 0 表示长期有效;reason 非空、最多 1024 个 Unicode 字符,不含换行或 NUL。capabilities 可选 `worker`、`events`、`schedules`、`workflows`、`recovery`、`resources`,不可重复。新建不传 namespace_id / credential_id,expected_revision 省略或为字符串 `"0"`。
|
||||||
|
|
||||||
|
prepare 响应 data 为固定操作对象:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"input": {"kind":"CREATE","application":"order-service","namespace":"default","environment":"test","queue":"orders","capabilities":["worker","events"],"expected_revision":"0","valid_days":0,"reason":"接入订单服务"},
|
||||||
|
"operation_id": "11111111111111111111111111111111",
|
||||||
|
"application_id": "22222222222222222222222222222222",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"queue_id": "44444444444444444444444444444444",
|
||||||
|
"credential_id": "55555555555555555555555555555555",
|
||||||
|
"token": "服务端生成的完整 Token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
以上是结构示意,ID、令牌及时间以服务端返回为准。将 **data 整体原样** POST 到 apply,不包 `data` 外层。应用、额度、命名空间、队列、凭据摘要、审计和幂等回执在同一事务提交。初始队列并发 4、开始速率 10/秒,沿用 bootstrap 默认策略;凭据队列范围为新命名空间的全部队列。重名应用返回 409,不覆盖原资源。
|
||||||
|
|
||||||
|
## 4. 平滑换证
|
||||||
|
|
||||||
|
prepare 请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "ROTATE",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"credential_id": "55555555555555555555555555555555",
|
||||||
|
"expected_revision": "1",
|
||||||
|
"valid_days": 0,
|
||||||
|
"grace_days": 7,
|
||||||
|
"reason": "更新接入凭据"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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,不能仅为解锁按钮而操作。已撤销凭据单独显示「已撤销 · 查看原因」,同样只打开说明。
|
||||||
|
|
||||||
|
apply 成功 data:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation_id": "11111111111111111111111111111111",
|
||||||
|
"application_id": "22222222222222222222222222222222",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"credential_id": "55555555555555555555555555555555",
|
||||||
|
"revision": "2",
|
||||||
|
"deduplicated": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 调整有效期与结束过渡
|
||||||
|
|
||||||
|
prepare 输入示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "SET_EXPIRY",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"credential_id": "55555555555555555555555555555555",
|
||||||
|
"expected_revision": "2",
|
||||||
|
"valid_days": 0,
|
||||||
|
"reason": "服务凭据设置为长期有效"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长期,1–365 为从准备时间计算的天数。FINALIZE 使用相同身份/版本字段,kind 改为 FINALIZE,valid_days 必须为 0,用于提前停用旧 Token。两者均禁止 grace_days 和新建字段,prepare 不返回 token;FINALIZE 也不返回 expires_at。apply 使用完整原对象,具有相同的并发版本、幂等和审计约束。
|
||||||
|
|
||||||
|
新版 prepare 对象带 version=2;expires_at 为空时**省略字段**,不要传显式 null。ROTATE 有过渡期时另外包含固定 previous_valid_until;详情中的 previous_valid_until 是受旧到期时间约束后的实际截止,未设置时省略。详情/回执不会返回旧秘密。历史不带 version 的固定操作仍按原语义重放。
|
||||||
|
|
||||||
|
完整滚动更新及 SDK 客户端生命周期说明见[凭据手册](authentication.md)。更换配置不会自动刷新已创建的客户端,须在过渡截止前完成重建或逐台重启并排空在途任务。
|
||||||
|
|
||||||
|
## 6. 超时、重复和审计
|
||||||
|
|
||||||
|
- 页面提交前把整个固定操作保留在当前内存;请求超时或结果未知时,按钮变成「重试确认」,重用同一份内容。不能再次 prepare 来代替核对。
|
||||||
|
- 页面允许「保存待核对操作」下载私密 JSON;含真实 Token,应放到受限目录。恢复时登录同一管理员后,向 apply 发送该文件正文。不要把 Cookie 或操作文件提交 Git,也不要放到 URL。
|
||||||
|
- 关闭/刷新页面会丢失内存中的 Token,页面会提醒先保存。若新建已经成功、交付内容未保存,可从应用列表重新生成同一凭据的 Token;不要再次创建同名应用。
|
||||||
|
- 创建/换证前和成功交付时明确提示「只显示一次」及过渡期限制;未复制/下载时,关闭确认也提示原文不可回看。若换证后的新 Token 未保存,先查下载文件或已更新的部署配置。确实丢失且仍在过渡期时,需要安排维护窗口暂停接收新任务并收尾在途执行,再由管理员结束过渡、重新换证,保存和更新配置后恢复运行;平台不自动解除保护,也不能从摘要还原原文。
|
||||||
|
- 原操作返回的回执是历史快照,可能已被后续轮换替代。页面会重新读取当前凭据版本后才展示配置。
|
||||||
|
- 管理审计主体是 `console:<user_id>`;正文只有权限、资源 ID、到期时间等元数据。列表、审计、幂等回执和业务记录都不保存秘密原文。
|
||||||
|
- 当前管理员退出、会话过期或账号停用后不能重放管理操作。SDK Token 撤销/到期与控制台登录资格沿用既有账号绑定约束。
|
||||||
|
|
||||||
|
| 错误 | HTTP | 处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| UNAUTHENTICATED | 401 | 登录有效控制台账号 |
|
||||||
|
| PERMISSION_DENIED | 403 | 核对唯一管理员配置、会话及同源请求 |
|
||||||
|
| INVALID_ARGUMENT | 400 | 核对字段、能力组、固定身份及有效期 |
|
||||||
|
| NOT_FOUND | 404 | 核对命名空间/凭据是否存在 |
|
||||||
|
| STATE_CONFLICT | 409 | 应用名称已存在、凭据已撤销、版本已变更或旧 Token 仍在过渡期;读取当前状态 |
|
||||||
|
| IDEMPOTENCY_CONFLICT | 409 | 原操作内容被修改,保留原始操作核对 |
|
||||||
|
| 数据库/网络故障 | 5xx / 网络错误 | 不假定失败,保留原操作重报 |
|
||||||
|
|
||||||
|
可重复运行的真实 HTTP 场景见 [test-console-applications.py](../../scripts/test-console-applications.py),标准容器入口 `make console-e2e` 同时验证此链路。基础 SDK 使用方式见 [SDK 接入](../sdk/README.md)。
|
||||||
|
|
||||||
|
业务数据的应用筛选、队列分页及会话隔离见[应用范围接口](console-scope.md)。
|
||||||
54
docs/api/console-scope.md
Normal file
54
docs/api/console-scope.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# 控制台应用范围
|
||||||
|
|
||||||
|
此接口服务于内置控制台的单管理员,用于按应用环境读取列表、详情并执行已有管理操作;不是 SDK 的跨应用调用方式。业务服务继续使用自己应用的 Token 和队列。
|
||||||
|
|
||||||
|
## 查看范围元数据
|
||||||
|
|
||||||
|
`GET /console/scope?after=<queue_id>` 只接受登录 Cookie,返回 `data`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entry": {
|
||||||
|
"application_id": "11111111111111111111111111111111",
|
||||||
|
"application": "sample-app",
|
||||||
|
"namespace_id": "22222222222222222222222222222222",
|
||||||
|
"namespace": "default",
|
||||||
|
"environment": "test",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"queues": [{"id": "33333333333333333333333333333333", "name": "jobs"}],
|
||||||
|
"can_switch": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不指定范围头时返回账号原始应用及授权队列。队列按 ID 升序,每页最多 100 项,存在更多时返回 `next`,下次用 `after=next`;没有更多则省略。应用目录继续使用 `GET /console/applications` 的 50 项分页。名称全部来自数据库,ID 为授权身份,不按应用名称猜测关联。
|
||||||
|
|
||||||
|
## 显式选择应用环境
|
||||||
|
|
||||||
|
部署配置 `ORBIT_CONSOLE_APPLICATION_ADMIN` 指定的登录管理员可发送:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-Orbit-Console: 1
|
||||||
|
X-Orbit-Namespace: 22222222222222222222222222222222
|
||||||
|
```
|
||||||
|
|
||||||
|
以上请求头只能搭配原登录 Cookie,不能搭配 `Authorization`。服务端不签发、不返回新业务 Token,不更改浏览器会话原始归属。部署配置为空或登录名不匹配时拒绝切换。
|
||||||
|
|
||||||
|
支持范围:
|
||||||
|
|
||||||
|
| 页面 | 请求 | 数据范围 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 任务执行 | 现有 `/v1/tasks` 列表、详情、历史及管理动作 | 所选命名空间 + 队列 |
|
||||||
|
| 定时计划 | 现有 `/v1/schedules` 查询、暂停/继续 | 所选命名空间 + 队列 |
|
||||||
|
| 流程实例 | 现有 `/v1/workflows` 查询、控制和人工恢复 | 所选命名空间,含流程固定版本所有队列 |
|
||||||
|
| 结果订阅 | 现有 `/v1/subscriptions` 查询、启停和交付重试 | 所选命名空间 + 队列,管理员可管理该范围内不同 SDK 凭据的已有订阅 |
|
||||||
|
| 保留策略 | `/console/retention`、预览、导出 | 所选命名空间 |
|
||||||
|
| 归档作业 | `/console/archive/plans`、`jobs` 及原管理动作 | 所选命名空间 |
|
||||||
|
|
||||||
|
队列选项仅对任务、计划、订阅生效。此桥接入口不提供创建任务/计划/订阅、发布定义、启动流程、发送信号、Worker 执行或结果消费权限。SDK 原有的权限与订阅归属校验不变。
|
||||||
|
|
||||||
|
每个业务事务重新核验管理员账号、原会话、绑定凭据有效性以及原/目标应用启用状态,再执行原有的资源归属、版本、幂等、核查证据和审计规则。管理审计操作者为 `console:<user_id>`,操作归入目标命名空间。读取其他范围的资源 ID 不会越权返回数据。不存在的范围、未授权范围不自动回退。
|
||||||
|
|
||||||
|
缺少 Cookie 或会话失效返回 401;不允许的范围切换、混用 SDK Bearer、跨站写操作返回 403;目标应用停用或资源不属于当前范围返回 404。非法范围 ID 返回 400。页面切换采用完整导航,旧详情和请求不会在新应用页面呈现;URL 只保留非敏感 ID。
|
||||||
|
|
||||||
|
任务列表沿用原有 SQL 筛选语义:请求其他应用的队列会得到空列表;该队列或任务的单条详情返回 404。页面会先核对 URL 中的队列归属,错误队列不会被当成正常空列表。
|
||||||
@@ -11,7 +11,7 @@ http://127.0.0.1:8080/console/
|
|||||||
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
|
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
|
||||||
- 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,浏览器不接触业务 Token。
|
- 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,浏览器不接触业务 Token。
|
||||||
- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;登录和权限信息不会占用统计区域。
|
- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;登录和权限信息不会占用统计区域。
|
||||||
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
|
- 页顶选择应用、环境与队列;任务、计划、流程、订阅、归档列表和详情显示应用归属。筛选保存在 URL,刷新、复制链接及后退可恢复。
|
||||||
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
|
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
|
||||||
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
|
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
|
||||||
- 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。
|
- 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。
|
||||||
@@ -20,10 +20,10 @@ http://127.0.0.1:8080/console/
|
|||||||
- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。
|
- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。
|
||||||
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
|
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
|
||||||
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
|
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
|
||||||
- 左侧六个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows`、`#/subscriptions` 和 `#/retention` 定位;切换视图不会把所有模块堆在一张长页面上。
|
- 左侧八个模块是独立视图:总览、任务、计划、流程、订阅、保留策略、归档作业和应用接入。
|
||||||
- “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。
|
- “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。
|
||||||
- 任务状态按 10 秒自动刷新;页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。
|
- 任务状态按 10 秒自动刷新;页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。
|
||||||
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
|
- 普通账号沿用绑定凭据范围;部署指定管理员可显式选择应用环境管理。读取失败显示错误,避免当作空列表。
|
||||||
|
|
||||||
## 本地打开
|
## 本地打开
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ secret-manager read orbit/console-admin-password | \
|
|||||||
--output .local/production.credentials.json
|
--output .local/production.credentials.json
|
||||||
```
|
```
|
||||||
|
|
||||||
密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,账号权限就是该凭据的权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码:
|
密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,未指定为应用管理员的账号沿用该凭据权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./bin/orbit-admin console-user create \
|
./bin/orbit-admin console-user create \
|
||||||
@@ -74,6 +74,18 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
|||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
控制台不直接访问数据库,也不绕过业务凭据权限。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台账号绑定业务凭据,不自动获得跨命名空间权限;不支持跨命名空间聚合,不代替平台的权限与审计 API。
|
控制台通过服务端授权访问数据库。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。普通账号不自动获得跨命名空间权限;部署指定的单管理员可显式选择应用范围,每个业务事务重新核验原会话及目标应用。各应用分别展示,SDK 权限不变。
|
||||||
|
|
||||||
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
||||||
|
|
||||||
|
## 应用接入
|
||||||
|
|
||||||
|
侧栏「应用接入」提供应用创建、队列与凭据查看、Token 重新生成和完整 SDK 配置交付。配置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一管理登录名;本地 Compose 已配置。业务页面支持该管理员选择应用范围,订阅原 SDK 凭据绑定不变。[功能、接口、权限和恢复说明](api/console-applications.md)。
|
||||||
|
|
||||||
|
## 按应用查看数据
|
||||||
|
|
||||||
|
页顶「应用 / 环境」选择已创建的应用,再选择队列。任务、定时计划和结果订阅按队列读取;流程、保留策略和归档按应用环境所属命名空间读取,页面有范围提示。应用每页 50 项、队列每页 100 项,选择「加载更多…」获取下一页。没有队列的应用仍能查看流程和归档。
|
||||||
|
|
||||||
|
切换采用完整导航,隔离旧请求和详情;有未保存策略、未确认操作或打开的详情时需先处理完。URL 只保存非敏感 ID。无效范围显示错误,不降级读取默认应用。总览仍是最近查询窗口的统计,不是全库累计。
|
||||||
|
|
||||||
|
只有部署指定管理员可以切换应用;其他账号只展示自己的应用和授权队列。后台停用应用、撤销绑定凭据或退出账号后,原页面范围不能继续授予权限。[范围接口及授权规则](api/console-scope.md)。
|
||||||
|
|||||||
29
docs/reports/console-applications.md
Normal file
29
docs/reports/console-applications.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 单管理员应用接入验收(2026-09-27)
|
||||||
|
|
||||||
|
本报告记录当时的立即换证语义;后续长期凭据和双 Token 过渡已在[增量报告](credential-overlap.md)单独说明。
|
||||||
|
|
||||||
|
## 交付范围
|
||||||
|
|
||||||
|
- 独立应用接入页:按应用/环境查看接入范围、队列与凭据,创建应用/命名空间/初始队列,选择业务能力,生成或轮换 Token,复制/下载 SDK 环境配置。
|
||||||
|
- 通过 `ORBIT_CONSOLE_APPLICATION_ADMIN` 显式指定唯一管理员,默认关闭;仅登录 Cookie 可访问管理端,业务 Token 不授予跨应用权限。同事务核验账号及会话,写请求额外核验同源与专用请求头。
|
||||||
|
- 新建资源、权限、审计、幂等回执同事务;轮换复用原凭据事务与 ID,任务、订阅和交付身份保留。只存秘密摘要,一次性交付明文;提交不明时保留原操作核对。
|
||||||
|
- 没有新增数据库表或外键,没有改变业务 SDK 接口,不需要升级 SDK v0.1.1。
|
||||||
|
|
||||||
|
## 已完成验证
|
||||||
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `make test vet build` | 七模块竞态测试、静态检查和构建通过 |
|
||||||
|
| `TestIntegrationConsoleApplications` | 真实 MySQL:SDK 身份与错误管理员拒绝、准备未生效、并发创建一次、同键改内容冲突、重名回滚、元数据脱敏、轮换原身份、旧秘密失效、重复回执、版本冲突、退出失效、审计主体及零外键通过 |
|
||||||
|
| 管理 HTTP 单元测试 | Cookie 必须、Bearer 单独拒绝、缺少防跨站头/异源/null Origin 拒绝、合法同源请求、未知字段拒绝、响应不可缓存通过 |
|
||||||
|
| 准备对象严格 JSON 往返 | CREATE 与 ROTATE 的可选 ID/空能力组原样提交通过;真实 HTTP 发现的空 ID 编码问题已修复并加入回归 |
|
||||||
|
| `scripts/test-console-applications.py` | 真实 HTTP 创建/重放/冲突,任务和订阅生成,轮换后旧 Token 401、新 Token 可读取原任务与订阅,订阅暂停收尾、退出会话失效通过 |
|
||||||
|
| `make console-e2e` | 全新 MySQL/容器初始化,应用接入新链路、登录/权限、真实 Worker/重试/定时、保留策略、周期归档及离线校验全部通过 |
|
||||||
|
| 本地 Compose | 更新现有 18080 环境,复用原管理员、初始业务凭据与数据库卷;新增独立 access-demo 测试应用,轮换只发生于该测试应用 |
|
||||||
|
| 浏览器检查 | 实际账号登录,独立页面与应用详情加载、新建表单显示、权限/队列/配置展示;当前 1280×720 视口布局正常,无浏览器错误 |
|
||||||
|
|
||||||
|
`make integration` 已完整退出 0:覆盖真实存储、HTTP/WSS、SDK、可运行示例、独立 Inbox/Outbox/HTTP 宿主、Worker 与事件进程故障以及流程多节点暂停恢复。页面布局与表单读取通过浏览器核对;实际创建与轮换的持久化链路通过真实 HTTP 验证,不混称为浏览器点击完成全部流程。文档链接检查覆盖 196 份 Markdown,全部解析通过。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
接入管理跨应用,其他业务页面仍使用账号原有默认范围。旧 Token 原文不可查看;新 Token 交付丢失时保留原应用并重新轮换。列表每页 50 个命名空间,单个详情队列和凭据各最多 100 项并显式标记截断。当前未增加删除应用、修改权限、凭据转移、复杂角色或多应用任务总览。
|
||||||
24
docs/reports/console-scope.md
Normal file
24
docs/reports/console-scope.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 控制台应用范围回归
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
任务、计划、流程、订阅和归档列表显示应用名称与环境;保留策略及详情显示归属。管理员可选择应用环境和队列,范围保存在 URL,页面切换隔离旧数据。后台按 Cookie 会话授权,普通 SDK 不接受范围覆盖。无表结构变更,无新增外键。
|
||||||
|
|
||||||
|
## 验证记录(2026-09-27)
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `make test vet build` | 通过,服务端、SDK 和各独立示例/适配模块;最后的 HTTP 范围边界测试及编译检查也通过 |
|
||||||
|
| `make integration` | 通过:真实 MySQL 全量存储回归、HTTP/WS、SDK、Inbox/Outbox/HTTP 宿主示例、进程中断及多节点流程恢复;存储回归约 307 秒,最终 SDK 进程故障组约 131 秒 |
|
||||||
|
| `TestIntegrationConsoleScope` | 原始/目标元数据、普通账号队列白名单、二进制 ID 游标、SDK/非指定管理员拒绝、目标队列隔离、仅管理不受理新工作、目标保留策略原键重放、原应用不变、人类审计、停用目标/退出后旧范围拒绝、零外键断言 |
|
||||||
|
| `make console-e2e` | 通过:隔离 MySQL 和实际 Orbit 容器;新建应用、任务/计划/流程/订阅/保留/归档范围读取,订阅管理及重放保留原 SDK 归属,跨应用单条 404、列表无泄漏,Bearer 混用/跨站写入拒绝,退出后 401;原任务执行、计划和归档端到端也通过 |
|
||||||
|
| HTTP 范围边界 | 通过 11 个 Cookie/Bearer/CSRF/读写入口场景;Worker、结果消费和资源创建不能借管理范围获得权限 |
|
||||||
|
| 静态与配置检查 | JS 语法、Python 编译、Markdown 链接、`git diff --check` 通过;单节点 + HA Compose 配置解析通过,双节点使用同一管理员环境配置 |
|
||||||
|
| 浏览器实际页面 | 任务列和订阅列表/详情显示应用标签;应用及队列切换、无数据范围、保留策略和归档范围提示、非法范围不降级、重置范围恢复均已检查 |
|
||||||
|
| 本地部署 | 本地 Compose 已部署本批代码;现有两个应用分别读取自己的范围,原 SDK Token 仍有效,无业务接入参数变更 |
|
||||||
|
|
||||||
|
应用、队列名称来自授权后的数据库元数据;队列分页首屏不使用空 ID,白名单使用 BINARY ID 参数。URL 中显式指定其他应用的队列会校验失败,不静默切换队列。打开详情、策略未保存或操作结果未确认时,页面阻止切换范围。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
当前按所选应用环境分别查看,不提供跨应用混合分页或全库统计。任务、计划、订阅按队列筛选;流程、保留与归档按命名空间展示。应用目录 50 项一页、队列 100 项一页。普通账号保留原绑定权限;只有部署指定的单管理员具有显式管理范围。此回归不替代生产部署的容量和跨故障域验收。
|
||||||
34
docs/reports/credential-overlap.md
Normal file
34
docs/reports/credential-overlap.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 长期凭据与平滑换证(2026-09-27)
|
||||||
|
|
||||||
|
新建应用与 bootstrap 默认长期 Token,可选有限有效期。已有 Token 支持 SET_EXPIRY 原地修改有效期,原秘密和接入配置保持不变;默认换证保留 7 天过渡,支持按配置调整,确认全部服务切换后可提前 FINALIZE。REVOKE 同时拒绝两代秘密。
|
||||||
|
|
||||||
|
## 实现与兼容
|
||||||
|
|
||||||
|
- 000032 在 orbit_credentials 增加 previous_secret_digest、previous_valid_until,CHECK 要求成对为空/非空。中文模型与 SQL 注释齐全,当前空库基线为 32,无外键。历史迁移不修改。
|
||||||
|
- 认证在原事务共享锁下读取两代摘要及各自截止;HTTP 和 Worker/结果消费复用原认证入口。截止使用数据库时间,不依赖内存、清理任务或节点时钟。
|
||||||
|
- 旧代实际截止不晚于其原有效期;重复轮换不会挤掉尚未切换的旧代,调整当前有效期也不会顺带延长旧代。凭据 ID、权限、任务、订阅与交付身份不变。
|
||||||
|
- 管理操作、版本、固定过渡截止、审计及幂等回执同事务。格式 v2 新增长期和过渡语义,历史 v1 仍按原固定到期/立即轮换恢复;重放历史轮换不复活已经停用的旧代。
|
||||||
|
- 管理端增加长期选项、调整有效期、平滑换证及结束过渡;CLI 增加 expiry/finalize、默认 --valid-for 0 和轮换 --grace 168h。没有修改 SDK 接口或 Token 格式。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
| 验证 | 证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标准回归 | `make test vet build` 七模块竞态测试、静态检查和构建通过 |
|
||||||
|
| 原 Token 改长期与在途执行 | `TestIntegrationCredentialOverlap`:运行中的任务原凭据改长期后继续授权;换证期间旧 Token 续租、完成结果、释放许可;两代认证、并发原操作去重、重复轮换冲突、结束过渡、历史重报、两代撤销及审计脱敏通过 |
|
||||||
|
| 期限与旧代隔离 | `TestIntegrationCredentialOverlapDeadline`:不超过旧有效期、两代独立到期、到期无需清理即可拒绝、下一轮不复活更旧秘密通过;时间边界由隔离测试库明确设置,不把测试耗时当作长期运行证据 |
|
||||||
|
| 双节点真实 WS | `TestIntegrationCredentialOverlapWebSocket`:两个 HTTP/WS 节点共享真实 MySQL;原长连接在轮换后可发控制命令,旧 Token 可跨节点重连,新 Token 同时连接;结束过渡后旧连接命令和新握手被拒绝,新连接继续可用 |
|
||||||
|
| 提交结果未知 | `TestIntegrationCredentialCommitFaults`:COMMIT 前截断及数据库提交成功后丢确认;旧 Token 始终可用,新 Token 只在实际提交后生效,原操作核对恢复 |
|
||||||
|
| 管理 HTTP | `scripts/test-console-applications.py`:真实 Cookie 管理、CSRF、定期改长期且原 Token 200、双 Token 过渡、提前结束、旧 Token 401、新 Token 200、历史回执不能复活旧秘密、原任务/订阅读取和退出失效通过 |
|
||||||
|
| 容器端到端 | `make console-e2e` 全新 MySQL/Orbit、上述管理链路、原登录/Worker/重试/定时/归档验收通过 |
|
||||||
|
| 全量集成 | `make integration` 退出 0:真实 MySQL 全部存储用例、历史结构前向升级、HTTP/WS、独立 SDK、任务/定时/流程示例、Inbox/Outbox/HTTP 宿主、Worker/结果消费者进程故障及多节点流程恢复通过 |
|
||||||
|
| 本地升级 | 复用现有数据库卷与管理员;迁移至 32,旧 CREATE 固定对象重放命中原回执;通过有审计的 SET_EXPIRY 更新既有测试凭据,原 Token 请求 200,host.env/docker.env 字节不变,跨应用队列访问仍 404 |
|
||||||
|
| 浏览器 | 实际登录会话读取到长期有效;新建表单默认长期,平滑换证默认 7 天,调整有效期表单正确;窄窗口换证弹窗可完整操作,未通过浏览器触发实际换证 |
|
||||||
|
|
||||||
|
文档链接检查覆盖 197 份 Markdown,通过;JS 语法、Python 语法及 Git diff 检查通过。涉及 Token 的变更链路在隔离真实 HTTP/MySQL 环境验证,浏览器只验证页面和表单,不把两者混称为浏览器全链路测试。
|
||||||
|
|
||||||
|
## 使用边界
|
||||||
|
|
||||||
|
调用方在截止前完成滚动更新并正常收尾在途任务。SDK 在创建客户端时读取 Token,改 `.env` 不会热更新现有客户端;须重建客户端或逐台重启服务。单实例重启仍有短暂停顿,平台不能替业务服务保证副本容量。提前结束过渡或到期会拒绝尚未切换的旧 Token,不承诺强行停用旧身份后其连接还能继续工作。
|
||||||
|
|
||||||
|
这次数据库升级完成且所有 Orbit 节点更新后才能启用双 Token 换证;旧版本节点不认识上一代摘要。只修改有授权的本地测试凭据为长期,迁移不会自动延长其他已有凭据。完整操作顺序见[凭据手册](../api/authentication.md)和[管理接口](../api/console-applications.md)。
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# P6:凭据操作与安全轮换
|
# P6:凭据操作与安全轮换
|
||||||
|
|
||||||
|
本报告记录当时的立即换证语义;后续长期凭据和双 Token 过渡已在[增量报告](credential-overlap.md)单独说明。
|
||||||
|
|
||||||
日期:2026-09-19。本文只记录管理凭据操作的已实现边界,不把凭据功能等同于 P6 全部生产验收。
|
日期:2026-09-19。本文只记录管理凭据操作的已实现边界,不把凭据功能等同于 P6 全部生产验收。
|
||||||
|
|
||||||
## 已实现行为
|
## 已实现行为
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
## 本次验证
|
## 本次验证
|
||||||
|
|
||||||
发布前检查结果如下;远端安装结果在发布后回写 main 的本记录:
|
本地检查与发布后远端安装均已完成,结果如下:
|
||||||
|
|
||||||
| 检查 | 结果 |
|
| 检查 | 结果 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
| `make integration` | 通过,退出 0;真实 MySQL、27 个示例场景、事务/HTTP 回执丢失、进程强杀及多节点恢复 |
|
| `make integration` | 通过,退出 0;真实 MySQL、27 个示例场景、事务/HTTP 回执丢失、进程强杀及多节点恢复 |
|
||||||
| `make console-e2e` | 通过;登录/保护路由、Worker/重试/计划、保留策略/周期归档作业、离线核验和退出登录 |
|
| `make console-e2e` | 通过;登录/保护路由、Worker/重试/计划、保留策略/周期归档作业、离线核验和退出登录 |
|
||||||
| 历史任务 API 双向 smoke | 通过;当前 SDK 对 a50d5fa 服务端、a50d5fa SDK 对当前服务端 |
|
| 历史任务 API 双向 smoke | 通过;当前 SDK 对 a50d5fa 服务端、a50d5fa SDK 对当前服务端 |
|
||||||
| 远端 main 与三组标签 | 待发布 |
|
| 远端 main 与三组标签 | 原子推送成功,默认分支 main;三组标签均指向发布提交 81e56bd633cfe9f7669b4243dee5d7683e5f6697 |
|
||||||
| 仓库外固定版本安装、构建、HTTP smoke 与校验和 | 待发布后验证 |
|
| 仓库外固定版本安装、构建、HTTP smoke 与校验和 | 空模块缓存、GOWORK=off、无 replace;两个模块精确解析为 v0.1.1,全部退出 0 |
|
||||||
|
|
||||||
## 版本约定
|
## 版本约定
|
||||||
|
|
||||||
@@ -32,3 +32,34 @@
|
|||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
上述检查验证示例迁移、当前平台链路和远端发布,不等同于目标生产容量或跨故障域验收。历史 API smoke 不冒充全协议跨标签矩阵,具体矩阵见[兼容说明](../sdk/compatibility.md)。归档继续保留源数据,不承诺源数据删除或其他数据库适配。
|
上述检查验证示例迁移、当前平台链路和远端发布,不等同于目标生产容量或跨故障域验收。历史 API smoke 不冒充全协议跨标签矩阵,具体矩阵见[兼容说明](../sdk/compatibility.md)。归档继续保留源数据,不承诺源数据删除或其他数据库适配。
|
||||||
|
|
||||||
|
## 远端发布证据
|
||||||
|
|
||||||
|
发布提交为 `81e56bd633cfe9f7669b4243dee5d7683e5f6697`。通过 `git ls-remote --symref origin` 核对默认分支为 main,平台/SDK/Outbox 三组标签解引用到同一提交。随后仅补充本验证记录,既有标签不移动。远端 main 的根 README 与 examples/README 原文逐字节核对一致。
|
||||||
|
|
||||||
|
在仓库外新建 `example.com/orbit-release-consumer`,使用全新 GOPATH/GOMODCACHE、`GOWORK=off`,执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.1 git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql@v0.1.1
|
||||||
|
go mod tidy
|
||||||
|
go build ./...
|
||||||
|
go run .
|
||||||
|
go mod verify
|
||||||
|
```
|
||||||
|
|
||||||
|
测试程序通过远端 SDK 调用本地 httptest 服务的健康接口,并读取远端 Outbox SchemaSQL 核对表名及零外键;这一步验证远端包安装与可用性,真实数据库事务/HTTP 业务链路由上方全量回归覆盖。实际输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
remote_sdk_http_smoke=passed outbox_schema_smoke=passed
|
||||||
|
all modules verified
|
||||||
|
remote_versions=v0.1.1 no_replace=true checksum_verification=enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
验证进程使用 `GOPROXY=https://goproxy.cn,direct`,仅在该进程沿用主机已有网络代理;`GOSUMDB=sum.golang.org` 保持开启,未关闭校验和、未设置私有模块例外、未修改全局 Go/Git 配置。解析结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.1 h1:xikN6Frz7HJsHdh4e3iHG9sX4cJNlRjKh0Wn/ApN3XI=
|
||||||
|
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.1/go.mod h1:9KlMW7Gy+EGaDoEwr3wHHFY47mnAymSfYeQpMS7h4ag=
|
||||||
|
git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql v0.1.1 h1:oqRfqtAq6Q1CWss4RFH1jnGu8S+2xr5e+QmeC5zfMPE=
|
||||||
|
git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql v0.1.1/go.mod h1:G+8DIXGFFssSeQ9T1HahnKUyoKNlF2GQDB21ENO3xqk=
|
||||||
|
```
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ task=<32位任务ID> duplicate=true listed=1 final_state=CANCELLED revision=2
|
|||||||
- [任务示例源码](../../examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消,再核对直接来源。
|
- [任务示例源码](../../examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消,再核对直接来源。
|
||||||
- 任务示例也接受 ORBIT_TOKEN 和 ORBIT_QUEUE_ID,环境值优先于凭据文件。
|
- 任务示例也接受 ORBIT_TOKEN 和 ORBIT_QUEUE_ID,环境值优先于凭据文件。
|
||||||
- 示例发生错误会打印错误并非零退出;打印内容不包含原始 token。
|
- 示例发生错误会打印错误并非零退出;打印内容不包含原始 token。
|
||||||
- 初始凭据 30 天有效。初始化行为和错误核对方式见 [凭据文档](../api/authentication.md)。
|
- 初始凭据默认长期有效,可配置固定到期时间;支持新旧 Token 过渡换证。初始化行为和错误核对方式见 [凭据文档](../api/authentication.md)。
|
||||||
- `make integration` 会在一次性数据库中自动完成上面的真实服务与 SDK 示例验证。
|
- `make integration` 会在一次性数据库中自动完成上面的真实服务与 SDK 示例验证。
|
||||||
|
|
||||||
## 2. 创建客户端与提交任务
|
## 2. 创建客户端与提交任务
|
||||||
|
|||||||
@@ -14,31 +14,33 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。
|
||||||
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
|
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
||||||
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
|
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
|
||||||
ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。
|
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
|
||||||
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
|
ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。
|
||||||
HTTPAddr string
|
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
|
||||||
MySQLDSN string
|
HTTPAddr string
|
||||||
MaxOpen int
|
MySQLDSN string
|
||||||
MaxIdle int
|
MaxOpen int
|
||||||
MetricsAddr string
|
MaxIdle int
|
||||||
WorkflowTickInterval time.Duration // WorkflowTickInterval Weave 持久化推进扫描间隔,范围100ms–1m。
|
MetricsAddr string
|
||||||
ScheduleTickInterval time.Duration // ScheduleTickInterval 计划扫描间隔,范围100ms–1m。
|
WorkflowTickInterval time.Duration // WorkflowTickInterval Weave 持久化推进扫描间隔,范围100ms–1m。
|
||||||
RecoveryTickInterval time.Duration // RecoveryTickInterval 执行租约恢复扫描间隔,范围100ms–1m。
|
ScheduleTickInterval time.Duration // ScheduleTickInterval 计划扫描间隔,范围100ms–1m。
|
||||||
RuntimeBatchLimit int // RuntimeBatchLimit 每个后台批次的候选上限,范围1–100。
|
RecoveryTickInterval time.Duration // RecoveryTickInterval 执行租约恢复扫描间隔,范围100ms–1m。
|
||||||
ShutdownTimeout time.Duration // ShutdownTimeout 优雅排空和关闭窗口,范围1s–5m。
|
RuntimeBatchLimit int // RuntimeBatchLimit 每个后台批次的候选上限,范围1–100。
|
||||||
WorkerConnectionLimit int // WorkerConnectionLimit 单节点 Worker WebSocket 连接上限,范围1–4096。
|
ShutdownTimeout time.Duration // ShutdownTimeout 优雅排空和关闭窗口,范围1s–5m。
|
||||||
EventConnectionLimit int // EventConnectionLimit 单节点 Event WebSocket 连接上限,范围1–4096。
|
WorkerConnectionLimit int // WorkerConnectionLimit 单节点 Worker WebSocket 连接上限,范围1–4096。
|
||||||
DispatchWorkers int // DispatchWorkers 单个 Hub 并发执行数据库领取调用的协程数,范围1–256。
|
EventConnectionLimit int // EventConnectionLimit 单节点 Event WebSocket 连接上限,范围1–4096。
|
||||||
DispatchCapacity int // DispatchCapacity 单个 Hub 领取调用的有界内存排队槽位,范围1–65536。
|
DispatchWorkers int // DispatchWorkers 单个 Hub 并发执行数据库领取调用的协程数,范围1–256。
|
||||||
|
DispatchCapacity int // DispatchCapacity 单个 Hub 领取调用的有界内存排队槽位,范围1–65536。
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) { return FromEnv(os.Getenv) }
|
func Load() (Config, error) { return FromEnv(os.Getenv) }
|
||||||
func FromEnv(get func(string) string) (Config, error) {
|
func FromEnv(get func(string) string) (Config, error) {
|
||||||
c := Config{
|
c := Config{
|
||||||
ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second,
|
ConsoleApplicationAdmin: get("ORBIT_CONSOLE_APPLICATION_ADMIN"),
|
||||||
|
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"),
|
HTTPAddr: get("ORBIT_HTTP_ADDR"),
|
||||||
MySQLDSN: get("ORBIT_MYSQL_DSN"),
|
MySQLDSN: get("ORBIT_MYSQL_DSN"),
|
||||||
MaxOpen: 20,
|
MaxOpen: 20,
|
||||||
|
|||||||
99
internal/consoleauth/applications.go
Normal file
99
internal/consoleauth/applications.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package consoleauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplicationEntry 是应用接入列表的一行;同一应用的不同命名空间分别展示。
|
||||||
|
type ApplicationEntry struct {
|
||||||
|
ApplicationID core.ID `json:"application_id"` // ApplicationID 所属应用标识。
|
||||||
|
Application string `json:"application"` // Application 应用名称。
|
||||||
|
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 数据隔离范围标识。
|
||||||
|
Namespace string `json:"namespace"` // Namespace 命名空间名称。
|
||||||
|
Environment string `json:"environment"` // Environment 环境名称。
|
||||||
|
Enabled bool `json:"enabled"` // Enabled 应用与命名空间均启用时为真。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationPage 按命名空间标识分页,每页最多 50 行,不包含秘密。
|
||||||
|
type ApplicationPage struct {
|
||||||
|
Items []ApplicationEntry `json:"items"` // Items 当前页接入范围。
|
||||||
|
Next core.ID `json:"next,omitempty"` // Next 下一页的排他起点。
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessQueue 是接入配置可选的队列。
|
||||||
|
type AccessQueue struct {
|
||||||
|
ID core.ID `json:"id"` // ID 队列标识。
|
||||||
|
Name string `json:"name"` // Name 队列名称。
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessCredential 是脱敏的凭据元数据,绝不返回秘密或秘密摘要。
|
||||||
|
type AccessCredential struct {
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil 上一代令牌实际截止,空表示无旧令牌。
|
||||||
|
ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。
|
||||||
|
Name string `json:"name"` // Name 凭据名称。
|
||||||
|
Revision uint64 `json:"revision,string"` // Revision 轮换的并发校验版本。
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 到期时间,空表示无固定到期时间。
|
||||||
|
RevokedAt *time.Time `json:"revoked_at,omitempty"` // RevokedAt 撤销时间,非空时禁止轮换。
|
||||||
|
Permissions auth.Permissions `json:"permissions"` // Permissions 动作和队列授权范围。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationDetail 展示一个命名空间的队列和凭据,读取量有界。
|
||||||
|
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 为真时不得把当前结果解释为完整清单。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationInput 描述创建应用或变更凭据的管理员意图。
|
||||||
|
type ApplicationInput struct {
|
||||||
|
Kind string `json:"kind"` // Kind CREATE、ROTATE、SET_EXPIRY 或 FINALIZE。
|
||||||
|
Application string `json:"application"` // Application 新应用名称,仅 CREATE 使用。
|
||||||
|
Namespace string `json:"namespace"` // Namespace 新命名空间名称,仅 CREATE 使用。
|
||||||
|
Environment string `json:"environment"` // Environment 新环境名称,仅 CREATE 使用。
|
||||||
|
Queue string `json:"queue"` // Queue 初始队列名称,仅 CREATE 使用。
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"` // Capabilities 显式选择的业务权限组,仅 CREATE 使用。
|
||||||
|
NamespaceID core.ID `json:"namespace_id,omitempty"` // NamespaceID 轮换目标所属范围。
|
||||||
|
CredentialID core.ID `json:"credential_id,omitempty"` // CredentialID 轮换目标凭据。
|
||||||
|
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 轮换时读取到的版本。
|
||||||
|
ValidDays int `json:"valid_days"` // ValidDays 有效天数;0 为长期,1–365 为定期。
|
||||||
|
GraceDays int `json:"grace_days,omitempty"` // GraceDays ROTATE 的旧令牌过渡天数,0–30;0 显式立即失效。
|
||||||
|
Reason string `json:"reason"` // Reason 写入审计的变更原因。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationOperation 是浏览器在提交前保存的固定操作;Token 不写入数据库。
|
||||||
|
// 网络结果未知时重放同一份内容,禁止重新生成操作身份或秘密。
|
||||||
|
type ApplicationOperation struct {
|
||||||
|
Version int `json:"version,omitempty"` // Version v2 支持长期令牌与过渡;旧固定操作未设置时沿用 v1。
|
||||||
|
Input ApplicationInput `json:"input"` // Input 固定的管理员意图。
|
||||||
|
OperationID core.ID `json:"operation_id"` // OperationID 稳定幂等身份。
|
||||||
|
ApplicationID core.ID `json:"application_id"` // ApplicationID 新建或已核验的应用标识。
|
||||||
|
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 固定命名空间标识。
|
||||||
|
QueueID core.ID `json:"queue_id,omitempty"` // QueueID 新建应用的初始队列。
|
||||||
|
CredentialID core.ID `json:"credential_id"` // CredentialID 固定凭据身份。
|
||||||
|
Token string `json:"token,omitempty"` // Token 一次性交付的秘密,只留在调用方内存或私密下载文件。
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 固定到期时间,空表示长期或不适用。
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil ROTATE 固定过渡截止。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationReceipt 是数据库持久化的脱敏回执。
|
||||||
|
type ApplicationReceipt struct {
|
||||||
|
OperationID core.ID `json:"operation_id"` // OperationID 原操作身份。
|
||||||
|
ApplicationID core.ID `json:"application_id"` // ApplicationID 应用标识。
|
||||||
|
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 命名空间标识。
|
||||||
|
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据标识。
|
||||||
|
Revision uint64 `json:"revision,string"` // Revision 操作提交后的凭据版本。
|
||||||
|
Deduplicated bool `json:"deduplicated"` // Deduplicated 命中原回执,不代表当前 Token 仍有效。
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applications 只面向部署指定的单管理员浏览器会话,不接收业务 SDK 身份。
|
||||||
|
type Applications interface {
|
||||||
|
ListApplications(context.Context, auth.Token, core.ID) (ApplicationPage, error)
|
||||||
|
GetApplication(context.Context, auth.Token, core.ID) (ApplicationDetail, error)
|
||||||
|
PrepareApplication(context.Context, auth.Token, ApplicationInput) (ApplicationOperation, error)
|
||||||
|
ApplyApplication(context.Context, auth.Token, ApplicationOperation) (ApplicationReceipt, error)
|
||||||
|
}
|
||||||
22
internal/consoleauth/scope.go
Normal file
22
internal/consoleauth/scope.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package consoleauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope 是当前查看范围的脱敏元数据。队列分页独立于应用分页。
|
||||||
|
type Scope struct {
|
||||||
|
Entry ApplicationEntry `json:"entry"` // Entry 数据库核实的应用、环境和命名空间。
|
||||||
|
Queues []AccessQueue `json:"queues"` // Queues 本页授权队列,最多 100 项。
|
||||||
|
Next core.ID `json:"next,omitempty"` // Next 下一页队列排他起点。
|
||||||
|
CanSwitch bool `json:"can_switch"` // CanSwitch 仅部署指定的管理员可以切换应用。
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes 只供登录 Cookie 使用,SDK 不具备范围切换接口。
|
||||||
|
type Scopes interface {
|
||||||
|
ResolveScope(context.Context, auth.Token, core.ID) (auth.Token, error)
|
||||||
|
GetScope(context.Context, auth.Token, core.ID) (Scope, error)
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
|||||||
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
|
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
|
||||||
// 已经存在的 v29/v30 表误当成迁移重复执行。
|
// 已经存在的 v29/v30 表误当成迁移重复执行。
|
||||||
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
||||||
|
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`)
|
||||||
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
|
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
|
||||||
if err := Migrate(ctx, db); err != nil {
|
if err := Migrate(ctx, db); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- Orbit fresh-install baseline, generated from migration version 31.
|
-- Orbit fresh-install baseline, generated from migration version 32.
|
||||||
-- This file intentionally contains no database-level relation constraints.
|
-- This file intentionally contains no database-level relation constraints.
|
||||||
CREATE TABLE `orbit_application_resources` (
|
CREATE TABLE `orbit_application_resources` (
|
||||||
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
|
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
|
||||||
@@ -59,12 +59,15 @@ CREATE TABLE `orbit_credentials` (
|
|||||||
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||||
`name` varchar(128) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
`name` varchar(128) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||||
`secret_digest` binary(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文',
|
`secret_digest` binary(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文',
|
||||||
|
`previous_secret_digest` binary(32) DEFAULT NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文',
|
||||||
|
`previous_valid_until` datetime(6) DEFAULT NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空',
|
||||||
`permissions` json NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验',
|
`permissions` json NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验',
|
||||||
`expires_at` datetime(6) DEFAULT NULL COMMENT '凭据过期时间,统一 UTC;为空表示没有配置到期时间',
|
`expires_at` datetime(6) DEFAULT NULL COMMENT '凭据过期时间,统一 UTC;为空表示没有配置到期时间',
|
||||||
`revoked_at` datetime(6) DEFAULT NULL COMMENT '撤销时间;为空表示尚未撤销',
|
`revoked_at` datetime(6) DEFAULT NULL COMMENT '撤销时间;为空表示尚未撤销',
|
||||||
PRIMARY KEY (`namespace_id`,`id`),
|
PRIMARY KEY (`namespace_id`,`id`),
|
||||||
UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`),
|
UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`),
|
||||||
CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0))
|
CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0)),
|
||||||
|
CONSTRAINT `ck_credential_overlap` CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围';
|
||||||
CREATE TABLE `orbit_deliveries` (
|
CREATE TABLE `orbit_deliveries` (
|
||||||
`namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
`namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||||
|
|||||||
@@ -16,39 +16,43 @@ import (
|
|||||||
|
|
||||||
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。
|
ValidFor time.Duration // ValidFor 初始令牌有效时长;0 为长期,定期范围 1 小时至 365 天。
|
||||||
WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。
|
WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。
|
||||||
WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。
|
WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。
|
||||||
WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。
|
WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。
|
||||||
WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。
|
WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。
|
||||||
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
|
WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。
|
||||||
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
|
||||||
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
||||||
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
|
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
||||||
Namespace string // Namespace 命名空间名称。
|
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
|
||||||
Environment string // Environment 环境名称,必须显式指定。
|
Namespace string // Namespace 命名空间名称。
|
||||||
Queue string // Queue 初始队列名称。
|
Environment string // Environment 环境名称,必须显式指定。
|
||||||
WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。
|
Queue string // Queue 初始队列名称。
|
||||||
ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。
|
WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。
|
||||||
ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。
|
ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。
|
||||||
ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。
|
ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。
|
||||||
ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。
|
ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。
|
||||||
ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。
|
ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。
|
||||||
ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。
|
ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。
|
||||||
|
ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。
|
||||||
}
|
}
|
||||||
|
|
||||||
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
|
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
|
||||||
type BootstrapResult struct {
|
type BootstrapResult struct {
|
||||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
|
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
|
||||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
|
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
|
||||||
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
|
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
|
||||||
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
|
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
|
||||||
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
|
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
|
||||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。
|
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 初始凭据到期时间,UTC;空为长期。
|
||||||
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
|
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) error {
|
||||||
|
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
||||||
|
return fmt.Errorf("validity must be zero or between one hour and one year")
|
||||||
|
}
|
||||||
for _, v := range []string{in.Namespace, in.Queue} {
|
for _, v := range []string{in.Namespace, in.Queue} {
|
||||||
if !service.ValidName(v, 128) {
|
if !service.ValidName(v, 128) {
|
||||||
return fmt.Errorf("invalid bootstrap resource name")
|
return fmt.Errorf("invalid bootstrap resource name")
|
||||||
@@ -154,7 +158,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.ExpiresAt = now.Add(30 * 24 * time.Hour)
|
if in.ValidFor > 0 {
|
||||||
|
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
|
||||||
|
}
|
||||||
if in.ExistingApplicationID == "" {
|
if in.ExistingApplicationID == "" {
|
||||||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil {
|
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
386
internal/storage/mysql/console_applications.go
Normal file
386
internal/storage/mysql/console_applications.go
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
driver "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConsoleApplicationStore 仅允许部署显式指定的单管理员管理跨应用接入。
|
||||||
|
// 使用运行数据库的 DML 权限,不需要迁移或数据库管理员权限。
|
||||||
|
type ConsoleApplicationStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConsoleApplicationStore(db *sql.DB, username string) *ConsoleApplicationStore {
|
||||||
|
return &ConsoleApplicationStore{db: db, username: username}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 == "" {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
var user core.ID
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s
|
||||||
|
JOIN orbit_console_users u ON u.id=s.user_id
|
||||||
|
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
|
||||||
|
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6)
|
||||||
|
AND u.enabled=TRUE AND u.username=? FOR SHARE OF u,s`, token.NamespaceID, token.CredentialID, token.Digest, s.username).Scan(&user)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
return "console:" + string(user), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConsoleApplicationStore) ListApplications(ctx context.Context, token auth.Token, after core.ID) (consoleauth.ApplicationPage, error) {
|
||||||
|
out := consoleauth.ApplicationPage{Items: []consoleauth.ApplicationEntry{}}
|
||||||
|
if after != "" && after.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid application cursor")
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
query := `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id`
|
||||||
|
args := []any{}
|
||||||
|
if after != "" {
|
||||||
|
query += ` WHERE n.id>?`
|
||||||
|
args = append(args, after)
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, query+` ORDER BY n.id LIMIT 51`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var e consoleauth.ApplicationEntry
|
||||||
|
if err := rows.Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, e)
|
||||||
|
}
|
||||||
|
if len(out.Items) > 50 {
|
||||||
|
out.Items = out.Items[:50]
|
||||||
|
out.Next = out.Items[49].NamespaceID
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth.Token, namespace core.ID) (consoleauth.ApplicationDetail, error) {
|
||||||
|
out := consoleauth.ApplicationDetail{Queues: []consoleauth.AccessQueue{}, Credentials: []consoleauth.AccessCredential{}}
|
||||||
|
if namespace.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid namespace")
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e := &out.Entry
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=?`, namespace).Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, `SELECT id,name FROM pulse_queues WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var q consoleauth.AccessQueue
|
||||||
|
if err := rows.Scan(&q.ID, &q.Name); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Queues = append(out.Queues, q)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &c.Permissions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.ExpiresAt = nullableTime(expires)
|
||||||
|
c.RevokedAt = nullableTime(revoked)
|
||||||
|
c.PreviousValidUntil = nullableTime(previous)
|
||||||
|
out.Credentials = append(out.Credentials, c)
|
||||||
|
}
|
||||||
|
if len(out.Queues) > 100 {
|
||||||
|
out.Truncated = true
|
||||||
|
out.Queues = out.Queues[:100]
|
||||||
|
}
|
||||||
|
if len(out.Credentials) > 100 {
|
||||||
|
out.Truncated = true
|
||||||
|
out.Credentials = out.Credentials[:100]
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var applicationCapabilities = map[string][]string{
|
||||||
|
"worker": {"workers:connect", "workers:consume"},
|
||||||
|
"schedules": {"schedules:read", "schedules:manage"},
|
||||||
|
"events": {"events:read", "events:manage", "events:consume"},
|
||||||
|
"workflows": {"workflows:definitions:read", "workflows:definitions:publish", "workflows:start", "workflows:read", "workflows:control", "workflows:signal", "workflows:audit:read"},
|
||||||
|
"recovery": {"tasks:review", "tasks:retry", "tasks:audit:read", "workflows:recover", "workflows:read"},
|
||||||
|
"resources": {"application:resources:read", "application:resources:manage"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationPermissions(in consoleauth.ApplicationInput) (auth.Permissions, error) {
|
||||||
|
p := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
|
||||||
|
if len(in.Capabilities) > len(applicationCapabilities) {
|
||||||
|
return p, fault.Invalid("invalid capabilities")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
actions := map[string]bool{}
|
||||||
|
for _, group := range in.Capabilities {
|
||||||
|
list, ok := applicationCapabilities[group]
|
||||||
|
if !ok || seen[group] {
|
||||||
|
return p, fault.Invalid("invalid or duplicate capability")
|
||||||
|
}
|
||||||
|
seen[group] = true
|
||||||
|
for _, a := range list {
|
||||||
|
if !actions[a] {
|
||||||
|
p.Actions = append(p.Actions, a)
|
||||||
|
actions[a] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, p.Validate()
|
||||||
|
}
|
||||||
|
func validateApplicationInput(in consoleauth.ApplicationInput) error {
|
||||||
|
if in.ValidDays < 0 || in.ValidDays > 365 {
|
||||||
|
return fault.Invalid("valid_days must be 0 (permanent) or 1-365")
|
||||||
|
}
|
||||||
|
if in.GraceDays < 0 || in.GraceDays > 30 || (in.Kind != "ROTATE" && in.GraceDays != 0) {
|
||||||
|
return fault.Invalid("grace_days must be 0-30 for rotation only")
|
||||||
|
}
|
||||||
|
if in.Kind == "FINALIZE" && in.ValidDays != 0 {
|
||||||
|
return fault.Invalid("finalize cannot set validity")
|
||||||
|
}
|
||||||
|
if err := validCredentialReason(in.Reason); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch in.Kind {
|
||||||
|
case "CREATE":
|
||||||
|
if !service.ValidName(in.Application, 128) || !service.ValidName(in.Namespace, 128) || !service.ValidName(in.Environment, 32) || !service.ValidName(in.Queue, 128) || in.NamespaceID != "" || in.CredentialID != "" || in.ExpectedRevision != 0 {
|
||||||
|
return fault.Invalid("invalid new application fields")
|
||||||
|
}
|
||||||
|
_, err := applicationPermissions(in)
|
||||||
|
return err
|
||||||
|
case "ROTATE", "SET_EXPIRY", "FINALIZE":
|
||||||
|
if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil || in.ExpectedRevision == 0 || in.Application != "" || in.Namespace != "" || in.Environment != "" || in.Queue != "" || len(in.Capabilities) != 0 {
|
||||||
|
return fault.Invalid("invalid rotation fields")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY or FINALIZE")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
||||||
|
out := consoleauth.ApplicationOperation{Version: 2, Input: in}
|
||||||
|
if err := validateApplicationInput(in); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now, err := dbNow(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.ValidDays > 0 {
|
||||||
|
out.ExpiresAt = timePtr(now.Add(time.Duration(in.ValidDays) * 24 * time.Hour))
|
||||||
|
}
|
||||||
|
if in.GraceDays > 0 {
|
||||||
|
out.PreviousValidUntil = timePtr(now.Add(time.Duration(in.GraceDays) * 24 * time.Hour))
|
||||||
|
}
|
||||||
|
out.OperationID, err = core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.Kind == "CREATE" {
|
||||||
|
for _, id := range []*core.ID{&out.ApplicationID, &out.NamespaceID, &out.QueueID, &out.CredentialID} {
|
||||||
|
*id, err = core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.NamespaceID, out.CredentialID = in.NamespaceID, in.CredentialID
|
||||||
|
var revision uint64
|
||||||
|
var revoked sql.NullTime
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT n.application_id,c.revision,c.revoked_at FROM orbit_credentials c JOIN orbit_namespaces n ON n.id=c.namespace_id WHERE c.namespace_id=? AND c.id=?`, in.NamespaceID, in.CredentialID).Scan(&out.ApplicationID, &revision, &revoked)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if revision != in.ExpectedRevision || revoked.Valid {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Kind == "CREATE" || in.Kind == "ROTATE" {
|
||||||
|
out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token auth.Token, op consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
|
||||||
|
out := consoleauth.ApplicationReceipt{}
|
||||||
|
if err := validateApplicationInput(op.Input); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if (op.Version != 0 && op.Version != 2) || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid fixed operation identity")
|
||||||
|
}
|
||||||
|
if (op.Input.ValidDays == 0) != (op.ExpiresAt == nil) || (op.Input.GraceDays == 0) != (op.PreviousValidUntil == nil) {
|
||||||
|
return out, fault.Invalid("fixed lifetime does not match input")
|
||||||
|
}
|
||||||
|
version := op.Version
|
||||||
|
if version == 0 {
|
||||||
|
version = 1
|
||||||
|
}
|
||||||
|
if op.Input.Kind != "CREATE" {
|
||||||
|
if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID {
|
||||||
|
return out, fault.Invalid("credential identity mismatch")
|
||||||
|
}
|
||||||
|
change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil}
|
||||||
|
receipt, err := applyCredentialOperation(ctx, s.db, change, func(tx *sql.Tx) (string, error) {
|
||||||
|
actor, err := s.admin(ctx, tx, token)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var app core.ID
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT application_id FROM orbit_namespaces WHERE id=?`, op.NamespaceID).Scan(&app)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", fault.NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if app != op.ApplicationID {
|
||||||
|
return "", fault.Invalid("application identity mismatch")
|
||||||
|
}
|
||||||
|
return actor, nil
|
||||||
|
})
|
||||||
|
return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err
|
||||||
|
}
|
||||||
|
parsed, err := auth.Parse(op.Token)
|
||||||
|
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || (version == 1 && op.ExpiresAt == nil) {
|
||||||
|
return out, fault.Invalid("invalid new credential")
|
||||||
|
}
|
||||||
|
if op.QueueID.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid queue identity")
|
||||||
|
}
|
||||||
|
permissions, err := applicationPermissions(op.Input)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(permissions)
|
||||||
|
digest, err := requestDigest(op)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
keyDigest := core.Digest(sha256.Sum256([]byte(op.OperationID)))
|
||||||
|
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
actor, err := s.admin(ctx, tx, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at)
|
||||||
|
VALUES (?,?,'console.application.create.v1',?,?,?,'application',?,JSON_OBJECT(),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 90 DAY)) ON DUPLICATE KEY UPDATE id=orbit_idempotency_keys.id`, token.NamespaceID, op.OperationID, actor, keyDigest, digest, op.ApplicationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var stored core.Digest
|
||||||
|
var response []byte
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT request_digest,response FROM orbit_idempotency_keys WHERE namespace_id=? AND operation='console.application.create.v1' AND scope_key=? AND key_digest=? FOR UPDATE`, token.NamespaceID, actor, keyDigest).Scan(&stored, &response)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if subtleCompareDigest(stored, digest) != 1 {
|
||||||
|
return fault.IdempotencyConflict
|
||||||
|
}
|
||||||
|
if string(response) != "{}" {
|
||||||
|
if err := json.Unmarshal(response, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Deduplicated = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now, err := dbNow(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
|
||||||
|
return fault.Invalid("expired or excessive credential lifetime")
|
||||||
|
}
|
||||||
|
statements := []struct {
|
||||||
|
query string
|
||||||
|
args []any
|
||||||
|
}{
|
||||||
|
{`INSERT INTO orbit_applications(id,name) VALUES (?,?)`, []any{op.ApplicationID, op.Input.Application}},
|
||||||
|
{`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}},
|
||||||
|
}
|
||||||
|
for _, st := range statements {
|
||||||
|
if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil {
|
||||||
|
var e *driver.MySQLError
|
||||||
|
if errors.As(err, &e) && e.Number == 1062 {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := appendAuditActor(ctx, tx, op.NamespaceID, actor, op.OperationID, "console.application.create", op.CredentialID, op.Input.Reason, nil, map[string]any{"application_id": op.ApplicationID, "application": op.Input.Application, "queue_id": op.QueueID, "permissions": permissions, "expires_at": op.ExpiresAt}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: 1}
|
||||||
|
receipt, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `UPDATE orbit_idempotency_keys SET response=?,revision=revision+1 WHERE namespace_id=? AND id=?`, receipt, token.NamespaceID, op.OperationID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
165
internal/storage/mysql/console_applications_integration_test.go
Normal file
165
internal/storage/mysql/console_applications_integration_test.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationConsoleApplications(t *testing.T) {
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := Open(ctx, dsn, 10, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
username := "access-" + string(testID(t))
|
||||||
|
password := "local-test-password-12345"
|
||||||
|
var initial BootstrapResult
|
||||||
|
if err := Bootstrap(ctx, db, BootstrapInput{Application: username, Namespace: "default", Environment: "test", Queue: "initial", ConsoleUsername: username, ConsolePassword: password}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
login, err := NewConsoleAuthStore(db).Login(ctx, username, password, "test", "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin, _ := auth.Parse(login.Token)
|
||||||
|
sdk, _ := auth.Parse(initial.Token)
|
||||||
|
store := NewConsoleApplicationStore(db, username)
|
||||||
|
if _, err := store.ListApplications(ctx, sdk, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("SDK reached admin: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NewConsoleApplicationStore(db, "").ListApplications(ctx, admin, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatal("disabled admin accepted")
|
||||||
|
}
|
||||||
|
if _, err := NewConsoleApplicationStore(db, "different-admin").ListApplications(ctx, admin, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatal("other user accepted")
|
||||||
|
}
|
||||||
|
in := consoleauth.ApplicationInput{Kind: "CREATE", Application: "created-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Capabilities: []string{"worker", "events", "schedules", "workflows"}, ValidDays: 30, Reason: "integration onboarding"}
|
||||||
|
op, err := store.PrepareApplication(ctx, admin, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 准备只生成秘密,尚未允许业务访问。
|
||||||
|
candidate, _ := auth.Parse(op.Token)
|
||||||
|
if err := NewTaskStore(db).AuthorizeSubmit(ctx, candidate); err == nil {
|
||||||
|
t.Fatal("prepared token accepted before commit")
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
receipts := make([]consoleauth.ApplicationReceipt, 2)
|
||||||
|
errs := make([]error, 2)
|
||||||
|
for i := range receipts {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) { defer wg.Done(); receipts[i], errs[i] = store.ApplyApplication(ctx, admin, op) }(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for _, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if receipts[0].Deduplicated == receipts[1].Deduplicated {
|
||||||
|
t.Fatal("concurrent original operation did not deduplicate")
|
||||||
|
}
|
||||||
|
if err := NewTaskStore(db).AuthorizeSubmit(ctx, candidate); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 同一操作身份不可改变请求,重名不能留下半套资源。
|
||||||
|
changed := op
|
||||||
|
changed.Input.Reason = "modified"
|
||||||
|
if _, err := store.ApplyApplication(ctx, admin, changed); fault.Code(err) != "IDEMPOTENCY_CONFLICT" {
|
||||||
|
t.Fatalf("changed operation accepted: %v", err)
|
||||||
|
}
|
||||||
|
duplicate, err := store.PrepareApplication(ctx, admin, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.ApplyApplication(ctx, admin, duplicate); fault.Code(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatalf("duplicate name accepted: %v", err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_namespaces WHERE id=?`, duplicate.NamespaceID).Scan(&n); err != nil || n != 0 {
|
||||||
|
t.Fatal("duplicate left namespace")
|
||||||
|
}
|
||||||
|
detail, err := store.GetApplication(ctx, admin, op.NamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if detail.Entry.ApplicationID != op.ApplicationID || len(detail.Queues) != 1 || len(detail.Credentials) != 1 || !detail.Credentials[0].Permissions.Allows("events:consume") {
|
||||||
|
t.Fatal("incomplete onboarding")
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(detail)
|
||||||
|
if strings.Contains(string(raw), op.Token) || strings.Contains(string(raw), "secret_digest") {
|
||||||
|
t.Fatal("detail leaked credential")
|
||||||
|
}
|
||||||
|
rotation := consoleauth.ApplicationInput{Kind: "ROTATE", NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: 1, ValidDays: 90, Reason: "scheduled rotation"}
|
||||||
|
rotated, err := store.PrepareApplication(ctx, admin, rotation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
receipt, err := store.ApplyApplication(ctx, admin, rotated)
|
||||||
|
if err != nil || receipt.Revision != 2 {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
newToken, _ := auth.Parse(rotated.Token)
|
||||||
|
if NewTaskStore(db).AuthorizeSubmit(ctx, candidate) == nil || NewTaskStore(db).AuthorizeSubmit(ctx, newToken) != nil {
|
||||||
|
t.Fatal("rotation auth boundary failed")
|
||||||
|
}
|
||||||
|
replay, err := store.ApplyApplication(ctx, admin, rotated)
|
||||||
|
if err != nil || !replay.Deduplicated {
|
||||||
|
t.Fatalf("rotation replay: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.PrepareApplication(ctx, admin, rotation); fault.Code(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatalf("stale revision accepted: %v", err)
|
||||||
|
}
|
||||||
|
detail, err = store.GetApplication(ctx, admin, op.NamespaceID)
|
||||||
|
if err != nil || len(detail.Credentials) != 1 || detail.Credentials[0].ID != op.CredentialID || detail.Queues[0].ID != op.QueueID {
|
||||||
|
t.Fatal("rotation changed identity")
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT actor_id,CAST(`+"`after`"+` AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND action IN ('console.application.create','credential.rotate')`, op.NamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var actor, body string
|
||||||
|
if err := rows.Scan(&actor, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if actor != "console:"+login.User.ID || strings.Contains(body, op.Token) || strings.Contains(body, rotated.Token) {
|
||||||
|
t.Fatal("audit principal or secrecy")
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if err != nil || count != 2 {
|
||||||
|
t.Fatalf("audit count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
// 登出在数据库生效后,原会话不能再次应用操作,即使持有原回执内容。
|
||||||
|
if err := NewConsoleAuthStore(db).RevokeSession(ctx, admin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.ApplyApplication(ctx, admin, rotated); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("logged out replay accepted: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema=DATABASE()`).Scan(&n); err != nil || n != 0 {
|
||||||
|
t.Fatalf("foreign keys=%d err=%v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
135
internal/storage/mysql/console_scope.go
Normal file
135
internal/storage/mysql/console_scope.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// consoleScopeActor 在业务读写的同一事务校验原会话、部署指定管理员及目标应用。
|
||||||
|
// 仅持共享身份锁,之后沿用业务既有的额度→队列→资源锁序。
|
||||||
|
func consoleScopeActor(ctx context.Context, tx *sql.Tx, t auth.Token) (core.ID, error) {
|
||||||
|
if t.Console == nil || t.Console.Username == "" || t.Console.SessionNamespaceID.Validate() != nil || t.NamespaceID.Validate() != nil {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
var user core.ID
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s
|
||||||
|
JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id
|
||||||
|
JOIN orbit_credentials c ON c.namespace_id=s.namespace_id AND c.id=s.credential_id
|
||||||
|
JOIN orbit_namespaces n ON n.id=s.namespace_id JOIN orbit_applications a ON a.id=n.application_id
|
||||||
|
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
|
||||||
|
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE AND u.username=?
|
||||||
|
AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
|
||||||
|
AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, t.Console.SessionNamespaceID, t.CredentialID, t.Digest, t.Console.Username).Scan(&user)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if t.Console.UserID != "" && user != t.Console.UserID {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
var id core.ID
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT n.id FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=? AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, t.NamespaceID).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", fault.NotFound
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleScopePermissions() auth.Permissions {
|
||||||
|
return auth.Permissions{Actions: []string{"tasks:read", "tasks:cancel", "tasks:review", "tasks:retry", "tasks:audit:read", "queues:read", "queues:manage", "schedules:read", "schedules:manage", "events:read", "events:manage", "workflows:read", "workflows:control", "workflows:recover", "workflows:audit:read"}, Queues: []string{"*"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConsoleApplicationStore) ResolveScope(ctx context.Context, t auth.Token, target core.ID) (auth.Token, error) {
|
||||||
|
if target.Validate() != nil {
|
||||||
|
return auth.Token{}, fault.Invalid("invalid console namespace")
|
||||||
|
}
|
||||||
|
out := t
|
||||||
|
out.NamespaceID = target
|
||||||
|
out.Console = &auth.ConsoleIdentity{SessionNamespaceID: t.NamespaceID, Username: s.username}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
user, err := consoleScopeActor(ctx, tx, out)
|
||||||
|
out.Console.UserID = user
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConsoleApplicationStore) GetScope(ctx context.Context, t auth.Token, after core.ID) (consoleauth.Scope, error) {
|
||||||
|
out := consoleauth.Scope{Queues: []consoleauth.AccessQueue{}}
|
||||||
|
if after != "" && after.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid queue cursor")
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
p, err := authenticate(ctx, tx, t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.Console != nil {
|
||||||
|
out.CanSwitch = true
|
||||||
|
} else {
|
||||||
|
// 元数据接口也必须核验真实控制台会话,SDK 不能读取此入口。
|
||||||
|
var user core.ID
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT u.id,(u.username=?) FROM orbit_console_sessions s JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE FOR SHARE`, s.username, t.NamespaceID, t.CredentialID, t.Digest).Scan(&user, &out.CanSwitch)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fault.Unauthorized
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e := &out.Entry
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=?`, t.NamespaceID).Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
query := `SELECT id,name FROM pulse_queues WHERE namespace_id=?`
|
||||||
|
args := []any{t.NamespaceID}
|
||||||
|
if after != "" {
|
||||||
|
query += ` AND id>?`
|
||||||
|
args = append(args, after)
|
||||||
|
}
|
||||||
|
all := false
|
||||||
|
for _, q := range p.Queues {
|
||||||
|
if q == "*" {
|
||||||
|
all = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !all {
|
||||||
|
query += " AND id IN ("
|
||||||
|
for i, q := range p.Queues {
|
||||||
|
if i > 0 {
|
||||||
|
query += ","
|
||||||
|
}
|
||||||
|
query += "?"
|
||||||
|
args = append(args, core.ID(q))
|
||||||
|
}
|
||||||
|
query += ")"
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, query+` ORDER BY id LIMIT 101`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var q consoleauth.AccessQueue
|
||||||
|
if err := rows.Scan(&q.ID, &q.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Queues = append(out.Queues, q)
|
||||||
|
}
|
||||||
|
if len(out.Queues) > 100 {
|
||||||
|
out.Queues = out.Queues[:100]
|
||||||
|
out.Next = out.Queues[99].ID
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
137
internal/storage/mysql/console_scope_integration_test.go
Normal file
137
internal/storage/mysql/console_scope_integration_test.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/retention"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationConsoleScope(t *testing.T) {
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := Open(ctx, dsn, 8, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err = Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
username := "scope-" + string(testID(t))
|
||||||
|
password := "test-password-for-scopes-123"
|
||||||
|
var source, target BootstrapResult
|
||||||
|
for i, dest := range []*BootstrapResult{&source, &target} {
|
||||||
|
in := BootstrapInput{Application: string(testID(t)), Namespace: "default", Environment: "test", Queue: "scope-queue"}
|
||||||
|
if i == 0 {
|
||||||
|
in.ConsoleUsername = username
|
||||||
|
in.ConsolePassword = password
|
||||||
|
}
|
||||||
|
if err = Bootstrap(ctx, db, in, func(v BootstrapResult) error { *dest = v; return nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts := NewConsoleAuthStore(db)
|
||||||
|
login, err := accounts.Login(ctx, username, password, "scope-test", "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session, _ := auth.Parse(login.Token)
|
||||||
|
sdk, _ := auth.Parse(source.Token)
|
||||||
|
scopes := NewConsoleApplicationStore(db, username)
|
||||||
|
initial, err := scopes.GetScope(ctx, session, "")
|
||||||
|
if err != nil || initial.Entry.NamespaceID != source.NamespaceID || !initial.CanSwitch {
|
||||||
|
t.Fatalf("initial scope: %+v %v", initial, err)
|
||||||
|
}
|
||||||
|
if _, err = scopes.GetScope(ctx, sdk, ""); fault.Code(err) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatalf("SDK scope metadata: %v", err)
|
||||||
|
}
|
||||||
|
// 普通账号的队列白名单使用 BINARY ID 查询,不能以字符串错误比较成空列表。
|
||||||
|
if _, err = db.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_OBJECT('actions',JSON_ARRAY('queues:read'),'queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(source.QueueID), source.NamespaceID, source.CredentialID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
limited, err := NewConsoleApplicationStore(db, "another-admin").GetScope(ctx, session, "")
|
||||||
|
if err != nil || limited.CanSwitch || len(limited.Queues) != 1 || limited.Queues[0].ID != source.QueueID {
|
||||||
|
t.Fatalf("limited scope: %+v %v", limited, err)
|
||||||
|
}
|
||||||
|
last, err := scopes.GetScope(ctx, session, source.QueueID)
|
||||||
|
if err != nil || len(last.Queues) != 0 || last.Next != "" {
|
||||||
|
t.Fatalf("queue cursor: %+v %v", last, err)
|
||||||
|
}
|
||||||
|
if _, err = scopes.ResolveScope(ctx, sdk, target.NamespaceID); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("SDK override: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"", "another-admin"} {
|
||||||
|
if _, err = NewConsoleApplicationStore(db, name).ResolveScope(ctx, session, target.NamespaceID); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("unconfigured admin: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scoped, err := scopes.ResolveScope(ctx, session, target.NamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tasks := NewTaskStore(db)
|
||||||
|
if _, err = tasks.GetQueue(ctx, scoped, target.QueueID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = tasks.GetQueue(ctx, scoped, source.QueueID); fault.Code(err) != "NOT_FOUND" {
|
||||||
|
t.Fatalf("foreign queue: %v", err)
|
||||||
|
}
|
||||||
|
if err = tasks.AuthorizeSubmit(ctx, scoped); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("scope must not create work: %v", err)
|
||||||
|
}
|
||||||
|
got, err := scopes.GetScope(ctx, scoped, "")
|
||||||
|
if err != nil || got.Entry.ApplicationID != target.ApplicationID || len(got.Queues) != 1 || got.Queues[0].ID != target.QueueID {
|
||||||
|
t.Fatalf("target metadata: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
// 完整应用/队列名称从数据库读取,包含特殊字符也不改变授权身份。
|
||||||
|
policy := NewRetentionPolicyStore(db)
|
||||||
|
in := retention.UpdateInput{ExpectedRevision: 0, Enabled: false, IdempotencyDays: 120, TaskHistoryDays: 400, WorkflowHistoryDays: 400, EventLogDays: 180, DeliveryHistoryDays: 120, InboxHistoryDays: 120, AuditLogDays: 2555, Reason: "scope policy test"}
|
||||||
|
saved, err := policy.UpdateRetentionPolicy(ctx, scoped, "scope-policy", in)
|
||||||
|
if err != nil || saved.NamespaceID != target.NamespaceID {
|
||||||
|
t.Fatalf("policy: %+v %v", saved, err)
|
||||||
|
}
|
||||||
|
if _, err = policy.UpdateRetentionPolicy(ctx, scoped, "scope-policy", in); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
original, err := policy.GetRetentionPolicy(ctx, source.NamespaceID)
|
||||||
|
if err != nil || original.Revision != 0 {
|
||||||
|
t.Fatalf("changed original scope: %+v %v", original, err)
|
||||||
|
}
|
||||||
|
var actor string
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT actor_id FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update'`, target.NamespaceID).Scan(&actor); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if actor != scoped.ActorID() {
|
||||||
|
t.Fatalf("audit actor: %s", actor)
|
||||||
|
}
|
||||||
|
// 解析后停用目标应用/退出会话,旧的内存范围不能继续授权。
|
||||||
|
if _, err = db.ExecContext(ctx, `UPDATE orbit_applications SET enabled=FALSE WHERE id=?`, target.ApplicationID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = tasks.GetQueue(ctx, scoped, target.QueueID); fault.Code(err) != "NOT_FOUND" {
|
||||||
|
t.Fatalf("disabled target: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = db.ExecContext(ctx, `UPDATE orbit_applications SET enabled=TRUE WHERE id=?`, target.ApplicationID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = accounts.RevokeSession(ctx, session); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in.ExpectedRevision = 1
|
||||||
|
if _, err = policy.UpdateRetentionPolicy(ctx, scoped, "after-logout", in); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("stale scope write: %v", err)
|
||||||
|
}
|
||||||
|
var foreignKeys int
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 {
|
||||||
|
t.Fatalf("foreign keys %d %v", foreignKeys, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ func TestIntegrationCredentialCommitFaults(t *testing.T) {
|
|||||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, Reason: "commit boundary"})
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, GraceFor: time.Hour, Reason: "commit boundary"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -175,6 +175,14 @@ func TestIntegrationCredentialCommitFaults(t *testing.T) {
|
|||||||
if after {
|
if after {
|
||||||
wantRevision = 2
|
wantRevision = 2
|
||||||
}
|
}
|
||||||
|
oldToken, _ := auth.Parse(initial.Token)
|
||||||
|
newToken, _ := auth.Parse(op.Token)
|
||||||
|
if err := NewTaskStore(db).AuthorizeSubmit(ctx, oldToken); err != nil {
|
||||||
|
t.Fatal("commit ambiguity invalidated old token", err)
|
||||||
|
}
|
||||||
|
if accepted := NewTaskStore(db).AuthorizeSubmit(ctx, newToken) == nil; accepted != after {
|
||||||
|
t.Fatal("commit boundary accepted wrong generation")
|
||||||
|
}
|
||||||
if status.Revision != wantRevision {
|
if status.Revision != wantRevision {
|
||||||
t.Fatalf("commit %v status revision=%d want=%d", after, status.Revision, wantRevision)
|
t.Fatalf("commit %v status revision=%d want=%d", after, status.Revision, wantRevision)
|
||||||
}
|
}
|
||||||
|
|||||||
283
internal/storage/mysql/credential_overlap_integration_test.go
Normal file
283
internal/storage/mysql/credential_overlap_integration_test.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func overlapDB(t *testing.T) (context.Context, *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
db, err := Open(ctx, dsn, 12, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
if err = Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ctx, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCredentialOverlap(t *testing.T) {
|
||||||
|
ctx, db := overlapDB(t)
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
old := f.token
|
||||||
|
// 先使用有限有效期,再原地改长期;在途授权、秘密及绑定均不改变。
|
||||||
|
change := func(action string, revision uint64, grace time.Duration) CredentialOperation {
|
||||||
|
t.Helper()
|
||||||
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: revision, Action: action, GraceFor: grace, Reason: "credential overlap regression"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return op
|
||||||
|
}
|
||||||
|
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 30 DAY) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
|
||||||
|
task := f.submit(1)
|
||||||
|
_, ref := f.claim()
|
||||||
|
if _, err := f.a.Start(ctx, old, f.binding, testID(t), ref); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expiry := change(credentialExpiryAction, 1, 0)
|
||||||
|
receipt, err := ApplyCredentialOperation(ctx, db, expiry)
|
||||||
|
if err != nil || receipt.ExpiresAt != nil || expiry.Token != "" {
|
||||||
|
t.Fatalf("expiry update: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.a.Authorize(ctx, old); err != nil {
|
||||||
|
t.Fatal("original token lost after expiry update", err)
|
||||||
|
}
|
||||||
|
rotation := change(credentialRotateAction, 2, 7*24*time.Hour)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
receipts := make([]CredentialChangeReceipt, 2)
|
||||||
|
errs := make([]error, 2)
|
||||||
|
for i := range receipts {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) { defer wg.Done(); receipts[i], errs[i] = ApplyCredentialOperation(ctx, db, rotation) }(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if errs[0] != nil || errs[1] != nil || receipts[0].Deduplicated == receipts[1].Deduplicated {
|
||||||
|
t.Fatalf("concurrent replay: %v", errs)
|
||||||
|
}
|
||||||
|
current, _ := auth.Parse(rotation.Token)
|
||||||
|
for _, token := range []auth.Token{old, current} {
|
||||||
|
if err := f.a.Authorize(ctx, token); err != nil {
|
||||||
|
t.Fatal("overlap auth rejected", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID)
|
||||||
|
if err != nil || status.ExpiresAt != nil || status.PreviousValidUntil == nil {
|
||||||
|
t.Fatal("missing permanent/overlap status", err)
|
||||||
|
}
|
||||||
|
originalUntil := *status.PreviousValidUntil
|
||||||
|
// 过渡期间第二次轮换必须冲突,而不能挤掉仍在工作的上一代。
|
||||||
|
blocked := change(credentialRotateAction, 3, time.Hour)
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, blocked); fault.Code(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatal("active predecessor overwritten", err)
|
||||||
|
}
|
||||||
|
renewed, err := f.a.Renew(ctx, old, f.binding, testID(t), ref)
|
||||||
|
if err != nil || !renewed.Lease.Active {
|
||||||
|
t.Fatal("in-flight old-token renew failed", err)
|
||||||
|
}
|
||||||
|
if _, err := f.a.Finish(ctx, old, f.binding, testID(t), worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"ok":true}`)}); err != nil {
|
||||||
|
t.Fatal("in-flight finish rejected", err)
|
||||||
|
}
|
||||||
|
if f.get(task.ID).State != pulse.TaskSucceeded || f.used() != 0 {
|
||||||
|
t.Fatal("task/permit changed by rotation")
|
||||||
|
}
|
||||||
|
final := change(credentialFinalizeAction, 3, 0)
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, final); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.a.Authorize(ctx, old); fault.Code(err) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("retired token accepted", err)
|
||||||
|
}
|
||||||
|
if err := f.a.Authorize(ctx, current); err != nil {
|
||||||
|
t.Fatal("current token rejected", err)
|
||||||
|
}
|
||||||
|
replay, err := ApplyCredentialOperation(ctx, db, rotation)
|
||||||
|
if err != nil || !replay.Deduplicated || !replay.PreviousValidUntil.Equal(originalUntil) {
|
||||||
|
t.Fatal("old receipt drifted", err)
|
||||||
|
}
|
||||||
|
if err := f.a.Authorize(ctx, old); err == nil {
|
||||||
|
t.Fatal("replay resurrected predecessor")
|
||||||
|
}
|
||||||
|
// 整张凭据撤销必须同时使两代令牌失效。
|
||||||
|
next := change(credentialRotateAction, 4, time.Hour)
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, next); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newest, _ := auth.Parse(next.Token)
|
||||||
|
revoke := change(credentialRevokeAction, 5, 0)
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, revoke); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, token := range []auth.Token{old, current, newest} {
|
||||||
|
if err := f.a.Authorize(ctx, token); fault.Code(err) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("revocation missed a generation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var audits string
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT CAST(JSON_ARRAYAGG(JSON_OBJECT('before',`+"`before`"+`,'after',`+"`after`"+`)) AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='credential'`, old.NamespaceID).Scan(&audits); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(audits, rotation.Token) || strings.Contains(audits, "secret_digest") {
|
||||||
|
t.Fatal("audit leaked secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCredentialOverlapDeadline(t *testing.T) {
|
||||||
|
ctx, db := overlapDB(t)
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
old := f.token
|
||||||
|
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
|
||||||
|
before, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: 24 * time.Hour, Reason: "bounded grace"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := ApplyCredentialOperation(ctx, db, op)
|
||||||
|
if err != nil || !out.PreviousValidUntil.Equal(*before.ExpiresAt) {
|
||||||
|
t.Fatal("grace extended original expiry", err)
|
||||||
|
}
|
||||||
|
current, _ := auth.Parse(op.Token)
|
||||||
|
// 新令牌过期不会改变独立的旧令牌截止;旧令牌也不能越过自己的截止。
|
||||||
|
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
|
||||||
|
if f.a.Authorize(ctx, old) != nil || fault.Code(f.a.Authorize(ctx, current)) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("expiry of two generations conflated")
|
||||||
|
}
|
||||||
|
f.exec(`UPDATE orbit_credentials SET expires_at=NULL,previous_valid_until=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 MICROSECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
|
||||||
|
if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("expired old token remained usable")
|
||||||
|
}
|
||||||
|
// 过期的旧令牌不需定时清理才能失效;下一轮可以重用单一旧代槽。
|
||||||
|
next, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 2, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "next rotation"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, next); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("wrong predecessor reused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个独立 HTTP/WS 节点共享真实 MySQL,验证旧长连接命令和跨节点重连。
|
||||||
|
func TestIntegrationCredentialOverlapWebSocket(t *testing.T) {
|
||||||
|
ctx, db := overlapDB(t)
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
serve := func(service *worker.Service) *httptest.Server {
|
||||||
|
hub := httpserver.NewWorkerHub(ctx, service)
|
||||||
|
server := httpserver.New("", nil, f.tasks)
|
||||||
|
httpserver.RegisterWorkers(server, hub)
|
||||||
|
ts := httptest.NewServer(server.Handler)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := hub.Shutdown(shutdown); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
ts.Close()
|
||||||
|
})
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
a, b := serve(f.a), serve(f.b)
|
||||||
|
hello := f.hello
|
||||||
|
hello.InstanceID = testID(t)
|
||||||
|
hello.Capabilities = []worker.Capability{{Handler: "rotation.idle", HandlerVersion: "v1", PayloadVersion: "v1"}}
|
||||||
|
type frame struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
SessionID core.ID `json:"session_id"`
|
||||||
|
Error *fault.Error `json:"error"`
|
||||||
|
}
|
||||||
|
connect := func(url, token string) (*websocket.Conn, core.ID) {
|
||||||
|
t.Helper()
|
||||||
|
c, _, err := websocket.Dial(ctx, url+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + token}}, Subprotocols: []string{"pulse.worker.v1"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { c.CloseNow() })
|
||||||
|
if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.hello", "message_id": testID(t), "body": hello}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out frame
|
||||||
|
if err := wsjson.Read(ctx, c, &out); err != nil || out.Type != "worker.welcome" {
|
||||||
|
t.Fatalf("WS welcome: %v, %s", err, out.Type)
|
||||||
|
}
|
||||||
|
return c, out.SessionID
|
||||||
|
}
|
||||||
|
capacity := func(c *websocket.Conn, session core.ID, seq int, want string) {
|
||||||
|
t.Helper()
|
||||||
|
body := worker.CapacityInput{Sequence: uint64(seq), Capacity: 0}
|
||||||
|
if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.capacity", "message_id": testID(t), "session_id": session, "body": body}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out frame
|
||||||
|
if err := wsjson.Read(ctx, c, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
code := ""
|
||||||
|
if out.Error != nil {
|
||||||
|
code = out.Error.Code
|
||||||
|
}
|
||||||
|
if code != want {
|
||||||
|
t.Fatalf("WS auth: %s, want %s", code, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldConn, session := connect(a.URL, f.resources.Token)
|
||||||
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "rolling switch"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, op); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
capacity(oldConn, session, 1, "")
|
||||||
|
oldConn.CloseNow()
|
||||||
|
reconnected, oldSession := connect(b.URL, f.resources.Token)
|
||||||
|
capacity(reconnected, oldSession, 1, "")
|
||||||
|
hello.InstanceID = testID(t)
|
||||||
|
current, newSession := connect(a.URL, op.Token)
|
||||||
|
capacity(current, newSession, 1, "")
|
||||||
|
final, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 2, Action: credentialFinalizeAction, Reason: "all services switched"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, final); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
capacity(reconnected, oldSession, 2, "UNAUTHENTICATED")
|
||||||
|
capacity(current, newSession, 2, "")
|
||||||
|
c, response, err := websocket.Dial(ctx, b.URL+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + f.resources.Token}}, Subprotocols: []string{"pulse.worker.v1"}})
|
||||||
|
if c != nil {
|
||||||
|
c.CloseNow()
|
||||||
|
}
|
||||||
|
if err == nil || response == nil || response.StatusCode != 401 {
|
||||||
|
t.Fatal("retired WS handshake accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
credentialOperationVersion = 1
|
credentialOperationVersion = 2
|
||||||
credentialRotateAction = "ROTATE"
|
credentialRotateAction = "ROTATE"
|
||||||
credentialRevokeAction = "REVOKE"
|
credentialRevokeAction = "REVOKE"
|
||||||
|
credentialExpiryAction = "SET_EXPIRY"
|
||||||
|
credentialFinalizeAction = "FINALIZE"
|
||||||
credentialOperationName = "credential.change.v1"
|
credentialOperationName = "credential.change.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ type CredentialStatus struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// Revision 凭据的乐观并发版本,所有变更都必须核对它。
|
// Revision 凭据的乐观并发版本,所有变更都必须核对它。
|
||||||
Revision uint64 `json:"revision"`
|
Revision uint64 `json:"revision"`
|
||||||
|
// PreviousValidUntil 上一代令牌可用的 UTC 截止;空表示无旧令牌。
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
|
||||||
// ExpiresAt 当前秘密的到期时间;空值表示未设置到期时间。
|
// ExpiresAt 当前秘密的到期时间;空值表示未设置到期时间。
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
// RevokedAt 撤销时间;空值表示尚未撤销。
|
// RevokedAt 撤销时间;空值表示尚未撤销。
|
||||||
@@ -52,9 +56,11 @@ type CredentialChangeInput struct {
|
|||||||
CredentialID core.ID
|
CredentialID core.ID
|
||||||
// ExpectedRevision 准备操作时观察到的版本。
|
// ExpectedRevision 准备操作时观察到的版本。
|
||||||
ExpectedRevision uint64
|
ExpectedRevision uint64
|
||||||
// Action 只能是 ROTATE 或 REVOKE。
|
// Action 为 ROTATE、REVOKE、SET_EXPIRY 或 FINALIZE。
|
||||||
Action string
|
Action string
|
||||||
// ValidFor 轮换后的有效时长;撤销时必须为零。
|
// GraceFor 轮换时旧令牌的共存时长;零为立即失效,最多 30 天。
|
||||||
|
GraceFor time.Duration
|
||||||
|
// ValidFor 新令牌或原令牌的新有效时长;零为长期,撤销/结束过渡时必须为零。
|
||||||
ValidFor time.Duration
|
ValidFor time.Duration
|
||||||
// Reason 必填审计原因。
|
// Reason 必填审计原因。
|
||||||
Reason string
|
Reason string
|
||||||
@@ -73,13 +79,15 @@ type CredentialOperation struct {
|
|||||||
CredentialID core.ID `json:"credential_id"`
|
CredentialID core.ID `json:"credential_id"`
|
||||||
// ExpectedRevision 只允许在该版本上首次应用。
|
// ExpectedRevision 只允许在该版本上首次应用。
|
||||||
ExpectedRevision uint64 `json:"expected_revision"`
|
ExpectedRevision uint64 `json:"expected_revision"`
|
||||||
// Action 固定为 ROTATE 或 REVOKE。
|
// Action 固定变更类型;格式 v1 仅支持立即 ROTATE 和 REVOKE。
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
// Reason 写入管理审计,不能包含控制字符或秘密。
|
// Reason 写入管理审计,不能包含控制字符或秘密。
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
// Token 轮换产生的一次性新令牌;撤销操作为空。
|
// Token 轮换产生的一次性新令牌;撤销操作为空。
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
// ExpiresAt 轮换后固定的绝对到期时间;重放不能重新计算。
|
// PreviousValidUntil ROTATE 请求的固定过渡截止;实际不超过旧令牌原到期时间。
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
|
||||||
|
// ExpiresAt 轮换/调整有效期后的固定绝对时间;v2 为空表示长期,重放不能重新计算。
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +106,9 @@ type CredentialChangeReceipt struct {
|
|||||||
Revision uint64 `json:"revision"`
|
Revision uint64 `json:"revision"`
|
||||||
// AppliedAt 操作提交事务使用的数据库时间。
|
// AppliedAt 操作提交事务使用的数据库时间。
|
||||||
AppliedAt time.Time `json:"applied_at"`
|
AppliedAt time.Time `json:"applied_at"`
|
||||||
// ExpiresAt 轮换后的固定到期时间。
|
// PreviousValidUntil 操作后的旧令牌截止;重报返回首次提交时的值。
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
|
||||||
|
// ExpiresAt 当前令牌的固定到期时间,空为长期。
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
// RevokedAt 撤销提交时间。
|
// RevokedAt 撤销提交时间。
|
||||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||||
@@ -115,13 +125,13 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential
|
|||||||
if err := CheckSchema(ctx, db); err != nil {
|
if err := CheckSchema(ctx, db); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
var expires, revoked sql.NullTime
|
var expires, revoked, previousUntil sql.NullTime
|
||||||
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,n.enabled,a.enabled
|
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
|
||||||
FROM orbit_credentials c
|
FROM orbit_credentials c
|
||||||
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
||||||
JOIN orbit_applications a ON a.id=n.application_id
|
JOIN orbit_applications a ON a.id=n.application_id
|
||||||
WHERE c.namespace_id=? AND c.id=?`, namespace, credential).
|
WHERE c.namespace_id=? AND c.id=?`, namespace, credential).
|
||||||
Scan(&out.Name, &out.Revision, &expires, &revoked, &out.NamespaceEnabled, &out.ApplicationEnabled)
|
Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return out, fault.NotFound
|
return out, fault.NotFound
|
||||||
}
|
}
|
||||||
@@ -130,6 +140,7 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential
|
|||||||
}
|
}
|
||||||
out.NamespaceID, out.CredentialID = namespace, credential
|
out.NamespaceID, out.CredentialID = namespace, credential
|
||||||
out.ExpiresAt = nullableTime(expires)
|
out.ExpiresAt = nullableTime(expires)
|
||||||
|
out.PreviousValidUntil = nullableTime(previousUntil)
|
||||||
out.RevokedAt = nullableTime(revoked)
|
out.RevokedAt = nullableTime(revoked)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -162,9 +173,12 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.Token = secret
|
out.Token = secret
|
||||||
|
if in.GraceFor > 0 {
|
||||||
|
out.PreviousValidUntil = timePtr(now.Add(in.GraceFor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.ValidFor > 0 {
|
||||||
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
|
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
|
||||||
} else if in.ValidFor != 0 {
|
|
||||||
return out, fault.Invalid("revoke operation cannot set validity")
|
|
||||||
}
|
}
|
||||||
out.Version = credentialOperationVersion
|
out.Version = credentialOperationVersion
|
||||||
out.NamespaceID = in.NamespaceID
|
out.NamespaceID = in.NamespaceID
|
||||||
@@ -182,25 +196,28 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh
|
|||||||
// ValidateCredentialOperation validates the exact private operation shape.
|
// ValidateCredentialOperation validates the exact private operation shape.
|
||||||
// It is exported so the admin CLI can reject tampered files before opening a DB.
|
// It is exported so the admin CLI can reject tampered files before opening a DB.
|
||||||
func ValidateCredentialOperation(op CredentialOperation) error {
|
func ValidateCredentialOperation(op CredentialOperation) error {
|
||||||
if op.Version != credentialOperationVersion || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
if (op.Version != 1 && op.Version != credentialOperationVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
||||||
return fault.Invalid("credential operation identity or version is invalid")
|
return fault.Invalid("credential operation identity or version is invalid")
|
||||||
}
|
}
|
||||||
if op.ExpectedRevision == 0 || (op.Action != credentialRotateAction && op.Action != credentialRevokeAction) {
|
if op.ExpectedRevision == 0 || !validCredentialAction(op.Action) {
|
||||||
return fault.Invalid("credential operation action or revision is invalid")
|
return fault.Invalid("credential operation action or revision is invalid")
|
||||||
}
|
}
|
||||||
if err := validCredentialReason(op.Reason); err != nil {
|
if err := validCredentialReason(op.Reason); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if op.Version == 1 && ((op.Action != credentialRotateAction && op.Action != credentialRevokeAction) || op.PreviousValidUntil != nil || (op.Action == credentialRotateAction && op.ExpiresAt == nil)) {
|
||||||
|
return fault.Invalid("legacy operation requires its original expiry and immediate rotation")
|
||||||
|
}
|
||||||
|
if op.ExpiresAt != nil && op.ExpiresAt.IsZero() || op.PreviousValidUntil != nil && op.PreviousValidUntil.IsZero() {
|
||||||
|
return fault.Invalid("fixed times must not be zero")
|
||||||
|
}
|
||||||
if op.Action == credentialRotateAction {
|
if op.Action == credentialRotateAction {
|
||||||
if op.ExpiresAt == nil || op.ExpiresAt.IsZero() || op.Token == "" {
|
|
||||||
return fault.Invalid("rotation operation must contain a token and fixed expiry")
|
|
||||||
}
|
|
||||||
parsed, err := auth.Parse(op.Token)
|
parsed, err := auth.Parse(op.Token)
|
||||||
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID {
|
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID {
|
||||||
return fault.Invalid("rotation token does not match credential identity")
|
return fault.Invalid("rotation token does not match credential identity")
|
||||||
}
|
}
|
||||||
} else if op.Token != "" || op.ExpiresAt != nil {
|
} else if op.Token != "" || op.PreviousValidUntil != nil || (op.Action != credentialExpiryAction && op.ExpiresAt != nil) {
|
||||||
return fault.Invalid("revoke operation must not contain a token or expiry")
|
return fault.Invalid("invalid fields for credential change")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -209,6 +226,11 @@ func ValidateCredentialOperation(op CredentialOperation) error {
|
|||||||
// receipt. No callback or file I/O occurs inside the transaction. A commit error
|
// receipt. No callback or file I/O occurs inside the transaction. A commit error
|
||||||
// is intentionally returned unchanged; replaying the same operation reconciles it.
|
// is intentionally returned unchanged; replaying the same operation reconciles it.
|
||||||
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation) (CredentialChangeReceipt, error) {
|
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation) (CredentialChangeReceipt, error) {
|
||||||
|
return applyCredentialOperation(ctx, db, op, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCredentialOperation 允许控制台在同一事务先核验管理员,并使用真实人类审计主体。
|
||||||
|
func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error)) (CredentialChangeReceipt, error) {
|
||||||
var out CredentialChangeReceipt
|
var out CredentialChangeReceipt
|
||||||
if err := ValidateCredentialOperation(op); err != nil {
|
if err := ValidateCredentialOperation(op); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -217,16 +239,24 @@ func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
err := WithTx(ctx, db, func(tx *sql.Tx) error {
|
err := WithTx(ctx, db, func(tx *sql.Tx) error {
|
||||||
|
actor := ""
|
||||||
|
if authorize != nil {
|
||||||
|
var err error
|
||||||
|
actor, err = authorize(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
var name string
|
var name string
|
||||||
var revision uint64
|
var revision uint64
|
||||||
var expires, revoked sql.NullTime
|
var expires, revoked, previousUntil sql.NullTime
|
||||||
var namespaceEnabled, applicationEnabled bool
|
var namespaceEnabled, applicationEnabled bool
|
||||||
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,n.enabled,a.enabled
|
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
|
||||||
FROM orbit_credentials c
|
FROM orbit_credentials c
|
||||||
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
||||||
JOIN orbit_applications a ON a.id=n.application_id
|
JOIN orbit_applications a ON a.id=n.application_id
|
||||||
WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID).
|
WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID).
|
||||||
Scan(&name, &revision, &expires, &revoked, &namespaceEnabled, &applicationEnabled)
|
Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return fault.NotFound
|
return fault.NotFound
|
||||||
}
|
}
|
||||||
@@ -254,41 +284,83 @@ func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "previous_valid_until": nullableTime(previousUntil), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
||||||
after := map[string]any{"name": name, "revision": revision + 1, "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
after := map[string]any{"name": name, "revision": revision + 1, "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
||||||
if op.Action == credentialRotateAction {
|
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
|
||||||
parsed, err := auth.Parse(op.Token)
|
return fault.Conflict
|
||||||
if err != nil {
|
}
|
||||||
return fault.Invalid("rotation token is invalid")
|
out.ExpiresAt = nullableTime(expires)
|
||||||
}
|
out.PreviousValidUntil = nullableTime(previousUntil)
|
||||||
if !op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) {
|
switch op.Action {
|
||||||
|
case credentialRotateAction:
|
||||||
|
parsed, _ := auth.Parse(op.Token)
|
||||||
|
// 仍在过渡期时不覆盖上一代,避免把尚未切换的服务突然踢下线。
|
||||||
|
if previousUntil.Valid && previousUntil.Time.After(now) {
|
||||||
return fault.Conflict
|
return fault.Conflict
|
||||||
}
|
}
|
||||||
|
var previousDigest any
|
||||||
|
out.PreviousValidUntil = nil
|
||||||
|
if op.PreviousValidUntil != nil {
|
||||||
|
if !op.PreviousValidUntil.After(now) || op.PreviousValidUntil.After(now.Add(30*24*time.Hour)) {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
until := *op.PreviousValidUntil
|
||||||
|
if expires.Valid && expires.Time.Before(until) {
|
||||||
|
until = expires.Time
|
||||||
|
}
|
||||||
|
if until.After(now) {
|
||||||
|
var old core.Digest
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT secret_digest FROM orbit_credentials WHERE namespace_id=? AND id=?`, op.NamespaceID, op.CredentialID).Scan(&old); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if subtleCompareDigest(old, parsed.Digest) == 1 {
|
||||||
|
return fault.Invalid("rotation must use a new secret")
|
||||||
|
}
|
||||||
|
previousDigest = old
|
||||||
|
out.PreviousValidUntil = timePtr(until)
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
||||||
SET secret_digest=?,expires_at=?,revoked_at=NULL,revision=revision+1,updated_at=?
|
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.UTC(), now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.ExpiresAt = timePtr(op.ExpiresAt.UTC())
|
out.ExpiresAt = op.ExpiresAt
|
||||||
after["expires_at"] = out.ExpiresAt
|
case credentialExpiryAction:
|
||||||
} else {
|
// 只变更当前令牌有效期;不能延长上一代截止或改变任何秘密。
|
||||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET expires_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, op.ExpiresAt, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||||
SET revoked_at=?,revision=revision+1,updated_at=?
|
return err
|
||||||
WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
}
|
||||||
|
out.ExpiresAt = op.ExpiresAt
|
||||||
|
case credentialFinalizeAction:
|
||||||
|
if !previousUntil.Valid {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.PreviousValidUntil = nil
|
||||||
|
case credentialRevokeAction:
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.RevokedAt = timePtr(now)
|
out.RevokedAt = timePtr(now)
|
||||||
after["revoked_at"] = now
|
out.PreviousValidUntil = nil
|
||||||
}
|
}
|
||||||
|
after["expires_at"] = out.ExpiresAt
|
||||||
|
after["previous_valid_until"] = out.PreviousValidUntil
|
||||||
|
after["revoked_at"] = out.RevokedAt
|
||||||
out.OperationID = op.OperationID
|
out.OperationID = op.OperationID
|
||||||
out.NamespaceID = op.NamespaceID
|
out.NamespaceID = op.NamespaceID
|
||||||
out.CredentialID = op.CredentialID
|
out.CredentialID = op.CredentialID
|
||||||
out.Action = op.Action
|
out.Action = op.Action
|
||||||
out.Revision = revision + 1
|
out.Revision = revision + 1
|
||||||
out.AppliedAt = now
|
out.AppliedAt = now
|
||||||
actor, err := databaseActor(ctx, tx)
|
if actor == "" {
|
||||||
if err != nil {
|
actor, err = databaseActor(ctx, tx)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := appendCredentialAudit(ctx, tx, actor, op, before, after); err != nil {
|
if err := appendCredentialAudit(ctx, tx, actor, op, before, after); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -395,17 +467,20 @@ func validateCredentialChangeInput(in CredentialChangeInput) error {
|
|||||||
if err := validateCredentialIDs(in.NamespaceID, in.CredentialID); err != nil {
|
if err := validateCredentialIDs(in.NamespaceID, in.CredentialID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if in.ExpectedRevision == 0 || (in.Action != credentialRotateAction && in.Action != credentialRevokeAction) {
|
if in.ExpectedRevision == 0 || !validCredentialAction(in.Action) {
|
||||||
return fault.Invalid("credential action and expected revision are required")
|
return fault.Invalid("credential action and expected revision are required")
|
||||||
}
|
}
|
||||||
if err := validCredentialReason(in.Reason); err != nil {
|
if err := validCredentialReason(in.Reason); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if in.Action == credentialRotateAction && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
||||||
return fault.Invalid("credential validity must be between one hour and one year")
|
return fault.Invalid("validity must be zero (permanent) or between one hour and one year")
|
||||||
}
|
}
|
||||||
if in.Action == credentialRevokeAction && in.ValidFor != 0 {
|
if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction) && in.ValidFor != 0 {
|
||||||
return fault.Invalid("revoke operation cannot set validity")
|
return fault.Invalid("this operation cannot set validity")
|
||||||
|
}
|
||||||
|
if in.GraceFor < 0 || in.GraceFor > 30*24*time.Hour || (in.Action != credentialRotateAction && in.GraceFor != 0) {
|
||||||
|
return fault.Invalid("rotation grace must be between zero and 30 days")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -429,3 +504,7 @@ func timePtr(v time.Time) *time.Time {
|
|||||||
t := v.UTC()
|
t := v.UTC()
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validCredentialAction(action string) bool {
|
||||||
|
return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ func (s *EventStore) ListSubscriptions(ctx context.Context, t auth.Token, queue
|
|||||||
}
|
}
|
||||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND credential_id=? AND queue_id=?`
|
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND credential_id=? AND queue_id=?`
|
||||||
args := []any{t.NamespaceID, t.CredentialID, queue}
|
args := []any{t.NamespaceID, t.CredentialID, queue}
|
||||||
|
if t.Console != nil {
|
||||||
|
q = `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND queue_id=?`
|
||||||
|
args = []any{t.NamespaceID, queue}
|
||||||
|
}
|
||||||
if in.Cursor != "" {
|
if in.Cursor != "" {
|
||||||
c, err := parseEventCursor(in.Cursor, t, queue, "subscriptions", "")
|
c, err := parseEventCursor(in.Cursor, t, queue, "subscriptions", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -28,10 +28,16 @@ const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,fil
|
|||||||
|
|
||||||
func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) {
|
func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) {
|
||||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL`
|
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL`
|
||||||
|
args := []any{t.NamespaceID, id, t.CredentialID}
|
||||||
|
if t.Console != nil {
|
||||||
|
// 调用方已在同一事务 authenticate;管理员可管理应用内已有订阅,SDK 仍绑定原凭据。
|
||||||
|
q = `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND queue_id IS NOT NULL`
|
||||||
|
args = []any{t.NamespaceID, id}
|
||||||
|
}
|
||||||
if lock {
|
if lock {
|
||||||
q += ` FOR UPDATE`
|
q += ` FOR UPDATE`
|
||||||
}
|
}
|
||||||
out, err := scanSubscription(tx.QueryRowContext(ctx, q, t.NamespaceID, id, t.CredentialID))
|
out, err := scanSubscription(tx.QueryRowContext(ctx, q, args...))
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return out, fault.NotFound
|
return out, fault.NotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ var migrationFiles embed.FS
|
|||||||
//go:embed baseline.sql
|
//go:embed baseline.sql
|
||||||
var freshInstallBaseline string
|
var freshInstallBaseline string
|
||||||
|
|
||||||
const freshInstallBaselineVersion uint64 = 31
|
const freshInstallBaselineVersion uint64 = 32
|
||||||
|
|
||||||
type Migration struct {
|
type Migration struct {
|
||||||
Version uint64
|
Version uint64
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 同一凭据最多保留一代旧秘密;截止时间由轮换事务固定,不依赖节点内存。
|
||||||
|
ALTER TABLE orbit_credentials
|
||||||
|
ADD COLUMN previous_secret_digest BINARY(32) NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文',
|
||||||
|
ADD COLUMN previous_valid_until DATETIME(6) NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空',
|
||||||
|
ADD CONSTRAINT ck_credential_overlap CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL));
|
||||||
@@ -71,8 +71,11 @@ func (s *RetentionPolicyStore) GetRetentionPolicy(ctx context.Context, namespace
|
|||||||
}
|
}
|
||||||
|
|
||||||
// retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。
|
// retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。
|
||||||
// 管理员必须具备当前命名空间全部队列的管理范围;不添加角色体系或跨命名空间权限。
|
// 普通会话要求全部队列管理范围;部署指定管理员的显式范围在同一事务重新核验。
|
||||||
func retentionActor(ctx context.Context, tx *sql.Tx, token auth.Token) (core.ID, error) {
|
func retentionActor(ctx context.Context, tx *sql.Tx, token auth.Token) (core.ID, error) {
|
||||||
|
if token.Console != nil {
|
||||||
|
return consoleScopeActor(ctx, tx, token)
|
||||||
|
}
|
||||||
p, err := authenticate(ctx, tx, token)
|
p, err := authenticate(ctx, tx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -45,19 +45,28 @@ func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action st
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) {
|
func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) {
|
||||||
|
if token.Console != nil {
|
||||||
|
_, err := consoleScopeActor(ctx, tx, token)
|
||||||
|
return consoleScopePermissions(), err
|
||||||
|
}
|
||||||
var digest core.Digest
|
var digest core.Digest
|
||||||
var raw []byte
|
var raw, previous []byte
|
||||||
err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions FROM orbit_credentials c
|
var currentValid, previousValid bool
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions,c.previous_secret_digest,
|
||||||
|
(c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)),
|
||||||
|
COALESCE(c.previous_valid_until>UTC_TIMESTAMP(6),FALSE) FROM orbit_credentials c
|
||||||
JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id
|
JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id
|
||||||
WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL
|
WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL
|
||||||
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw)
|
AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw, &previous, ¤tValid, &previousValid)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return auth.Permissions{}, fault.Unauthorized
|
return auth.Permissions{}, fault.Unauthorized
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return auth.Permissions{}, err
|
return auth.Permissions{}, err
|
||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare(digest[:], token.Digest[:]) != 1 {
|
currentMatch := subtle.ConstantTimeCompare(digest[:], token.Digest[:])
|
||||||
|
previousMatch := subtle.ConstantTimeCompare(previous, token.Digest[:])
|
||||||
|
if !(currentValid && currentMatch == 1) && !(previousValid && previousMatch == 1) {
|
||||||
// 控制台会话使用独立的短期令牌摘要,不轮换也不覆盖业务 SDK
|
// 控制台会话使用独立的短期令牌摘要,不轮换也不覆盖业务 SDK
|
||||||
// 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验,
|
// 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验,
|
||||||
// 才允许沿用该业务凭据的权限快照。
|
// 才允许沿用该业务凭据的权限快照。
|
||||||
@@ -173,7 +182,7 @@ func appendAuditWithEvidence(ctx context.Context, tx *sql.Tx, token auth.Token,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,NULLIF(?,''),?,?)", token.NamespaceID, id, operation, string(token.CredentialID), action, kind, resource, reason, evidence, string(old), string(current))
|
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,NULLIF(?,''),?,?)", token.NamespaceID, id, operation, token.ActorID(), action, kind, resource, reason, evidence, string(old), string(current))
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func RegisterConsoleArchiveJobs(server *http.Server, consoleAuth RetentionConsol
|
|||||||
writeError(w, "", fault.Unauthorized)
|
writeError(w, "", fault.Unauthorized)
|
||||||
return auth.Token{}, false
|
return auth.Token{}, false
|
||||||
}
|
}
|
||||||
return t, true
|
return scopedConsoleToken(r, t), true
|
||||||
}
|
}
|
||||||
key := func(w http.ResponseWriter, r *http.Request) (string, bool) {
|
key := func(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
keys := r.Header.Values("Idempotency-Key")
|
keys := r.Header.Values("Idempotency-Key")
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
"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"
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,11 +121,32 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
|||||||
|
|
||||||
// ConsoleSessionMiddleware 将 HttpOnly 会话转换为内部 Bearer 请求头。
|
// ConsoleSessionMiddleware 将 HttpOnly 会话转换为内部 Bearer 请求头。
|
||||||
// 浏览器脚本永远看不到业务令牌;现有所有 v1 handler 仍走同一个鉴权入口。
|
// 浏览器脚本永远看不到业务令牌;现有所有 v1 handler 仍走同一个鉴权入口。
|
||||||
func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store) http.Handler {
|
func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store, scopes ...consoleauth.Scopes) http.Handler {
|
||||||
if next == nil || consoleAuth == nil {
|
if next == nil || consoleAuth == nil {
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if values := r.Header.Values("X-Orbit-Namespace"); len(values) > 0 {
|
||||||
|
if len(values) != 1 || len(scopes) == 0 || scopes[0] == nil || len(r.Header.Values("Authorization")) > 0 || r.Header.Get("X-Orbit-Console") != "1" || !consoleScopePath(r) {
|
||||||
|
writeError(w, "", fault.Forbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := http.NewCrossOriginProtection().Check(r); err != nil {
|
||||||
|
writeError(w, "", fault.Forbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, ok := consoleCookieToken(r)
|
||||||
|
if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
|
||||||
|
writeError(w, "", fault.Unauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scoped, err := scopes[0].ResolveScope(r.Context(), token, core.ID(values[0]))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), consoleScopeKey{}, scoped))
|
||||||
|
}
|
||||||
if len(r.Header.Values("Authorization")) == 0 {
|
if len(r.Header.Values("Authorization")) == 0 {
|
||||||
if token, ok := consoleCookieToken(r); ok && consoleAuth.ValidateSession(r.Context(), token) == nil {
|
if token, ok := consoleCookieToken(r); ok && consoleAuth.ValidateSession(r.Context(), token) == nil {
|
||||||
clone := r.Clone(r.Context())
|
clone := r.Clone(r.Context())
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, retention: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, scope: null, scopeReady: false, scopeNamespace: '', mutations: 0, retention: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
||||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业' };
|
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' };
|
||||||
|
|
||||||
function showToast(message) {
|
function showToast(message) {
|
||||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||||
@@ -23,13 +23,21 @@
|
|||||||
}
|
}
|
||||||
function setView(view, updateHash = true) {
|
function setView(view, updateHash = true) {
|
||||||
const next = pageNames[view] ? view : 'overview';
|
const next = pageNames[view] ? view : 'overview';
|
||||||
|
const leavingApplications = state.scopeReady && $('application-scope').hidden && next !== 'applications';
|
||||||
document.querySelectorAll('.page-view').forEach(page => page.classList.toggle('active', page.dataset.page === next));
|
document.querySelectorAll('.page-view').forEach(page => page.classList.toggle('active', page.dataset.page === next));
|
||||||
document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.view === next));
|
document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.view === next));
|
||||||
text('page-title', pageNames[next]);
|
text('page-title', pageNames[next]);
|
||||||
|
$('application-scope').hidden = next === 'applications';
|
||||||
|
if (leavingApplications && state.scope?.can_switch) {
|
||||||
|
scopeUI.nextApplication = '';
|
||||||
|
loadScopeApplications().catch(error => showToast(`应用目录读取失败:${error.message}`));
|
||||||
|
}
|
||||||
|
text('scope-hint', ['workflows', 'retention', 'archives'].includes(next) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示');
|
||||||
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
|
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
if (next === 'retention' && state.authenticated) loadRetention();
|
if (next === 'retention' && state.scopeReady) loadRetention();
|
||||||
if (next === 'archives' && state.authenticated) loadArchives();
|
if (next === 'archives' && state.scopeReady) loadArchives();
|
||||||
|
if (next === 'applications' && state.authenticated) window.orbitApplications.load();
|
||||||
}
|
}
|
||||||
function redirectToLogin() {
|
function redirectToLogin() {
|
||||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||||
@@ -42,13 +50,18 @@
|
|||||||
state.authenticated = true; return true;
|
state.authenticated = true; return true;
|
||||||
}
|
}
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const headers = { Accept: 'application/json', ...(options.headers || {}) };
|
const scoped = path.startsWith('/v1/') || path === '/console/scope' || path.startsWith('/console/scope?') || path.startsWith('/console/retention') || path.startsWith('/console/archive/');
|
||||||
|
const headers = { Accept: 'application/json', ...(scoped && state.scopeNamespace ? { 'X-Orbit-Namespace': state.scopeNamespace, 'X-Orbit-Console': '1' } : {}), ...(options.headers || {}) };
|
||||||
|
const mutation = options.method && options.method !== 'GET';
|
||||||
|
if (mutation) state.mutations++;
|
||||||
|
try {
|
||||||
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' });
|
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||||
const raw = await response.text(); let body = {};
|
const raw = await response.text(); let body = {};
|
||||||
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
||||||
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
|
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
|
||||||
if (!response.ok) { const error = new Error(body?.error?.message || body?.message || `HTTP ${response.status}`); error.status = response.status; throw error; }
|
if (!response.ok) { const error = new Error(body?.error?.message || body?.message || `HTTP ${response.status}`); error.status = response.status; throw error; }
|
||||||
return body;
|
return body;
|
||||||
|
} finally { if (mutation) state.mutations--; }
|
||||||
}
|
}
|
||||||
async function loadConsoleConfig() {
|
async function loadConsoleConfig() {
|
||||||
try {
|
try {
|
||||||
@@ -64,6 +77,102 @@
|
|||||||
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
|
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const scopeUI = { applications: [], nextApplication: '', nextQueue: '', busy: false };
|
||||||
|
function scopeBadge() {
|
||||||
|
const e = state.scope?.entry;
|
||||||
|
if (!e) return '';
|
||||||
|
return `<span class="application-badge" title="${escapeHTML(e.application + ' / ' + e.environment + ' / ' + e.namespace)}"><i></i><b>${escapeHTML(e.application)}</b><em>${escapeHTML(e.environment)}</em></span>`;
|
||||||
|
}
|
||||||
|
function appendScopeOptions(select, items, selected, more, kind) {
|
||||||
|
select.replaceChildren();
|
||||||
|
for (const item of items) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = kind === 'application' ? item.namespace_id : item.id;
|
||||||
|
option.textContent = kind === 'application' ? `${item.application} · ${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}` : item.name;
|
||||||
|
option.disabled = kind === 'application' && !item.enabled;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
if (!items.length) select.append(new Option('暂无队列', ''));
|
||||||
|
if (more) select.append(new Option('加载更多…', '__more__'));
|
||||||
|
select.value = selected;
|
||||||
|
}
|
||||||
|
async function loadScopeApplications() {
|
||||||
|
const page = extractData(await request(`/console/applications${scopeUI.nextApplication ? `?after=${encodeURIComponent(scopeUI.nextApplication)}` : ''}`));
|
||||||
|
for (const item of page.items || []) if (!scopeUI.applications.some(e => e.namespace_id === item.namespace_id)) scopeUI.applications.push(item);
|
||||||
|
scopeUI.nextApplication = page.next || '';
|
||||||
|
appendScopeOptions($('scope-application'), scopeUI.applications, state.scope.entry.namespace_id, scopeUI.nextApplication, 'application');
|
||||||
|
}
|
||||||
|
async function loadScope() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
state.scopeNamespace = params.get('application') || '';
|
||||||
|
try {
|
||||||
|
const scope = extractData(await request('/console/scope'));
|
||||||
|
state.scope = scope;
|
||||||
|
// 指定管理员在默认应用内也使用显式管理范围,普通账号继续沿用原有队列权限。
|
||||||
|
if (scope.can_switch && !state.scopeNamespace) {
|
||||||
|
state.scopeNamespace = scope.entry.namespace_id;
|
||||||
|
state.scope = extractData(await request('/console/scope'));
|
||||||
|
}
|
||||||
|
const queues = state.scope.queues;
|
||||||
|
const requested = params.get('queue');
|
||||||
|
const preferred = requested || state.consoleConfig?.default_queue_id;
|
||||||
|
state.queue = queues.some(q => q.id === preferred) ? preferred : (queues[0]?.id || '');
|
||||||
|
if (requested && !queues.some(q => q.id === requested)) {
|
||||||
|
const queue = extractData(await request(`/v1/queues/${encodeURIComponent(requested)}`));
|
||||||
|
queues.push({ id: queue.id, name: queue.name }); state.queue = requested;
|
||||||
|
}
|
||||||
|
scopeUI.nextQueue = state.scope.next || '';
|
||||||
|
scopeUI.applications = [state.scope.entry];
|
||||||
|
appendScopeOptions($('scope-application'), scopeUI.applications, scope.entry.namespace_id, '', 'application');
|
||||||
|
appendScopeOptions($('scope-queue'), queues, state.queue, scopeUI.nextQueue, 'queue');
|
||||||
|
$('scope-application').disabled = !scope.can_switch;
|
||||||
|
$('scope-queue').disabled = !queues.length;
|
||||||
|
if (scope.can_switch) {
|
||||||
|
try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); }
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.detail-scope').forEach(el => { el.innerHTML = scopeBadge(); });
|
||||||
|
$('retention-scope').innerHTML = scopeBadge();
|
||||||
|
text('scope-summary', `${scope.entry.application} / ${scope.entry.environment} / ${scope.entry.namespace}`);
|
||||||
|
state.scopeReady = true; renderConnectionSummary();
|
||||||
|
} catch (error) {
|
||||||
|
state.queue = ''; state.scopeReady = false;
|
||||||
|
text('scope-summary', '范围不可用');
|
||||||
|
$('scope-application').replaceChildren(new Option('范围不可用', ''));
|
||||||
|
$('scope-queue').replaceChildren(new Option('未加载', ''));
|
||||||
|
$('scope-application').disabled = true; $('scope-queue').disabled = true;
|
||||||
|
$('task-table').innerHTML = '<tr><td colspan="6" class="empty-row"><span class="empty-icon">◇</span><b>当前范围未加载</b><small>请检查应用范围,或点击“重置范围”</small></td></tr>';
|
||||||
|
text('scope-error', `当前应用范围不可用:${error.message}。可点击“重置范围”返回默认应用。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function navigateScope(namespace, queue = '') {
|
||||||
|
if (state.mutations || retentionUI.busy || archiveUI.busy || retentionUI.pendingSave || archiveUI.pending) { showToast('有操作尚未确认,请先核对完成后再切换应用'); return false; }
|
||||||
|
if (retentionUI.dirty || archiveUI.dirty || document.querySelector('.drawer-backdrop:not([hidden])')) { showToast('请先保存配置并关闭详情,再切换查看范围'); return false; }
|
||||||
|
const url = new URL(location.href); url.searchParams.delete('application'); url.searchParams.delete('queue');
|
||||||
|
if (namespace) url.searchParams.set('application', namespace);
|
||||||
|
if (queue) url.searchParams.set('queue', queue);
|
||||||
|
// 完整导航隔离不同范围的请求、详情及幂等状态;后退和刷新都恢复 URL 中的范围。
|
||||||
|
location.assign(url.href); return true;
|
||||||
|
}
|
||||||
|
$('scope-application').addEventListener('change', async event => {
|
||||||
|
const value = event.target.value; event.target.value = state.scope?.entry.namespace_id || '';
|
||||||
|
if (scopeUI.busy) return;
|
||||||
|
if (value !== '__more__') { navigateScope(value); return; }
|
||||||
|
scopeUI.busy = true;
|
||||||
|
try { await loadScopeApplications(); } catch (error) { showToast(error.message); } finally { scopeUI.busy = false; }
|
||||||
|
});
|
||||||
|
$('scope-queue').addEventListener('change', async event => {
|
||||||
|
const value = event.target.value; event.target.value = state.queue;
|
||||||
|
if (scopeUI.busy) return;
|
||||||
|
if (value !== '__more__') { navigateScope(state.scopeNamespace, value); return; }
|
||||||
|
scopeUI.busy = true;
|
||||||
|
try {
|
||||||
|
const page = extractData(await request(`/console/scope?after=${encodeURIComponent(scopeUI.nextQueue)}`));
|
||||||
|
for (const queue of page.queues) if (!state.scope.queues.some(q => q.id === queue.id)) state.scope.queues.push(queue);
|
||||||
|
scopeUI.nextQueue = page.next || '';
|
||||||
|
appendScopeOptions($('scope-queue'), state.scope.queues, state.queue, scopeUI.nextQueue, 'queue');
|
||||||
|
} catch (error) { showToast(error.message); } finally { scopeUI.busy = false; }
|
||||||
|
});
|
||||||
|
$('scope-reset').addEventListener('click', () => navigateScope(''));
|
||||||
const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null };
|
const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null };
|
||||||
function syncRetentionControls() {
|
function syncRetentionControls() {
|
||||||
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
|
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
|
||||||
@@ -220,7 +329,7 @@
|
|||||||
text('archive-executor', archiveUI.configured ? '已配置持久卷' : '未配置持久卷');
|
text('archive-executor', archiveUI.configured ? '已配置持久卷' : '未配置持久卷');
|
||||||
$('archive-executor').classList.toggle('ready', archiveUI.configured);
|
$('archive-executor').classList.toggle('ready', archiveUI.configured);
|
||||||
$('archive-plan-chips').innerHTML = archiveUI.plans.map(p => `<span class="${p.enabled ? 'enabled' : ''}">${archiveLabels[p.kind]} · ${p.enabled ? '开启' : '关闭'}</span>`).join('');
|
$('archive-plan-chips').innerHTML = archiveUI.plans.map(p => `<span class="${p.enabled ? 'enabled' : ''}">${archiveLabels[p.kind]} · ${p.enabled ? '开启' : '关闭'}</span>`).join('');
|
||||||
$('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `<article class="archive-job"><div class="archive-job-head"><div><b>${escapeHTML(archiveLabels[j.kind] || j.kind)}</b><span class="state-pill ${stateClass(j.state)}">${escapeHTML(archiveStates[j.state] || j.state)}</span></div><time>${escapeHTML(formatTime(j.created_at))}</time></div><code class="archive-job-id">${escapeHTML(j.id)}</code><div class="archive-progress"><span><b>${j.batches}</b> 批已确认</span><span><b>${j.roots.toLocaleString('zh-CN')}</b> 条根记录</span><span><b>${archiveSize(j.bytes)}</b> / ${j.max_bytes / 1048576} MiB</span><span>策略 v${escapeHTML(j.policy_revision)}</span></div>${j.last_sha256 ? `<details><summary>查看文件校验摘要</summary><code class="archive-digest">${escapeHTML(j.last_sha256)}</code></details>` : ''}${j.error_code ? `<p class="archive-job-error">${escapeHTML(archiveErrors[j.error_code] || j.error_code)}</p>` : ''}${j.state === 'RUNNING' ? `<p class="archive-job-note">领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。</p>` : ''}${j.state === 'FAILED' ? `<div class="archive-job-actions"><button type="button" class="secondary-button compact" data-archive-action="retry" data-id="${escapeHTML(j.id)}">从原作业重试 ↻</button><button type="button" class="text-button" data-archive-action="abandon" data-id="${escapeHTML(j.id)}">关闭此作业</button></div>` : ''}</article>`).join('') : '<div class="empty-row archive-empty"><span class="empty-icon">▤</span><b>还没有归档作业</b><small>保存并开启周期后,系统会按保留窗口生成作业。</small></div>';
|
$('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `<article class="archive-job"><div class="archive-job-head"><div><b>${escapeHTML(archiveLabels[j.kind] || j.kind)}</b><span class="state-pill ${stateClass(j.state)}">${escapeHTML(archiveStates[j.state] || j.state)}</span></div><time>${escapeHTML(formatTime(j.created_at))}</time></div>${scopeBadge()}<code class="archive-job-id">${escapeHTML(j.id)}</code><div class="archive-progress"><span><b>${j.batches}</b> 批已确认</span><span><b>${j.roots.toLocaleString('zh-CN')}</b> 条根记录</span><span><b>${archiveSize(j.bytes)}</b> / ${j.max_bytes / 1048576} MiB</span><span>策略 v${escapeHTML(j.policy_revision)}</span></div>${j.last_sha256 ? `<details><summary>查看文件校验摘要</summary><code class="archive-digest">${escapeHTML(j.last_sha256)}</code></details>` : ''}${j.error_code ? `<p class="archive-job-error">${escapeHTML(archiveErrors[j.error_code] || j.error_code)}</p>` : ''}${j.state === 'RUNNING' ? `<p class="archive-job-note">领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。</p>` : ''}${j.state === 'FAILED' ? `<div class="archive-job-actions"><button type="button" class="secondary-button compact" data-archive-action="retry" data-id="${escapeHTML(j.id)}">从原作业重试 ↻</button><button type="button" class="text-button" data-archive-action="abandon" data-id="${escapeHTML(j.id)}">关闭此作业</button></div>` : ''}</article>`).join('') : '<div class="empty-row archive-empty"><span class="empty-icon">▤</span><b>还没有归档作业</b><small>保存并开启周期后,系统会按保留窗口生成作业。</small></div>';
|
||||||
syncArchiveControls();
|
syncArchiveControls();
|
||||||
}
|
}
|
||||||
async function loadArchives() {
|
async function loadArchives() {
|
||||||
@@ -316,8 +425,8 @@
|
|||||||
$('task-bar-succeeded').style.width = width('succeeded'); $('task-bar-running').style.width = width('running'); $('task-bar-waiting').style.width = `${((counts.waiting || 0) + (counts.pending || 0)) / total * 100}%`; $('task-bar-other').style.width = `${((counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0)) / total * 100}%`;
|
$('task-bar-succeeded').style.width = width('succeeded'); $('task-bar-running').style.width = width('running'); $('task-bar-waiting').style.width = `${((counts.waiting || 0) + (counts.pending || 0)) / total * 100}%`; $('task-bar-other').style.width = `${((counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0)) / total * 100}%`;
|
||||||
text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`);
|
text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`);
|
||||||
const body = $('task-table');
|
const body = $('task-table');
|
||||||
if (!items.length) { body.innerHTML = '<tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
|
if (!items.length) { body.innerHTML = '<tr><td colspan="6" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
|
||||||
body.innerHTML = items.slice(0, 8).map(item => `<tr class="task-row" data-task-id="${escapeHTML(item.id)}" tabindex="0" title="打开任务详情"><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td class="task-processor">${escapeHTML(item.handler || '—')}<br><small>${escapeHTML(item.handler_version || '')}</small></td><td><span class="state-pill ${stateClass(item.state)}">${escapeHTML(item.state || 'UNKNOWN')}</span></td><td>${escapeHTML(item.revision || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td></tr>`).join('');
|
body.innerHTML = items.map(item => `<tr class="task-row" data-task-id="${escapeHTML(item.id)}" tabindex="0" title="打开任务详情"><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td>${scopeBadge()}</td><td class="task-processor">${escapeHTML(item.handler || '—')}<br><small>${escapeHTML(item.handler_version || '')}</small></td><td><span class="state-pill ${stateClass(item.state)}">${escapeHTML(item.state || 'UNKNOWN')}</span></td><td>${escapeHTML(item.revision || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td></tr>`).join('');
|
||||||
body.querySelectorAll('.task-row').forEach(row => {
|
body.querySelectorAll('.task-row').forEach(row => {
|
||||||
row.addEventListener('click', () => openTaskDetail(row.dataset.taskId));
|
row.addEventListener('click', () => openTaskDetail(row.dataset.taskId));
|
||||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(row.dataset.taskId); } });
|
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(row.dataset.taskId); } });
|
||||||
@@ -325,8 +434,8 @@
|
|||||||
}
|
}
|
||||||
function renderSchedules(items) {
|
function renderSchedules(items) {
|
||||||
text('schedule-page-hint', `${items.length} 项 · 点击计划查看详情`);
|
text('schedule-page-hint', `${items.length} 项 · 点击计划查看详情`);
|
||||||
const el = $('schedule-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>◷</span><div><b>当前没有定时计划</b><small>需要 schedules:read 权限</small></div></div>'; return; }
|
const el = $('schedule-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>◷</span><div><b>当前没有定时计划</b><small>当前应用与队列还没有创建计划</small></div></div>'; return; }
|
||||||
el.innerHTML = items.map(item => `<div class="list-row schedule-row" data-schedule-id="${escapeHTML(item.id)}" tabindex="0" title="打开计划详情"><div class="list-avatar">◷</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}</small></div><div class="list-meta">${escapeHTML(item.paused ? '已暂停' : '运行中')}</div></div>`).join('');
|
el.innerHTML = items.map(item => `<div class="list-row schedule-row" data-schedule-id="${escapeHTML(item.id)}" tabindex="0" title="打开计划详情"><div class="list-avatar">◷</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${escapeHTML(item.paused ? '已暂停' : '运行中')}</div></div>`).join('');
|
||||||
el.querySelectorAll('.schedule-row').forEach(row => {
|
el.querySelectorAll('.schedule-row').forEach(row => {
|
||||||
row.addEventListener('click', () => openScheduleDetail(row.dataset.scheduleId));
|
row.addEventListener('click', () => openScheduleDetail(row.dataset.scheduleId));
|
||||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScheduleDetail(row.dataset.scheduleId); } });
|
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScheduleDetail(row.dataset.scheduleId); } });
|
||||||
@@ -334,8 +443,8 @@
|
|||||||
}
|
}
|
||||||
function renderWorkflows(items) {
|
function renderWorkflows(items) {
|
||||||
text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例');
|
text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例');
|
||||||
const el = $('workflow-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>需要 workflows:read 权限</small></div></div>'; return; }
|
const el = $('workflow-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>当前应用环境还没有启动流程</small></div></div>'; return; }
|
||||||
el.innerHTML = items.map(item => `<div class="list-row workflow-row" data-workflow-id="${escapeHTML(item.id)}" tabindex="0" title="打开流程详情"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}</small></div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join('');
|
el.innerHTML = items.map(item => `<div class="list-row workflow-row" data-workflow-id="${escapeHTML(item.id)}" tabindex="0" title="打开流程详情"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join('');
|
||||||
el.querySelectorAll('.workflow-row').forEach(row => {
|
el.querySelectorAll('.workflow-row').forEach(row => {
|
||||||
row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId));
|
row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId));
|
||||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } });
|
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } });
|
||||||
@@ -345,8 +454,8 @@
|
|||||||
text('metric-subscriptions', String(items.length));
|
text('metric-subscriptions', String(items.length));
|
||||||
const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`;
|
const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`;
|
||||||
text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅');
|
text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅');
|
||||||
const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>需要 events:read 权限</small></div></div>'; return; }
|
const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>当前应用与队列还没有创建订阅</small></div></div>'; return; }
|
||||||
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
|
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
|
||||||
el.querySelectorAll('.subscription-row').forEach(row => {
|
el.querySelectorAll('.subscription-row').forEach(row => {
|
||||||
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
|
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
|
||||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
|
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
|
||||||
@@ -670,19 +779,23 @@
|
|||||||
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
||||||
try {
|
try {
|
||||||
await loadHealth();
|
await loadHealth();
|
||||||
if (!state.authenticated || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
if (currentView() === 'applications') { await window.orbitApplications.load(); setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); return; }
|
||||||
const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); const taskItems = extractItems(tasks); renderTasks(taskItems);
|
if (!state.authenticated || !state.scopeReady) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
||||||
|
const tasks = state.queue ? await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : { items: [] }; const taskItems = extractItems(tasks); renderTasks(taskItems);
|
||||||
const optional = await Promise.allSettled([
|
const optional = await Promise.allSettled([
|
||||||
request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
|
state.queue ? request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : Promise.resolve({ items: [] }),
|
||||||
request('/v1/workflows?limit=20'),
|
request('/v1/workflows?limit=20'),
|
||||||
request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
|
state.queue ? request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : Promise.resolve({ items: [] }),
|
||||||
]);
|
]);
|
||||||
const scheduleItems = optional[0].status === 'fulfilled' ? extractItems(optional[0].value) : null;
|
const scheduleItems = optional[0].status === 'fulfilled' ? extractItems(optional[0].value) : null;
|
||||||
const workflowItems = optional[1].status === 'fulfilled' ? extractItems(optional[1].value) : null;
|
const workflowItems = optional[1].status === 'fulfilled' ? extractItems(optional[1].value) : null;
|
||||||
const subscriptionItems = optional[2].status === 'fulfilled' ? extractItems(optional[2].value) : null;
|
const subscriptionItems = optional[2].status === 'fulfilled' ? extractItems(optional[2].value) : null;
|
||||||
if (scheduleItems) renderSchedules(scheduleItems);
|
renderSchedules(scheduleItems || []);
|
||||||
if (workflowItems) renderWorkflows(workflowItems);
|
if (!scheduleItems) $('schedule-list').textContent = '定时计划读取失败,请刷新重试或检查权限';
|
||||||
if (subscriptionItems) renderSubscriptions(subscriptionItems);
|
renderWorkflows(workflowItems || []);
|
||||||
|
if (!workflowItems) { $('workflow-list').textContent = '流程实例读取失败,请刷新重试或检查权限'; text('metric-workflows', '—'); text('metric-workflows-sub', '读取失败'); }
|
||||||
|
renderSubscriptions(subscriptionItems || []);
|
||||||
|
if (!subscriptionItems) { $('subscription-list').textContent = '结果订阅读取失败,请刷新重试或检查权限'; text('metric-subscriptions', '—'); text('metric-subscriptions-sub', '读取失败'); }
|
||||||
setConnection(true); renderRuntimeStats(taskItems, scheduleItems, workflowItems, subscriptionItems); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); text('metric-health-sub', '数据库与 Schema 正常');
|
setConnection(true); renderRuntimeStats(taskItems, scheduleItems, workflowItems, subscriptionItems); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); text('metric-health-sub', '数据库与 Schema 正常');
|
||||||
} catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
|
} catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
|
||||||
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
||||||
@@ -707,6 +820,6 @@
|
|||||||
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
||||||
window.addEventListener('hashchange', () => setView(currentView(), false));
|
window.addEventListener('hashchange', () => setView(currentView(), false));
|
||||||
setView(currentView(), false);
|
setView(currentView(), false);
|
||||||
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(() => Promise.all([loadRetention(), refresh(), currentView() === 'archives' ? loadArchives() : Promise.resolve()])); }).catch(() => redirectToLogin());
|
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(loadScope).then(() => Promise.all([state.scopeReady ? loadRetention() : Promise.resolve(), refresh(), state.scopeReady && currentView() === 'archives' ? loadArchives() : Promise.resolve()])); }).catch(() => redirectToLogin());
|
||||||
state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (currentView() === 'archives') loadArchives(); } }, 10000);
|
state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (state.scopeReady && currentView() === 'archives') loadArchives(); } }, 10000);
|
||||||
})();
|
})();
|
||||||
|
|||||||
170
internal/transport/httpserver/console/applications.js
Normal file
170
internal/transport/httpserver/console/applications.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
(() => {
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
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 }) : '长期有效';
|
||||||
|
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) } : {}) });
|
||||||
|
const value = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 401) location.replace(`/console/login?return=${encodeURIComponent('/console/#/applications')}`);
|
||||||
|
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) {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
if (ui.loading || ui.busy) return;
|
||||||
|
ui.loading = true; $('access-error').textContent = '';
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
} catch (error) { $('access-error').textContent = message(error); }
|
||||||
|
finally { ui.loading = false; }
|
||||||
|
}
|
||||||
|
async function select(namespace) {
|
||||||
|
const requested = namespace; ui.selected = namespace;
|
||||||
|
try {
|
||||||
|
const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested) return; ui.detail = detail;
|
||||||
|
$('access-empty').hidden = true; $('access-detail').hidden = false;
|
||||||
|
$('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用';
|
||||||
|
$('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`;
|
||||||
|
const previous = $('access-queue').value;
|
||||||
|
$('access-queue').innerHTML = detail.queues.map(q => `<option value="${escape(q.id)}">${escape(q.name)} · ${escape(q.id.slice(0, 8))}</option>`).join('');
|
||||||
|
if (detail.queues.some(q => q.id === previous)) $('access-queue').value = previous;
|
||||||
|
$('access-truncated').hidden = !detail.truncated;
|
||||||
|
const now = Date.now();
|
||||||
|
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||||
|
const status = c.revoked_at ? '已撤销' : c.expires_at && Date.parse(c.expires_at) <= now ? '已到期' : '有效';
|
||||||
|
const transitioning = !c.revoked_at && Date.parse(c.previous_valid_until) > now;
|
||||||
|
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 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>
|
||||||
|
${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>
|
||||||
|
</section>`;
|
||||||
|
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
|
||||||
|
if ($('access-help-dialog').open) renderCredentialHelp();
|
||||||
|
document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig();
|
||||||
|
} catch (error) { $('access-error').textContent = message(error); }
|
||||||
|
}
|
||||||
|
function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||||
|
function renderCredentialHelp() {
|
||||||
|
const credential = ui.detail?.credentials.find(c => c.id === ui.helpCredential);
|
||||||
|
if (!credential) { $('access-help-dialog').close(); return; }
|
||||||
|
const transitioning = !credential.revoked_at && Date.parse(credential.previous_valid_until) > Date.now();
|
||||||
|
$('access-help-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · ${credential.name} · 凭据版本 ${credential.revision}`;
|
||||||
|
$('access-help-state').textContent = credential.revoked_at ? '凭据已撤销,不能再用于接入或换证。' : transitioning ? `上一轮换证已生效。旧 Token 过渡截止:${time(credential.previous_valid_until)}。期间暂不能再次换证,以免中断还在用旧 Token 的服务。` : '当前没有有效的旧 Token 过渡期,可从凭据卡片发起换证。';
|
||||||
|
$('access-help-transition').hidden = !transitioning;
|
||||||
|
}
|
||||||
|
function showCredentialHelp(credential) {
|
||||||
|
ui.helpCredential = credential.id; renderCredentialHelp(); $('access-help-dialog').showModal();
|
||||||
|
}
|
||||||
|
function open(credential = null, mode = 'ROTATE') {
|
||||||
|
if (ui.busy) return;
|
||||||
|
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.pending = null; ui.result = null; ui.saved = true;
|
||||||
|
$('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = '';
|
||||||
|
$('access-result-config').value = ''; $('access-clipboard-status').textContent = '';
|
||||||
|
$('access-create-fields').hidden = !!credential;
|
||||||
|
$('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; });
|
||||||
|
const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡' };
|
||||||
|
$('access-form-title').textContent = titles[ui.mode];
|
||||||
|
$('access-valid-field').hidden = ui.mode === 'FINALIZE';
|
||||||
|
$('access-grace-field').hidden = ui.mode !== 'ROTATE';
|
||||||
|
$('access-form-note').textContent = {
|
||||||
|
CREATE: '为应用创建独立的环境、队列与调用凭据,默认长期有效。',
|
||||||
|
ROTATE: '生成新 Token 后,旧 Token 在过渡期内继续可用。逐台更新服务配置并正常收尾在途任务;确认全部切换后,再结束过渡。',
|
||||||
|
SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。',
|
||||||
|
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。'
|
||||||
|
}[ui.mode];
|
||||||
|
$('access-token-once-note').hidden = !['CREATE', 'ROTATE'].includes(ui.mode);
|
||||||
|
$('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`;
|
||||||
|
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
if (ui.busy) return;
|
||||||
|
if ((ui.pending || !ui.saved) && !window.confirm(ui.pending ? '操作结果尚未确认,请先保存待核对操作。仍要关闭吗?' : '请确认已保存完整 SDK 配置。关闭后无法再次查看 Token;如果还在换证过渡期,也不能立即再次换证。仍要关闭吗?')) 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') {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type })); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
async function submit(event) {
|
||||||
|
event.preventDefault(); if (ui.busy) return; ui.busy = true; $('access-form-error').textContent = '';
|
||||||
|
const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]);
|
||||||
|
try {
|
||||||
|
if (!ui.pending) {
|
||||||
|
const input = { kind: ui.mode, valid_days: ui.mode === 'FINALIZE' ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||||
|
if (ui.mode === 'ROTATE') input.grace_days = Number($('access-grace-days').value);
|
||||||
|
if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id });
|
||||||
|
else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) });
|
||||||
|
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||||
|
ui.pending = await api('/prepare', input);
|
||||||
|
}
|
||||||
|
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||||
|
const op = ui.pending;
|
||||||
|
await api('/apply', op);
|
||||||
|
ui.saved = !op.token;
|
||||||
|
let queue = op.queue_id || $('access-queue').value;
|
||||||
|
// 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。
|
||||||
|
await select(op.namespace_id);
|
||||||
|
const credential = ui.detail?.entry.namespace_id === op.namespace_id && ui.detail.credentials.find(c => c.id === op.credential_id);
|
||||||
|
const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n);
|
||||||
|
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。');
|
||||||
|
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||||
|
const preparedConfig = op.token ? config(op.token, queue, op.namespace_id) : '';
|
||||||
|
ui.result = op; ui.pending = null;
|
||||||
|
$('access-form').hidden = true; $('access-result').hidden = false;
|
||||||
|
$('access-token-once-note').hidden = true;
|
||||||
|
$('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用' }[op.input.kind];
|
||||||
|
$('access-result-note').hidden = !op.token; $('access-result-config-field').hidden = !op.token; $('access-result-actions').hidden = !op.token;
|
||||||
|
$('access-result-config').value = preparedConfig;
|
||||||
|
$('access-result-expiry').textContent = `当前 Token:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 截止:${time(credential.previous_valid_until)},请在此之前完成切换。` : '无旧 Token 过渡。'}`;
|
||||||
|
} catch (error) {
|
||||||
|
$('access-form-error').textContent = `${message(error)}${ui.pending ? '。保留当前窗口,点击“重试确认”会使用原操作核对结果。' : ''}`;
|
||||||
|
} finally {
|
||||||
|
ui.busy = false; disabled.forEach(([el, value]) => { el.disabled = value; });
|
||||||
|
$('access-backup').hidden = !ui.pending;
|
||||||
|
if (ui.pending) { form.querySelectorAll('input,select').forEach(el => { el.disabled = true; }); $('access-submit').textContent = '重试确认 →'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('access-url').value = location.origin;
|
||||||
|
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) select(el.dataset.accessId); });
|
||||||
|
$('access-credentials').addEventListener('click', e => {
|
||||||
|
const el = e.target.closest('[data-access-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 credential = ui.detail?.credentials.find(c => c.id === id);
|
||||||
|
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-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-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; });
|
||||||
|
$('access-backup').addEventListener('click', () => { if (ui.pending) download(`orbit-operation-${ui.pending.operation_id}.json`, JSON.stringify(ui.pending, null, 2), 'application/json'); });
|
||||||
|
window.addEventListener('beforeunload', e => { if (ui.pending || !ui.saved) { e.preventDefault(); e.returnValue = ''; } });
|
||||||
|
window.orbitApplications = { load };
|
||||||
|
})();
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
|
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
|
||||||
<button class="nav-item" data-view="retention"><span class="nav-icon">⌛</span><span>保留策略</span><em>06</em></button>
|
<button class="nav-item" data-view="retention"><span class="nav-icon">⌛</span><span>保留策略</span><em>06</em></button>
|
||||||
<button class="nav-item" data-view="archives"><span class="nav-icon">▤</span><span>归档作业</span><em>07</em></button>
|
<button class="nav-item" data-view="archives"><span class="nav-icon">▤</span><span>归档作业</span><em>07</em></button>
|
||||||
|
<button class="nav-item" data-view="applications"><span class="nav-icon">⊞</span><span>应用接入</span><em>08</em></button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
<div class="rail-card">
|
<div class="rail-card">
|
||||||
@@ -47,6 +48,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section class="application-scope" id="application-scope" aria-label="应用筛选">
|
||||||
|
<div class="scope-heading"><span class="scope-symbol">⊞</span><div><b>应用范围</b><small id="scope-summary">正在读取…</small></div></div>
|
||||||
|
<label class="scope-field"><span>应用 / 环境</span><select id="scope-application" disabled aria-label="选择应用与环境"><option>正在读取…</option></select></label>
|
||||||
|
<label class="scope-field scope-queue"><span>队列</span><select id="scope-queue" disabled aria-label="选择队列"><option>正在读取…</option></select></label>
|
||||||
|
<div class="scope-note"><small id="scope-hint">任务、计划和订阅按所选队列显示</small><button id="scope-reset" type="button" class="text-button">重置范围 ↗</button></div>
|
||||||
|
<p id="scope-error" class="scope-error" role="status"></p>
|
||||||
|
</section>
|
||||||
<div class="page-view active" id="view-overview" data-page="overview">
|
<div class="page-view active" id="view-overview" data-page="overview">
|
||||||
<section class="hero" id="overview">
|
<section class="hero" id="overview">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
@@ -108,7 +116,7 @@
|
|||||||
<section class="content-grid single-column">
|
<section class="content-grid single-column">
|
||||||
<article class="panel wide-panel" id="tasks">
|
<article class="panel wide-panel" id="tasks">
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">PULSE / TASKS</span><h2>任务执行流</h2></div><div class="panel-tools"><span class="table-count" id="task-count">0 项</span><button class="text-button" data-view="overview">返回总览 <span>←</span></button></div></div>
|
<div class="panel-heading"><div><span class="panel-kicker">PULSE / TASKS</span><h2>任务执行流</h2></div><div class="panel-tools"><span class="table-count" id="task-count">0 项</span><button class="text-button" data-view="overview">返回总览 <span>←</span></button></div></div>
|
||||||
<div class="table-wrap"><table><thead><tr><th>任务</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>登录后按账号权限自动加载</small></td></tr></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th>任务</th><th>应用 / 环境</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="6" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>登录后按账号权限自动加载</small></td></tr></tbody></table></div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +179,7 @@
|
|||||||
<article class="panel wide-panel retention-panel">
|
<article class="panel wide-panel retention-panel">
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">DATA / RETENTION</span><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
|
<div class="panel-heading"><div><span class="panel-kicker">DATA / RETENTION</span><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
|
||||||
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;预览和导出会核对终态与未确认结果。当前提供只读快照,不自动清理源数据。</p>
|
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;预览和导出会核对终态与未确认结果。当前提供只读快照,不自动清理源数据。</p>
|
||||||
<form id="retention-form" class="retention-form">
|
<div id="retention-scope" class="retention-scope"></div><form id="retention-form" class="retention-form">
|
||||||
<div class="retention-switch"><div><b>允许导出只读归档</b><small>默认关闭。开启并保存后,可分批导出已满足保留窗口的历史;未确认结果仍受保护。</small></div><label class="switch"><input id="retention-enabled" type="checkbox" aria-label="允许导出只读归档"><span></span></label></div>
|
<div class="retention-switch"><div><b>允许导出只读归档</b><small>默认关闭。开启并保存后,可分批导出已满足保留窗口的历史;未确认结果仍受保护。</small></div><label class="switch"><input id="retention-enabled" type="checkbox" aria-label="允许导出只读归档"><span></span></label></div>
|
||||||
<div class="retention-grid">
|
<div class="retention-grid">
|
||||||
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="90" max="3650" required></label>
|
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="90" max="3650" required></label>
|
||||||
@@ -199,12 +207,38 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="page-view" id="view-applications" data-page="applications">
|
||||||
|
<div class="access-heading"><div><span class="panel-kicker">CONNECT / APPLICATIONS</span><h1>让应用,轻松接入。</h1><p>创建应用、管理凭据,把任务交给正确的队列。</p></div><button class="primary-button compact" id="access-create">新建应用 <span>+</span></button></div>
|
||||||
|
<div class="access-guide"><span><b>01</b> 创建应用与队列</span><i>→</i><span><b>02</b> 复制 SDK 配置</span><i>→</i><span><b>03</b> 注册 Worker 开始执行</span></div>
|
||||||
|
<div id="access-error" class="action-error" role="alert"></div>
|
||||||
|
<section class="access-grid">
|
||||||
|
<article class="panel access-list-panel"><div class="panel-heading"><div><span class="panel-kicker">YOUR APPLICATIONS</span><h2>接入应用</h2></div><button class="text-button" id="access-refresh">刷新 ↻</button></div>
|
||||||
|
<div id="access-list" class="access-list"><p class="panel-description">正在读取应用…</p></div>
|
||||||
|
<div class="access-pager"><button class="text-button" id="access-first">第一页</button><button class="text-button" id="access-next" disabled>下一页 →</button></div>
|
||||||
|
</article>
|
||||||
|
<article class="panel access-detail-panel"><div id="access-empty" class="empty-row">选择应用,查看队列和接入配置。</div>
|
||||||
|
<div id="access-detail" hidden>
|
||||||
|
<div class="panel-heading"><div><span class="panel-kicker">APPLICATION DETAILS</span><h2 id="access-title"></h2></div><span class="connection-state" id="access-status"></span></div>
|
||||||
|
<div class="access-meta"><div><span>应用 ID</span><code id="access-app-id"></code></div><div><span>环境 / 命名空间</span><b id="access-scope"></b></div><div><span>命名空间 ID</span><code id="access-ns-id"></code></div></div>
|
||||||
|
<div class="access-config-grid"><label>服务地址<input id="access-url" type="url" autocomplete="off" spellcheck="false"></label><label>任务队列<select id="access-queue"></select></label></div>
|
||||||
|
<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>
|
||||||
|
<pre class="access-code" id="access-config"></pre>
|
||||||
|
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
||||||
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
||||||
<div class="drawer-header">
|
<div class="detail-scope"></div><div class="drawer-header">
|
||||||
<div><span class="panel-kicker">PULSE / TASK DETAIL</span><h2 id="detail-title">任务详情</h2><small class="drawer-id" id="detail-id">—</small></div>
|
<div><span class="panel-kicker">PULSE / TASK DETAIL</span><h2 id="detail-title">任务详情</h2><small class="drawer-id" id="detail-id">—</small></div>
|
||||||
<button class="icon-button" id="close-task-drawer" type="button" aria-label="关闭任务详情">×</button>
|
<button class="icon-button" id="close-task-drawer" type="button" aria-label="关闭任务详情">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +273,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="drawer-backdrop" id="schedule-drawer-backdrop" hidden>
|
<div class="drawer-backdrop" id="schedule-drawer-backdrop" hidden>
|
||||||
<aside class="task-drawer schedule-drawer" role="dialog" aria-modal="true" aria-labelledby="schedule-detail-title">
|
<aside class="task-drawer schedule-drawer" role="dialog" aria-modal="true" aria-labelledby="schedule-detail-title">
|
||||||
<div class="drawer-header">
|
<div class="detail-scope"></div><div class="drawer-header">
|
||||||
<div><span class="panel-kicker">PULSE / SCHEDULE DETAIL</span><h2 id="schedule-detail-title">定时计划</h2><small class="drawer-id" id="schedule-detail-id">—</small></div>
|
<div><span class="panel-kicker">PULSE / SCHEDULE DETAIL</span><h2 id="schedule-detail-title">定时计划</h2><small class="drawer-id" id="schedule-detail-id">—</small></div>
|
||||||
<button class="icon-button" id="close-schedule-drawer" type="button" aria-label="关闭计划详情">×</button>
|
<button class="icon-button" id="close-schedule-drawer" type="button" aria-label="关闭计划详情">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,7 +301,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="drawer-backdrop" id="workflow-drawer-backdrop" hidden>
|
<div class="drawer-backdrop" id="workflow-drawer-backdrop" hidden>
|
||||||
<aside class="task-drawer workflow-drawer" role="dialog" aria-modal="true" aria-labelledby="workflow-detail-title">
|
<aside class="task-drawer workflow-drawer" role="dialog" aria-modal="true" aria-labelledby="workflow-detail-title">
|
||||||
<div class="drawer-header">
|
<div class="detail-scope"></div><div class="drawer-header">
|
||||||
<div><span class="panel-kicker">WEAVE / WORKFLOW DETAIL</span><h2 id="workflow-detail-title">流程实例</h2><small class="drawer-id" id="workflow-detail-id">—</small></div>
|
<div><span class="panel-kicker">WEAVE / WORKFLOW DETAIL</span><h2 id="workflow-detail-title">流程实例</h2><small class="drawer-id" id="workflow-detail-id">—</small></div>
|
||||||
<button class="icon-button" id="close-workflow-drawer" type="button" aria-label="关闭流程详情">×</button>
|
<button class="icon-button" id="close-workflow-drawer" type="button" aria-label="关闭流程详情">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,7 +358,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="drawer-backdrop" id="subscription-drawer-backdrop" hidden>
|
<div class="drawer-backdrop" id="subscription-drawer-backdrop" hidden>
|
||||||
<aside class="task-drawer subscription-drawer" role="dialog" aria-modal="true" aria-labelledby="subscription-detail-title">
|
<aside class="task-drawer subscription-drawer" role="dialog" aria-modal="true" aria-labelledby="subscription-detail-title">
|
||||||
<div class="drawer-header">
|
<div class="detail-scope"></div><div class="drawer-header">
|
||||||
<div><span class="panel-kicker">EVENTS / SUBSCRIPTION DETAIL</span><h2 id="subscription-detail-title">结果订阅</h2><small class="drawer-id" id="subscription-detail-id">—</small></div>
|
<div><span class="panel-kicker">EVENTS / SUBSCRIPTION DETAIL</span><h2 id="subscription-detail-title">结果订阅</h2><small class="drawer-id" id="subscription-detail-id">—</small></div>
|
||||||
<button class="icon-button" id="close-subscription-drawer" type="button" aria-label="关闭订阅详情">×</button>
|
<button class="icon-button" id="close-subscription-drawer" type="button" aria-label="关闭订阅详情">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,6 +394,39 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast" role="status"></div>
|
<div class="toast" id="toast" role="status"></div>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<div class="access-config-grid"><label>环境<select id="access-environment"><option value="test">测试 · test</option><option value="development">开发 · development</option><option value="production">生产 · production</option></select></label><label>命名空间<input id="access-namespace" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label></div>
|
||||||
|
<label>初始队列名称<input id="access-queue-name" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||||
|
<fieldset class="access-capabilities"><legend>接入能力 <small>默认包含任务提交、查询与队列管理</small></legend><label><input type="checkbox" value="worker" checked> Worker 执行</label><label><input type="checkbox" value="events" checked> 结果订阅</label><label><input type="checkbox" value="schedules" checked> 定时调度</label><label><input type="checkbox" value="workflows" checked> 流程编排</label><label><input type="checkbox" value="recovery"> 人工恢复</label><label><input type="checkbox" value="resources"> 应用额度管理</label></fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="access-config-grid"><label id="access-valid-field">凭据有效期<select id="access-valid-days"><option value="0">长期有效</option><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option></select></label><label>操作原因<input id="access-reason" maxlength="1024" required placeholder="例如:接入订单服务"></label></div>
|
||||||
|
<label id="access-grace-field" hidden>旧 Token 过渡期<select id="access-grace-days"><option value="7">7 天</option><option value="1">1 天</option><option value="30">30 天</option></select><small>新旧 Token 同时可用。请在截止前完成所有服务的配置更新;旧 Token 原到期时间更早时,以原到期时间为准。</small></label>
|
||||||
|
<p class="action-error" id="access-form-error" role="alert"></p>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<div class="access-form-actions"><button type="button" class="primary-button compact" id="access-help-done">我知道了</button></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/console/applications.js"></script>
|
||||||
<script src="/console/app.js"></script>
|
<script src="/console/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,3 +12,16 @@
|
|||||||
.retention-tool-buttons{flex-wrap:wrap}.secondary-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid rgba(156,183,218,.18);border-radius:9px;background:rgba(23,46,65,.6);color:#b9cddd;font:inherit;font-size:10px;cursor:pointer}.secondary-button:hover{border-color:#62e6d4;color:#9beade}.retention-form button:disabled{opacity:.45;cursor:not-allowed}.retention-export-result{display:flex;flex-wrap:wrap;gap:10px;color:#9eb4cb;font-size:10px;line-height:1.6}.retention-export-result a{color:#62e6d4}.retention-export-result code{width:100%;font-size:9px;overflow-wrap:anywhere;color:#7189a3}.retention-export-result [hidden]{display:none}.retention-tools{flex-wrap:wrap}
|
.retention-tool-buttons{flex-wrap:wrap}.secondary-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid rgba(156,183,218,.18);border-radius:9px;background:rgba(23,46,65,.6);color:#b9cddd;font:inherit;font-size:10px;cursor:pointer}.secondary-button:hover{border-color:#62e6d4;color:#9beade}.retention-form button:disabled{opacity:.45;cursor:not-allowed}.retention-export-result{display:flex;flex-wrap:wrap;gap:10px;color:#9eb4cb;font-size:10px;line-height:1.6}.retention-export-result a{color:#62e6d4}.retention-export-result code{width:100%;font-size:9px;overflow-wrap:anywhere;color:#7189a3}.retention-export-result [hidden]{display:none}.retention-tools{flex-wrap:wrap}
|
||||||
|
|
||||||
.archive-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin:22px 0}.archive-heading h1{font-size:28px;letter-spacing:-.7px;margin:12px 0;color:#e4effb}.archive-heading p{color:#849bb3;font-size:12px;line-height:1.7}.archive-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;margin-bottom:20px}.archive-stats article{padding:22px 24px;border:1px solid #20354a;border-radius:18px;background:linear-gradient(125deg,#112439,#0a1728)}.archive-stats article:first-child{background:linear-gradient(125deg,#13373e,#0b202d);border-color:#24535a}.archive-stats span,.archive-stats small{display:block;color:#8ba2b8;font-size:11px}.archive-stats strong{display:block;font-size:30px;margin:10px 0;color:#e5f9f5}.archive-grid{display:grid;grid-template-columns:minmax(290px,360px) minmax(0,1fr);gap:20px;align-items:start}.archive-config,.archive-history{padding:24px;min-width:0}.archive-config .panel-heading,.archive-history .panel-heading{padding:0;margin-bottom:20px}.archive-config input:not([type=checkbox]),.archive-config select,#archive-action-form input{width:100%;border:1px solid #294157;border-radius:9px;padding:11px 12px;background:#09192a;color:#dbe8f5;font:inherit;font-size:12px;outline:0}.archive-config input:focus,.archive-config select:focus,#archive-action-form input:focus{border-color:#61dfd0;box-shadow:0 0 0 3px #61dfd010}.archive-config .field-label,#archive-action-form .field-label{display:block;margin:18px 0 9px;font-size:11px}.archive-config .retention-switch{margin-top:19px;padding:13px 12px}.archive-input-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:20px;color:#94adc4;font-size:11px}.archive-input-grid input{margin-top:9px}.archive-plan-meta{display:grid;gap:9px;margin:17px 0;color:#7893ae;font-size:10px;line-height:1.6}.archive-config .primary-button{width:100%;margin:17px 0 6px}.archive-config .panel-description{line-height:1.8;font-size:10px}.archive-plan-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.archive-plan-chips span{padding:6px 8px;border:1px solid #24374a;border-radius:7px;font-size:9px;color:#7590aa}.archive-plan-chips .enabled{color:#85e5d8;border-color:#2d6564;background:#184b4b44}.archive-history>.panel-description{margin-bottom:18px;line-height:1.8}.archive-job{border:1px solid #243a50;border-radius:13px;padding:18px;margin:12px 0;background:#09172766}.archive-job-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.archive-job-head>div{display:flex;align-items:center;gap:12px}.archive-job-head b{font-size:13px;color:#d2e4f1}.archive-job-head time{font-size:10px;color:#7c98b1}.archive-job-id{display:block;margin:10px 0;color:#65849f;font-size:10px;overflow-wrap:anywhere}.archive-progress{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0;color:#8eaac1;font-size:10px}.archive-progress b{color:#ccdee9;font-size:12px;font-weight:550}.archive-job details{font-size:10px;color:#7297b5;line-height:1.7}.archive-job summary{cursor:pointer}.archive-digest{display:block;overflow-wrap:anywhere;margin-top:8px;color:#9cbad0}.archive-job-error{padding:10px 12px;background:#59412338;border-radius:8px;color:#d9b484;font-size:11px;line-height:1.8}.archive-job-note{font-size:10px;color:#7698b2}.archive-job-actions,.archive-pager{display:flex;gap:14px;align-items:center;margin-top:16px}.archive-pager{justify-content:space-between}.archive-empty{padding:60px 20px}.archive-empty b,.archive-empty small{display:block;margin-top:15px}.archive-grid button:disabled{opacity:.4;cursor:not-allowed}.archive-history [hidden]{display:none}#archive-action-form{border:1px solid #36736f;border-radius:13px;padding:20px;margin-top:20px;background:#102c3544}#archive-action-form h3{font-size:16px;color:#cdeae6}.state-pill.abandoned{color:#9bb0c2;background:#52637933}@media(max-width:1150px){.archive-grid{grid-template-columns:1fr}.archive-stats{gap:10px}.archive-heading h1{font-size:24px}}@media(max-width:600px){.archive-stats{grid-template-columns:1fr}.archive-heading{align-items:flex-start;flex-direction:column}.archive-config,.archive-history{padding:18px}.archive-job-head{align-items:flex-start;flex-direction:column}}
|
.archive-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin:22px 0}.archive-heading h1{font-size:28px;letter-spacing:-.7px;margin:12px 0;color:#e4effb}.archive-heading p{color:#849bb3;font-size:12px;line-height:1.7}.archive-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;margin-bottom:20px}.archive-stats article{padding:22px 24px;border:1px solid #20354a;border-radius:18px;background:linear-gradient(125deg,#112439,#0a1728)}.archive-stats article:first-child{background:linear-gradient(125deg,#13373e,#0b202d);border-color:#24535a}.archive-stats span,.archive-stats small{display:block;color:#8ba2b8;font-size:11px}.archive-stats strong{display:block;font-size:30px;margin:10px 0;color:#e5f9f5}.archive-grid{display:grid;grid-template-columns:minmax(290px,360px) minmax(0,1fr);gap:20px;align-items:start}.archive-config,.archive-history{padding:24px;min-width:0}.archive-config .panel-heading,.archive-history .panel-heading{padding:0;margin-bottom:20px}.archive-config input:not([type=checkbox]),.archive-config select,#archive-action-form input{width:100%;border:1px solid #294157;border-radius:9px;padding:11px 12px;background:#09192a;color:#dbe8f5;font:inherit;font-size:12px;outline:0}.archive-config input:focus,.archive-config select:focus,#archive-action-form input:focus{border-color:#61dfd0;box-shadow:0 0 0 3px #61dfd010}.archive-config .field-label,#archive-action-form .field-label{display:block;margin:18px 0 9px;font-size:11px}.archive-config .retention-switch{margin-top:19px;padding:13px 12px}.archive-input-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:20px;color:#94adc4;font-size:11px}.archive-input-grid input{margin-top:9px}.archive-plan-meta{display:grid;gap:9px;margin:17px 0;color:#7893ae;font-size:10px;line-height:1.6}.archive-config .primary-button{width:100%;margin:17px 0 6px}.archive-config .panel-description{line-height:1.8;font-size:10px}.archive-plan-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.archive-plan-chips span{padding:6px 8px;border:1px solid #24374a;border-radius:7px;font-size:9px;color:#7590aa}.archive-plan-chips .enabled{color:#85e5d8;border-color:#2d6564;background:#184b4b44}.archive-history>.panel-description{margin-bottom:18px;line-height:1.8}.archive-job{border:1px solid #243a50;border-radius:13px;padding:18px;margin:12px 0;background:#09172766}.archive-job-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.archive-job-head>div{display:flex;align-items:center;gap:12px}.archive-job-head b{font-size:13px;color:#d2e4f1}.archive-job-head time{font-size:10px;color:#7c98b1}.archive-job-id{display:block;margin:10px 0;color:#65849f;font-size:10px;overflow-wrap:anywhere}.archive-progress{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0;color:#8eaac1;font-size:10px}.archive-progress b{color:#ccdee9;font-size:12px;font-weight:550}.archive-job details{font-size:10px;color:#7297b5;line-height:1.7}.archive-job summary{cursor:pointer}.archive-digest{display:block;overflow-wrap:anywhere;margin-top:8px;color:#9cbad0}.archive-job-error{padding:10px 12px;background:#59412338;border-radius:8px;color:#d9b484;font-size:11px;line-height:1.8}.archive-job-note{font-size:10px;color:#7698b2}.archive-job-actions,.archive-pager{display:flex;gap:14px;align-items:center;margin-top:16px}.archive-pager{justify-content:space-between}.archive-empty{padding:60px 20px}.archive-empty b,.archive-empty small{display:block;margin-top:15px}.archive-grid button:disabled{opacity:.4;cursor:not-allowed}.archive-history [hidden]{display:none}#archive-action-form{border:1px solid #36736f;border-radius:13px;padding:20px;margin-top:20px;background:#102c3544}#archive-action-form h3{font-size:16px;color:#cdeae6}.state-pill.abandoned{color:#9bb0c2;background:#52637933}@media(max-width:1150px){.archive-grid{grid-template-columns:1fr}.archive-stats{gap:10px}.archive-heading h1{font-size:24px}}@media(max-width:600px){.archive-stats{grid-template-columns:1fr}.archive-heading{align-items:flex-start;flex-direction:column}.archive-config,.archive-history{padding:18px}.archive-job-head{align-items:flex-start;flex-direction:column}}
|
||||||
|
|
||||||
|
/* 应用接入:独立的应用目录、凭据详情和一次性配置交付。 */
|
||||||
|
.access-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:20px 0 26px}.access-heading h1{font-size:34px;margin:12px 0}.access-heading p{color:#91a6be;margin:0}.access-guide{display:flex;align-items:center;gap:26px;padding:20px 26px;margin-bottom:26px;border:1px solid #223748;background:linear-gradient(110deg,#12303770,#0d192b80);border-radius:18px;color:#a9bed0;font-size:13px}.access-guide b{color:#68e6d7;margin-right:10px;font-size:12px}.access-guide i{font-style:normal;color:#50647b}.access-grid{display:grid;grid-template-columns:minmax(250px,.8fr) minmax(0,1.6fr);gap:24px;align-items:start}.access-list{display:flex;flex-direction:column;gap:10px}.access-item{display:flex;align-items:center;gap:14px;width:100%;padding:18px;border:1px solid #233448;border-radius:14px;background:#0a1626;color:#d4e2f1;text-align:left;cursor:pointer}.access-item:hover,.access-item.selected{border-color:#55cdbf;background:#12303860}.access-avatar{display:grid;place-items:center;width:42px;height:42px;flex:0 0 auto;border-radius:12px;background:#163b4050;color:#75e9da;font-size:17px}.access-item strong{display:block;overflow-wrap:anywhere;font-size:14px}.access-item small{display:block;margin-top:6px;color:#869db5;font-size:11px}.access-item>span:last-child{min-width:0}.access-pager{display:flex;justify-content:space-between;margin-top:18px}.access-meta{display:grid;gap:14px;margin-bottom:24px;padding:18px;background:#08152380;border:1px solid #203246;border-radius:14px}.access-meta>div{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}.access-meta span{font-size:12px;color:#8299b0}.access-meta b,.access-meta code{font-size:12px;font-weight:400;overflow-wrap:anywhere;color:#c0d5e7}.access-config-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.access-grid label,.access-dialog label{display:block;color:#9fb3c7;font-size:12px}.access-grid input,.access-grid select,.access-dialog input:not([type=checkbox]),.access-dialog select,.access-dialog textarea{display:block;box-sizing:border-box;width:100%;margin-top:9px;padding:12px 14px;border-radius:10px;border:1px solid #2a3b50;background:#081422;color:#d8e6f5;font:inherit;min-width:0}.access-dialog input:focus,.access-dialog select:focus,.access-dialog textarea:focus,.access-grid input:focus,.access-grid select:focus{outline:2px solid #56ccbf;outline-offset:2px}.access-credential{margin:20px 0;padding:18px;border:1px solid #283c53;border-radius:14px}.access-credential-header{display:flex;align-items:center;justify-content:space-between;gap:10px}.access-credential-header b{font-size:13px}.access-credential p{font-size:12px;color:#8da4bc;overflow-wrap:anywhere;line-height:1.8;margin:10px 0}.access-permissions{display:flex;flex-wrap:wrap;gap:5px}.access-permissions span{padding:4px 7px;border-radius:5px;background:#18293a;color:#9bb6ca;font-size:10px}.access-config-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.access-config-heading h3{font-size:14px}.access-code{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.9;font-size:12px;padding:18px;border:1px solid #24384c;border-radius:12px;background:#06131e;color:#a5d7d3}.access-help{margin-top:18px;padding-left:14px;border-left:2px solid #529d99;color:#809bb0;font-size:12px;line-height:1.9}.access-dialog{box-sizing:border-box;width:min(640px,calc(100vw - 32px));max-height:90vh;overflow:auto;border:1px solid #334c60;border-radius:22px;padding:30px;background:linear-gradient(145deg,#112238,#081522);color:#dceafa;box-shadow:0 32px 100px #0008}.access-dialog::backdrop{background:#020a14cc;backdrop-filter:blur(6px)}.access-dialog-top{display:flex;align-items:center;justify-content:space-between}.access-dialog h2{margin:16px 0 10px;font-size:26px}.access-dialog form>div,.access-dialog form>label,#access-create-fields>label,#access-create-fields>.access-config-grid{margin-top:18px}.access-capabilities{margin:20px 0;padding:12px 0;border:0;display:grid;grid-template-columns:1fr 1fr;gap:14px}.access-capabilities legend{font-size:12px;color:#c1d4e4;padding-bottom:12px}.access-capabilities small{font-size:10px;color:#7c96ad;display:block;margin-top:6px}.access-capabilities label{display:flex;align-items:center;gap:8px}.access-capabilities input{accent-color:#68dfd0;width:15px;height:15px}.access-form-actions{display:flex;align-items:center;gap:12px;margin-top:22px;flex-wrap:wrap}.access-success{background:#153e3d;color:#80eed7;padding:15px;border-radius:12px;margin:22px 0}.access-dialog textarea{font-family:monospace;font-size:12px;line-height:1.8;resize:vertical}.access-dialog [hidden]{display:none!important}.access-dialog button:disabled{opacity:.45;cursor:not-allowed}.access-grid [hidden]{display:none!important}
|
||||||
|
@media(max-width:1100px){.access-grid{grid-template-columns:1fr}.access-guide{gap:14px;flex-wrap:wrap}.access-list{display:grid;grid-template-columns:1fr 1fr}.access-heading h1{font-size:28px}}
|
||||||
|
@media(max-width:600px){.access-heading{align-items:flex-start;flex-direction:column}.access-config-grid,.access-list{grid-template-columns:1fr}.access-dialog{padding:22px}.access-guide{flex-direction:column;align-items:flex-start;gap:10px}.access-guide>i{display:none}.access-heading p{font-size:13px}}
|
||||||
|
.access-permission-details{margin-top:14px;font-size:11px;color:#8ba5bc}.access-permission-details summary{cursor:pointer}.access-permission-details p{font-size:10px;line-height:2}
|
||||||
|
.access-credential-header{flex-wrap:wrap}.access-credential button:disabled{color:#93a8b7;cursor:not-allowed}.access-credential-header .access-rotation-blocked{padding:7px 10px;border:1px solid #76603988;border-radius:7px;background:#55411d24;color:#e2c58e}.access-rotation-blocked:hover,.access-rotation-blocked:focus-visible{border-color:#e2c58e}.access-rotation-notice{margin-top:16px;padding:14px 16px;border:1px solid #76603966;border-left:3px solid #caa869;border-radius:10px;background:#55411d24}.access-rotation-notice strong{font-size:12px;color:#e2c58e}.access-rotation-notice p{margin:7px 0 0;color:#b5bdc5}.access-rotation-notice time{color:#e2c58e;font-variant-numeric:tabular-nums}
|
||||||
|
.access-token-summary{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;padding:14px;border:1px solid #284050;background:#08152380;border-radius:10px;margin-top:14px}.access-token-summary strong{display:block;font-size:12px;color:#c0d5e7}.access-token-summary span{display:block;font-size:11px;color:#8da4bc;margin-top:7px}.access-token-summary .text-button{color:#77dccc}.access-token-once-note{font-size:12px;line-height:1.9;padding:14px 16px;border:1px solid #76603966;border-radius:10px;background:#55411d24;color:#e2c58e}.access-credential-help h3{font-size:15px;margin:24px 0 10px;color:#d4e2f1}.access-credential-help p{font-size:12px;line-height:1.9;color:#a3b8ca;overflow-wrap:anywhere}.access-credential-help code{color:#83dacd}.access-token-recovery{margin-top:20px;padding-top:16px;border-top:1px solid #283c53}.access-token-recovery summary{cursor:pointer;font-size:12px;color:#e2c58e}.access-credential-help .access-form-actions{justify-content:flex-end}
|
||||||
|
|
||||||
|
/* 应用范围在页面导航间保持;列表归属信息使用同一组数据库元数据。 */
|
||||||
|
.application-scope{display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin:24px 0 8px;padding:20px 24px;background:linear-gradient(110deg,rgba(39,105,112,.16),rgba(13,25,43,.68));border:1px solid var(--line);border-radius:16px}.application-scope[hidden]{display:none}.scope-heading{display:flex;align-items:center;gap:12px;min-width:140px}.scope-symbol{display:grid;place-items:center;width:36px;height:36px;background:rgba(98,230,212,.09);color:var(--cyan);border-radius:10px;font-size:23px}.scope-heading b{font-size:13px}.scope-heading small{display:block;color:var(--muted);font-size:10px;margin-top:6px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scope-field{flex:1;min-width:170px;max-width:340px}.scope-field>span{display:block;color:var(--muted);font-size:10px;margin-bottom:7px}.scope-field select{width:100%;padding:10px 30px 10px 12px;border:1px solid var(--line);border-radius:9px;background:#0b1829;color:var(--text);font:inherit;font-size:12px;text-overflow:ellipsis}.scope-field select:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}.scope-queue{max-width:240px}.scope-note{margin-left:auto;display:grid;gap:9px;text-align:right}.scope-note small{color:var(--muted);font-size:10px}.scope-note button{justify-self:end;font-size:11px}.scope-error{width:100%;margin:0;color:var(--orange);font-size:12px}.scope-error:empty{display:none}.application-badge{display:inline-flex;align-items:center;gap:7px;max-width:260px;padding:5px 8px;border:1px solid rgba(98,230,212,.13);border-radius:7px;background:rgba(98,230,212,.055);font-size:11px;white-space:nowrap}.application-badge i{width:5px;height:5px;border-radius:50%;background:var(--cyan);flex-shrink:0}.application-badge b{overflow:hidden;text-overflow:ellipsis;font-weight:500;color:#d4eae8;max-width:170px}.application-badge em{font-style:normal;font-size:10px;color:var(--muted);border-left:1px solid var(--line);padding-left:7px}.list-application{margin-left:auto;margin-right:18px}.list-copy{min-width:0}.list-copy b{overflow:hidden;text-overflow:ellipsis}.archive-job>.application-badge{margin:12px 0 0}.detail-scope,.retention-scope{margin-bottom:14px}.scope-field select:disabled{opacity:.7}.task-processor{max-width:260px;overflow-wrap:anywhere}
|
||||||
|
@media(max-width:1250px){.scope-note{width:100%;display:flex;align-items:center;justify-content:space-between;text-align:left}.application-scope{gap:16px}.scope-heading{min-width:100px}.scope-heading small{max-width:150px}}
|
||||||
|
@media(max-width:700px){.application-scope{padding:16px;gap:14px}.scope-heading{width:100%}.scope-heading small{max-width:260px}.scope-field,.scope-queue{min-width:0;max-width:none;flex-basis:100%}.list-row{flex-wrap:wrap}.list-application{order:3;flex-basis:100%;margin:8px 0 0 42px}.scope-note{align-items:flex-start;gap:12px}.application-badge{max-width:220px}}
|
||||||
|
|||||||
105
internal/transport/httpserver/console_applications.go
Normal file
105
internal/transport/httpserver/console_applications.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterConsoleApplications 只接受登录 Cookie;业务 Bearer 不能访问跨应用管理。
|
||||||
|
func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.Store, store consoleauth.Applications) {
|
||||||
|
if server == nil || consoleAuth == nil || store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux, ok := server.Handler.(*http.ServeMux)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
check := func(w http.ResponseWriter, r *http.Request) (auth.Token, bool) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
token, ok := consoleCookieToken(r)
|
||||||
|
if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
|
||||||
|
writeError(w, "", fault.Unauthorized)
|
||||||
|
return auth.Token{}, false
|
||||||
|
}
|
||||||
|
// 自定义请求头触发跨域预检;不开放 CORS,另校验浏览器 Origin 和 Fetch Metadata。
|
||||||
|
if r.Method != "GET" {
|
||||||
|
if r.Header.Get("X-Orbit-Console") != "1" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||||||
|
writeError(w, "", fault.Forbidden)
|
||||||
|
return auth.Token{}, false
|
||||||
|
}
|
||||||
|
if origin := r.Header.Get("Origin"); origin != "" {
|
||||||
|
u, err := url.Parse(origin)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || !strings.EqualFold(u.Host, r.Host) || u.User != nil {
|
||||||
|
writeError(w, "", fault.Forbidden)
|
||||||
|
return auth.Token{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token, true
|
||||||
|
}
|
||||||
|
mux.HandleFunc("GET /console/applications", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.ListApplications(r.Context(), token, core.ID(r.URL.Query().Get("after")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /console/applications/{namespace}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.GetApplication(r.Context(), token, core.ID(r.PathValue("namespace")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /console/applications/prepare", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in consoleauth.ApplicationInput
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.PrepareApplication(r.Context(), token, in)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /console/applications/apply", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in consoleauth.ApplicationOperation
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.ApplyApplication(r.Context(), token, in)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
}
|
||||||
127
internal/transport/httpserver/console_applications_test.go
Normal file
127
internal/transport/httpserver/console_applications_test.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeApplications struct{ calls int }
|
||||||
|
|
||||||
|
func (f *fakeApplications) ListApplications(context.Context, auth.Token, core.ID) (consoleauth.ApplicationPage, error) {
|
||||||
|
f.calls++
|
||||||
|
return consoleauth.ApplicationPage{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeApplications) GetApplication(context.Context, auth.Token, core.ID) (consoleauth.ApplicationDetail, error) {
|
||||||
|
f.calls++
|
||||||
|
return consoleauth.ApplicationDetail{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeApplications) PrepareApplication(context.Context, auth.Token, consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
||||||
|
f.calls++
|
||||||
|
return consoleauth.ApplicationOperation{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeApplications) ApplyApplication(context.Context, auth.Token, consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
|
||||||
|
f.calls++
|
||||||
|
return consoleauth.ApplicationReceipt{}, nil
|
||||||
|
}
|
||||||
|
func TestConsoleApplicationSessionAndCSRF(t *testing.T) {
|
||||||
|
ns, _ := core.NewID()
|
||||||
|
id, _ := core.NewID()
|
||||||
|
raw, _, _ := auth.Issue(ns, id)
|
||||||
|
cases := []struct {
|
||||||
|
name, method, path, origin, header string
|
||||||
|
cookie bool
|
||||||
|
body string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{name: "no login", method: "GET", path: "/console/applications", status: 401},
|
||||||
|
{name: "SDK bearer only", method: "POST", path: "/console/applications/prepare", header: "1", body: `{}`, status: 401},
|
||||||
|
{name: "missing csrf header", method: "POST", path: "/console/applications/prepare", cookie: true, body: `{}`, status: 403},
|
||||||
|
{name: "cross origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "https://evil.example", body: `{}`, status: 403},
|
||||||
|
{name: "null origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "null", body: `{}`, status: 403},
|
||||||
|
{name: "same origin", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", origin: "http://example.com", body: `{}`, status: 200},
|
||||||
|
{name: "unknown field", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", body: `{"admin":true}`, status: 400},
|
||||||
|
{name: "read", method: "GET", path: "/console/applications", cookie: true, status: 200},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
authStore := &fakeConsoleAuth{valid: true}
|
||||||
|
store := &fakeApplications{}
|
||||||
|
server := NewWithConsoleAuth("", nil, authStore)
|
||||||
|
RegisterConsoleApplications(server, authStore, store)
|
||||||
|
req := httptest.NewRequest(tt.method, "http://example.com"+tt.path, strings.NewReader(tt.body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+raw)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Orbit-Console", tt.header)
|
||||||
|
if tt.origin != "" {
|
||||||
|
req.Header.Set("Origin", tt.origin)
|
||||||
|
}
|
||||||
|
if tt.cookie {
|
||||||
|
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
|
||||||
|
}
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
server.Handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != tt.status {
|
||||||
|
t.Fatalf("status=%d wanted=%d", res.Code, tt.status)
|
||||||
|
}
|
||||||
|
if tt.status != 200 && store.calls != 0 {
|
||||||
|
t.Fatal("rejected request reached store")
|
||||||
|
}
|
||||||
|
if res.Header().Get("Cache-Control") != "no-store" {
|
||||||
|
t.Fatal("management response cacheable")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备接口生成的内容必须可被严格解码器原样提交,覆盖可选 ID 和空能力组。
|
||||||
|
func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||||
|
ns, _ := core.NewID()
|
||||||
|
cred, _ := core.NewID()
|
||||||
|
app, _ := core.NewID()
|
||||||
|
queue, _ := core.NewID()
|
||||||
|
opID, _ := core.NewID()
|
||||||
|
token, _, _ := auth.Issue(ns, cred)
|
||||||
|
for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE"} {
|
||||||
|
op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: func() *time.Time { v := time.Now().UTC(); return &v }()}
|
||||||
|
if kind == "CREATE" {
|
||||||
|
op.QueueID = queue
|
||||||
|
op.Input.Application = "sample"
|
||||||
|
op.Input.Namespace = "default"
|
||||||
|
op.Input.Environment = "test"
|
||||||
|
op.Input.Queue = "work"
|
||||||
|
} else {
|
||||||
|
op.Input.NamespaceID = ns
|
||||||
|
op.Input.CredentialID = cred
|
||||||
|
op.Input.ExpectedRevision = 1
|
||||||
|
}
|
||||||
|
if kind == "SET_EXPIRY" || kind == "FINALIZE" {
|
||||||
|
op.Version = 2
|
||||||
|
op.Token = ""
|
||||||
|
op.ExpiresAt = nil
|
||||||
|
op.Input.ValidDays = 0
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(op)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/console/applications/apply", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
var got consoleauth.ApplicationOperation
|
||||||
|
if err := decodeBody(req, &got); err != nil {
|
||||||
|
t.Fatalf("%s prepared output rejected: %v", kind, err)
|
||||||
|
}
|
||||||
|
if got.Token != op.Token || got.Input.Kind != kind {
|
||||||
|
t.Fatal("prepared operation changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/transport/httpserver/console_scope.go
Normal file
64
internal/transport/httpserver/console_scope.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type consoleScopeKey struct{}
|
||||||
|
|
||||||
|
func scopedConsoleToken(r *http.Request, original auth.Token) auth.Token {
|
||||||
|
if t, ok := r.Context().Value(consoleScopeKey{}).(auth.Token); ok && t.Console != nil && t.Console.SessionNamespaceID == original.NamespaceID && t.CredentialID == original.CredentialID && t.Digest == original.Digest {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleScopePath 仅开放已有管理读写,不允许借控制台范围启动 Worker/结果消费或创建业务资源。
|
||||||
|
func consoleScopePath(r *http.Request) bool {
|
||||||
|
p := r.URL.Path
|
||||||
|
if p == "/console/scope" {
|
||||||
|
return r.Method == "GET"
|
||||||
|
}
|
||||||
|
if p == "/console/retention" || strings.HasPrefix(p, "/console/retention/") || strings.HasPrefix(p, "/console/archive/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, resource := range []string{"tasks", "schedules", "workflows", "subscriptions", "queues"} {
|
||||||
|
prefix := "/v1/" + resource
|
||||||
|
if p == prefix {
|
||||||
|
return r.Method == "GET"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, prefix+"/") {
|
||||||
|
return r.Method == "GET" || (resource != "queues" && r.Method == "POST")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterConsoleScopes(server *http.Server, store consoleauth.Scopes) {
|
||||||
|
if server == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux, ok := server.Handler.(*http.ServeMux)
|
||||||
|
if !ok || store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux.HandleFunc("GET /console/scope", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t, ok := consoleCookieToken(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, "", fault.Unauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.GetScope(r.Context(), scopedConsoleToken(r, t), core.ID(r.URL.Query().Get("after")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
}
|
||||||
79
internal/transport/httpserver/console_scope_test.go
Normal file
79
internal/transport/httpserver/console_scope_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeConsoleScopes struct{ calls int }
|
||||||
|
|
||||||
|
func (s *fakeConsoleScopes) ResolveScope(_ context.Context, t auth.Token, target core.ID) (auth.Token, error) {
|
||||||
|
s.calls++
|
||||||
|
t.Console = &auth.ConsoleIdentity{SessionNamespaceID: t.NamespaceID, Username: "admin", UserID: t.CredentialID}
|
||||||
|
t.NamespaceID = target
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
func (*fakeConsoleScopes) GetScope(context.Context, auth.Token, core.ID) (consoleauth.Scope, error) {
|
||||||
|
return consoleauth.Scope{}, nil
|
||||||
|
}
|
||||||
|
func TestConsoleScopeOnlyBridgesCookieManagementRequests(t *testing.T) {
|
||||||
|
ns, _ := core.NewID()
|
||||||
|
cred, _ := core.NewID()
|
||||||
|
target, _ := core.NewID()
|
||||||
|
raw, _, _ := auth.Issue(ns, cred)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, method, path, origin, bearer string
|
||||||
|
cookie, marker bool
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"read", "GET", "/v1/tasks", "", "", true, true, 200},
|
||||||
|
{"control", "POST", "/v1/tasks/task:cancel", "http://example.com", "", true, true, 200},
|
||||||
|
{"SDK", "GET", "/v1/tasks", "", "Bearer " + raw, false, true, 403},
|
||||||
|
{"ambiguous", "GET", "/v1/tasks", "", "Bearer " + raw, true, true, 403},
|
||||||
|
{"anonymous", "GET", "/v1/tasks", "", "", false, true, 401},
|
||||||
|
{"missing-marker", "GET", "/v1/tasks", "", "", true, false, 403},
|
||||||
|
{"cross-origin", "POST", "/v1/tasks/task:cancel", "https://other.invalid", "", true, true, 403},
|
||||||
|
{"worker", "GET", "/v1/worker/connect", "", "", true, true, 403},
|
||||||
|
{"consumer", "GET", "/v1/events/connect", "", "", true, true, 403},
|
||||||
|
{"create", "POST", "/v1/tasks", "", "", true, true, 403},
|
||||||
|
{"onboarding", "POST", "/console/applications/prepare", "", "", true, true, 403},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
scope := &fakeConsoleScopes{}
|
||||||
|
handler := ConsoleSessionMiddleware(wrap(func(r *http.Request, tok auth.Token) (any, int, error) {
|
||||||
|
if tok.NamespaceID != target || tok.Console == nil || tok.Console.SessionNamespaceID != ns {
|
||||||
|
t.Fatal("scope lost")
|
||||||
|
}
|
||||||
|
return map[string]bool{"ok": true}, 200, nil
|
||||||
|
}), &fakeConsoleAuth{valid: true}, scope)
|
||||||
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||||
|
req.Header.Set("X-Orbit-Namespace", string(target))
|
||||||
|
if tc.marker {
|
||||||
|
req.Header.Set("X-Orbit-Console", "1")
|
||||||
|
}
|
||||||
|
if tc.origin != "" {
|
||||||
|
req.Header.Set("Origin", tc.origin)
|
||||||
|
}
|
||||||
|
if tc.bearer != "" {
|
||||||
|
req.Header.Set("Authorization", tc.bearer)
|
||||||
|
}
|
||||||
|
if tc.cookie {
|
||||||
|
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
|
||||||
|
}
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != tc.status {
|
||||||
|
t.Fatalf("HTTP %d: %s", res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
if tc.status != 200 && scope.calls != 0 {
|
||||||
|
t.Fatal("rejected request reached scope resolver")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ func RegisterConsoleRetention(server *http.Server, consoleAuth RetentionConsoleA
|
|||||||
writeError(w, "", fault.Unauthorized)
|
writeError(w, "", fault.Unauthorized)
|
||||||
return auth.Token{}, false
|
return auth.Token{}, false
|
||||||
}
|
}
|
||||||
return t, true
|
return scopedConsoleToken(r, t), true
|
||||||
}
|
}
|
||||||
mux.HandleFunc("GET /console/retention", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /console/retention", func(w http.ResponseWriter, r *http.Request) {
|
||||||
t, ok := check(w, r)
|
t, ok := check(w, r)
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ func newServer(addr string, ready Checker, consoleAuth consoleauth.Store, tasks
|
|||||||
|
|
||||||
// WrapConsoleAuth 必须在所有 Register* 路由完成后调用,因为路由注册器需要
|
// WrapConsoleAuth 必须在所有 Register* 路由完成后调用,因为路由注册器需要
|
||||||
// 直接访问底层 ServeMux;包装完成后,浏览器会话才会转换为内部 Bearer 请求。
|
// 直接访问底层 ServeMux;包装完成后,浏览器会话才会转换为内部 Bearer 请求。
|
||||||
func WrapConsoleAuth(server *http.Server, consoleAuth consoleauth.Store) {
|
func WrapConsoleAuth(server *http.Server, consoleAuth consoleauth.Store, scopes ...consoleauth.Scopes) {
|
||||||
if server == nil || consoleAuth == nil {
|
if server == nil || consoleAuth == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
server.Handler = ConsoleSessionMiddleware(server.Handler, consoleAuth)
|
server.Handler = ConsoleSessionMiddleware(server.Handler, consoleAuth, scopes...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapMetrics must run after all Register* functions because those functions
|
// WrapMetrics must run after all Register* functions because those functions
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func wrap(next endpoint) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
data, status, err := next(r.WithContext(ctx), token)
|
data, status, err := next(r.WithContext(ctx), scopedConsoleToken(r, token))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if fault.Code(err) == "UNAVAILABLE" {
|
if fault.Code(err) == "UNAVAILABLE" {
|
||||||
slog.Error("Orbit API operation failed", "request_id", id, "code", "UNAVAILABLE")
|
slog.Error("Orbit API operation failed", "request_id", id, "code", "UNAVAILABLE")
|
||||||
|
|||||||
@@ -13,12 +13,29 @@ import (
|
|||||||
|
|
||||||
// Token 是已解析但尚未认证的身份,服务端仍须核对数据库摘要与有效期。
|
// Token 是已解析但尚未认证的身份,服务端仍须核对数据库摘要与有效期。
|
||||||
type Token struct {
|
type Token struct {
|
||||||
// NamespaceID 凭据所属命名空间,不能由请求体覆盖。
|
// NamespaceID 凭据所属命名空间;仅服务端已核验的 Console 身份可指定管理范围。
|
||||||
NamespaceID core.ID
|
NamespaceID core.ID
|
||||||
// CredentialID 凭据记录标识。
|
// CredentialID 凭据记录标识。
|
||||||
CredentialID core.ID
|
CredentialID core.ID
|
||||||
// Digest 来自原始秘密的摘要,不包含可重放的原始秘密。
|
// Digest 来自原始秘密的摘要,不包含可重放的原始秘密。
|
||||||
Digest core.Digest
|
Digest core.Digest
|
||||||
|
// Console 仅由服务端控制台桥接器设置,Parse 永远不从外部令牌读取。
|
||||||
|
Console *ConsoleIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsoleIdentity 保留跨应用管理请求的原始会话身份;每个业务事务必须重新核验。
|
||||||
|
type ConsoleIdentity struct {
|
||||||
|
SessionNamespaceID core.ID // SessionNamespaceID 登录会话原有范围,不随页面筛选改变。
|
||||||
|
Username string // Username 部署指定的单管理员账号,不能来自请求参数。
|
||||||
|
UserID core.ID // UserID 已解析的人类账号;事务中仍核对当前会话归属。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActorID 为业务审计提供实际操作者,不把跨应用操作记成目标应用的 SDK。
|
||||||
|
func (t Token) ActorID() string {
|
||||||
|
if t.Console != nil {
|
||||||
|
return "console:" + string(t.Console.UserID)
|
||||||
|
}
|
||||||
|
return string(t.CredentialID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Issue(namespace, credential core.ID) (string, core.Digest, error) {
|
func Issue(namespace, credential core.ID) (string, core.Digest, error) {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ type Credential struct {
|
|||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
// SecretDigest 凭据秘密的 SHA-256 摘要,不保存或输出原文。
|
// SecretDigest 凭据秘密的 SHA-256 摘要,不保存或输出原文。
|
||||||
SecretDigest core.Digest `db:"secret_digest"`
|
SecretDigest core.Digest `db:"secret_digest"`
|
||||||
|
// PreviousSecretDigest 上一代秘密摘要;仅过渡期使用,空表示未保留旧令牌。
|
||||||
|
PreviousSecretDigest *core.Digest `db:"previous_secret_digest"`
|
||||||
|
// PreviousValidUntil 旧令牌的固定 UTC 截止;不晚于原到期时间,与旧摘要同时为空或非空。
|
||||||
|
PreviousValidUntil *time.Time `db:"previous_valid_until"`
|
||||||
// Permissions 授权范围的 JSON 快照,必须由服务端解释和校验。
|
// Permissions 授权范围的 JSON 快照,必须由服务端解释和校验。
|
||||||
Permissions core.JSON `db:"permissions"`
|
Permissions core.JSON `db:"permissions"`
|
||||||
// ExpiresAt 凭据过期时间,统一 UTC;为空表示没有配置到期时间。
|
// ExpiresAt 凭据过期时间,统一 UTC;为空表示没有配置到期时间。
|
||||||
|
|||||||
173
scripts/orbit-local.py
Executable file
173
scripts/orbit-local.py
Executable file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""部署持久化的本地联调环境;私密配置只保存在 .local/compose-test。"""
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PRIVATE = ROOT / '.local' / 'compose-test'
|
||||||
|
ENV_FILE = PRIVATE / 'compose.env'
|
||||||
|
CREDENTIALS = PRIVATE / 'sdk.credentials.json'
|
||||||
|
BASE_URL = 'http://127.0.0.1:18080'
|
||||||
|
|
||||||
|
|
||||||
|
def private_write(path, value):
|
||||||
|
temporary = path.with_name(path.name + '.tmp')
|
||||||
|
with temporary.open('w', encoding='utf-8') as file:
|
||||||
|
os.chmod(temporary, 0o600)
|
||||||
|
file.write(value)
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def settings():
|
||||||
|
return dict(line.split('=', 1) for line in ENV_FILE.read_text().splitlines()
|
||||||
|
if line and not line.startswith('#'))
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(values):
|
||||||
|
private_write(ENV_FILE, ''.join(f'{key}={value}\n' for key, value in values.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def compose(*args, **kwargs):
|
||||||
|
# 不继承其他联调项目的 ORBIT_/COMPOSE_,防止配置串到旧库或旧端口。
|
||||||
|
env = {k: v for k, v in os.environ.items()
|
||||||
|
if not k.startswith(('ORBIT_', 'COMPOSE_'))}
|
||||||
|
env.update(settings())
|
||||||
|
env.update(ORBIT_LOCAL_DIRECTORY=str(PRIVATE),
|
||||||
|
ORBIT_LOCAL_USER=f'{os.getuid()}:{os.getgid()}')
|
||||||
|
# 本地环境使用 Docker 自带构建器,不占用用户选中的跨平台构建器。
|
||||||
|
env.update(BUILDX_BUILDER=subprocess.check_output(
|
||||||
|
['docker', 'context', 'show'], text=True).strip(), COMPOSE_BAKE='false')
|
||||||
|
command = ['docker', 'compose', '--project-name', 'orbit-test',
|
||||||
|
'--env-file', str(ENV_FILE), '-f', str(ROOT / 'deploy/docker-compose.yml'),
|
||||||
|
'-f', str(ROOT / 'deploy/local/compose.yml'), *args]
|
||||||
|
return subprocess.run(command, cwd=ROOT, env=env, check=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_ready():
|
||||||
|
for _ in range(60):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(BASE_URL + '/health/ready', timeout=2) as response:
|
||||||
|
if response.status == 200 and json.load(response)['status'] == 'ready':
|
||||||
|
return
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError('Orbit readiness 未通过;使用 scripts/orbit-local.py logs orbit 排查。')
|
||||||
|
|
||||||
|
|
||||||
|
def setup():
|
||||||
|
os.umask(0o077)
|
||||||
|
PRIVATE.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
if not ENV_FILE.exists():
|
||||||
|
# 配置缺失而卷仍存在时不能换密码,先恢复旧配置。
|
||||||
|
volumes = subprocess.check_output(
|
||||||
|
['docker', 'volume', 'ls', '--filter', 'label=com.docker.compose.project=orbit-test', '-q'],
|
||||||
|
text=True).strip()
|
||||||
|
if volumes or any(PRIVATE.iterdir()):
|
||||||
|
raise RuntimeError('发现既有卷或初始化文件;请恢复 compose.env,不会生成替代密码。')
|
||||||
|
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
|
||||||
|
save_settings({
|
||||||
|
'ORBIT_IMAGE': 'orbit:local-test', 'ORBIT_DB_NAME': 'orbit',
|
||||||
|
'ORBIT_DB_USER': 'orbit_runtime', 'ORBIT_DB_PASSWORD': secrets.token_hex(24),
|
||||||
|
'ORBIT_DB_ROOT_PASSWORD': secrets.token_hex(24),
|
||||||
|
'ORBIT_HTTP_BIND': '127.0.0.1', 'ORBIT_HTTP_PORT': '18080',
|
||||||
|
'ORBIT_TEST_BACKEND_SUBNET': '10.253.240.0/24',
|
||||||
|
'ORBIT_TEST_SERVICES_SUBNET': '10.253.241.0/24',
|
||||||
|
'ORBIT_CONSOLE_QUEUE_ID': '', 'ORBIT_ARCHIVE_STORAGE_ID': secrets.token_hex(16),
|
||||||
|
'ORBIT_BUILD_VERSION': 'v0.1.1-local', 'ORBIT_BUILD_REVISION': revision,
|
||||||
|
'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()
|
||||||
|
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('run', '--rm', '-T', '--no-deps', 'verify')
|
||||||
|
if not CREDENTIALS.exists():
|
||||||
|
marker = PRIVATE / 'bootstrap.started'
|
||||||
|
if marker.exists():
|
||||||
|
raise RuntimeError('上次初始化未确认;保留现场核查,不能重新创建管理员/凭据。')
|
||||||
|
password = (PRIVATE / 'admin.password').read_text()
|
||||||
|
private_write(marker, 'bootstrap started; retain credentials on uncertain commit\n')
|
||||||
|
compose('run', '--rm', '-T', '--no-deps', 'bootstrap', 'bootstrap',
|
||||||
|
'--application', 'local-testing', '--namespace', 'default', '--environment', 'test',
|
||||||
|
'--queue', 'integration', '--worker-access', '--schedule-access', '--event-access',
|
||||||
|
'--recovery-access', '--resource-access', '--workflow-access', '--workflow-run-access',
|
||||||
|
'--workflow-control-access', '--workflow-signal-access', '--workflow-recovery-access',
|
||||||
|
'--workflow-audit-access', '--console-username', 'admin',
|
||||||
|
'--console-display-name', 'Orbit 测试管理员', '--console-password-stdin',
|
||||||
|
'--output', '/run/local/sdk.credentials.json', input=password, text=True)
|
||||||
|
credential = json.loads(CREDENTIALS.read_text())
|
||||||
|
# 私密回执可能在 COMMIT 确认前写出;重启先核验库中同一身份,不重新 bootstrap。
|
||||||
|
status = compose('run', '--rm', '-T', '--no-deps', 'bootstrap', 'credential', 'status',
|
||||||
|
'--namespace-id', credential['namespace_id'], '--credential-id', credential['credential_id'],
|
||||||
|
stdout=subprocess.PIPE, text=True)
|
||||||
|
status = json.loads(status.stdout)
|
||||||
|
expired = status.get('expires_at') and datetime.datetime.fromisoformat(
|
||||||
|
status['expires_at'].replace('Z', '+00:00')) <= datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
if status.get('revoked_at') or expired or not status['namespace_enabled'] or not status['application_enabled']:
|
||||||
|
raise RuntimeError('原凭据已停用或过期;请按凭据轮换手册处理,数据与原身份保持不变。')
|
||||||
|
values = settings()
|
||||||
|
values['ORBIT_CONSOLE_QUEUE_ID'] = credential['queue_id']
|
||||||
|
save_settings(values)
|
||||||
|
for name, url in [('host.env', BASE_URL), ('docker.env', 'http://orbit-test:8080')]:
|
||||||
|
private_write(PRIVATE / name, '\n'.join([
|
||||||
|
'ORBIT_URL=' + url, 'ORBIT_TOKEN=' + credential['token'],
|
||||||
|
'ORBIT_NAMESPACE_ID=' + credential['namespace_id'],
|
||||||
|
'ORBIT_QUEUE_ID=' + credential['queue_id'], '']) )
|
||||||
|
compose('up', '-d', 'orbit')
|
||||||
|
wait_ready()
|
||||||
|
request = urllib.request.Request(BASE_URL + '/v1/queues/' + credential['queue_id'],
|
||||||
|
headers={'Authorization': 'Bearer ' + credential['token']})
|
||||||
|
with urllib.request.urlopen(request, timeout=5) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
raise RuntimeError('已保存的 SDK 凭据未通过队列访问校验。')
|
||||||
|
print('Orbit ready: ' + BASE_URL + '/console/')
|
||||||
|
print('Docker network: orbit-test-services; API: http://orbit-test:8080')
|
||||||
|
print('Admin: admin; password file: ' + str(PRIVATE / 'admin.password'))
|
||||||
|
print('SDK settings: ' + str(PRIVATE / 'host.env') + ' / docker.env')
|
||||||
|
print('Credential expiry: ' + (status.get('expires_at') or 'permanent'))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
action = sys.argv[1] if len(sys.argv) > 1 else 'status'
|
||||||
|
if action == 'up':
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
raise RuntimeError('用法:scripts/orbit-local.py up')
|
||||||
|
setup()
|
||||||
|
return
|
||||||
|
if not ENV_FILE.exists():
|
||||||
|
raise RuntimeError('请先运行 scripts/orbit-local.py up。')
|
||||||
|
if action in ('status', 'ps'):
|
||||||
|
compose('ps', '--all')
|
||||||
|
elif action == 'logs':
|
||||||
|
compose('logs', '--tail', '100', *(sys.argv[2:] or ['orbit']))
|
||||||
|
elif action == 'stop':
|
||||||
|
compose('stop', 'orbit', 'mysql')
|
||||||
|
elif action == 'start':
|
||||||
|
compose('start', 'mysql', 'orbit')
|
||||||
|
wait_ready()
|
||||||
|
elif action == 'restart':
|
||||||
|
compose('restart', 'orbit')
|
||||||
|
wait_ready()
|
||||||
|
else:
|
||||||
|
raise RuntimeError('用法:scripts/orbit-local.py up|status|logs [service]|stop|start|restart')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (RuntimeError, subprocess.CalledProcessError) as error:
|
||||||
|
print(str(error), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
141
scripts/test-console-applications.py
Normal file
141
scripts/test-console-applications.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""通过真实 HTTP 验证应用接入,不打印或记录密码、Cookie 与 Token。"""
|
||||||
|
import copy
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
base = os.environ['ORBIT_E2E_URL'].rstrip('/')
|
||||||
|
user = os.environ['ORBIT_E2E_USER']
|
||||||
|
password = Path(os.environ['ORBIT_E2E_PASSWORD_FILE']).read_text().strip()
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||||
|
|
||||||
|
|
||||||
|
def call(path, body=None, token=None, headers=None, expected=200, session=True):
|
||||||
|
hdr = {'Accept': 'application/json', **(headers or {})}
|
||||||
|
if body is not None:
|
||||||
|
hdr['Content-Type'] = 'application/json'
|
||||||
|
if path.startswith('/console/applications'):
|
||||||
|
hdr.setdefault('X-Orbit-Console', '1')
|
||||||
|
hdr.setdefault('Origin', base)
|
||||||
|
if token:
|
||||||
|
hdr['Authorization'] = 'Bearer ' + token
|
||||||
|
req = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(), headers=hdr)
|
||||||
|
try:
|
||||||
|
response = (opener.open if session else urllib.request.urlopen)(req, timeout=15)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
response = error
|
||||||
|
with response:
|
||||||
|
status = response.status
|
||||||
|
value = json.load(response)
|
||||||
|
assert status == expected, f'{path}: HTTP {status}, expected {expected}; code={value.get("error", {}).get("code")}'
|
||||||
|
return value.get('data', value)
|
||||||
|
|
||||||
|
|
||||||
|
call('/console/applications', expected=401, session=False)
|
||||||
|
call('/console/auth/login', {'username': user, 'password': password})
|
||||||
|
assert call('/console/auth/session')['authenticated']
|
||||||
|
call('/console/applications/prepare', {}, headers={'Origin': 'https://other.invalid'}, expected=403)
|
||||||
|
name = 'access-demo-' + uuid.uuid4().hex[:8]
|
||||||
|
request = {'kind': 'CREATE', 'application': name, 'namespace': 'default', 'environment': 'test', 'queue': 'integration', 'capabilities': ['worker', 'events', 'schedules', 'workflows'], 'valid_days': 30, 'expected_revision': '0', 'reason': 'application onboarding acceptance'}
|
||||||
|
op = call('/console/applications/prepare', request)
|
||||||
|
# 固定操作在提交前写入测试私密目录,以便提交结果不明时核对。
|
||||||
|
private = os.environ.get('ORBIT_E2E_APPLICATION_DIR')
|
||||||
|
if private:
|
||||||
|
os.umask(0o077)
|
||||||
|
Path(private).mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(private, 'create.operation.json').write_text(json.dumps(op))
|
||||||
|
first = call('/console/applications/apply', op)
|
||||||
|
assert not first['deduplicated']
|
||||||
|
assert call('/console/applications/apply', op)['deduplicated']
|
||||||
|
changed = copy.deepcopy(op)
|
||||||
|
changed['input']['reason'] = 'changed'
|
||||||
|
call('/console/applications/apply', changed, expected=409)
|
||||||
|
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)
|
||||||
|
queue = detail['queues'][0]['id']
|
||||||
|
assert queue == op['queue_id']
|
||||||
|
call('/console/applications', token=op['token'], expected=401, session=False)
|
||||||
|
call('/v1/queues/' + queue, token=op['token'], session=False)
|
||||||
|
sub = call('/v1/subscriptions', {'name': 'result-example', 'queue_id': queue}, token=op['token'], headers={'Idempotency-Key': 'onboarding-subscription'}, expected=201, session=False)
|
||||||
|
subscription_id = sub['subscription']['id']
|
||||||
|
task = call('/v1/tasks', {'queue_id': queue, 'handler': 'onboarding.noop', 'handler_version': 'v1', 'payload_version': 'v1', 'payload': {}}, token=op['token'], headers={'Idempotency-Key': 'onboarding-task'}, expected=201, session=False)
|
||||||
|
task_id = task['task']['id']
|
||||||
|
call('/v1/tasks/' + task_id + ':cancel', {'expected_revision': task['task']['revision'], 'reason': 'acceptance complete'}, token=op['token'], headers={'Idempotency-Key': 'onboarding-cancel'}, session=False)
|
||||||
|
expiry = call('/console/applications/prepare', {'kind': 'SET_EXPIRY', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '1', 'valid_days': 0, 'reason': 'long-lived service credential'})
|
||||||
|
assert 'token' not in expiry and 'expires_at' not in expiry
|
||||||
|
if private:
|
||||||
|
Path(private, 'expiry.operation.json').write_text(json.dumps(expiry))
|
||||||
|
assert call('/console/applications/apply', expiry)['revision'] == '2'
|
||||||
|
assert call('/console/applications/apply', expiry)['deduplicated']
|
||||||
|
assert 'expires_at' not in call('/console/applications/' + op['namespace_id'])['credentials'][0]
|
||||||
|
call('/v1/queues/' + queue, token=op['token'], session=False)
|
||||||
|
rotate = call('/console/applications/prepare', {'kind': 'ROTATE', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '2', 'valid_days': 0, 'grace_days': 7, 'reason': 'rotation acceptance'})
|
||||||
|
if private:
|
||||||
|
Path(private, 'rotate.operation.json').write_text(json.dumps(rotate))
|
||||||
|
assert call('/console/applications/apply', rotate)['revision'] == '3'
|
||||||
|
assert call('/console/applications/apply', rotate)['deduplicated']
|
||||||
|
call('/v1/queues/' + queue, token=op['token'], session=False)
|
||||||
|
call('/v1/queues/' + queue, token=rotate['token'], session=False)
|
||||||
|
assert call('/console/applications/' + op['namespace_id'])['credentials'][0]['previous_valid_until']
|
||||||
|
finalize = call('/console/applications/prepare', {'kind': 'FINALIZE', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '3', 'valid_days': 0, 'reason': 'all services switched'})
|
||||||
|
if private:
|
||||||
|
Path(private, 'finalize.operation.json').write_text(json.dumps(finalize))
|
||||||
|
assert 'token' not in finalize
|
||||||
|
assert call('/console/applications/apply', finalize)['revision'] == '4'
|
||||||
|
assert call('/console/applications/apply', finalize)['deduplicated']
|
||||||
|
call('/v1/queues/' + queue, token=op['token'], expected=401, session=False)
|
||||||
|
call('/v1/queues/' + queue, token=rotate['token'], session=False)
|
||||||
|
# 重放旧轮换回执不能重新启用已经结束过渡的秘密。
|
||||||
|
assert call('/console/applications/apply', rotate)['deduplicated']
|
||||||
|
call('/v1/queues/' + queue, token=op['token'], expected=401, session=False)
|
||||||
|
current_task = call('/v1/tasks/' + task_id, token=rotate['token'], session=False)
|
||||||
|
assert current_task['state'] == 'CANCELLED'
|
||||||
|
current_sub = call('/v1/subscriptions/' + subscription_id, token=rotate['token'], session=False)
|
||||||
|
assert current_sub['id'] == subscription_id
|
||||||
|
# 不让联调示例遗留启用的订阅,历史和稳定凭据身份保持不变。
|
||||||
|
call('/v1/subscriptions/' + subscription_id + ':configure', {'expected_revision': current_sub['revision'], 'enabled': False, 'reason': 'acceptance complete'}, token=rotate['token'], headers={'Idempotency-Key': 'onboarding-pause-subscription'}, session=False)
|
||||||
|
if private:
|
||||||
|
Path(private, 'host.env').write_text(f"ORBIT_URL={base}\nORBIT_TOKEN={rotate['token']}\nORBIT_NAMESPACE_ID={op['namespace_id']}\nORBIT_QUEUE_ID={queue}\n")
|
||||||
|
Path(private, 'result.json').write_text(json.dumps({'application': name, 'namespace_id': op['namespace_id'], 'queue_id': queue, 'credential_id': op['credential_id'], 'task_id': task_id, 'subscription_id': subscription_id}))
|
||||||
|
# 登录管理员按应用范围读取和管理;SDK 不能使用范围请求头。
|
||||||
|
scope_headers = {'X-Orbit-Namespace': op['namespace_id'], 'X-Orbit-Console': '1'}
|
||||||
|
original_scope = call('/console/scope')
|
||||||
|
scope = call('/console/scope', headers=scope_headers)
|
||||||
|
assert scope['entry']['application'] == name and scope['entry']['environment'] == 'test'
|
||||||
|
assert scope['entry']['namespace_id'] == op['namespace_id'] and scope['can_switch']
|
||||||
|
assert any(q['id'] == queue for q in scope['queues'])
|
||||||
|
call('/console/scope', token=rotate['token'], headers=scope_headers, expected=403, session=False)
|
||||||
|
call('/v1/tasks/' + task_id, headers=scope_headers, token=rotate['token'], expected=403)
|
||||||
|
call('/v1/tasks/' + task_id, expected=404)
|
||||||
|
assert call('/v1/tasks/' + task_id, headers=scope_headers)['id'] == task_id
|
||||||
|
assert any(t['id'] == task_id for t in call('/v1/tasks?queue_id=' + queue, headers=scope_headers)['items'])
|
||||||
|
assert any(s['id'] == subscription_id for s in call('/v1/subscriptions?queue_id=' + queue, headers=scope_headers)['items'])
|
||||||
|
assert call('/v1/subscriptions/' + subscription_id, headers=scope_headers)['credential_id'] == op['credential_id']
|
||||||
|
for path in ['/v1/schedules?queue_id=' + queue, '/v1/workflows', '/console/retention/preview', '/console/archive/plans', '/console/archive/jobs']:
|
||||||
|
call(path, headers=scope_headers)
|
||||||
|
foreign_queue = original_scope['queues'][0]['id']
|
||||||
|
assert call('/v1/tasks?queue_id=' + foreign_queue, headers=scope_headers)['items'] == []
|
||||||
|
call('/v1/queues/' + foreign_queue, headers=scope_headers, expected=404)
|
||||||
|
call('/console/retention/export', {'kind': 'tasks', 'expected_revision': '0'}, headers={**scope_headers, 'Origin': 'https://other.invalid'}, expected=403)
|
||||||
|
# 管理订阅不改绑原 SDK 凭据,重复操作仍返回同一回执。
|
||||||
|
sub_current = call('/v1/subscriptions/' + subscription_id, headers=scope_headers)
|
||||||
|
pause = {'expected_revision': sub_current['revision'], 'enabled': False, 'reason': 'scoped console acceptance'}
|
||||||
|
manage_headers = {**scope_headers, 'Idempotency-Key': 'scope-pause-subscription'}
|
||||||
|
call('/v1/subscriptions/' + subscription_id + ':configure', pause, headers=manage_headers)
|
||||||
|
call('/v1/subscriptions/' + subscription_id + ':configure', pause, headers=manage_headers)
|
||||||
|
assert call('/v1/subscriptions/' + subscription_id, token=rotate['token'], session=False)['credential_id'] == op['credential_id']
|
||||||
|
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('/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('Test application: ' + name)
|
||||||
@@ -106,6 +106,7 @@ printf '%s\n' "[4/8] start Orbit container"
|
|||||||
--env ORBIT_MYSQL_DSN="$E2E_DSN" \
|
--env ORBIT_MYSQL_DSN="$E2E_DSN" \
|
||||||
--env ORBIT_HTTP_ADDR=0.0.0.0:8080 \
|
--env ORBIT_HTTP_ADDR=0.0.0.0:8080 \
|
||||||
--env ORBIT_CONSOLE_QUEUE_ID="$E2E_QUEUE" \
|
--env ORBIT_CONSOLE_QUEUE_ID="$E2E_QUEUE" \
|
||||||
|
--env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \
|
||||||
--env ORBIT_ARCHIVE_ROOT=/var/lib/orbit/archive \
|
--env ORBIT_ARCHIVE_ROOT=/var/lib/orbit/archive \
|
||||||
--env ORBIT_ARCHIVE_STORAGE_ID="$E2E_STORAGE_ID" \
|
--env ORBIT_ARCHIVE_STORAGE_ID="$E2E_STORAGE_ID" \
|
||||||
--env ORBIT_ARCHIVE_TICK_INTERVAL=1s \
|
--env ORBIT_ARCHIVE_TICK_INTERVAL=1s \
|
||||||
@@ -139,6 +140,9 @@ if [ "$E2E_READY" -ne 1 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_USER="$E2E_ADMIN" \
|
||||||
|
ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" python3 ./scripts/test-console-applications.py
|
||||||
|
|
||||||
printf '%s\n' "[5/8] console login and protected-page check"
|
printf '%s\n' "[5/8] console login and protected-page check"
|
||||||
ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_QUEUE="$E2E_QUEUE" ORBIT_E2E_USER="$E2E_ADMIN" \
|
ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_QUEUE="$E2E_QUEUE" ORBIT_E2E_USER="$E2E_ADMIN" \
|
||||||
ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" python3 - <<'PY'
|
ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" python3 - <<'PY'
|
||||||
|
|||||||
Reference in New Issue
Block a user