From 87aa710013cd686a63f256d8873addf97fda02b1 Mon Sep 17 00:00:00 2001 From: k2safe Date: Sun, 27 Sep 2026 10:18:31 +0800 Subject: [PATCH] feat: add single-admin application onboarding and token rotation --- README.md | 2 + cmd/orbit/main.go | 1 + deploy/docker-compose.yml | 1 + deploy/local/README.md | 6 + deploy/local/compose.yml | 1 + docs/08-development-plan.md | 8 + docs/api/README.md | 2 + docs/api/authentication.md | 4 +- docs/api/console-applications.md | 124 ++++++ docs/console.md | 4 + docs/reports/console-applications.md | 27 ++ internal/config/config.go | 42 +- internal/consoleauth/applications.go | 95 +++++ .../storage/mysql/console_applications.go | 362 ++++++++++++++++++ .../console_applications_integration_test.go | 165 ++++++++ internal/storage/mysql/credentials.go | 21 +- internal/transport/httpserver/console/app.js | 4 +- .../httpserver/console/applications.js | 125 ++++++ .../transport/httpserver/console/index.html | 45 +++ .../transport/httpserver/console/styles.css | 6 + .../httpserver/console_applications.go | 105 +++++ .../httpserver/console_applications_test.go | 121 ++++++ scripts/test-console-applications.py | 91 +++++ scripts/test-console-e2e.sh | 4 + 24 files changed, 1341 insertions(+), 25 deletions(-) create mode 100644 docs/api/console-applications.md create mode 100644 docs/reports/console-applications.md create mode 100644 internal/consoleauth/applications.go create mode 100644 internal/storage/mysql/console_applications.go create mode 100644 internal/storage/mysql/console_applications_integration_test.go create mode 100644 internal/transport/httpserver/console/applications.js create mode 100644 internal/transport/httpserver/console_applications.go create mode 100644 internal/transport/httpserver/console_applications_test.go create mode 100644 scripts/test-console-applications.py diff --git a/README.md b/README.md index 2eaa286..ddc3f51 100644 --- a/README.md +++ b/README.md @@ -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. 启动平台,进入控制台 diff --git a/cmd/orbit/main.go b/cmd/orbit/main.go index 7488e71..1dc9aee 100644 --- a/cmd/orbit/main.go +++ b/cmd/orbit/main.go @@ -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) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index c8a91c0..5fb9e49 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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: diff --git a/deploy/local/README.md b/deploy/local/README.md index 80d5eeb..ab81f3a 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -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`,包含: diff --git a/deploy/local/compose.yml b/deploy/local/compose.yml index 6c08947..25860bb 100644 --- a/deploy/local/compose.yml +++ b/deploy/local/compose.yml @@ -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 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 55760e9..1f1138c 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -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)。 diff --git a/docs/api/README.md b/docs/api/README.md index f5310b3..268b9c0 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -66,3 +66,5 @@ P1 业务接口已遵守下述约定,后续接口继续沿用: [流程实例筛选分页与权限](workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。 - [周期归档与作业管理](archive-scheduler.md) + +- [控制台应用接入与 Token 管理](console-applications.md) diff --git a/docs/api/authentication.md b/docs/api/authentication.md index f53f2cc..e1f51a0 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.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 认证。平台不会批量改写在途任务、交付记录或历史会话证据。 先读取脱敏状态和当前版本: diff --git a/docs/api/console-applications.md b/docs/api/console-applications.md new file mode 100644 index 0000000..9920217 --- /dev/null +++ b/docs/api/console-applications.md @@ -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=` | 按命名空间 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:`;正文只有权限、资源 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)。 diff --git a/docs/console.md b/docs/console.md index 40b5eb2..f04167c 100644 --- a/docs/console.md +++ b/docs/console.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)。 diff --git a/docs/reports/console-applications.md b/docs/reports/console-applications.md new file mode 100644 index 0000000..dff5c12 --- /dev/null +++ b/docs/reports/console-applications.md @@ -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 项并显式标记截断。当前未增加删除应用、修改权限、凭据转移、复杂角色或多应用任务总览。 diff --git a/internal/config/config.go b/internal/config/config.go index 76a1113..1ddabf7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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, diff --git a/internal/consoleauth/applications.go b/internal/consoleauth/applications.go new file mode 100644 index 0000000..a59af75 --- /dev/null +++ b/internal/consoleauth/applications.go @@ -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) +} diff --git a/internal/storage/mysql/console_applications.go b/internal/storage/mysql/console_applications.go new file mode 100644 index 0000000..840be2f --- /dev/null +++ b/internal/storage/mysql/console_applications.go @@ -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 +} diff --git a/internal/storage/mysql/console_applications_integration_test.go b/internal/storage/mysql/console_applications_integration_test.go new file mode 100644 index 0000000..d4b91d1 --- /dev/null +++ b/internal/storage/mysql/console_applications_integration_test.go @@ -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) + } +} diff --git a/internal/storage/mysql/credentials.go b/internal/storage/mysql/credentials.go index e1eb7f9..628ec66 100644 --- a/internal/storage/mysql/credentials.go +++ b/internal/storage/mysql/credentials.go @@ -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 diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index 64da899..6a4b3fc 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -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([ diff --git a/internal/transport/httpserver/console/applications.js b/internal/transport/httpserver/console/applications.js new file mode 100644 index 0000000..2f8f6d7 --- /dev/null +++ b/internal/transport/httpserver/console/applications.js @@ -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 => ``).join('') : '

还没有接入应用,点击右上角新建。

'; + 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 => ``).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 `
${escape(c.name)} · ${status}

凭据 ID:${escape(c.id)}
有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}
Token 原文不保存在数据库;已有服务继续使用原配置。

${groups.map(a => `${escape(a)}`).join('')}
查看具体权限

${c.permissions.actions.map(escape).join(' · ')}
授权队列:${escape(c.permissions.queues.join(', '))}

`; + }).join('') || '

该环境暂无凭据。

'; + 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 }; +})(); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 35cf49b..1fb2584 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -25,6 +25,7 @@ + @@ -360,6 +386,25 @@
+ +
ORBIT / CONNECT
+

新建应用

为应用创建独立的环境、队列与调用凭据。

+
+
+ +
+ +
接入能力 默认包含任务提交、查询与队列管理
+
+
+ +
+
+

+ +
+ + diff --git a/internal/transport/httpserver/console/styles.css b/internal/transport/httpserver/console/styles.css index 48a1fa3..0c89339 100644 --- a/internal/transport/httpserver/console/styles.css +++ b/internal/transport/httpserver/console/styles.css @@ -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} diff --git a/internal/transport/httpserver/console_applications.go b/internal/transport/httpserver/console_applications.go new file mode 100644 index 0000000..3d66ff4 --- /dev/null +++ b/internal/transport/httpserver/console_applications.go @@ -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}) + }) +} diff --git a/internal/transport/httpserver/console_applications_test.go b/internal/transport/httpserver/console_applications_test.go new file mode 100644 index 0000000..976951c --- /dev/null +++ b/internal/transport/httpserver/console_applications_test.go @@ -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") + } + } +} diff --git a/scripts/test-console-applications.py b/scripts/test-console-applications.py new file mode 100644 index 0000000..bf8570d --- /dev/null +++ b/scripts/test-console-applications.py @@ -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) diff --git a/scripts/test-console-e2e.sh b/scripts/test-console-e2e.sh index 420c665..03b958c 100755 --- a/scripts/test-console-e2e.sh +++ b/scripts/test-console-e2e.sh @@ -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'