feat: add single-admin application onboarding and token rotation
This commit is contained in:
@@ -103,6 +103,8 @@ python3 -c 'import getpass; print(getpass.getpass("设置管理员密码(至
|
||||
--console-password-stdin --output .local/development.credentials.json
|
||||
```
|
||||
|
||||
管理员可在「应用接入」页面新建应用和初始队列、查看凭据有效期、重新生成 Token,并复制或下载 SDK 配置。设置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一接入管理员;本地 Compose 已启用。[应用接入使用与接口文档](docs/api/console-applications.md)。
|
||||
|
||||
初始化只运行一次,后续重启复用数据库、账号和 SDK 凭据。凭据 JSON 是服务端程序访问 API 的秘密文件,**控制台不需要选择或上传它**。已有资源增加管理员的方法见 [Console 手册](docs/console.md);真实接入按实际角色缩小权限。
|
||||
|
||||
### 3. 启动平台,进入控制台
|
||||
|
||||
@@ -125,6 +125,7 @@ func run() error {
|
||||
httpserver.RegisterWorkflows(server, instance.New(mysql.NewWorkflowInstanceStore(db)), control.New(mysql.NewWorkflowControlStore(db)), weavesignal.New(mysql.NewWorkflowSignalStore(db)), recovery.New(mysql.NewWorkflowRecoveryStore(db)))
|
||||
httpserver.RegisterConsoleRetention(server, consoleAuth, retentionStore, retentionStore)
|
||||
httpserver.RegisterConsoleArchiveJobs(server, consoleAuth, retentionStore, cfg.ArchiveStorageID)
|
||||
httpserver.RegisterConsoleApplications(server, consoleAuth, mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin))
|
||||
httpserver.WrapConsoleAuth(server, consoleAuth)
|
||||
httpserver.WrapMetrics(server, metrics)
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ services:
|
||||
ORBIT_RETENTION_DELIVERY_DAYS: ${ORBIT_RETENTION_DELIVERY_DAYS:-90}
|
||||
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
||||
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
||||
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
|
||||
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
||||
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
||||
ports:
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
|
||||
浏览器直接账号密码登录,自动使用同源地址,不需要上传凭据 JSON。MySQL 仅在环境内部网络开放,业务服务通过 Orbit API 接入。
|
||||
|
||||
## 为其他应用分配独立配置
|
||||
|
||||
登录后打开侧栏「应用接入」,新建应用并选择能力,成功后复制/下载该应用的 `.env`。详情可以随时查看队列、权限和有效期,重新生成 Token 会立即使旧 Token 失效,但保留原任务和订阅。完整 Token 仅在生成时显示。
|
||||
|
||||
`host.env` / `docker.env` 是初始 local-testing 应用的联调身份;独立应用应使用页面分配的自己的配置。应用接入管理只授权 `admin`,无需创建多角色。[详细操作和恢复手册](../../docs/api/console-applications.md)。
|
||||
|
||||
## 本机业务服务
|
||||
|
||||
初始化生成 `.local/compose-test/host.env`,包含:
|
||||
|
||||
@@ -32,6 +32,7 @@ services:
|
||||
archive-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
ORBIT_CONSOLE_APPLICATION_ADMIN: admin
|
||||
ORBIT_ARCHIVE_ROOT: /var/lib/orbit/archives
|
||||
ORBIT_ARCHIVE_STORAGE_ID: ${ORBIT_ARCHIVE_STORAGE_ID}
|
||||
ORBIT_ARCHIVE_MAX_BYTES: 1073741824
|
||||
|
||||
@@ -274,3 +274,11 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
|
||||
- 提供 orbit-test-services 共享网络与 http://orbit-test:8080 地址,固定可配置子网,解决本机默认 Docker 地址池耗尽的问题。
|
||||
- 已验证:真实容器构建/迁移/零外键/权限收紧;浏览器登录协议和未登录跳转;另一容器运行 Worker 完成三项任务及事件 ACK 重报;定时/流程示例;重复部署与 Orbit/MySQL 停止再启动后原任务、身份保持。
|
||||
- 接入、账号位置、启动/停止及网络声明见[本地部署手册](../deploy/local/README.md)。这是本地联调环境,不改变平台或 SDK 协议。
|
||||
|
||||
### 单管理员应用接入(2026-09-27)
|
||||
|
||||
- 新增独立应用接入页:应用/环境分页、队列及凭据详情、选择业务能力新建应用、重新生成 Token、复制/下载 SDK 配置;沿用单管理员,不引入角色系统。
|
||||
- 新增 Cookie 专用管理接口,部署显式指定唯一登录名;同事务校验管理员与有效会话,写入要求同源与专用请求头。Token 只在生成时交付,数据库只存摘要。
|
||||
- 新建资源/审计/幂等回执同事务,轮换复用现有凭据 ID 与轮换核心。请求不明保留原操作,原样重报;原有任务、订阅和交付身份不变。无新表、无外键、无 SDK 协议修改。
|
||||
- 已通过定向 MySQL 并发创建、幂等冲突、重名回滚、轮换/旧 Token 拒绝、退出失效与审计脱敏;真实 HTTP 已验证创建、轮换后原任务和订阅可读。标准/全量集成和页面验收记录见[本批报告](reports/console-applications.md)。
|
||||
- [页面使用、HTTP 接口及故障恢复](api/console-applications.md)。
|
||||
|
||||
@@ -66,3 +66,5 @@ P1 业务接口已遵守下述约定,后续接口继续沿用:
|
||||
[流程实例筛选分页与权限](workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。
|
||||
|
||||
- [周期归档与作业管理](archive-scheduler.md)
|
||||
|
||||
- [控制台应用接入与 Token 管理](console-applications.md)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
状态:P1 已实现。业务接口每次在数据库事务内校验凭据摘要、撤销/到期、应用与命名空间启停,以及动作和队列范围。授权与写入使用同一事务;管理变更与已经获得授权锁的请求按事务先后次序生效。
|
||||
|
||||
已提供单管理员[应用接入页面](console-applications.md):新建应用、查看队列与凭据、重新生成 Token、复制 SDK 配置。下方 CLI 继续用于首次安装及运维。
|
||||
|
||||
## 初始化一个新的接入应用
|
||||
|
||||
前置:专用 MySQL 数据库已经执行 orbit-migrate up,使用持有初始化权限的数据库管理连接。此命令创建新应用、命名空间、队列和凭据,不会覆盖现有同名应用。
|
||||
@@ -44,7 +46,7 @@ secret-manager read orbit/console-admin-password | \
|
||||
|
||||
## 轮换与撤销
|
||||
|
||||
凭据轮换和撤销只允许在受保护的数据库管理环境执行,不提供公网管理接口。轮换保留 `credential_id`,因此订阅和 Worker 实例的授权绑定仍指向同一记录;新的 Worker 进程必须按现有进程身份规则重新连接,旧令牌不能再通过任何 API 或 WSS 认证。平台不会批量改写在途任务、交付记录或历史会话证据。
|
||||
管理 CLI 在受保护的数据库管理环境执行;控制台也可由部署指定的单管理员轮换凭据,见[应用接入](console-applications.md)。业务 SDK 不开放凭据管理,撤销仍由 CLI 执行。轮换保留 `credential_id`,因此订阅和 Worker 实例的授权绑定仍指向同一记录;新的 Worker 进程必须按现有进程身份规则重新连接,旧令牌不能再通过任何 API 或 WSS 认证。平台不会批量改写在途任务、交付记录或历史会话证据。
|
||||
|
||||
先读取脱敏状态和当前版本:
|
||||
|
||||
|
||||
124
docs/api/console-applications.md
Normal file
124
docs/api/console-applications.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# 控制台应用接入
|
||||
|
||||
单管理员在「应用接入」页面创建应用、初始队列与 SDK 凭据,查看环境、队列、权限和有效期,重新生成 Token,复制或下载 `.env` 配置。数据库只保存 Token 摘要:完整 Token 仅在新建或轮换时交付,列表和详情不能反查旧秘密。
|
||||
|
||||
## 1. 启用和使用
|
||||
|
||||
部署配置指定唯一有跨应用接入管理权限的控制台登录名:
|
||||
|
||||
```sh
|
||||
export ORBIT_CONSOLE_APPLICATION_ADMIN=admin
|
||||
```
|
||||
|
||||
该账号必须已经通过 bootstrap 或 console-user create 创建。配置为空时所有应用接入管理请求返回 403,不会自动提升已有账号;多节点使用相同配置。继续使用单管理员,不引入角色管理系统。现有控制台账号与会话的绑定凭据仍须有效;到期前可在本页轮换。轮换 SDK Token 不改变浏览器独立会话秘密。
|
||||
|
||||
本地 Compose 已为 `admin` 配置该入口,访问 `http://127.0.0.1:18080/console/#/applications`。
|
||||
|
||||
1. 点击「新建应用」,填写应用名称、环境、命名空间、初始队列和操作原因。
|
||||
2. 选择 Worker、结果订阅、定时、流程等需要的能力及有效期。基础权限包含任务提交/查询/取消、队列读取/管理。
|
||||
3. 创建成功后复制或下载完整 `.env`,交给该应用的服务端配置。默认地址使用当前页面来源,详情中可改为容器地址或部署域名。
|
||||
4. 已接入应用可以随时查看元数据和配置模板。模板中的 Token 是占位值,必须替换成已经保存的原 Token。
|
||||
5. 「重新生成 Token」保留 application_id、namespace_id、credential_id、队列、任务和订阅;旧 Token 在轮换事务提交后不能再通过业务认证,服务端配置需要同步更新。
|
||||
|
||||
环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面目前支持新建应用及其初始队列,未提供应用删除、身份迁移、跨应用任务列表或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。接入管理页不会改变其他业务页面的账号默认范围。
|
||||
|
||||
## 2. 路由与认证
|
||||
|
||||
这些是浏览器管理端接口,不属于业务 SDK。只接受有效的 `orbit_console_session` HttpOnly Cookie,并在数据库事务中重新核验部署指定的管理员账号和会话;即使 SDK 凭据拥有全部业务权限也不能访问。
|
||||
|
||||
| 方法与路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `GET /console/applications?after=<namespace_id>` | 按命名空间 ID 升序读取 50 条接入范围,返回 `items`、可选 `next`;`after` 为排他起点 |
|
||||
| `GET /console/applications/{namespace_id}` | 应用/环境详情、最多 100 个队列和 100 张脱敏凭据;超出返回 `truncated=true` |
|
||||
| `POST /console/applications/prepare` | 校验意图,生成固定操作 ID、资源 ID、Token、绝对到期时间;不创建资源、不轮换秘密 |
|
||||
| `POST /console/applications/apply` | 提交准备结果,原样重报返回原脱敏回执 |
|
||||
|
||||
POST 必须有 `Content-Type: application/json` 和 `X-Orbit-Console: 1`;浏览器 Origin 必须匹配当前主机,拒绝跨站 Fetch Metadata,不提供跨域授权。响应禁止缓存,成功信封为 `{"data": ...}`。未知字段、重复 JSON 键、显式 null 和错误类型被拒绝,正文上限 512 KiB。
|
||||
|
||||
## 3. 新建应用
|
||||
|
||||
prepare 请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "CREATE",
|
||||
"application": "order-service",
|
||||
"namespace": "default",
|
||||
"environment": "test",
|
||||
"queue": "orders",
|
||||
"capabilities": ["worker", "events", "schedules", "workflows"],
|
||||
"expected_revision": "0",
|
||||
"valid_days": 30,
|
||||
"reason": "接入订单服务"
|
||||
}
|
||||
```
|
||||
|
||||
名称以字母或数字开头,后续只允许字母、数字、`_ . : -`,environment 最长 32,其余最长 128。valid_days 为 1–365 的整数;reason 非空、最多 1024 个 Unicode 字符,不含换行或 NUL。capabilities 可选 `worker`、`events`、`schedules`、`workflows`、`recovery`、`resources`,不可重复。新建不传 namespace_id / credential_id,expected_revision 省略或为字符串 `"0"`。
|
||||
|
||||
prepare 响应 data 为固定操作对象:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": {"kind":"CREATE","application":"order-service","namespace":"default","environment":"test","queue":"orders","capabilities":["worker","events"],"expected_revision":"0","valid_days":30,"reason":"接入订单服务"},
|
||||
"operation_id": "11111111111111111111111111111111",
|
||||
"application_id": "22222222222222222222222222222222",
|
||||
"namespace_id": "33333333333333333333333333333333",
|
||||
"queue_id": "44444444444444444444444444444444",
|
||||
"credential_id": "55555555555555555555555555555555",
|
||||
"token": "服务端生成的完整 Token",
|
||||
"expires_at": "2026-10-27T02:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
以上是结构示意,ID、令牌及时间以服务端返回为准。将 **data 整体原样** POST 到 apply,不包 `data` 外层。应用、额度、命名空间、队列、凭据摘要、审计和幂等回执在同一事务提交。初始队列并发 4、开始速率 10/秒,沿用 bootstrap 默认策略;凭据队列范围为新命名空间的全部队列。重名应用返回 409,不覆盖原资源。
|
||||
|
||||
## 4. 重新生成 Token
|
||||
|
||||
prepare 请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "ROTATE",
|
||||
"namespace_id": "33333333333333333333333333333333",
|
||||
"credential_id": "55555555555555555555555555555555",
|
||||
"expected_revision": "1",
|
||||
"valid_days": 30,
|
||||
"reason": "更新接入凭据"
|
||||
}
|
||||
```
|
||||
|
||||
expected_revision 为读取到的字符串版本。只允许上述轮换字段,不混入新应用名称或能力组。准备对象包含原 application_id / namespace_id / credential_id,没有 queue_id。apply 复用原有凭据轮换事务:替换秘密摘要与到期时间,版本加一,保存脱敏回执及人类账号审计;权限组不改变。已撤销的凭据禁止轮换。
|
||||
|
||||
apply 成功 data:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation_id": "11111111111111111111111111111111",
|
||||
"application_id": "22222222222222222222222222222222",
|
||||
"namespace_id": "33333333333333333333333333333333",
|
||||
"credential_id": "55555555555555555555555555555555",
|
||||
"revision": "2",
|
||||
"deduplicated": false
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 超时、重复和审计
|
||||
|
||||
- 页面提交前把整个固定操作保留在当前内存;请求超时或结果未知时,按钮变成「重试确认」,重用同一份内容。不能再次 prepare 来代替核对。
|
||||
- 页面允许「保存待核对操作」下载私密 JSON;含真实 Token,应放到受限目录。恢复时登录同一管理员后,向 apply 发送该文件正文。不要把 Cookie 或操作文件提交 Git,也不要放到 URL。
|
||||
- 关闭/刷新页面会丢失内存中的 Token,页面会提醒先保存。若新建已经成功、交付内容未保存,可从应用列表重新生成同一凭据的 Token;不要再次创建同名应用。
|
||||
- 原操作返回的回执是历史快照,可能已被后续轮换替代。页面会重新读取当前凭据版本后才展示配置。
|
||||
- 管理审计主体是 `console:<user_id>`;正文只有权限、资源 ID、到期时间等元数据。列表、审计、幂等回执和业务记录都不保存秘密原文。
|
||||
- 当前管理员退出、会话过期或账号停用后不能重放管理操作。SDK Token 撤销/到期与控制台登录资格沿用既有账号绑定约束。
|
||||
|
||||
| 错误 | HTTP | 处理 |
|
||||
| --- | --- | --- |
|
||||
| UNAUTHENTICATED | 401 | 登录有效控制台账号 |
|
||||
| PERMISSION_DENIED | 403 | 核对唯一管理员配置、会话及同源请求 |
|
||||
| INVALID_ARGUMENT | 400 | 核对字段、能力组、固定身份及有效期 |
|
||||
| NOT_FOUND | 404 | 核对命名空间/凭据是否存在 |
|
||||
| STATE_CONFLICT | 409 | 应用名称已存在、凭据已撤销或版本已变更;读取当前状态 |
|
||||
| IDEMPOTENCY_CONFLICT | 409 | 原操作内容被修改,保留原始操作核对 |
|
||||
| 数据库/网络故障 | 5xx / 网络错误 | 不假定失败,保留原操作重报 |
|
||||
|
||||
可重复运行的真实 HTTP 场景见 [test-console-applications.py](../../scripts/test-console-applications.py),标准容器入口 `make console-e2e` 同时验证此链路。基础 SDK 使用方式见 [SDK 接入](../sdk/README.md)。
|
||||
@@ -77,3 +77,7 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
||||
控制台不直接访问数据库,也不绕过业务凭据权限。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台账号绑定业务凭据,不自动获得跨命名空间权限;不支持跨命名空间聚合,不代替平台的权限与审计 API。
|
||||
|
||||
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
||||
|
||||
## 应用接入
|
||||
|
||||
侧栏「应用接入」提供应用创建、队列与凭据查看、Token 重新生成和完整 SDK 配置交付。配置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一管理登录名;本地 Compose 已配置。其他业务页面继续使用账号原有默认范围。[功能、接口、权限和恢复说明](api/console-applications.md)。
|
||||
|
||||
27
docs/reports/console-applications.md
Normal file
27
docs/reports/console-applications.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 单管理员应用接入验收(2026-09-27)
|
||||
|
||||
## 交付范围
|
||||
|
||||
- 独立应用接入页:按应用/环境查看接入范围、队列与凭据,创建应用/命名空间/初始队列,选择业务能力,生成或轮换 Token,复制/下载 SDK 环境配置。
|
||||
- 通过 `ORBIT_CONSOLE_APPLICATION_ADMIN` 显式指定唯一管理员,默认关闭;仅登录 Cookie 可访问管理端,业务 Token 不授予跨应用权限。同事务核验账号及会话,写请求额外核验同源与专用请求头。
|
||||
- 新建资源、权限、审计、幂等回执同事务;轮换复用原凭据事务与 ID,任务、订阅和交付身份保留。只存秘密摘要,一次性交付明文;提交不明时保留原操作核对。
|
||||
- 没有新增数据库表或外键,没有改变业务 SDK 接口,不需要升级 SDK v0.1.1。
|
||||
|
||||
## 已完成验证
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| `make test vet build` | 七模块竞态测试、静态检查和构建通过 |
|
||||
| `TestIntegrationConsoleApplications` | 真实 MySQL:SDK 身份与错误管理员拒绝、准备未生效、并发创建一次、同键改内容冲突、重名回滚、元数据脱敏、轮换原身份、旧秘密失效、重复回执、版本冲突、退出失效、审计主体及零外键通过 |
|
||||
| 管理 HTTP 单元测试 | Cookie 必须、Bearer 单独拒绝、缺少防跨站头/异源/null Origin 拒绝、合法同源请求、未知字段拒绝、响应不可缓存通过 |
|
||||
| 准备对象严格 JSON 往返 | CREATE 与 ROTATE 的可选 ID/空能力组原样提交通过;真实 HTTP 发现的空 ID 编码问题已修复并加入回归 |
|
||||
| `scripts/test-console-applications.py` | 真实 HTTP 创建/重放/冲突,任务和订阅生成,轮换后旧 Token 401、新 Token 可读取原任务与订阅,订阅暂停收尾、退出会话失效通过 |
|
||||
| `make console-e2e` | 全新 MySQL/容器初始化,应用接入新链路、登录/权限、真实 Worker/重试/定时、保留策略、周期归档及离线校验全部通过 |
|
||||
| 本地 Compose | 更新现有 18080 环境,复用原管理员、初始业务凭据与数据库卷;新增独立 access-demo 测试应用,轮换只发生于该测试应用 |
|
||||
| 浏览器检查 | 实际账号登录,独立页面与应用详情加载、新建表单显示、权限/队列/配置展示;当前 1280×720 视口布局正常,无浏览器错误 |
|
||||
|
||||
`make integration` 已完整退出 0:覆盖真实存储、HTTP/WSS、SDK、可运行示例、独立 Inbox/Outbox/HTTP 宿主、Worker 与事件进程故障以及流程多节点暂停恢复。页面布局与表单读取通过浏览器核对;实际创建与轮换的持久化链路通过真实 HTTP 验证,不混称为浏览器点击完成全部流程。文档链接检查覆盖 196 份 Markdown,全部解析通过。
|
||||
|
||||
## 边界
|
||||
|
||||
接入管理跨应用,其他业务页面仍使用账号原有默认范围。旧 Token 原文不可查看;新 Token 交付丢失时保留原应用并重新轮换。列表每页 50 个命名空间,单个详情队列和凭据各最多 100 项并显式标记截断。当前未增加删除应用、修改权限、凭据转移、复杂角色或多应用任务总览。
|
||||
@@ -14,31 +14,33 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
||||
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
|
||||
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
|
||||
ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。
|
||||
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
|
||||
HTTPAddr string
|
||||
MySQLDSN string
|
||||
MaxOpen int
|
||||
MaxIdle int
|
||||
MetricsAddr string
|
||||
WorkflowTickInterval time.Duration // WorkflowTickInterval Weave 持久化推进扫描间隔,范围100ms–1m。
|
||||
ScheduleTickInterval time.Duration // ScheduleTickInterval 计划扫描间隔,范围100ms–1m。
|
||||
RecoveryTickInterval time.Duration // RecoveryTickInterval 执行租约恢复扫描间隔,范围100ms–1m。
|
||||
RuntimeBatchLimit int // RuntimeBatchLimit 每个后台批次的候选上限,范围1–100。
|
||||
ShutdownTimeout time.Duration // ShutdownTimeout 优雅排空和关闭窗口,范围1s–5m。
|
||||
WorkerConnectionLimit int // WorkerConnectionLimit 单节点 Worker WebSocket 连接上限,范围1–4096。
|
||||
EventConnectionLimit int // EventConnectionLimit 单节点 Event WebSocket 连接上限,范围1–4096。
|
||||
DispatchWorkers int // DispatchWorkers 单个 Hub 并发执行数据库领取调用的协程数,范围1–256。
|
||||
DispatchCapacity int // DispatchCapacity 单个 Hub 领取调用的有界内存排队槽位,范围1–65536。
|
||||
ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。
|
||||
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
||||
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
|
||||
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
|
||||
ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。
|
||||
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
|
||||
HTTPAddr string
|
||||
MySQLDSN string
|
||||
MaxOpen int
|
||||
MaxIdle int
|
||||
MetricsAddr string
|
||||
WorkflowTickInterval time.Duration // WorkflowTickInterval Weave 持久化推进扫描间隔,范围100ms–1m。
|
||||
ScheduleTickInterval time.Duration // ScheduleTickInterval 计划扫描间隔,范围100ms–1m。
|
||||
RecoveryTickInterval time.Duration // RecoveryTickInterval 执行租约恢复扫描间隔,范围100ms–1m。
|
||||
RuntimeBatchLimit int // RuntimeBatchLimit 每个后台批次的候选上限,范围1–100。
|
||||
ShutdownTimeout time.Duration // ShutdownTimeout 优雅排空和关闭窗口,范围1s–5m。
|
||||
WorkerConnectionLimit int // WorkerConnectionLimit 单节点 Worker WebSocket 连接上限,范围1–4096。
|
||||
EventConnectionLimit int // EventConnectionLimit 单节点 Event WebSocket 连接上限,范围1–4096。
|
||||
DispatchWorkers int // DispatchWorkers 单个 Hub 并发执行数据库领取调用的协程数,范围1–256。
|
||||
DispatchCapacity int // DispatchCapacity 单个 Hub 领取调用的有界内存排队槽位,范围1–65536。
|
||||
}
|
||||
|
||||
func Load() (Config, error) { return FromEnv(os.Getenv) }
|
||||
func FromEnv(get func(string) string) (Config, error) {
|
||||
c := Config{
|
||||
ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second,
|
||||
ConsoleApplicationAdmin: get("ORBIT_CONSOLE_APPLICATION_ADMIN"),
|
||||
ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second,
|
||||
HTTPAddr: get("ORBIT_HTTP_ADDR"),
|
||||
MySQLDSN: get("ORBIT_MYSQL_DSN"),
|
||||
MaxOpen: 20,
|
||||
|
||||
95
internal/consoleauth/applications.go
Normal file
95
internal/consoleauth/applications.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package consoleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
// ApplicationEntry 是应用接入列表的一行;同一应用的不同命名空间分别展示。
|
||||
type ApplicationEntry struct {
|
||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 所属应用标识。
|
||||
Application string `json:"application"` // Application 应用名称。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 数据隔离范围标识。
|
||||
Namespace string `json:"namespace"` // Namespace 命名空间名称。
|
||||
Environment string `json:"environment"` // Environment 环境名称。
|
||||
Enabled bool `json:"enabled"` // Enabled 应用与命名空间均启用时为真。
|
||||
}
|
||||
|
||||
// ApplicationPage 按命名空间标识分页,每页最多 50 行,不包含秘密。
|
||||
type ApplicationPage struct {
|
||||
Items []ApplicationEntry `json:"items"` // Items 当前页接入范围。
|
||||
Next core.ID `json:"next,omitempty"` // Next 下一页的排他起点。
|
||||
}
|
||||
|
||||
// AccessQueue 是接入配置可选的队列。
|
||||
type AccessQueue struct {
|
||||
ID core.ID `json:"id"` // ID 队列标识。
|
||||
Name string `json:"name"` // Name 队列名称。
|
||||
}
|
||||
|
||||
// AccessCredential 是脱敏的凭据元数据,绝不返回秘密或秘密摘要。
|
||||
type AccessCredential struct {
|
||||
ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。
|
||||
Name string `json:"name"` // Name 凭据名称。
|
||||
Revision uint64 `json:"revision,string"` // Revision 轮换的并发校验版本。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 到期时间,空表示无固定到期时间。
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"` // RevokedAt 撤销时间,非空时禁止轮换。
|
||||
Permissions auth.Permissions `json:"permissions"` // Permissions 动作和队列授权范围。
|
||||
}
|
||||
|
||||
// ApplicationDetail 展示一个命名空间的队列和凭据,读取量有界。
|
||||
type ApplicationDetail struct {
|
||||
Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。
|
||||
Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。
|
||||
Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。
|
||||
Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。
|
||||
}
|
||||
|
||||
// ApplicationInput 描述创建应用或轮换凭据的管理员意图。
|
||||
type ApplicationInput struct {
|
||||
Kind string `json:"kind"` // Kind CREATE 或 ROTATE。
|
||||
Application string `json:"application"` // Application 新应用名称,仅 CREATE 使用。
|
||||
Namespace string `json:"namespace"` // Namespace 新命名空间名称,仅 CREATE 使用。
|
||||
Environment string `json:"environment"` // Environment 新环境名称,仅 CREATE 使用。
|
||||
Queue string `json:"queue"` // Queue 初始队列名称,仅 CREATE 使用。
|
||||
Capabilities []string `json:"capabilities,omitempty"` // Capabilities 显式选择的业务权限组,仅 CREATE 使用。
|
||||
NamespaceID core.ID `json:"namespace_id,omitempty"` // NamespaceID 轮换目标所属范围。
|
||||
CredentialID core.ID `json:"credential_id,omitempty"` // CredentialID 轮换目标凭据。
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 轮换时读取到的版本。
|
||||
ValidDays int `json:"valid_days"` // ValidDays 新令牌有效天数,1–365。
|
||||
Reason string `json:"reason"` // Reason 写入审计的变更原因。
|
||||
}
|
||||
|
||||
// ApplicationOperation 是浏览器在提交前保存的固定操作;Token 不写入数据库。
|
||||
// 网络结果未知时重放同一份内容,禁止重新生成操作身份或秘密。
|
||||
type ApplicationOperation struct {
|
||||
Input ApplicationInput `json:"input"` // Input 固定的管理员意图。
|
||||
OperationID core.ID `json:"operation_id"` // OperationID 稳定幂等身份。
|
||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 新建或已核验的应用标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 固定命名空间标识。
|
||||
QueueID core.ID `json:"queue_id,omitempty"` // QueueID 新建应用的初始队列。
|
||||
CredentialID core.ID `json:"credential_id"` // CredentialID 固定凭据身份。
|
||||
Token string `json:"token"` // Token 一次性交付的秘密,只留在调用方内存或私密下载文件。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 首次准备时冻结的绝对到期时间。
|
||||
}
|
||||
|
||||
// ApplicationReceipt 是数据库持久化的脱敏回执。
|
||||
type ApplicationReceipt struct {
|
||||
OperationID core.ID `json:"operation_id"` // OperationID 原操作身份。
|
||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 应用标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 命名空间标识。
|
||||
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据标识。
|
||||
Revision uint64 `json:"revision,string"` // Revision 操作提交后的凭据版本。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 命中原回执,不代表当前 Token 仍有效。
|
||||
}
|
||||
|
||||
// Applications 只面向部署指定的单管理员浏览器会话,不接收业务 SDK 身份。
|
||||
type Applications interface {
|
||||
ListApplications(context.Context, auth.Token, core.ID) (ApplicationPage, error)
|
||||
GetApplication(context.Context, auth.Token, core.ID) (ApplicationDetail, error)
|
||||
PrepareApplication(context.Context, auth.Token, ApplicationInput) (ApplicationOperation, error)
|
||||
ApplyApplication(context.Context, auth.Token, ApplicationOperation) (ApplicationReceipt, error)
|
||||
}
|
||||
362
internal/storage/mysql/console_applications.go
Normal file
362
internal/storage/mysql/console_applications.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// ConsoleApplicationStore 仅允许部署显式指定的单管理员管理跨应用接入。
|
||||
// 使用运行数据库的 DML 权限,不需要迁移或数据库管理员权限。
|
||||
type ConsoleApplicationStore struct {
|
||||
db *sql.DB
|
||||
username string
|
||||
}
|
||||
|
||||
func NewConsoleApplicationStore(db *sql.DB, username string) *ConsoleApplicationStore {
|
||||
return &ConsoleApplicationStore{db: db, username: username}
|
||||
}
|
||||
|
||||
var _ consoleauth.Applications = (*ConsoleApplicationStore)(nil)
|
||||
|
||||
// admin 在实际读写事务中锁定人类账号及会话;普通 SDK Token 无对应会话,无法通过。
|
||||
// 不锁业务凭据,轮换当前账号所绑定的 SDK 凭据也不会产生共享锁升级。
|
||||
func (s *ConsoleApplicationStore) admin(ctx context.Context, tx *sql.Tx, token auth.Token) (string, error) {
|
||||
if s.username == "" {
|
||||
return "", fault.Forbidden
|
||||
}
|
||||
var user core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s
|
||||
JOIN orbit_console_users u ON u.id=s.user_id
|
||||
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
|
||||
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6)
|
||||
AND u.enabled=TRUE AND u.username=? FOR SHARE OF u,s`, token.NamespaceID, token.CredentialID, token.Digest, s.username).Scan(&user)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fault.Forbidden
|
||||
}
|
||||
return "console:" + string(user), err
|
||||
}
|
||||
|
||||
func (s *ConsoleApplicationStore) ListApplications(ctx context.Context, token auth.Token, after core.ID) (consoleauth.ApplicationPage, error) {
|
||||
out := consoleauth.ApplicationPage{Items: []consoleauth.ApplicationEntry{}}
|
||||
if after != "" && after.Validate() != nil {
|
||||
return out, fault.Invalid("invalid application cursor")
|
||||
}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
query := `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id`
|
||||
args := []any{}
|
||||
if after != "" {
|
||||
query += ` WHERE n.id>?`
|
||||
args = append(args, after)
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, query+` ORDER BY n.id LIMIT 51`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e consoleauth.ApplicationEntry
|
||||
if err := rows.Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, e)
|
||||
}
|
||||
if len(out.Items) > 50 {
|
||||
out.Items = out.Items[:50]
|
||||
out.Next = out.Items[49].NamespaceID
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth.Token, namespace core.ID) (consoleauth.ApplicationDetail, error) {
|
||||
out := consoleauth.ApplicationDetail{Queues: []consoleauth.AccessQueue{}, Credentials: []consoleauth.AccessCredential{}}
|
||||
if namespace.Validate() != nil {
|
||||
return out, fault.Invalid("invalid namespace")
|
||||
}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
e := &out.Entry
|
||||
err := tx.QueryRowContext(ctx, `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=?`, namespace).Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id,name FROM pulse_queues WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var q consoleauth.AccessQueue
|
||||
if err := rows.Scan(&q.ID, &q.Name); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
out.Queues = append(out.Queues, q)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var c consoleauth.AccessCredential
|
||||
var expires, revoked sql.NullTime
|
||||
var raw []byte
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &c.Permissions); err != nil {
|
||||
return err
|
||||
}
|
||||
c.ExpiresAt = nullableTime(expires)
|
||||
c.RevokedAt = nullableTime(revoked)
|
||||
out.Credentials = append(out.Credentials, c)
|
||||
}
|
||||
if len(out.Queues) > 100 {
|
||||
out.Truncated = true
|
||||
out.Queues = out.Queues[:100]
|
||||
}
|
||||
if len(out.Credentials) > 100 {
|
||||
out.Truncated = true
|
||||
out.Credentials = out.Credentials[:100]
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
var applicationCapabilities = map[string][]string{
|
||||
"worker": {"workers:connect", "workers:consume"},
|
||||
"schedules": {"schedules:read", "schedules:manage"},
|
||||
"events": {"events:read", "events:manage", "events:consume"},
|
||||
"workflows": {"workflows:definitions:read", "workflows:definitions:publish", "workflows:start", "workflows:read", "workflows:control", "workflows:signal", "workflows:audit:read"},
|
||||
"recovery": {"tasks:review", "tasks:retry", "tasks:audit:read", "workflows:recover", "workflows:read"},
|
||||
"resources": {"application:resources:read", "application:resources:manage"},
|
||||
}
|
||||
|
||||
func applicationPermissions(in consoleauth.ApplicationInput) (auth.Permissions, error) {
|
||||
p := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
|
||||
if len(in.Capabilities) > len(applicationCapabilities) {
|
||||
return p, fault.Invalid("invalid capabilities")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
actions := map[string]bool{}
|
||||
for _, group := range in.Capabilities {
|
||||
list, ok := applicationCapabilities[group]
|
||||
if !ok || seen[group] {
|
||||
return p, fault.Invalid("invalid or duplicate capability")
|
||||
}
|
||||
seen[group] = true
|
||||
for _, a := range list {
|
||||
if !actions[a] {
|
||||
p.Actions = append(p.Actions, a)
|
||||
actions[a] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return p, p.Validate()
|
||||
}
|
||||
func validateApplicationInput(in consoleauth.ApplicationInput) error {
|
||||
if in.ValidDays < 1 || in.ValidDays > 365 {
|
||||
return fault.Invalid("valid_days must be 1-365")
|
||||
}
|
||||
if err := validCredentialReason(in.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
switch in.Kind {
|
||||
case "CREATE":
|
||||
if !service.ValidName(in.Application, 128) || !service.ValidName(in.Namespace, 128) || !service.ValidName(in.Environment, 32) || !service.ValidName(in.Queue, 128) || in.NamespaceID != "" || in.CredentialID != "" || in.ExpectedRevision != 0 {
|
||||
return fault.Invalid("invalid new application fields")
|
||||
}
|
||||
_, err := applicationPermissions(in)
|
||||
return err
|
||||
case "ROTATE":
|
||||
if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil || in.ExpectedRevision == 0 || in.Application != "" || in.Namespace != "" || in.Environment != "" || in.Queue != "" || len(in.Capabilities) != 0 {
|
||||
return fault.Invalid("invalid rotation fields")
|
||||
}
|
||||
default:
|
||||
return fault.Invalid("kind must be CREATE or ROTATE")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
||||
out := consoleauth.ApplicationOperation{Input: in}
|
||||
if err := validateApplicationInput(in); err != nil {
|
||||
return out, err
|
||||
}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.ExpiresAt = now.Add(time.Duration(in.ValidDays) * 24 * time.Hour)
|
||||
out.OperationID, err = core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Kind == "CREATE" {
|
||||
for _, id := range []*core.ID{&out.ApplicationID, &out.NamespaceID, &out.QueueID, &out.CredentialID} {
|
||||
*id, err = core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.NamespaceID, out.CredentialID = in.NamespaceID, in.CredentialID
|
||||
var revision uint64
|
||||
var revoked sql.NullTime
|
||||
err := tx.QueryRowContext(ctx, `SELECT n.application_id,c.revision,c.revoked_at FROM orbit_credentials c JOIN orbit_namespaces n ON n.id=c.namespace_id WHERE c.namespace_id=? AND c.id=?`, in.NamespaceID, in.CredentialID).Scan(&out.ApplicationID, &revision, &revoked)
|
||||
if err == sql.ErrNoRows {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revision != in.ExpectedRevision || revoked.Valid {
|
||||
return fault.Conflict
|
||||
}
|
||||
}
|
||||
out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token auth.Token, op consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
|
||||
out := consoleauth.ApplicationReceipt{}
|
||||
if err := validateApplicationInput(op.Input); err != nil {
|
||||
return out, err
|
||||
}
|
||||
parsed, err := auth.Parse(op.Token)
|
||||
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.ExpiresAt.IsZero() {
|
||||
return out, fault.Invalid("invalid fixed operation")
|
||||
}
|
||||
if op.Input.Kind == "ROTATE" {
|
||||
if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID {
|
||||
return out, fault.Invalid("rotation identity mismatch")
|
||||
}
|
||||
change := CredentialOperation{Version: 1, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: "ROTATE", Reason: op.Input.Reason, Token: op.Token, ExpiresAt: &op.ExpiresAt}
|
||||
receipt, err := applyCredentialOperation(ctx, s.db, change, func(tx *sql.Tx) (string, error) {
|
||||
actor, err := s.admin(ctx, tx, token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var app core.ID
|
||||
err = tx.QueryRowContext(ctx, `SELECT application_id FROM orbit_namespaces WHERE id=?`, op.NamespaceID).Scan(&app)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if app != op.ApplicationID {
|
||||
return "", fault.Invalid("application identity mismatch")
|
||||
}
|
||||
return actor, nil
|
||||
})
|
||||
return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err
|
||||
}
|
||||
if op.QueueID.Validate() != nil {
|
||||
return out, fault.Invalid("invalid queue identity")
|
||||
}
|
||||
permissions, err := applicationPermissions(op.Input)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
raw, _ := json.Marshal(permissions)
|
||||
digest, err := requestDigest(op)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
keyDigest := core.Digest(sha256.Sum256([]byte(op.OperationID)))
|
||||
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
actor, err := s.admin(ctx, tx, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at)
|
||||
VALUES (?,?,'console.application.create.v1',?,?,?,'application',?,JSON_OBJECT(),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 90 DAY)) ON DUPLICATE KEY UPDATE id=orbit_idempotency_keys.id`, token.NamespaceID, op.OperationID, actor, keyDigest, digest, op.ApplicationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var stored core.Digest
|
||||
var response []byte
|
||||
err = tx.QueryRowContext(ctx, `SELECT request_digest,response FROM orbit_idempotency_keys WHERE namespace_id=? AND operation='console.application.create.v1' AND scope_key=? AND key_digest=? FOR UPDATE`, token.NamespaceID, actor, keyDigest).Scan(&stored, &response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if subtleCompareDigest(stored, digest) != 1 {
|
||||
return fault.IdempotencyConflict
|
||||
}
|
||||
if string(response) != "{}" {
|
||||
if err := json.Unmarshal(response, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) {
|
||||
return fault.Invalid("expired or excessive credential lifetime")
|
||||
}
|
||||
statements := []struct {
|
||||
query string
|
||||
args []any
|
||||
}{
|
||||
{`INSERT INTO orbit_applications(id,name) VALUES (?,?)`, []any{op.ApplicationID, op.Input.Application}},
|
||||
{`INSERT INTO orbit_application_resources(application_id) VALUES (?)`, []any{op.ApplicationID}},
|
||||
{`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)`, []any{op.NamespaceID, op.ApplicationID, op.Input.Namespace, op.Input.Environment}},
|
||||
{`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, []any{op.NamespaceID, op.QueueID, op.Input.Queue}},
|
||||
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt.UTC()}},
|
||||
}
|
||||
for _, st := range statements {
|
||||
if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil {
|
||||
var e *driver.MySQLError
|
||||
if errors.As(err, &e) && e.Number == 1062 {
|
||||
return fault.Conflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := appendAuditActor(ctx, tx, op.NamespaceID, actor, op.OperationID, "console.application.create", op.CredentialID, op.Input.Reason, nil, map[string]any{"application_id": op.ApplicationID, "application": op.Input.Application, "queue_id": op.QueueID, "permissions": permissions, "expires_at": op.ExpiresAt}); err != nil {
|
||||
return err
|
||||
}
|
||||
out = consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: 1}
|
||||
receipt, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE orbit_idempotency_keys SET response=?,revision=revision+1 WHERE namespace_id=? AND id=?`, receipt, token.NamespaceID, op.OperationID)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
165
internal/storage/mysql/console_applications_integration_test.go
Normal file
165
internal/storage/mysql/console_applications_integration_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
func TestIntegrationConsoleApplications(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 10, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
username := "access-" + string(testID(t))
|
||||
password := "local-test-password-12345"
|
||||
var initial BootstrapResult
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: username, Namespace: "default", Environment: "test", Queue: "initial", ConsoleUsername: username, ConsolePassword: password}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login, err := NewConsoleAuthStore(db).Login(ctx, username, password, "test", "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _ := auth.Parse(login.Token)
|
||||
sdk, _ := auth.Parse(initial.Token)
|
||||
store := NewConsoleApplicationStore(db, username)
|
||||
if _, err := store.ListApplications(ctx, sdk, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatalf("SDK reached admin: %v", err)
|
||||
}
|
||||
if _, err := NewConsoleApplicationStore(db, "").ListApplications(ctx, admin, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("disabled admin accepted")
|
||||
}
|
||||
if _, err := NewConsoleApplicationStore(db, "different-admin").ListApplications(ctx, admin, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("other user accepted")
|
||||
}
|
||||
in := consoleauth.ApplicationInput{Kind: "CREATE", Application: "created-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Capabilities: []string{"worker", "events", "schedules", "workflows"}, ValidDays: 30, Reason: "integration onboarding"}
|
||||
op, err := store.PrepareApplication(ctx, admin, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 准备只生成秘密,尚未允许业务访问。
|
||||
candidate, _ := auth.Parse(op.Token)
|
||||
if err := NewTaskStore(db).AuthorizeSubmit(ctx, candidate); err == nil {
|
||||
t.Fatal("prepared token accepted before commit")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
receipts := make([]consoleauth.ApplicationReceipt, 2)
|
||||
errs := make([]error, 2)
|
||||
for i := range receipts {
|
||||
wg.Add(1)
|
||||
go func(i int) { defer wg.Done(); receipts[i], errs[i] = store.ApplyApplication(ctx, admin, op) }(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if receipts[0].Deduplicated == receipts[1].Deduplicated {
|
||||
t.Fatal("concurrent original operation did not deduplicate")
|
||||
}
|
||||
if err := NewTaskStore(db).AuthorizeSubmit(ctx, candidate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同一操作身份不可改变请求,重名不能留下半套资源。
|
||||
changed := op
|
||||
changed.Input.Reason = "modified"
|
||||
if _, err := store.ApplyApplication(ctx, admin, changed); fault.Code(err) != "IDEMPOTENCY_CONFLICT" {
|
||||
t.Fatalf("changed operation accepted: %v", err)
|
||||
}
|
||||
duplicate, err := store.PrepareApplication(ctx, admin, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ApplyApplication(ctx, admin, duplicate); fault.Code(err) != "STATE_CONFLICT" {
|
||||
t.Fatalf("duplicate name accepted: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_namespaces WHERE id=?`, duplicate.NamespaceID).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatal("duplicate left namespace")
|
||||
}
|
||||
detail, err := store.GetApplication(ctx, admin, op.NamespaceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Entry.ApplicationID != op.ApplicationID || len(detail.Queues) != 1 || len(detail.Credentials) != 1 || !detail.Credentials[0].Permissions.Allows("events:consume") {
|
||||
t.Fatal("incomplete onboarding")
|
||||
}
|
||||
raw, _ := json.Marshal(detail)
|
||||
if strings.Contains(string(raw), op.Token) || strings.Contains(string(raw), "secret_digest") {
|
||||
t.Fatal("detail leaked credential")
|
||||
}
|
||||
rotation := consoleauth.ApplicationInput{Kind: "ROTATE", NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: 1, ValidDays: 90, Reason: "scheduled rotation"}
|
||||
rotated, err := store.PrepareApplication(ctx, admin, rotation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := store.ApplyApplication(ctx, admin, rotated)
|
||||
if err != nil || receipt.Revision != 2 {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
newToken, _ := auth.Parse(rotated.Token)
|
||||
if NewTaskStore(db).AuthorizeSubmit(ctx, candidate) == nil || NewTaskStore(db).AuthorizeSubmit(ctx, newToken) != nil {
|
||||
t.Fatal("rotation auth boundary failed")
|
||||
}
|
||||
replay, err := store.ApplyApplication(ctx, admin, rotated)
|
||||
if err != nil || !replay.Deduplicated {
|
||||
t.Fatalf("rotation replay: %v", err)
|
||||
}
|
||||
if _, err := store.PrepareApplication(ctx, admin, rotation); fault.Code(err) != "STATE_CONFLICT" {
|
||||
t.Fatalf("stale revision accepted: %v", err)
|
||||
}
|
||||
detail, err = store.GetApplication(ctx, admin, op.NamespaceID)
|
||||
if err != nil || len(detail.Credentials) != 1 || detail.Credentials[0].ID != op.CredentialID || detail.Queues[0].ID != op.QueueID {
|
||||
t.Fatal("rotation changed identity")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT actor_id,CAST(`+"`after`"+` AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND action IN ('console.application.create','credential.rotate')`, op.NamespaceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var actor, body string
|
||||
if err := rows.Scan(&actor, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actor != "console:"+login.User.ID || strings.Contains(body, op.Token) || strings.Contains(body, rotated.Token) {
|
||||
t.Fatal("audit principal or secrecy")
|
||||
}
|
||||
count++
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil || count != 2 {
|
||||
t.Fatalf("audit count=%d err=%v", count, err)
|
||||
}
|
||||
// 登出在数据库生效后,原会话不能再次应用操作,即使持有原回执内容。
|
||||
if err := NewConsoleAuthStore(db).RevokeSession(ctx, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ApplyApplication(ctx, admin, rotated); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatalf("logged out replay accepted: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema=DATABASE()`).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("foreign keys=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,11 @@ func ValidateCredentialOperation(op CredentialOperation) error {
|
||||
// receipt. No callback or file I/O occurs inside the transaction. A commit error
|
||||
// is intentionally returned unchanged; replaying the same operation reconciles it.
|
||||
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation) (CredentialChangeReceipt, error) {
|
||||
return applyCredentialOperation(ctx, db, op, nil)
|
||||
}
|
||||
|
||||
// applyCredentialOperation 允许控制台在同一事务先核验管理员,并使用真实人类审计主体。
|
||||
func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error)) (CredentialChangeReceipt, error) {
|
||||
var out CredentialChangeReceipt
|
||||
if err := ValidateCredentialOperation(op); err != nil {
|
||||
return out, err
|
||||
@@ -217,6 +222,14 @@ func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
return out, err
|
||||
}
|
||||
err := WithTx(ctx, db, func(tx *sql.Tx) error {
|
||||
actor := ""
|
||||
if authorize != nil {
|
||||
var err error
|
||||
actor, err = authorize(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var name string
|
||||
var revision uint64
|
||||
var expires, revoked sql.NullTime
|
||||
@@ -286,9 +299,11 @@ func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
out.Action = op.Action
|
||||
out.Revision = revision + 1
|
||||
out.AppliedAt = now
|
||||
actor, err := databaseActor(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
if actor == "" {
|
||||
actor, err = databaseActor(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := appendCredentialAudit(ctx, tx, actor, op, before, after); err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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 $ = (id) => document.getElementById(id);
|
||||
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业' };
|
||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' };
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||
@@ -30,6 +30,7 @@
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
if (next === 'retention' && state.authenticated) loadRetention();
|
||||
if (next === 'archives' && state.authenticated) loadArchives();
|
||||
if (next === 'applications' && state.authenticated) window.orbitApplications.load();
|
||||
}
|
||||
function redirectToLogin() {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
@@ -670,6 +671,7 @@
|
||||
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
||||
try {
|
||||
await loadHealth();
|
||||
if (currentView() === 'applications') { await window.orbitApplications.load(); setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); return; }
|
||||
if (!state.authenticated || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
||||
const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); const taskItems = extractItems(tasks); renderTasks(taskItems);
|
||||
const optional = await Promise.allSettled([
|
||||
|
||||
125
internal/transport/httpserver/console/applications.js
Normal file
125
internal/transport/httpserver/console/applications.js
Normal file
@@ -0,0 +1,125 @@
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true };
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
|
||||
async function api(path = '', body) {
|
||||
const response = await fetch(`/console/applications${path}`, { method: body ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json', 'X-Orbit-Console': '1' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
||||
const value = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) location.replace(`/console/login?return=${encodeURIComponent('/console/#/applications')}`);
|
||||
if (!response.ok) { const error = new Error(value.error?.message || `请求失败 (${response.status})`); error.status = response.status; throw error; }
|
||||
return value.data;
|
||||
}
|
||||
function config(token = '<填入已保存的 Token>', queue = $('access-queue').value, namespace = ui.selected) {
|
||||
const url = $('access-url').value.trim() || location.origin;
|
||||
let parsed; try { parsed = new URL(url); } catch { throw new Error('请输入完整的 HTTP / HTTPS 服务地址'); }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
|
||||
return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_TOKEN=${token}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\n`;
|
||||
}
|
||||
async function load() {
|
||||
if (ui.loading || ui.busy) return;
|
||||
ui.loading = true; $('access-error').textContent = '';
|
||||
try {
|
||||
const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : ''); ui.next = page.next || ''; $('access-next').disabled = !ui.next;
|
||||
$('access-list').innerHTML = page.items.length ? page.items.map(entry => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
|
||||
if (!ui.selected && page.items.length) await select(page.items[0].namespace_id);
|
||||
else if (ui.selected && !$('access-dialog').open) await select(ui.selected);
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
finally { ui.loading = false; }
|
||||
}
|
||||
async function select(namespace) {
|
||||
const requested = namespace; ui.selected = namespace;
|
||||
try {
|
||||
const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested) return; ui.detail = detail;
|
||||
$('access-empty').hidden = true; $('access-detail').hidden = false;
|
||||
$('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用';
|
||||
$('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`;
|
||||
const previous = $('access-queue').value;
|
||||
$('access-queue').innerHTML = detail.queues.map(q => `<option value="${escape(q.id)}">${escape(q.name)} · ${escape(q.id.slice(0, 8))}</option>`).join('');
|
||||
if (detail.queues.some(q => q.id === previous)) $('access-queue').value = previous;
|
||||
$('access-truncated').hidden = !detail.truncated;
|
||||
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||
const status = c.revoked_at ? '已撤销' : c.expires_at && new Date(c.expires_at) <= new Date() ? '已到期' : '有效';
|
||||
const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name);
|
||||
return `<section class="access-credential"><div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button" data-access-rotate="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>重新生成 Token ↗</button></div><p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}<br>Token 原文不保存在数据库;已有服务继续使用原配置。</p><div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details></section>`;
|
||||
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
|
||||
document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig();
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
}
|
||||
function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||
function open(credential = null) {
|
||||
if (ui.busy) return;
|
||||
ui.rotating = credential; ui.pending = null; ui.result = null; ui.saved = true;
|
||||
$('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = '';
|
||||
$('access-result-config').value = ''; $('access-clipboard-status').textContent = '';
|
||||
$('access-create-fields').hidden = !!credential;
|
||||
$('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; });
|
||||
$('access-form-title').textContent = credential ? '重新生成 Token' : '新建应用';
|
||||
$('access-form-note').textContent = credential ? `正在更新 ${ui.detail.entry.application} 的凭据。确认后旧 Token 立即失效,请同步更新业务服务配置;应用、队列、订阅和历史记录保持原有归属。` : '为应用创建独立的环境、队列与调用凭据。';
|
||||
$('access-submit').textContent = credential ? '确认重新生成 →' : '创建并生成凭据 →';
|
||||
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
|
||||
}
|
||||
function close() {
|
||||
if (ui.busy) return;
|
||||
if ((ui.pending || !ui.saved) && !window.confirm(ui.pending ? '操作结果尚未确认,请先保存待核对操作。仍要关闭吗?' : '请确认已保存新 Token。关闭后无法再次查看原文。')) return;
|
||||
$('access-dialog').close(); ui.pending = null; ui.result = null; ui.saved = true; $('access-result-config').value = ''; load();
|
||||
}
|
||||
function download(name, content, type = 'text/plain') {
|
||||
const url = URL.createObjectURL(new Blob([content], { type })); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
async function copy(value, button) {
|
||||
try { await navigator.clipboard.writeText(value); const old = button.textContent; button.textContent = '已复制 ✓'; setTimeout(() => { button.textContent = old; }, 2000); return true; }
|
||||
catch { ($('access-dialog').open ? $('access-clipboard-status') : $('access-error')).textContent = '浏览器未允许复制,请选中配置后手动复制,或下载 .env 文件。'; return false; }
|
||||
}
|
||||
async function submit(event) {
|
||||
event.preventDefault(); if (ui.busy) return; ui.busy = true; $('access-form-error').textContent = '';
|
||||
const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]);
|
||||
try {
|
||||
if (!ui.pending) {
|
||||
const input = { kind: ui.rotating ? 'ROTATE' : 'CREATE', valid_days: Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||
if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id });
|
||||
else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) });
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
ui.pending = await api('/prepare', input);
|
||||
}
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
const op = ui.pending;
|
||||
await api('/apply', op);
|
||||
ui.saved = false;
|
||||
let queue = op.queue_id || $('access-queue').value;
|
||||
// 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。
|
||||
await select(op.namespace_id);
|
||||
const credential = ui.detail?.entry.namespace_id === op.namespace_id && ui.detail.credentials.find(c => c.id === op.credential_id);
|
||||
const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n);
|
||||
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。');
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||
const preparedConfig = config(op.token, queue, op.namespace_id);
|
||||
ui.result = op; ui.pending = null;
|
||||
$('access-form').hidden = true; $('access-result').hidden = false;
|
||||
$('access-result-title').textContent = op.input.kind === 'CREATE' ? '应用已创建,可以接入' : '新 Token 已生效';
|
||||
$('access-result-config').value = preparedConfig;
|
||||
$('access-result-expiry').textContent = `有效期至 ${time(op.expires_at)}。业务服务读取这些配置后交给 SDK。`;
|
||||
} catch (error) {
|
||||
$('access-form-error').textContent = `${message(error)}${ui.pending ? '。保留当前窗口,点击“重试确认”会使用原操作核对结果。' : ''}`;
|
||||
} finally {
|
||||
ui.busy = false; disabled.forEach(([el, value]) => { el.disabled = value; });
|
||||
$('access-backup').hidden = !ui.pending;
|
||||
if (ui.pending) { form.querySelectorAll('input,select').forEach(el => { el.disabled = true; }); $('access-submit').textContent = '重试确认 →'; }
|
||||
}
|
||||
}
|
||||
$('access-url').value = location.origin;
|
||||
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) select(el.dataset.accessId); });
|
||||
$('access-credentials').addEventListener('click', e => { const el = e.target.closest('[data-access-rotate]'); const credential = ui.detail?.credentials.find(c => c.id === el?.dataset.accessRotate); if (credential && !credential.revoked_at) open(credential); });
|
||||
$('access-create').addEventListener('click', () => open()); $('access-close').addEventListener('click', close);
|
||||
$('access-dialog').addEventListener('cancel', e => { e.preventDefault(); close(); });
|
||||
$('access-refresh').addEventListener('click', load); $('access-next').addEventListener('click', () => { ui.after = ui.next; load(); }); $('access-first').addEventListener('click', () => { ui.after = ''; load(); });
|
||||
$('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', renderConfig);
|
||||
$('access-copy-template').addEventListener('click', async () => { try { await copy(config(), $('access-copy-template')); } catch (error) { $('access-error').textContent = error.message; } });
|
||||
$('access-form').addEventListener('submit', submit);
|
||||
$('access-copy').addEventListener('click', async () => { if (await copy($('access-result-config').value, $('access-copy'))) ui.saved = true; });
|
||||
$('access-download').addEventListener('click', () => { download('orbit.env', $('access-result-config').value); ui.saved = true; });
|
||||
$('access-backup').addEventListener('click', () => { if (ui.pending) download(`orbit-operation-${ui.pending.operation_id}.json`, JSON.stringify(ui.pending, null, 2), 'application/json'); });
|
||||
window.addEventListener('beforeunload', e => { if (ui.pending || !ui.saved) { e.preventDefault(); e.returnValue = ''; } });
|
||||
window.orbitApplications = { load };
|
||||
})();
|
||||
@@ -25,6 +25,7 @@
|
||||
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
|
||||
<button class="nav-item" data-view="retention"><span class="nav-icon">⌛</span><span>保留策略</span><em>06</em></button>
|
||||
<button class="nav-item" data-view="archives"><span class="nav-icon">▤</span><span>归档作业</span><em>07</em></button>
|
||||
<button class="nav-item" data-view="applications"><span class="nav-icon">⊞</span><span>应用接入</span><em>08</em></button>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="rail-card">
|
||||
@@ -199,6 +200,31 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-applications" data-page="applications">
|
||||
<div class="access-heading"><div><span class="panel-kicker">CONNECT / APPLICATIONS</span><h1>让应用,轻松接入。</h1><p>创建应用、管理凭据,把任务交给正确的队列。</p></div><button class="primary-button compact" id="access-create">新建应用 <span>+</span></button></div>
|
||||
<div class="access-guide"><span><b>01</b> 创建应用与队列</span><i>→</i><span><b>02</b> 复制 SDK 配置</span><i>→</i><span><b>03</b> 注册 Worker 开始执行</span></div>
|
||||
<div id="access-error" class="action-error" role="alert"></div>
|
||||
<section class="access-grid">
|
||||
<article class="panel access-list-panel"><div class="panel-heading"><div><span class="panel-kicker">YOUR APPLICATIONS</span><h2>接入应用</h2></div><button class="text-button" id="access-refresh">刷新 ↻</button></div>
|
||||
<div id="access-list" class="access-list"><p class="panel-description">正在读取应用…</p></div>
|
||||
<div class="access-pager"><button class="text-button" id="access-first">第一页</button><button class="text-button" id="access-next" disabled>下一页 →</button></div>
|
||||
</article>
|
||||
<article class="panel access-detail-panel"><div id="access-empty" class="empty-row">选择应用,查看队列和接入配置。</div>
|
||||
<div id="access-detail" hidden>
|
||||
<div class="panel-heading"><div><span class="panel-kicker">APPLICATION DETAILS</span><h2 id="access-title"></h2></div><span class="connection-state" id="access-status"></span></div>
|
||||
<div class="access-meta"><div><span>应用 ID</span><code id="access-app-id"></code></div><div><span>环境 / 命名空间</span><b id="access-scope"></b></div><div><span>命名空间 ID</span><code id="access-ns-id"></code></div></div>
|
||||
<div class="access-config-grid"><label>服务地址<input id="access-url" type="url" autocomplete="off" spellcheck="false"></label><label>任务队列<select id="access-queue"></select></label></div>
|
||||
<p class="panel-description">本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。</p>
|
||||
<div class="access-credentials" id="access-credentials"></div>
|
||||
<p class="panel-description" id="access-truncated" hidden>队列或凭据超过 100 项,此页只展示前 100 项。</p>
|
||||
<div class="access-config-heading"><h3>SDK 接入配置</h3><button class="text-button" id="access-copy-template">复制配置模板 ↗</button></div>
|
||||
<pre class="access-code" id="access-config"></pre>
|
||||
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||
</main>
|
||||
</div>
|
||||
@@ -360,6 +386,25 @@
|
||||
</aside>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status"></div>
|
||||
<dialog id="access-dialog" class="access-dialog">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CONNECT</span><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
|
||||
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>
|
||||
<form id="access-form">
|
||||
<div id="access-create-fields">
|
||||
<label>应用名称<input id="access-name" maxlength="128" placeholder="例如 order-service" autocomplete="off" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||
<div class="access-config-grid"><label>环境<select id="access-environment"><option value="test">测试 · test</option><option value="development">开发 · development</option><option value="production">生产 · production</option></select></label><label>命名空间<input id="access-namespace" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label></div>
|
||||
<label>初始队列名称<input id="access-queue-name" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||
<fieldset class="access-capabilities"><legend>接入能力 <small>默认包含任务提交、查询与队列管理</small></legend><label><input type="checkbox" value="worker" checked> Worker 执行</label><label><input type="checkbox" value="events" checked> 结果订阅</label><label><input type="checkbox" value="schedules" checked> 定时调度</label><label><input type="checkbox" value="workflows" checked> 流程编排</label><label><input type="checkbox" value="recovery"> 人工恢复</label><label><input type="checkbox" value="resources"> 应用额度管理</label></fieldset>
|
||||
</div>
|
||||
<div class="access-config-grid"><label>凭据有效期<select id="access-valid-days"><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option></select></label><label>操作原因<input id="access-reason" maxlength="1024" required placeholder="例如:接入订单服务"></label></div>
|
||||
<p class="action-error" id="access-form-error" role="alert"></p>
|
||||
<div class="access-form-actions"><button type="submit" class="primary-button compact" id="access-submit">创建并生成凭据 →</button><button type="button" class="text-button" id="access-backup" hidden>保存待核对操作</button></div>
|
||||
</form>
|
||||
<p id="access-clipboard-status" class="panel-description" role="status"></p>
|
||||
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="panel-description">完整 Token 仅在本次操作中显示。请复制或下载到服务配置中;页面关闭后只能重新生成。</p><label>完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||
</dialog>
|
||||
|
||||
<script src="/console/applications.js"></script>
|
||||
<script src="/console/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,3 +12,9 @@
|
||||
.retention-tool-buttons{flex-wrap:wrap}.secondary-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid rgba(156,183,218,.18);border-radius:9px;background:rgba(23,46,65,.6);color:#b9cddd;font:inherit;font-size:10px;cursor:pointer}.secondary-button:hover{border-color:#62e6d4;color:#9beade}.retention-form button:disabled{opacity:.45;cursor:not-allowed}.retention-export-result{display:flex;flex-wrap:wrap;gap:10px;color:#9eb4cb;font-size:10px;line-height:1.6}.retention-export-result a{color:#62e6d4}.retention-export-result code{width:100%;font-size:9px;overflow-wrap:anywhere;color:#7189a3}.retention-export-result [hidden]{display:none}.retention-tools{flex-wrap:wrap}
|
||||
|
||||
.archive-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin:22px 0}.archive-heading h1{font-size:28px;letter-spacing:-.7px;margin:12px 0;color:#e4effb}.archive-heading p{color:#849bb3;font-size:12px;line-height:1.7}.archive-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;margin-bottom:20px}.archive-stats article{padding:22px 24px;border:1px solid #20354a;border-radius:18px;background:linear-gradient(125deg,#112439,#0a1728)}.archive-stats article:first-child{background:linear-gradient(125deg,#13373e,#0b202d);border-color:#24535a}.archive-stats span,.archive-stats small{display:block;color:#8ba2b8;font-size:11px}.archive-stats strong{display:block;font-size:30px;margin:10px 0;color:#e5f9f5}.archive-grid{display:grid;grid-template-columns:minmax(290px,360px) minmax(0,1fr);gap:20px;align-items:start}.archive-config,.archive-history{padding:24px;min-width:0}.archive-config .panel-heading,.archive-history .panel-heading{padding:0;margin-bottom:20px}.archive-config input:not([type=checkbox]),.archive-config select,#archive-action-form input{width:100%;border:1px solid #294157;border-radius:9px;padding:11px 12px;background:#09192a;color:#dbe8f5;font:inherit;font-size:12px;outline:0}.archive-config input:focus,.archive-config select:focus,#archive-action-form input:focus{border-color:#61dfd0;box-shadow:0 0 0 3px #61dfd010}.archive-config .field-label,#archive-action-form .field-label{display:block;margin:18px 0 9px;font-size:11px}.archive-config .retention-switch{margin-top:19px;padding:13px 12px}.archive-input-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:20px;color:#94adc4;font-size:11px}.archive-input-grid input{margin-top:9px}.archive-plan-meta{display:grid;gap:9px;margin:17px 0;color:#7893ae;font-size:10px;line-height:1.6}.archive-config .primary-button{width:100%;margin:17px 0 6px}.archive-config .panel-description{line-height:1.8;font-size:10px}.archive-plan-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.archive-plan-chips span{padding:6px 8px;border:1px solid #24374a;border-radius:7px;font-size:9px;color:#7590aa}.archive-plan-chips .enabled{color:#85e5d8;border-color:#2d6564;background:#184b4b44}.archive-history>.panel-description{margin-bottom:18px;line-height:1.8}.archive-job{border:1px solid #243a50;border-radius:13px;padding:18px;margin:12px 0;background:#09172766}.archive-job-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.archive-job-head>div{display:flex;align-items:center;gap:12px}.archive-job-head b{font-size:13px;color:#d2e4f1}.archive-job-head time{font-size:10px;color:#7c98b1}.archive-job-id{display:block;margin:10px 0;color:#65849f;font-size:10px;overflow-wrap:anywhere}.archive-progress{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0;color:#8eaac1;font-size:10px}.archive-progress b{color:#ccdee9;font-size:12px;font-weight:550}.archive-job details{font-size:10px;color:#7297b5;line-height:1.7}.archive-job summary{cursor:pointer}.archive-digest{display:block;overflow-wrap:anywhere;margin-top:8px;color:#9cbad0}.archive-job-error{padding:10px 12px;background:#59412338;border-radius:8px;color:#d9b484;font-size:11px;line-height:1.8}.archive-job-note{font-size:10px;color:#7698b2}.archive-job-actions,.archive-pager{display:flex;gap:14px;align-items:center;margin-top:16px}.archive-pager{justify-content:space-between}.archive-empty{padding:60px 20px}.archive-empty b,.archive-empty small{display:block;margin-top:15px}.archive-grid button:disabled{opacity:.4;cursor:not-allowed}.archive-history [hidden]{display:none}#archive-action-form{border:1px solid #36736f;border-radius:13px;padding:20px;margin-top:20px;background:#102c3544}#archive-action-form h3{font-size:16px;color:#cdeae6}.state-pill.abandoned{color:#9bb0c2;background:#52637933}@media(max-width:1150px){.archive-grid{grid-template-columns:1fr}.archive-stats{gap:10px}.archive-heading h1{font-size:24px}}@media(max-width:600px){.archive-stats{grid-template-columns:1fr}.archive-heading{align-items:flex-start;flex-direction:column}.archive-config,.archive-history{padding:18px}.archive-job-head{align-items:flex-start;flex-direction:column}}
|
||||
|
||||
/* 应用接入:独立的应用目录、凭据详情和一次性配置交付。 */
|
||||
.access-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:20px 0 26px}.access-heading h1{font-size:34px;margin:12px 0}.access-heading p{color:#91a6be;margin:0}.access-guide{display:flex;align-items:center;gap:26px;padding:20px 26px;margin-bottom:26px;border:1px solid #223748;background:linear-gradient(110deg,#12303770,#0d192b80);border-radius:18px;color:#a9bed0;font-size:13px}.access-guide b{color:#68e6d7;margin-right:10px;font-size:12px}.access-guide i{font-style:normal;color:#50647b}.access-grid{display:grid;grid-template-columns:minmax(250px,.8fr) minmax(0,1.6fr);gap:24px;align-items:start}.access-list{display:flex;flex-direction:column;gap:10px}.access-item{display:flex;align-items:center;gap:14px;width:100%;padding:18px;border:1px solid #233448;border-radius:14px;background:#0a1626;color:#d4e2f1;text-align:left;cursor:pointer}.access-item:hover,.access-item.selected{border-color:#55cdbf;background:#12303860}.access-avatar{display:grid;place-items:center;width:42px;height:42px;flex:0 0 auto;border-radius:12px;background:#163b4050;color:#75e9da;font-size:17px}.access-item strong{display:block;overflow-wrap:anywhere;font-size:14px}.access-item small{display:block;margin-top:6px;color:#869db5;font-size:11px}.access-item>span:last-child{min-width:0}.access-pager{display:flex;justify-content:space-between;margin-top:18px}.access-meta{display:grid;gap:14px;margin-bottom:24px;padding:18px;background:#08152380;border:1px solid #203246;border-radius:14px}.access-meta>div{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}.access-meta span{font-size:12px;color:#8299b0}.access-meta b,.access-meta code{font-size:12px;font-weight:400;overflow-wrap:anywhere;color:#c0d5e7}.access-config-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.access-grid label,.access-dialog label{display:block;color:#9fb3c7;font-size:12px}.access-grid input,.access-grid select,.access-dialog input:not([type=checkbox]),.access-dialog select,.access-dialog textarea{display:block;box-sizing:border-box;width:100%;margin-top:9px;padding:12px 14px;border-radius:10px;border:1px solid #2a3b50;background:#081422;color:#d8e6f5;font:inherit;min-width:0}.access-dialog input:focus,.access-dialog select:focus,.access-dialog textarea:focus,.access-grid input:focus,.access-grid select:focus{outline:2px solid #56ccbf;outline-offset:2px}.access-credential{margin:20px 0;padding:18px;border:1px solid #283c53;border-radius:14px}.access-credential-header{display:flex;align-items:center;justify-content:space-between;gap:10px}.access-credential-header b{font-size:13px}.access-credential p{font-size:12px;color:#8da4bc;overflow-wrap:anywhere;line-height:1.8;margin:10px 0}.access-permissions{display:flex;flex-wrap:wrap;gap:5px}.access-permissions span{padding:4px 7px;border-radius:5px;background:#18293a;color:#9bb6ca;font-size:10px}.access-config-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.access-config-heading h3{font-size:14px}.access-code{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.9;font-size:12px;padding:18px;border:1px solid #24384c;border-radius:12px;background:#06131e;color:#a5d7d3}.access-help{margin-top:18px;padding-left:14px;border-left:2px solid #529d99;color:#809bb0;font-size:12px;line-height:1.9}.access-dialog{box-sizing:border-box;width:min(640px,calc(100vw - 32px));max-height:90vh;overflow:auto;border:1px solid #334c60;border-radius:22px;padding:30px;background:linear-gradient(145deg,#112238,#081522);color:#dceafa;box-shadow:0 32px 100px #0008}.access-dialog::backdrop{background:#020a14cc;backdrop-filter:blur(6px)}.access-dialog-top{display:flex;align-items:center;justify-content:space-between}.access-dialog h2{margin:16px 0 10px;font-size:26px}.access-dialog form>div,.access-dialog form>label,#access-create-fields>label,#access-create-fields>.access-config-grid{margin-top:18px}.access-capabilities{margin:20px 0;padding:12px 0;border:0;display:grid;grid-template-columns:1fr 1fr;gap:14px}.access-capabilities legend{font-size:12px;color:#c1d4e4;padding-bottom:12px}.access-capabilities small{font-size:10px;color:#7c96ad;display:block;margin-top:6px}.access-capabilities label{display:flex;align-items:center;gap:8px}.access-capabilities input{accent-color:#68dfd0;width:15px;height:15px}.access-form-actions{display:flex;align-items:center;gap:12px;margin-top:22px;flex-wrap:wrap}.access-success{background:#153e3d;color:#80eed7;padding:15px;border-radius:12px;margin:22px 0}.access-dialog textarea{font-family:monospace;font-size:12px;line-height:1.8;resize:vertical}.access-dialog [hidden]{display:none!important}.access-dialog button:disabled{opacity:.45;cursor:not-allowed}.access-grid [hidden]{display:none!important}
|
||||
@media(max-width:1100px){.access-grid{grid-template-columns:1fr}.access-guide{gap:14px;flex-wrap:wrap}.access-list{display:grid;grid-template-columns:1fr 1fr}.access-heading h1{font-size:28px}}
|
||||
@media(max-width:600px){.access-heading{align-items:flex-start;flex-direction:column}.access-config-grid,.access-list{grid-template-columns:1fr}.access-dialog{padding:22px}.access-guide{flex-direction:column;align-items:flex-start;gap:10px}.access-guide>i{display:none}.access-heading p{font-size:13px}}
|
||||
.access-permission-details{margin-top:14px;font-size:11px;color:#8ba5bc}.access-permission-details summary{cursor:pointer}.access-permission-details p{font-size:10px;line-height:2}
|
||||
|
||||
105
internal/transport/httpserver/console_applications.go
Normal file
105
internal/transport/httpserver/console_applications.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
// RegisterConsoleApplications 只接受登录 Cookie;业务 Bearer 不能访问跨应用管理。
|
||||
func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.Store, store consoleauth.Applications) {
|
||||
if server == nil || consoleAuth == nil || store == nil {
|
||||
return
|
||||
}
|
||||
mux, ok := server.Handler.(*http.ServeMux)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
check := func(w http.ResponseWriter, r *http.Request) (auth.Token, bool) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
token, ok := consoleCookieToken(r)
|
||||
if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
|
||||
writeError(w, "", fault.Unauthorized)
|
||||
return auth.Token{}, false
|
||||
}
|
||||
// 自定义请求头触发跨域预检;不开放 CORS,另校验浏览器 Origin 和 Fetch Metadata。
|
||||
if r.Method != "GET" {
|
||||
if r.Header.Get("X-Orbit-Console") != "1" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||||
writeError(w, "", fault.Forbidden)
|
||||
return auth.Token{}, false
|
||||
}
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || !strings.EqualFold(u.Host, r.Host) || u.User != nil {
|
||||
writeError(w, "", fault.Forbidden)
|
||||
return auth.Token{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
mux.HandleFunc("GET /console/applications", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := store.ListApplications(r.Context(), token, core.ID(r.URL.Query().Get("after")))
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
mux.HandleFunc("GET /console/applications/{namespace}", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := store.GetApplication(r.Context(), token, core.ID(r.PathValue("namespace")))
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
mux.HandleFunc("POST /console/applications/prepare", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in consoleauth.ApplicationInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
out, err := store.PrepareApplication(r.Context(), token, in)
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
mux.HandleFunc("POST /console/applications/apply", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in consoleauth.ApplicationOperation
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
out, err := store.ApplyApplication(r.Context(), token, in)
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
}
|
||||
121
internal/transport/httpserver/console_applications_test.go
Normal file
121
internal/transport/httpserver/console_applications_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
type fakeApplications struct{ calls int }
|
||||
|
||||
func (f *fakeApplications) ListApplications(context.Context, auth.Token, core.ID) (consoleauth.ApplicationPage, error) {
|
||||
f.calls++
|
||||
return consoleauth.ApplicationPage{}, nil
|
||||
}
|
||||
func (f *fakeApplications) GetApplication(context.Context, auth.Token, core.ID) (consoleauth.ApplicationDetail, error) {
|
||||
f.calls++
|
||||
return consoleauth.ApplicationDetail{}, nil
|
||||
}
|
||||
func (f *fakeApplications) PrepareApplication(context.Context, auth.Token, consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
||||
f.calls++
|
||||
return consoleauth.ApplicationOperation{}, nil
|
||||
}
|
||||
func (f *fakeApplications) ApplyApplication(context.Context, auth.Token, consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
|
||||
f.calls++
|
||||
return consoleauth.ApplicationReceipt{}, nil
|
||||
}
|
||||
func TestConsoleApplicationSessionAndCSRF(t *testing.T) {
|
||||
ns, _ := core.NewID()
|
||||
id, _ := core.NewID()
|
||||
raw, _, _ := auth.Issue(ns, id)
|
||||
cases := []struct {
|
||||
name, method, path, origin, header string
|
||||
cookie bool
|
||||
body string
|
||||
status int
|
||||
}{
|
||||
{name: "no login", method: "GET", path: "/console/applications", status: 401},
|
||||
{name: "SDK bearer only", method: "POST", path: "/console/applications/prepare", header: "1", body: `{}`, status: 401},
|
||||
{name: "missing csrf header", method: "POST", path: "/console/applications/prepare", cookie: true, body: `{}`, status: 403},
|
||||
{name: "cross origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "https://evil.example", body: `{}`, status: 403},
|
||||
{name: "null origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "null", body: `{}`, status: 403},
|
||||
{name: "same origin", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", origin: "http://example.com", body: `{}`, status: 200},
|
||||
{name: "unknown field", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", body: `{"admin":true}`, status: 400},
|
||||
{name: "read", method: "GET", path: "/console/applications", cookie: true, status: 200},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authStore := &fakeConsoleAuth{valid: true}
|
||||
store := &fakeApplications{}
|
||||
server := NewWithConsoleAuth("", nil, authStore)
|
||||
RegisterConsoleApplications(server, authStore, store)
|
||||
req := httptest.NewRequest(tt.method, "http://example.com"+tt.path, strings.NewReader(tt.body))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Orbit-Console", tt.header)
|
||||
if tt.origin != "" {
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
}
|
||||
if tt.cookie {
|
||||
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
|
||||
}
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(res, req)
|
||||
if res.Code != tt.status {
|
||||
t.Fatalf("status=%d wanted=%d", res.Code, tt.status)
|
||||
}
|
||||
if tt.status != 200 && store.calls != 0 {
|
||||
t.Fatal("rejected request reached store")
|
||||
}
|
||||
if res.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("management response cacheable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 准备接口生成的内容必须可被严格解码器原样提交,覆盖可选 ID 和空能力组。
|
||||
func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||
ns, _ := core.NewID()
|
||||
cred, _ := core.NewID()
|
||||
app, _ := core.NewID()
|
||||
queue, _ := core.NewID()
|
||||
opID, _ := core.NewID()
|
||||
token, _, _ := auth.Issue(ns, cred)
|
||||
for _, kind := range []string{"CREATE", "ROTATE"} {
|
||||
op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: time.Now().UTC()}
|
||||
if kind == "CREATE" {
|
||||
op.QueueID = queue
|
||||
op.Input.Application = "sample"
|
||||
op.Input.Namespace = "default"
|
||||
op.Input.Environment = "test"
|
||||
op.Input.Queue = "work"
|
||||
} else {
|
||||
op.Input.NamespaceID = ns
|
||||
op.Input.CredentialID = cred
|
||||
op.Input.ExpectedRevision = 1
|
||||
}
|
||||
raw, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/console/applications/apply", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
var got consoleauth.ApplicationOperation
|
||||
if err := decodeBody(req, &got); err != nil {
|
||||
t.Fatalf("%s prepared output rejected: %v", kind, err)
|
||||
}
|
||||
if got.Token != token || got.Input.Kind != kind {
|
||||
t.Fatal("prepared operation changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
91
scripts/test-console-applications.py
Normal file
91
scripts/test-console-applications.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""通过真实 HTTP 验证应用接入,不打印或记录密码、Cookie 与 Token。"""
|
||||
import copy
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
base = os.environ['ORBIT_E2E_URL'].rstrip('/')
|
||||
user = os.environ['ORBIT_E2E_USER']
|
||||
password = Path(os.environ['ORBIT_E2E_PASSWORD_FILE']).read_text().strip()
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
|
||||
def call(path, body=None, token=None, headers=None, expected=200, session=True):
|
||||
hdr = {'Accept': 'application/json', **(headers or {})}
|
||||
if body is not None:
|
||||
hdr['Content-Type'] = 'application/json'
|
||||
if path.startswith('/console/applications'):
|
||||
hdr.setdefault('X-Orbit-Console', '1')
|
||||
hdr.setdefault('Origin', base)
|
||||
if token:
|
||||
hdr['Authorization'] = 'Bearer ' + token
|
||||
req = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(), headers=hdr)
|
||||
try:
|
||||
response = (opener.open if session else urllib.request.urlopen)(req, timeout=15)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
status = response.status
|
||||
value = json.load(response)
|
||||
assert status == expected, f'{path}: HTTP {status}, expected {expected}; code={value.get("error", {}).get("code")}'
|
||||
return value.get('data', value)
|
||||
|
||||
|
||||
call('/console/applications', expected=401, session=False)
|
||||
call('/console/auth/login', {'username': user, 'password': password})
|
||||
assert call('/console/auth/session')['authenticated']
|
||||
call('/console/applications/prepare', {}, headers={'Origin': 'https://other.invalid'}, expected=403)
|
||||
name = 'access-demo-' + uuid.uuid4().hex[:8]
|
||||
request = {'kind': 'CREATE', 'application': name, 'namespace': 'default', 'environment': 'test', 'queue': 'integration', 'capabilities': ['worker', 'events', 'schedules', 'workflows'], 'valid_days': 30, 'expected_revision': '0', 'reason': 'application onboarding acceptance'}
|
||||
op = call('/console/applications/prepare', request)
|
||||
# 固定操作在提交前写入测试私密目录,以便提交结果不明时核对。
|
||||
private = os.environ.get('ORBIT_E2E_APPLICATION_DIR')
|
||||
if private:
|
||||
os.umask(0o077)
|
||||
Path(private).mkdir(parents=True, exist_ok=True)
|
||||
Path(private, 'create.operation.json').write_text(json.dumps(op))
|
||||
first = call('/console/applications/apply', op)
|
||||
assert not first['deduplicated']
|
||||
assert call('/console/applications/apply', op)['deduplicated']
|
||||
changed = copy.deepcopy(op)
|
||||
changed['input']['reason'] = 'changed'
|
||||
call('/console/applications/apply', changed, expected=409)
|
||||
page = call('/console/applications')
|
||||
assert any(x['namespace_id'] == op['namespace_id'] for x in page['items'])
|
||||
detail = call('/console/applications/' + op['namespace_id'])
|
||||
assert op['token'] not in json.dumps(detail)
|
||||
queue = detail['queues'][0]['id']
|
||||
assert queue == op['queue_id']
|
||||
call('/console/applications', token=op['token'], expected=401, session=False)
|
||||
call('/v1/queues/' + queue, token=op['token'], session=False)
|
||||
sub = call('/v1/subscriptions', {'name': 'result-example', 'queue_id': queue}, token=op['token'], headers={'Idempotency-Key': 'onboarding-subscription'}, expected=201, session=False)
|
||||
subscription_id = sub['subscription']['id']
|
||||
task = call('/v1/tasks', {'queue_id': queue, 'handler': 'onboarding.noop', 'handler_version': 'v1', 'payload_version': 'v1', 'payload': {}}, token=op['token'], headers={'Idempotency-Key': 'onboarding-task'}, expected=201, session=False)
|
||||
task_id = task['task']['id']
|
||||
call('/v1/tasks/' + task_id + ':cancel', {'expected_revision': task['task']['revision'], 'reason': 'acceptance complete'}, token=op['token'], headers={'Idempotency-Key': 'onboarding-cancel'}, session=False)
|
||||
rotate = call('/console/applications/prepare', {'kind': 'ROTATE', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '1', 'valid_days': 30, 'reason': 'rotation acceptance'})
|
||||
if private:
|
||||
Path(private, 'rotate.operation.json').write_text(json.dumps(rotate))
|
||||
assert call('/console/applications/apply', rotate)['revision'] == '2'
|
||||
assert call('/console/applications/apply', rotate)['deduplicated']
|
||||
call('/v1/queues/' + queue, token=op['token'], expected=401, session=False)
|
||||
call('/v1/queues/' + queue, token=rotate['token'], session=False)
|
||||
current_task = call('/v1/tasks/' + task_id, token=rotate['token'], session=False)
|
||||
assert current_task['state'] == 'CANCELLED'
|
||||
current_sub = call('/v1/subscriptions/' + subscription_id, token=rotate['token'], session=False)
|
||||
assert current_sub['id'] == subscription_id
|
||||
# 不让联调示例遗留启用的订阅,历史和稳定凭据身份保持不变。
|
||||
call('/v1/subscriptions/' + subscription_id + ':configure', {'expected_revision': current_sub['revision'], 'enabled': False, 'reason': 'acceptance complete'}, token=rotate['token'], headers={'Idempotency-Key': 'onboarding-pause-subscription'}, session=False)
|
||||
if private:
|
||||
Path(private, 'host.env').write_text(f"ORBIT_URL={base}\nORBIT_TOKEN={rotate['token']}\nORBIT_NAMESPACE_ID={op['namespace_id']}\nORBIT_QUEUE_ID={queue}\n")
|
||||
Path(private, 'result.json').write_text(json.dumps({'application': name, 'namespace_id': op['namespace_id'], 'queue_id': queue, 'credential_id': op['credential_id'], 'task_id': task_id, 'subscription_id': subscription_id}))
|
||||
call('/console/auth/logout', {})
|
||||
call('/console/applications', expected=401)
|
||||
print('PASS: application create/replay, CSRF/session isolation, token rotation, original task/subscription preserved, logout')
|
||||
print('Test application: ' + name)
|
||||
@@ -106,6 +106,7 @@ printf '%s\n' "[4/8] start Orbit container"
|
||||
--env ORBIT_MYSQL_DSN="$E2E_DSN" \
|
||||
--env ORBIT_HTTP_ADDR=0.0.0.0:8080 \
|
||||
--env ORBIT_CONSOLE_QUEUE_ID="$E2E_QUEUE" \
|
||||
--env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \
|
||||
--env ORBIT_ARCHIVE_ROOT=/var/lib/orbit/archive \
|
||||
--env ORBIT_ARCHIVE_STORAGE_ID="$E2E_STORAGE_ID" \
|
||||
--env ORBIT_ARCHIVE_TICK_INTERVAL=1s \
|
||||
@@ -139,6 +140,9 @@ if [ "$E2E_READY" -ne 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_USER="$E2E_ADMIN" \
|
||||
ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" python3 ./scripts/test-console-applications.py
|
||||
|
||||
printf '%s\n' "[5/8] console login and protected-page check"
|
||||
ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_QUEUE="$E2E_QUEUE" ORBIT_E2E_USER="$E2E_ADMIN" \
|
||||
ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" python3 - <<'PY'
|
||||
|
||||
Reference in New Issue
Block a user