2 Commits

28 changed files with 796 additions and 50 deletions

View File

@@ -4,7 +4,7 @@
- **Pulse · 脉动**:异步任务、延迟执行、定时计划、执行授权、重试与结果订阅。
- **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。
- **Console**:账号密码登录的管理页面,查看任务、计划、流程、结果交付及归档作业。
- **Console**:账号密码登录的管理页面,按应用与环境查看任务、计划、流程、结果交付及归档作业。
主分支:`main`。当前版本:**`v0.1.1`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。
@@ -65,7 +65,7 @@
| 业务结果 Inbox | 事件去重与业务更新同事务,提交成功后确认交付 | [result-inbox](examples/result-inbox/README.md) |
| HTTP 服务完整生命周期 | 业务接口、Outbox、Worker、结果消费、就绪检查和有界关闭放进同一业务服务 | [http-service](examples/http-service/README.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) |
| 运维与观测 | 健康/readiness、独立指标端口、迁移核验、负载工具、备份恢复与故障演练 | [观测](docs/api/observability.md) · [运维](docs/20-operations-runbook.md) |
@@ -223,6 +223,7 @@ fmt.Println(current.ID, current.State)
| `ORBIT_MYSQL_DSN` | 平台数据库连接,迁移账号与运行账号按部署手册分开 |
| `ORBIT_HTTP_ADDR` | 服务进程监听地址,例 `127.0.0.1:8080` |
| `ORBIT_CONSOLE_QUEUE_ID` | 控制台默认队列;不授予账号额外权限 |
| `ORBIT_CONSOLE_APPLICATION_ADMIN` | 指定唯一可接入和管理各应用的控制台登录名;空值不开放跨应用权限,多节点保持一致 |
| `ORBIT_DB_MAX_OPEN` / `ORBIT_DB_MAX_IDLE` | 数据库连接池上限 |
| `ORBIT_SHUTDOWN_TIMEOUT` | SIGTERM 关闭窗口,默认 10s |
| `ORBIT_WORKFLOW_TICK_INTERVAL` / `ORBIT_SCHEDULE_TICK_INTERVAL` | 流程/计划后台扫描间隔,默认 1s |

View File

@@ -125,8 +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.RegisterConsoleRetention(server, consoleAuth, retentionStore, retentionStore)
httpserver.RegisterConsoleArchiveJobs(server, consoleAuth, retentionStore, cfg.ArchiveStorageID)
httpserver.RegisterConsoleApplications(server, consoleAuth, mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin))
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)
if cfg.ArchiveRoot != "" {

View File

@@ -24,6 +24,7 @@ x-orbit-environment: &orbit-environment
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
services:

View File

@@ -289,3 +289,9 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
- 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)。

View File

@@ -1,6 +1,6 @@
# 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/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
| `GET /version` | 已实现,返回版本、提交、构建时间及协议版本,不包含数据库、凭据或租户信息 | [健康检查](health.md) |
| `GET /console/scope` 与控制台显式应用范围 | 已实现单管理员应用/环境切换、队列分页和列表归属;SDK 不可覆盖范围 | [应用范围](console-scope.md) |
| 独立监听的 `GET /metrics` | 已实现基础运行指标,默认关闭 | [运行观测](observability.md) |
| `/v1/tasks` 提交、查询、分页与待执行取消 | 已实现,P3扩展业务键与创建时间筛选 | [任务接口](tasks.md) / [查询契约](task-queries.md) |
| `GET /v1/tasks/{task_id}/source` | 已实现普通、人工重试、计划固定版本及流程历史激活/轮次来源 | [来源查询](task-source.md) |

View File

