feat: show application ownership and scoped console management
This commit is contained in:
@@ -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)。
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.RegisterConsoleApplications(server, consoleAuth, mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin))
|
consoleApplications := mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin)
|
||||||
httpserver.WrapConsoleAuth(server, consoleAuth)
|
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 != "" {
|
||||||
|
|||||||
@@ -289,3 +289,9 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
|
|||||||
- 000032 与基线增加旧摘要/固定截止,无外键。控制台/CLI 默认 7 天过渡;同一身份的 API、WS 控制命令和跨节点重连均核验两代有效性,权限与任务归属保持不变。
|
- 000032 与基线增加旧摘要/固定截止,无外键。控制台/CLI 默认 7 天过渡;同一身份的 API、WS 控制命令和跨节点重连均核验两代有效性,权限与任务归属保持不变。
|
||||||
- 结束过渡、两代同时撤销、过渡期防重复换证、旧 v1 固定操作兼容、幂等审计及中文模型注释随功能交付。
|
- 结束过渡、两代同时撤销、过渡期防重复换证、旧 v1 固定操作兼容、幂等审计及中文模型注释随功能交付。
|
||||||
- 验证结果见[长期凭据与平滑换证](reports/credential-overlap.md)。SDK 无接口修改;调用方须在截止前滚动更新配置并收尾在途执行。
|
- 验证结果见[长期凭据与平滑换证](reports/credential-overlap.md)。SDK 无接口修改;调用方须在截止前滚动更新配置并收尾在途执行。
|
||||||
|
|
||||||
|
### 控制台应用范围与列表归属(2026-09-27)
|
||||||
|
|
||||||
|
- 已实现:页顶应用/环境与队列筛选、列表和详情应用标签、URL 保持筛选;切换隔离旧请求和表单,错误范围不降级读取默认数据。
|
||||||
|
- 单管理员通过 Cookie 显式选择范围;业务事务重新核验会话与目标应用,SDK 不支持范围覆盖,订阅原凭据绑定不变。
|
||||||
|
- 同步提供[范围接口](api/console-scope.md)、真实 MySQL 授权/审计/退出后拒绝测试和 HTTP 列表及订阅管理回归。最终结果见[本批报告](reports/console-scope.md)。
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export ORBIT_CONSOLE_APPLICATION_ADMIN=admin
|
|||||||
5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。
|
5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。
|
||||||
6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。
|
6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。
|
||||||
|
|
||||||
环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面目前支持新建应用及其初始队列,未提供应用删除、身份迁移、跨应用任务列表或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。接入管理页不会改变其他业务页面的账号默认范围。
|
环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面支持新建应用及其初始队列;业务列表可通过应用筛选栏切换范围,分别展示各应用数据。未提供应用删除、身份迁移或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。
|
||||||
|
|
||||||
## 2. 路由与认证
|
## 2. 路由与认证
|
||||||
|
|
||||||
@@ -145,3 +145,5 @@ SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长
|
|||||||
| 数据库/网络故障 | 5xx / 网络错误 | 不假定失败,保留原操作重报 |
|
| 数据库/网络故障 | 5xx / 网络错误 | 不假定失败,保留原操作重报 |
|
||||||
|
|
||||||
可重复运行的真实 HTTP 场景见 [test-console-applications.py](../../scripts/test-console-applications.py),标准容器入口 `make console-e2e` 同时验证此链路。基础 SDK 使用方式见 [SDK 接入](../sdk/README.md)。
|
可重复运行的真实 HTTP 场景见 [test-console-applications.py](../../scripts/test-console-applications.py),标准容器入口 `make console-e2e` 同时验证此链路。基础 SDK 使用方式见 [SDK 接入](../sdk/README.md)。
|
||||||
|
|
||||||
|
业务数据的应用筛选、队列分页及会话隔离见[应用范围接口](console-scope.md)。
|
||||||
|
|||||||
52
docs/api/console-scope.md
Normal file
52
docs/api/console-scope.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 控制台应用范围
|
||||||
|
|
||||||
|
此接口服务于内置控制台的单管理员,用于按应用环境读取列表、详情并执行已有管理操作;不是 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。
|
||||||
@@ -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,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 长期放进浏览器本地存储,也不能让控制台直连 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)。
|
||||||
|
|||||||
9
docs/reports/console-scope.md
Normal file
9
docs/reports/console-scope.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 控制台应用范围回归
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
任务、计划、流程、订阅和归档列表显示应用名称与环境;保留策略及详情显示归属。管理员可选择应用环境和队列,范围保存在 URL,页面切换隔离旧数据。后台按 Cookie 会话授权,普通 SDK 不接受范围覆盖。无表结构变更,无新增外键。
|
||||||
|
|
||||||
|
## 验证记录
|
||||||
|
|
||||||
|
验证进行中,最终结果在本次交付前更新。
|
||||||
21
internal/consoleauth/scope.go
Normal file
21
internal/consoleauth/scope.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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) {
|
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, previous []byte
|
var raw, previous []byte
|
||||||
var currentValid, previousValid bool
|
var currentValid, previousValid bool
|
||||||
@@ -178,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,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 $ = (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: '归档作业', applications: '应用接入' };
|
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' };
|
||||||
@@ -26,10 +26,12 @@
|
|||||||
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';
|
||||||
|
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();
|
if (next === 'applications' && state.authenticated) window.orbitApplications.load();
|
||||||
}
|
}
|
||||||
function redirectToLogin() {
|
function redirectToLogin() {
|
||||||
@@ -43,13 +45,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 {
|
||||||
@@ -65,6 +72,97 @@
|
|||||||
// 配置只是已登录会话的默认队列,失败时业务 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-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; });
|
||||||
@@ -221,7 +319,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() {
|
||||||
@@ -317,8 +415,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); } });
|
||||||
@@ -326,8 +424,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); } });
|
||||||
@@ -335,8 +433,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); } });
|
||||||
@@ -346,8 +444,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); } });
|
||||||
@@ -672,19 +770,22 @@
|
|||||||
try {
|
try {
|
||||||
await loadHealth();
|
await loadHealth();
|
||||||
if (currentView() === 'applications') { await window.orbitApplications.load(); 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; }
|
||||||
if (!state.authenticated || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
if (!state.authenticated || !state.scopeReady) { 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);
|
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 = '流程实例读取失败,请刷新重试或检查权限';
|
||||||
|
renderSubscriptions(subscriptionItems || []);
|
||||||
|
if (!subscriptionItems) $('subscription-list').textContent = '结果订阅读取失败,请刷新重试或检查权限';
|
||||||
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'); }
|
||||||
@@ -709,6 +810,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);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -48,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">
|
||||||
@@ -109,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>
|
||||||
@@ -172,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>
|
||||||
@@ -230,7 +237,7 @@
|
|||||||
</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>
|
||||||
@@ -265,7 +272,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>
|
||||||
@@ -293,7 +300,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>
|
||||||
@@ -350,7 +357,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>
|
||||||
|
|||||||
@@ -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: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}}
|
@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-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}}
|
||||||
|
|||||||
60
internal/transport/httpserver/console_scope.go
Normal file
60
internal/transport/httpserver/console_scope.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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})
|
||||||
|
})
|
||||||
|
}
|
||||||
78
internal/transport/httpserver/console_scope_test.go
Normal file
78
internal/transport/httpserver/console_scope_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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/workers/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")
|
||||||
|
|||||||
@@ -19,6 +19,23 @@ type Token struct {
|
|||||||
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) {
|
||||||
|
|||||||
@@ -105,7 +105,37 @@ call('/v1/subscriptions/' + subscription_id + ':configure', {'expected_revision'
|
|||||||
if private:
|
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, '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}))
|
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/auth/logout', {})
|
||||||
call('/console/applications', expected=401)
|
call('/console/applications', expected=401)
|
||||||
|
call('/v1/tasks/' + task_id, headers=scope_headers, expected=401)
|
||||||
print('PASS: application create/replay, CSRF/session isolation, permanent expiry, overlapping rotation/finalization, original task/subscription preserved, logout')
|
print('PASS: application create/replay, CSRF/session isolation, permanent expiry, overlapping rotation/finalization, original task/subscription preserved, logout')
|
||||||
print('Test application: ' + name)
|
print('Test application: ' + name)
|
||||||
|
|||||||
Reference in New Issue
Block a user