@@ -21,7 +21,7 @@ export ORBIT_CONSOLE_APPLICATION_ADMIN=admin
5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。
6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。
环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面目前支持新建应用及其初始队列,未提供应用删除、身份迁移、跨应用任务列表或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。接入管理页不会改变其他业务页面的账号默认范围。
环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面支持新建应用及其初始队列;业务列表可通过应用筛选栏切换范围,分别展示各应用数据。未提供应用删除、身份迁移或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。
## 2. 路由与认证
@@ -145,3 +145,5 @@ SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长
| 数据库/网络故障 | 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
View 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 中的队列归属,错误队列不会被当成正常空列表。

View File

@@ -11,7 +11,7 @@ http://127.0.0.1:8080/console/
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
- 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,浏览器不接触业务 Token。
- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;登录和权限信息不会占用统计区域。
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
- 页顶选择应用、环境与队列;任务、计划、流程、订阅、归档列表和详情显示应用归属。筛选保存在 URL,刷新、复制链接及后退可恢复。
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
- 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。
@@ -20,10 +20,10 @@ http://127.0.0.1:8080/console/
- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
- 左侧六个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows`、`#/subscriptions` 和 `#/retention` 定位;切换视图不会把所有模块堆在一张长页面上。
- 左侧八个模块是独立视图:总览、任务、计划、流程、订阅、保留策略、归档作业和应用接入。
- “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。
- 任务状态按 10 秒自动刷新;页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
- 普通账号沿用绑定凭据范围;部署指定管理员可显式选择应用环境管理。读取失败显示错误,避免当作空列表。
## 本地打开
@@ -48,7 +48,7 @@ secret-manager read orbit/console-admin-password | \
--output .local/production.credentials.json
```
密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,账号权限就是该凭据的权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码:
密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,未指定为应用管理员的账号沿用该凭据权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码:
```sh
./bin/orbit-admin console-user create \
@@ -74,10 +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 重新生成和完整 SDK 配置交付。配置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一管理登录名;本地 Compose 已配置。其他业务页面继续使用账号原有默认范围。[功能、接口、权限和恢复说明](api/console-applications.md)。
侧栏「应用接入」提供应用创建、队列与凭据查看、Token 重新生成和完整 SDK 配置交付。配置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一管理登录名;本地 Compose 已配置。业务页面支持该管理员选择应用范围,订阅原 SDK 凭据绑定不变。[功能、接口、权限和恢复说明](api/console-applications.md)。
## 按应用查看数据
页顶「应用 / 环境」选择已创建的应用,再选择队列。任务、定时计划和结果订阅按队列读取;流程、保留策略和归档按应用环境所属命名空间读取,页面有范围提示。应用每页 50 项、队列每页 100 项,选择「加载更多…」获取下一页。没有队列的应用仍能查看流程和归档。
切换采用完整导航,隔离旧请求和详情;有未保存策略、未确认操作或打开的详情时需先处理完。URL 只保存非敏感 ID。无效范围显示错误,不降级读取默认应用。总览仍是最近查询窗口的统计,不是全库累计。
只有部署指定管理员可以切换应用;其他账号只展示自己的应用和授权队列。后台停用应用、撤销绑定凭据或退出账号后,原页面范围不能继续授予权限。[范围接口及授权规则](api/console-scope.md)。

View 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 项一页。普通账号保留原绑定权限;只有部署指定的单管理员具有显式管理范围。此回归不替代生产部署的容量和跨故障域验收。

View 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)
}

View 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
}

View 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)
}
}

View File

@@ -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=?`
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 != "" {
c, err := parseEventCursor(in.Cursor, t, queue, "subscriptions", "")
if err != nil {

View File

@@ -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) {
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 {
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) {
return out, fault.NotFound
}

View File

@@ -71,8 +71,11 @@ func (s *RetentionPolicyStore) GetRetentionPolicy(ctx context.Context, namespace
}
// retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。
// 管理员必须具备当前命名空间全部队列的管理范围;不添加角色体系或跨命名空间权限。
// 普通会话要求全部队列管理范围;部署指定管理员的显式范围在同一事务重新核验。
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)
if err != nil {
return "", err

View File

@@ -45,6 +45,10 @@ 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) {
if token.Console != nil {
_, err := consoleScopeActor(ctx, tx, token)
return consoleScopePermissions(), err
}
var digest core.Digest
var raw, previous []byte
var currentValid, previousValid bool
@@ -178,7 +182,7 @@ func appendAuditWithEvidence(ctx context.Context, tx *sql.Tx, token auth.Token,
if err != nil {
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
}
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {

View File

@@ -29,7 +29,7 @@ func RegisterConsoleArchiveJobs(server *http.Server, consoleAuth RetentionConsol
writeError(w, "", fault.Unauthorized)
return auth.Token{}, false
}
return t, true
return scopedConsoleToken(r, t), true
}
key := func(w http.ResponseWriter, r *http.Request) (string, bool) {
keys := r.Header.Values("Idempotency-Key")

View File

@@ -13,6 +13,8 @@ import (
"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"
)
@@ -119,11 +121,32 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
// ConsoleSessionMiddleware 将 HttpOnly 会话转换为内部 Bearer 请求头。
// 浏览器脚本永远看不到业务令牌;现有所有 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 {
return next
}
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 token, ok := consoleCookieToken(r); ok && consoleAuth.ValidateSession(r.Context(), token) == nil {
clone := r.Clone(r.Context())

View File

@@ -1,5 +1,5 @@
(() => {
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 text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' };
@@ -23,13 +23,20 @@
}
function setView(view, updateHash = true) {
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('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.view === 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}`;
window.scrollTo({ top: 0, behavior: 'smooth' });
if (next === 'retention' && state.authenticated) loadRetention();
if (next === 'archives' && state.authenticated) loadArchives();
if (next === 'retention' && state.scopeReady) loadRetention();
if (next === 'archives' && state.scopeReady) loadArchives();
if (next === 'applications' && state.authenticated) window.orbitApplications.load();
}
function redirectToLogin() {
@@ -43,13 +50,18 @@
state.authenticated = true; return true;
}
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 raw = await response.text(); let body = {};
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
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; }
return body;
} finally { if (mutation) state.mutations--; }
}
async function loadConsoleConfig() {
try {
@@ -65,6 +77,102 @@
// 配置只是已登录会话的默认队列,失败时业务 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 };
function syncRetentionControls() {
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
@@ -221,7 +329,7 @@
text('archive-executor', 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-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();
}
async function loadArchives() {
@@ -317,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}%`;
text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`);
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; }
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('');
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.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 => {
row.addEventListener('click', () => openTaskDetail(row.dataset.taskId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(row.dataset.taskId); } });
@@ -326,8 +434,8 @@
}
function renderSchedules(items) {
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; }
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('');
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-application">${scopeBadge()}</div><div class="list-meta">${escapeHTML(item.paused ? '已暂停' : '运行中')}</div></div>`).join('');
el.querySelectorAll('.schedule-row').forEach(row => {
row.addEventListener('click', () => openScheduleDetail(row.dataset.scheduleId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScheduleDetail(row.dataset.scheduleId); } });
@@ -335,8 +443,8 @@
}
function renderWorkflows(items) {
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; }
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('');
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-application">${scopeBadge()}</div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join('');
el.querySelectorAll('.workflow-row').forEach(row => {
row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } });
@@ -346,8 +454,8 @@
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}%`;
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; }
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('');
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-application">${scopeBadge()}</div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
el.querySelectorAll('.subscription-row').forEach(row => {
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
@@ -672,19 +780,22 @@
try {
await loadHealth();
if (currentView() === 'applications') { await window.orbitApplications.load(); setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); return; }
if (!state.authenticated || !state.queue) { renderRuntimeStats(null, null, null, null); 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([
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/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 workflowItems = optional[1].status === 'fulfilled' ? extractItems(optional[1].value) : null;
const subscriptionItems = optional[2].status === 'fulfilled' ? extractItems(optional[2].value) : null;
if (scheduleItems) renderSchedules(scheduleItems);
if (workflowItems) renderWorkflows(workflowItems);
if (subscriptionItems) renderSubscriptions(subscriptionItems);
renderSchedules(scheduleItems || []);
if (!scheduleItems) $('schedule-list').textContent = '定时计划读取失败,请刷新重试或检查权限';
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 正常');
} catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
@@ -709,6 +820,6 @@
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
window.addEventListener('hashchange', () => 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());
state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (currentView() === 'archives') loadArchives(); } }, 10000);
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 (state.scopeReady && currentView() === 'archives') loadArchives(); } }, 10000);
})();

View File

@@ -48,6 +48,13 @@
</div>
</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">
<section class="hero" id="overview">
<div class="hero-copy">
@@ -109,7 +116,7 @@
<section class="content-grid single-column">
<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="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>
</section>
</div>
@@ -172,7 +179,7 @@
<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>
<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-grid">
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="90" max="3650" required></label>
@@ -230,7 +237,7 @@
</div>
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
<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>
<button class="icon-button" id="close-task-drawer" type="button" aria-label="关闭任务详情">×</button>
</div>
@@ -265,7 +272,7 @@
</div>
<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">
<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>
<button class="icon-button" id="close-schedule-drawer" type="button" aria-label="关闭计划详情">×</button>
</div>
@@ -293,7 +300,7 @@
</div>
<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">
<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>
<button class="icon-button" id="close-workflow-drawer" type="button" aria-label="关闭流程详情">×</button>
</div>
@@ -350,7 +357,7 @@
</div>
<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">
<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>
<button class="icon-button" id="close-subscription-drawer" type="button" aria-label="关闭订阅详情">×</button>
</div>

View File

@@ -18,3 +18,8 @@
@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}
/* 应用范围在页面导航间保持;列表归属信息使用同一组数据库元数据。 */
.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}}

View 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})
})
}

View 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")
}
})
}
}

View File

@@ -26,7 +26,7 @@ func RegisterConsoleRetention(server *http.Server, consoleAuth RetentionConsoleA
writeError(w, "", fault.Unauthorized)
return auth.Token{}, false
}
return t, true
return scopedConsoleToken(r, t), true
}
mux.HandleFunc("GET /console/retention", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)

View File

@@ -100,11 +100,11 @@ func newServer(addr string, ready Checker, consoleAuth consoleauth.Store, tasks
// WrapConsoleAuth 必须在所有 Register* 路由完成后调用,因为路由注册器需要
// 直接访问底层 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 {
return
}
server.Handler = ConsoleSessionMiddleware(server.Handler, consoleAuth)
server.Handler = ConsoleSessionMiddleware(server.Handler, consoleAuth, scopes...)
}
// WrapMetrics must run after all Register* functions because those functions

View File

@@ -207,7 +207,7 @@ func wrap(next endpoint) http.HandlerFunc {
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
data, status, err := next(r.WithContext(ctx), token)
data, status, err := next(r.WithContext(ctx), scopedConsoleToken(r, token))
if err != nil {
if fault.Code(err) == "UNAVAILABLE" {
slog.Error("Orbit API operation failed", "request_id", id, "code", "UNAVAILABLE")

View File

@@ -13,12 +13,29 @@ import (
// Token 是已解析但尚未认证的身份,服务端仍须核对数据库摘要与有效期。
type Token struct {
// NamespaceID 凭据所属命名空间,不能由请求体覆盖。
// NamespaceID 凭据所属命名空间;仅服务端已核验的 Console 身份可指定管理范围。
NamespaceID core.ID
// CredentialID 凭据记录标识。
CredentialID core.ID
// 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) {

View File

@@ -105,7 +105,37 @@ call('/v1/subscriptions/' + subscription_id + ':configure', {'expected_revision'
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)