From 48e3d24ad69728bbb0605a023e31791d3b4e5de1 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:11:15 +0800 Subject: [PATCH] fix: converge v02 authorization on OpenFGA (#899) Co-authored-by: Codex Agent --- docs/reference/gateway-outbound-demo.md | 2 +- docs/reference/spec-device-pod.md | 24 +- docs/reference/spec-user-access.md | 29 +-- docs/reference/spec-v02-auth.md | 18 +- docs/reference/spec-v02-hwlab-cloud-api.md | 16 +- .../spec-v02-hwlab-device-pod-service.md | 8 +- .../spec-v02-openfga-authorization.md | 17 +- docs/reference/spec-v02-services.md | 4 +- internal/cloud/access-control.test.ts | 153 +++++++++---- internal/cloud/access-control.ts | 103 +++------ internal/cloud/openfga-authorization.test.ts | 42 ++++ internal/cloud/openfga-authorization.ts | 22 +- internal/db/runtime-store.test.ts | 2 +- internal/db/runtime-store.ts | 4 +- internal/db/schema.ts | 6 - internal/protocol/index.mjs | 1 - scripts/g14-gitops-render.mjs | 2 +- scripts/g14-gitops-render.test.ts | 2 +- tools/device-pod-cli.test.ts | 8 +- tools/hwlab-cli/client.test.ts | 15 +- tools/src/device-pod-cli-lib.ts | 11 +- tools/src/hwlab-cli-lib.ts | 208 ++++++++++++++---- 22 files changed, 420 insertions(+), 277 deletions(-) create mode 100644 internal/cloud/openfga-authorization.test.ts diff --git a/docs/reference/gateway-outbound-demo.md b/docs/reference/gateway-outbound-demo.md index 97fbe39c..9e1280ce 100644 --- a/docs/reference/gateway-outbound-demo.md +++ b/docs/reference/gateway-outbound-demo.md @@ -7,7 +7,7 @@ - `hwlab-gateway` 运行在用户 PC 或本地环境,主动访问 `hwlab-cloud-api`;cloud 不需要也不应入站访问 gateway。 - demo 采用普通 HTTP poll/result:gateway 调 `POST /v1/gateway/poll` 拉取命令,执行后调 `POST /v1/gateway/result` 回传 JSON-RPC response。 - `hardware.invoke.shell` 仍是 cloud-api 对外 RPC 方法;有在线 gateway 时通过主动出站链路派发,没有在线 gateway 时保留 `not_connected` 降级返回。 -- 当前命令执行能力只用于受限 demo;正式真实硬件控制以 [spec-device-pod.md](spec-device-pod.md) 和 [spec-user-access.md](spec-user-access.md) 为权威:用户权限由 `cloud-api` 的 `admin/user` 与 device pod grant 判断,不拆 capability;设备执行收敛到 `cloud-api -> hwlab-device-pod -> gateway`,硬件 trace/evidence/audit 只作为硬件证据链,不作为用户权限模型。 +- 当前命令执行能力只用于受限 demo;正式真实硬件控制以 [spec-device-pod.md](spec-device-pod.md) 和 [spec-user-access.md](spec-user-access.md) 为权威:用户权限由 `cloud-api` 的 `admin/user` 与 OpenFGA relation 判断;设备执行收敛到 `cloud-api -> hwlab-device-pod -> gateway`,硬件 trace/evidence/audit 只作为硬件证据链,不作为用户权限模型。 ## Cloud API 入口 diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index 292d0d82..e6665419 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -8,7 +8,7 @@ ```text browser Cloud Web UI or hwpod/device-pod-cli --> cloud-api AuthPrincipal + device_pod_grants +-> cloud-api AuthPrincipal + OpenFGA relation check -> hwlab-device-pod internal REST -> gateway transport -> device-host-cli @@ -26,7 +26,7 @@ AgentRun runner 和 `hwpod` 的标准设备 API 入口是 `HWLAB_RUNTIME_API_URL ## 设计目标 - 用最少组件把 `device-pod-cli` 从“本地 profile + RPC/gateway 调用”迁到“1:1 REST 请求”。 -- `cloud-api` 是用户身份、用户 API key、device grant 和 profile authority 判断入口。 +- `cloud-api` 是用户身份、用户 API key、OpenFGA relation 和 profile authority 判断入口。 - `hwlab-device-pod` 承接设备业务:profile 校验后的运行、job 生命周期、freshness、blocker、bounded output 和 gateway 调用。 - `device-pod-cli` 只做 selector 解析、cloud-api REST 请求和 JSON 输出;默认正式模式不读取 `.device-pod/*.json`,不保存、不上传、不修改权威 profile。 - 第一阶段只部署一个 `hwlab-device-pod` Deployment/Service,管理多个逻辑 `devicePodId`,避免为每台设备创建独立 k8s Service/Deployment。 @@ -86,7 +86,7 @@ code agent 本地文件只能作为非权威 hint/cache,最多包含: ## 内部架构 -正式 device-pod 由 profile registry、job lifecycle、freshness/blocker、bounded output、gateway/device-host adapter 和用户 API key integration 组成。第一阶段只有一个 `hwlab-device-pod` Deployment 管理多个 `devicePodId`;profile authority、user grant 和 `api_keys` 在 cloud-api/Postgres 中,device-pod 服务只接受 cloud-api 内部调用。 +正式 device-pod 由 profile registry、job lifecycle、freshness/blocker、bounded output、gateway/device-host adapter 和用户 API key integration 组成。第一阶段只有一个 `hwlab-device-pod` Deployment 管理多个 `devicePodId`;profile authority、OpenFGA relation 和 `api_keys` 在 cloud-api/Postgres 中,device-pod 服务只接受 cloud-api 内部调用。 当前 v02 部署中的 `hwlab-device-pod` 微服务实现情况见 [spec-v02-hwlab-device-pod-service.md](spec-v02-hwlab-device-pod-service.md)。 @@ -147,7 +147,7 @@ CREATE TABLE IF NOT EXISTS device_pods ( 历史版本中的 `profile_ref`、`gateway_ref` 或 `device_pod_json` 可以在迁移时折叠进 `profile_json`。第一阶段不新增 `device_pod_profile_revisions`;需要审计版本、回滚或多环境批准时再引入 profile revision 表。 -`device_pod_grants` 只作为旧全权限 grant 的迁移兼容表;目标状态的 device pod 可见、操作、profile 修改和 job 提交权限由 OpenFGA relation 表达。它不保存 profile,也不再作为细粒度 capability authority。 +历史版本中的 `device_pod_grants` 不是当前 v0.2 device pod 授权契约。目标状态的 device pod 可见、操作、profile 修改和 job 提交权限只由 OpenFGA relation 表达;旧表若仍存在于已发布数据库中,只能作为历史迁移输入,不得作为写入口、健康必需表或 allow 判定来源。 ## REST API @@ -172,8 +172,8 @@ POST /v1/device-pods/{devicePodId}/jobs/{jobId}/cancel ```text POST /v1/admin/device-pods PUT /v1/admin/device-pods/{devicePodId} -POST /v1/admin/device-pod-grants -DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId} +PUT /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation} +DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation} ``` `POST /v1/device-pods/{devicePodId}/jobs` 用 `intent` 表达具体业务,避免把 REST surface 扩张成大量一次性 route: @@ -216,12 +216,12 @@ DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId} | 服务 | 职责 | | --- | --- | -| `hwlab-cloud-api` | 用户身份、admin/user、用户 API key、device grant、profile authority、用户态 REST API、转发到内部 device-pod。 | +| `hwlab-cloud-api` | 用户身份、admin/user、用户 API key、OpenFGA relation、profile authority、用户态 REST API、转发到内部 device-pod。 | | `hwlab-device-pod` | 多 `devicePodId` 运行 registry、profile runtime validation、job store、freshness、bounded output、gateway/device-host-cli adapter。 | | `device-pod-cli` | 把 `devicePodId:surface:path operation args` 1:1 转成 cloud-api REST;不保存权威 profile、不读取本地 profile 作为默认 authority、不直连 gateway。 | | `device-host-cli` | Windows host 侧自包含业务工具,负责 Keil、pyOCD、UART、workspace 文件操作。 | | `hwlab-gateway` | 只做受控 transport,不理解用户权限和 device-pod 授权。 | -| `hwlab-cloud-web` | 展示用户可见 device pod、admin 管理 profile/grant、显示 job/status/freshness。 | +| `hwlab-cloud-web` | 展示用户可见 device pod、admin 管理 profile/relation、显示 job/status/freshness。 | ## Kubernetes 口径 @@ -262,7 +262,7 @@ manages: many devicePodId - `profile list/show` 调用 cloud-api `/v1/device-pods` 和 `/status`,只显示服务端脱敏 profile 摘要和 `profileHash`。 - AgentRun runner 中只使用装配好的 `HWLAB_RUNTIME_API_URL` 和映射到当前用户的 `HWLAB_API_KEY`,直接访问 `hwlab-cloud-api`;不得手动传 `--api-base-url`,也不得改走 Cloud Web 同源代理。 -- `setup first-admin` 和 `admin device-pod upsert/grant` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile/grant 管理;它们接受显式 `--profile-json` 或 `--device-pod-json`,不得读取本地 `.device-pod/*.json` 作为权威 profile。 +- `setup first-admin` 和 `admin device-pod upsert` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile 管理;device pod 授权统一使用 `hwlab-cli client access device-pods grant/revoke` 的 Admin Access API,不走 `hwpod admin grant`。 - `devicePodId:workspace|debug-probe|io-probe...` selector 转换为 `POST /v1/device-pods/{devicePodId}/jobs` 或 job status/output/cancel REST,不直接调用 `/v1/rpc/hardware.invoke.shell`。 - `bootsharp --pod-id ` 和 `:workspace:/ bootsharp` 都转换为 `workspace.bootsharp` job,用于返回 workspace tree、AGENTS.md 摘要和当前路径提示;该入口是上下文恢复和 DS 派单的首个探测动作,不读取本地 profile。 - workspace 写操作覆盖 `apply-patch`、`put`、`rm`、`rmdir`、`build` 和 `keil add-source/remove-source` 等 Keil 工程维护动作。 @@ -270,7 +270,7 @@ manages: many devicePodId - UART 业务覆盖 `read`、`write`、`read-after-launch-flash` 和 `jsonrpc`;JSON-RPC 请求必须由 device-host-cli 校验 response id,除非显式传入允许 id mismatch 的业务参数。 - apply-patch 类失败必须返回可定位的 patch hint,例如缺少 `*** End Patch`、hunk 上下文不匹配或 header 错误;调用方应先重新读取目标文件再重试小 hunk,不应默认绕到整文件覆盖。 - mutating job 由 cloud-api 侧强制 `reason`;CLI 只转发 `intent`、`args` 和 `reason`。 -- `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile/grant。 +- `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile,并使用 Admin Access API 管理 relation。 - DS/device-pod prompt 和 skill 示例必须优先使用 `hwpod`,避免把长路径 wrapper 复制成常态命令;host job 轮询时 job id 必须作为 `status/output/cancel` 的紧随位置参数传入,不能放到 flags 后面或靠 shell 管道解析 JSON。 ## D601 F103 v0.2 Gateway SOP @@ -426,8 +426,8 @@ hwpod D601-F103-V2:workspace:/ rg \ | --- | --- | --- | | 逻辑 device-pod 模型 | 已实现为规格 | 四要素、profile shape 和 Kubernetes 口径已定义。 | | profile server authority | 部分实现 | cloud-api 保存正式 DB profile 并向用户返回脱敏摘要;device-pod executor 不接受用户上传 profile。 | -| 用户 grant + 用户 API key | 部分实现 | cloud-api 已实现 admin grant、可见性过滤和强副作用 job reason 校验;AgentRun/hwpod 仍需从旧 shared device-pod key 收敛到映射用户的 `HWLAB_API_KEY`。 | +| 用户 relation + 用户 API key | 部分实现 | cloud-api 已实现 Admin Access relation、可见性过滤和强副作用 job reason 校验;AgentRun/hwpod 仍需从旧 shared device-pod key 收敛到映射用户的 `HWLAB_API_KEY`。 | | REST/job API | 部分实现 | cloud-api 已实现 list/status/events/probe/job/output/cancel,并可把已授权 job 转发给内部 `hwlab-device-pod` executor;executor 已实现内部 job create/get/output/cancel lifecycle 和 gateway/device-host-cli dispatch adapter,无在线 gateway/device-host-cli 时返回 blocker。 | -| G14 device-host 功能吸收 | 部分实现 | v0.2 job intent 已覆盖 workspace put/rm/rmdir、Keil 工程维护和 UART JSON-RPC,保持 cloud-api grant/profile authority 和用户 API key runtime auth。 | +| G14 device-host 功能吸收 | 部分实现 | v0.2 job intent 已覆盖 workspace put/rm/rmdir、Keil 工程维护和 UART JSON-RPC,保持 cloud-api profile authority、OpenFGA relation 和用户 API key runtime auth。 | | 禁止 fake 作为 DEV-LIVE | 已实现/持续约束 | 规格和服务 payload 要求显式标记 fake/source。 | diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 489f2b43..74766ee5 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -23,7 +23,7 @@ Postgres 是用户、session、业务对象和迁移 ledger 的持久化边界 - 只保留两类角色:`admin` 和 `user`。 - `code agent session` 直接归属于创建它的用户;普通用户只能查看、继续和取消自己的 session。 - `device pod` 由 `admin` 或被授予 `profile_editor` 的用户管理;普通用户只有在被授权后才能看到、操作或提交对应 device pod job。 -- device pod 授权按 `viewer`、`operator`、`profile_editor`、`job_submitter` 等 relation 表达;旧 `device_pod_grants` 只作为迁移兼容和 shadow 对照,不再代表目标状态的完整使用权。 +- device pod 授权按 `viewer`、`operator`、`profile_editor`、`job_submitter` 等 OpenFGA relation 表达;旧 `device_pod_grants` 不再代表目标状态的完整使用权,也不得作为 allow 判定或管理入口。 - 工具能力必须独立授权,例如 `hwpod`、`unidesk_ssh`、`trans_cmd` 和 GitHub 写工具;拥有 Code Agent session 不等于拥有这些工具。 - MVP 不新增产品级 `audit_events` 用户审计表,也不把用户权限依赖到 audit。现有硬件 trace/evidence/audit 字段属于硬件闭环证据,不是多用户权限模型的一部分。 - 强副作用 device-pod job 只额外要求业务 `reason`;设备互斥由 executor、gateway 和硬件 host 串行化或返回 blocker,不进入用户权限模型。 @@ -125,19 +125,9 @@ CREATE TABLE IF NOT EXISTS device_pods ( `profile_json` 中的 gateway route、host workspace、probe UID、串口端口和 host CLI 都是服务端权威字段;普通用户响应只能看到脱敏 profile 摘要和 `profile_hash`。 -### `device_pod_grants` +### 历史 `device_pod_grants` -迁移期普通用户对 device pod 的兼容授权关系。目标状态以 OpenFGA tuple 为准;该表只用于从旧全权限 grant 导入 `operator/job_submitter`、在 `shadow` 模式下对照 mismatch,或在 OpenFGA `off` 的本地开发中保持 legacy fallback。 - -```sql -CREATE TABLE IF NOT EXISTS device_pod_grants ( - device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_by_admin_id TEXT NOT NULL REFERENCES users(id), - created_at TEXT NOT NULL, - PRIMARY KEY (device_pod_id, user_id) -); -``` +历史版本曾用 `device_pod_grants` 表表达普通用户对 device pod 的全权限兼容授权。当前 v0.2 目标状态以 OpenFGA tuple 为准;该表不再由当前 schema 创建,不是 runtime readiness 必需表,不得新增写入口,也不得作为正式 allow source。若已有数据库仍保留该表,只能作为一次性迁移输入读取,迁移完成后权限必须写入 OpenFGA relation。 - 不新增 `capability`、`scope`、`expires_at` 字段;细粒度 relation 不回写到该表,统一写 OpenFGA。 - 在 OpenFGA `enforce` 模式下,撤销授权就是删除对应 tuple;该表若仍有 legacy 行也不能放行请求。 @@ -211,7 +201,7 @@ browser admin Access UI -> return effective permission matrix ``` -撤销授权走 `DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}`。旧 `POST /v1/admin/device-pod-grants` 和 `DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}` 可作为迁移兼容入口,但目标实现必须把它们映射为 OpenFGA tuple 写入或删除,不能继续只写 DB grant 表。 +撤销授权走 `DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}`。`/v1/admin/device-pod-grants` 旧全权限入口不再保留;管理员只能通过 Admin Access API 写入或删除 OpenFGA relation。 ### 用户列出 device pod @@ -259,7 +249,7 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device `hwlab-cloud-api` 内部应按 auth/session/API key、authorization、agent session owner、device-pod grant 和 admin API 模块分层。所有模块共享同一 Postgres runtime store 和 migration ledger,避免拆出早期 `hwlab-user-api` 造成跨服务一致性成本。 -`user_sessions` 存浏览器 session token hash;`api_keys` 存映射到用户的 CLI/runner API key;`agent_sessions.owner_user_id` 绑定 Code Agent session;`device_pods` 存 profile authority;OpenFGA tuple 表示用户对 device pod、agent session 和工具的细粒度能力;`device_pod_grants` 只作为旧全权限 grant 的迁移兼容表。不得再引入对所有正式 device pod 授权的共享用户 key;`HWLAB_DEVICE_POD_API_KEY` 如仍存在,只能作为 cloud-api 到 device-pod 的内部服务凭据。 +`user_sessions` 存浏览器 session token hash;`api_keys` 存映射到用户的 CLI/runner API key;`agent_sessions.owner_user_id` 绑定 Code Agent session;`device_pods` 存 profile authority;OpenFGA tuple 表示用户对 device pod、agent session 和工具的细粒度能力。`device_pod_grants` 只能作为迁移时读取旧数据的历史表,不得作为正式授权写入口或 allow 决策来源。不得再引入对所有正式 device pod 授权的共享用户 key;`HWLAB_DEVICE_POD_API_KEY` 如仍存在,只能作为 cloud-api 到 device-pod 的内部服务凭据。 ## API 接口说明 @@ -275,11 +265,10 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device | `POST /v1/admin/users` | admin 创建用户,响应不得返回 `password_hash` 或 token。 | | `POST /v1/admin/device-pods`、`PUT /v1/admin/device-pods/{devicePodId}` | admin 管理 device pod profile authority。 | | `GET/PATCH/PUT/DELETE /v1/admin/access...` | admin Access API,读写 OpenFGA 授权、tool capability、role/status 和 effective matrix;见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 | -| `POST /v1/admin/device-pod-grants`、`DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}` | legacy/兼容入口;目标实现必须映射到 OpenFGA relation,不能只写旧 grant 表。 | | `GET /v1/device-pods` 和 device-pod 操作 API | 按 actor role、OpenFGA relation 和 tool capability 过滤可见/可用 device pod。 | | `POST /v1/agent/chat` 及 result/trace/cancel | 必须校验 `agent_sessions.owner_user_id`;admin 可跨用户查看和取消。 | -`POST /v1/setup/first-admin` 的 device-pod 初始化只用于空库首次进入系统,不能作为长期 profile 管理入口。每个 seed 必须包含 `devicePodId` 和 object `profile`;cloud-api 会写入 `device_pods.profile_json/profile_hash` 并创建 `device_pod_grants(device_pod_id, first_admin_user_id)`。响应只能返回脱敏 profile、profileHash 和 grant summary,不得返回 `gatewaySessionId`、`hostWorkspaceRoot`、password 或 session token 原文。 +`POST /v1/setup/first-admin` 的 device-pod 初始化只用于空库首次进入系统,不能作为长期 profile 管理入口。每个 seed 必须包含 `devicePodId` 和 object `profile`;cloud-api 会写入 `device_pods.profile_json/profile_hash`,并通过 Admin Access/OpenFGA relation 授权给首个 admin。响应只能返回脱敏 profile、profileHash 和授权摘要,不得返回 `gatewaySessionId`、`hostWorkspaceRoot`、password 或 session token 原文。 ## 微服务设计 @@ -334,8 +323,8 @@ Kubernetes 只做运行时隔离和资源兜底,不承载 HWLAB 用户权限 | 规格项 | 状态 | 说明 | | --- | --- | --- | | admin/user 两角色模型 | 部分实现 | cloud-api 已实现 `/auth/*`、bootstrap admin、admin/user 创建和 admin-only 路由。 | -| `users`、`user_sessions`、grant 和 job 表 | 部分实现 | 0001 schema 和 access-control bootstrap 覆盖 users、sessions、device_pods、grants 和 jobs;Device Pod 强副作用 job 已接入 reason 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 | +| `users`、`user_sessions`、OpenFGA relation 和 job 表 | 部分实现 | access-control bootstrap 覆盖 users、sessions、device_pods、access_tuples 和 jobs;Device Pod 强副作用 job 已接入 reason 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 | | Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidence;trace/result cache 也按 owner/admin 限制访问。 | -| OpenFGA 细粒度授权模型 | 目标状态 | 需要按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 实现 device pod relation、tool capability、Access API/WebUI/CLI 和 shadow/enforce。 | -| legacy device pod grant 兼容 | 部分实现 | cloud-api 已实现 admin profile/grant、普通用户可见性和 job 持久化;后续必须作为 OpenFGA 迁移兼容,而不是目标授权 authority。 | +| OpenFGA 细粒度授权模型 | 目标状态 | 需要按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 实现 device pod relation、tool capability、Access API/WebUI/CLI 和 enforce 运行面。 | +| legacy device pod grant 兼容 | 已退出目标路径 | `/v1/admin/device-pod-grants` 和旧全权限 grant fallback 不再保留;历史表只可作为一次性迁移输入,不作为正式授权 authority。 | | 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 | diff --git a/docs/reference/spec-v02-auth.md b/docs/reference/spec-v02-auth.md index eb7c6346..2f2b9117 100644 --- a/docs/reference/spec-v02-auth.md +++ b/docs/reference/spec-v02-auth.md @@ -2,7 +2,7 @@ 本文是 HWLAB `v0.2` 登录、认证和应用层鉴权入口的长期规格。正式用户鉴权只保留两类凭据:浏览器使用 `hwlab_session` Web session,CLI/API/AgentRun runner 使用用户级 `HWLAB_API_KEY`。Keycloak 只负责 Web 身份认证和注册,HWLAB 在 callback 后发行自己的 session、API key 和 `AuthPrincipal`,避免把 Keycloak token、本地密码、浏览器 cookie 和设备内部系统 key 混用。 -基础设施实施跟踪见 [pikasTech/HWLAB#788](https://github.com/pikasTech/HWLAB/issues/788),Keycloak 到 HWLAB 的接入收口见 [pikasTech/HWLAB#814](https://github.com/pikasTech/HWLAB/issues/814)。用户角色、Code Agent session owner、device pod grant 和资源授权矩阵见 [spec-user-access.md](spec-user-access.md);本文只定义“如何登录、如何恢复 actor、如何把请求归一成 actor”。 +基础设施实施跟踪见 [pikasTech/HWLAB#788](https://github.com/pikasTech/HWLAB/issues/788),Keycloak 到 HWLAB 的接入收口见 [pikasTech/HWLAB#814](https://github.com/pikasTech/HWLAB/issues/814)。用户角色、Code Agent session owner、OpenFGA relation 和资源授权矩阵见 [spec-user-access.md](spec-user-access.md);本文只定义“如何登录、如何恢复 actor、如何把请求归一成 actor”。 ## 设计目标 @@ -12,8 +12,8 @@ - AgentRun runner 内的 `hwpod` 也必须使用同一类用户 API key 认证,映射到发起 Code Agent session 的 `users.id`;`HWLAB_DEVICE_POD_API_KEY` 只能作为 cloud-api 到 device-pod 的内部服务凭据或迁移期兼容项,不能作为正式用户鉴权方式。 - 每个用户在首次登录后自动拥有一个默认 API key;用户也可以在 Web 中创建、查看、失效或重新生成 API key。 - API key 长效有效,除非用户或管理员手动 revoke/regenerate;它不跟 Web session 的 24 小时过期绑定。 -- Keycloak 只做身份认证和账号注册;HWLAB 的 `users.role`、`users.status`、device pod grant 和 Code Agent owner 仍是应用层授权 source of truth。 -- device-pod 权限只由 `users.role/status`、Code Agent session owner 和 `device_pod_grants` 控制;不存在“对所有正式 device pod 授权”的共享 runner key。 +- Keycloak 只做身份认证和账号注册;HWLAB 的 `users.role`、`users.status`、OpenFGA relation 和 Code Agent owner 仍是应用层授权 source of truth。 +- device-pod 权限只由 `users.role/status`、Code Agent session owner 和 OpenFGA relation 控制;不存在“对所有正式 device pod 授权”的共享 runner key。 ## 系统边界 @@ -72,7 +72,7 @@ browser - 公网管理入口是 `https://auth.74-48-78-17.nip.io/admin/master/console/`,`/admin/` 可以重定向到该原生 console;不要在 HWLAB Cloud Web 中重做 Keycloak 管理 UI。 - 公网 issuer 是 `https://auth.74-48-78-17.nip.io/realms/hwlab`,discovery 与 JWKS 必须全部返回 HTTPS URL。 - Keycloak management 端口 `9000` 不作为公网入口暴露;健康和管理探测留在集群内或受控透传内完成。 -- `hwlab` realm 必须 enabled,并在小范围测试阶段允许 self-registration;新注册用户在 HWLAB 应用层只能默认成为普通 `user`,不自动拥有 device pod grant。 +- `hwlab` realm 必须 enabled,并在小范围测试阶段允许 self-registration;新注册用户在 HWLAB 应用层只能默认成为普通 `user`,不自动拥有 device pod relation。 - `hwlab-cloud-web` client 的 redirect URI 指向 `https://hwlab.74-48-78-17.nip.io/auth/oidc/callback`,web origin 指向 `https://hwlab.74-48-78-17.nip.io`,Direct Access Grants 必须关闭,避免把 Keycloak password grant 变成第三种 CLI 鉴权方式。 管理员凭据边界: @@ -92,7 +92,7 @@ browser 短期小范围测试允许 Keycloak 开启自助注册,并且不要求邮箱或手机校验。该策略只有在以下条件同时满足时成立: - 新注册用户默认只映射为 HWLAB `user` 角色,不自动成为 `admin`。 -- 新用户没有 device pod grant,也不能看到或使用任何 device pod,直到 `admin` 在 HWLAB 中授权。 +- 新用户没有 device pod relation,也不能看到或使用任何 device pod,直到 `admin` 在 HWLAB 中授权。 - Keycloak 账号状态必须能被管理员禁用;HWLAB `users.status='disabled'` 也必须能独立阻断 session 和 API key。 - 如果公网注册出现垃圾账号或撞库迹象,第一优先级是关闭 Keycloak self-registration 或增加邀请码/管理员审核;不要把防滥用逻辑塞进 device pod 授权。 @@ -247,9 +247,9 @@ API key 行为: 登录和认证只回答“请求是谁”。资源授权仍由 [spec-user-access.md](spec-user-access.md) 定义: -- `admin` 可以管理用户、device pod profile/grant,并跨用户查看或取消 Code Agent session。 +- `admin` 可以管理用户、device pod profile/relation,并跨用户查看或取消 Code Agent session。 - `user` 只能访问自己的 Code Agent session 和被授权的 device pod。 -- Web session、CLI API key 和 AgentRun runner 内 `hwpod` API key 得到同一个 `users.id` 时,应看到相同 device pod grant 和账号 workspace。 +- Web session、CLI API key 和 AgentRun runner 内 `hwpod` API key 得到同一个 `users.id` 时,应看到相同 OpenFGA device pod relation 和账号 workspace。 - Keycloak realm role、group 或 claim 不直接决定 HWLAB device pod 权限;最多作为创建/绑定用户时的输入线索。 ## 测试规格 @@ -283,13 +283,13 @@ API key 行为: | 规格项 | 状态 | 说明 | | --- | --- | --- | | Keycloak 独立 namespace 与公网 HTTPS issuer | 部署已完成 | `keycloak` namespace、Caddy/FRP HTTPS、`hwlab` issuer、admin console 和 bootstrap Job 已形成部署基线;后续只按本文件继续硬化。 | -| Keycloak 自助注册且不强制邮箱/手机验证 | Keycloak 侧已完成 | 只适合小范围测试;HWLAB 应用层仍必须默认 `user`、无 device pod grant。 | +| Keycloak 自助注册且不强制邮箱/手机验证 | Keycloak 侧已完成 | 只适合小范围测试;HWLAB 应用层仍必须默认 `user`、无 device pod relation。 | | Web OIDC login/callback | 待 HWLAB 接入收口 | Keycloak client、Cloud API/Web rollout 和浏览器 callback 验收见 #814;redirect URI 必须使用 `https://hwlab.74-48-78-17.nip.io/auth/oidc/callback`。 | | Web session 24 小时轮换 | 待 HWLAB 接入收口 | 当前目标是 callback 成功后由 `hwlab-cloud-api` 发行 24 小时 `hwlab_session`;验收见 #814。 | | CLI/AgentRun `HWLAB_API_KEY` 一等登录 | 待 HWLAB 接入收口 | 目标是统一 env API key 映射到用户,无浏览器跳转,无 Keycloak token,无跨用户 device-pod key;验收见 #814。 | | API key 一次性显示和 revoke/regenerate | 待 HWLAB 接入收口 | 目标状态只在创建或 regenerate 时显示完整 key;列表和已存在默认 key 只返回 metadata。 | | `AuthPrincipal` 归一 | 待 HWLAB 接入收口 | 后续实现必须把 Web session、CLI API key 和 AgentRun `hwpod` API key 都归一成同一用户 actor;legacy/internal key 不作为正式用户方法。 | -| `admin/user` 与 device pod grant 授权 | 部分实现 | 现有 cloud-api 已有本地用户、session、grant 和 Code Agent owner 绑定;资源授权继续按 spec-user-access 收敛。 | +| `admin/user` 与 device pod relation 授权 | 部分实现 | 现有 cloud-api 已有本地用户、session、OpenFGA relation 和 Code Agent owner 绑定;资源授权继续按 spec-user-access 收敛。 | ## Keycloak 部署纪律 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index c7f0c12b..ae3e302f 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -13,8 +13,8 @@ - `cmd/hwlab-cloud-api/main.ts` 负责启动 HTTP server、解析端口和 Code Agent timeout。 - `internal/cloud/server.ts` 负责 HTTP route、REST/RPC bridge、health、live-builds、device-pod authority、gateway poll/result 和 Code Agent chat。 -- `internal/cloud/openfga-authorization.ts` 或等价模块负责 OpenFGA client、store/model bootstrap、`off/shadow/enforce` 策略、check/write、tuple import 和 structured authorization decision;最终规格见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 -- `internal/cloud/access-control.ts` 负责 `/auth/*`、OIDC callback、Web session、用户 API key、admin/user、device pod profile/grant legacy compatibility、device job lifecycle 和 Code Agent owner binding;登录与鉴权 authority 见 [spec-v02-auth.md](spec-v02-auth.md)。当前本地 `/auth/login` 只作为 bootstrap/legacy fallback。 +- `internal/cloud/openfga-authorization.ts` 或等价模块负责 OpenFGA client、store/model bootstrap、`enforce` 策略、check/write 和 structured authorization decision;最终规格见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 +- `internal/cloud/access-control.ts` 负责 `/auth/*`、OIDC callback、Web session、用户 API key、admin/user、device pod profile/relation、device job lifecycle 和 Code Agent owner binding;登录与鉴权 authority 见 [spec-v02-auth.md](spec-v02-auth.md)。当前本地 `/auth/login` 只作为 bootstrap fallback。 - `internal/cloud/access-control.ts` 也是账号 workspace authority:`account_workspaces` 记录同一账号的 Workbench 当前 workspace、selected conversation/session、active trace、provider profile 和 revision。 - Code Agent session 生命周期必须显式化。Cloud API 目标入口为 `POST /v1/agent/sessions` 创建 session、`GET/PATCH /v1/agent/sessions*` 查询/选择/标记状态;`POST /v1/agent/chat` 只接受显式传入或账号 workspace 中已显式选中的 usable session。没有 session 时返回 `session_required`,session failed/stale/canceled 时返回 `session_not_usable`,不得自动创建、滚动或替换 session。 - Code Agent session record 是 provider profile authority。`POST /v1/agent/chat` 带显式 session 且请求未显式给出 provider profile 时,Cloud API 必须继承该 session 的 `providerProfile` 并映射为 AgentRun `backendProfile`;请求显式覆盖 provider profile 时,覆盖必须进入 trace/result 可见字段。账号 workspace 的 provider profile 只能在 selected session 与目标 session 完全一致时作为 fallback,不得让旧 workspace 覆盖显式 session。 @@ -40,13 +40,13 @@ | `GET /v1` | REST adapter 索引、RPC 方法、runtime readiness 和 device-pod/M3 能力摘要。 | | `POST /rpc`、`POST /json-rpc` | JSON-RPC 入口,支持 system、adapter、gateway、hardware、audit、evidence 和 M3 方法。 | | `POST /v1/rpc/{method}` | REST 到 JSON-RPC 的桥接入口。 | -| `GET /v1/device-pods...` | 经 cloud-api 鉴权后读取服务端 profile/grant/job authority;probe GET 会创建只读 device job 并经 `hwlab-device-pod` executor/gateway 执行或返回同源 blocker,不会回退到 fake device pod 数据。 | +| `GET /v1/device-pods...` | 经 cloud-api 鉴权后读取服务端 profile/OpenFGA relation/job authority;probe GET 会创建只读 device job 并经 `hwlab-device-pod` executor/gateway 执行或返回同源 blocker,不会回退到 fake device pod 数据。 | | `GET /auth/oidc/login`、`GET /auth/oidc/callback`、`GET /auth/session`、`POST /auth/logout` | Keycloak OIDC、Web session 24 小时轮换和 logout 入口,最终规格见 [spec-v02-auth.md](spec-v02-auth.md)。 | | `POST /auth/login` | 本地账号密码 bootstrap/legacy fallback;目标 Web/CLI 登录不依赖该入口。 | | `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | v0.2 用户/session/setup 的 REST 状态和兼容入口;响应不得暴露 password hash、session token 原文或 Secret 值。 | | `GET/POST /v1/api-keys...` | 用户 API key 管理入口;CLI 和 AgentRun runner 内 `hwpod` 都使用 `HWLAB_API_KEY`,映射到用户后再按权限表授权。 | -| `POST /v1/admin/users`、`POST/PUT /v1/admin/device-pods`、`POST/DELETE /v1/admin/device-pod-grants...` | `admin` 管理用户、device pod profile 和 grant 的入口。 | -| `GET/PATCH/PUT/DELETE /v1/admin/access...` | Admin Access API;读写 OpenFGA 细粒度授权、tool capability、role/status 和 effective matrix。 | +| `POST /v1/admin/users`、`POST/PUT /v1/admin/device-pods` | `admin` 管理用户和 device pod profile 的入口。 | +| `GET/PATCH/PUT/DELETE /v1/admin/access...` | Admin Access API;唯一正式授权管理入口,读写 OpenFGA 细粒度授权、tool capability、role/status 和 effective matrix。 | | `POST /v1/admin/access/check` | 管理员调试授权 check;返回 mode、decision、object/relation 和 redacted actor,不返回 OpenFGA token。 | | `GET/PATCH /v1/workbench/workspace`、`POST /v1/workbench/workspace/{id}/reset`、`GET /events` | 账号级共享 workspace authority;所有读写按 ownerUserId 隔离,写入使用 revision 观测冲突,active trace 只表示最近活动/当前选中 trace,不作为同账号 Code Agent 并发互斥锁。 | | `POST /v1/agent/sessions`、`GET/PATCH /v1/agent/sessions...` | 显式 Code Agent session 生命周期入口;创建/选择/状态标记 session,并与账号 workspace selection 同步。 | @@ -104,8 +104,8 @@ OpenFGA、Access API、工具能力和 shadow/enforce 策略的最终规格见 [ | 显式 Code Agent session 生命周期 | 目标状态 | `/v1/agent/sessions*` 管理 session create/select/status;`/v1/agent/chat` 无 session 时返回 `session_required`,失败 session 不自动滚动。 | | Postgres durable runtime | 已实现 | 通过 v02 独立 DB SecretRef 和 migration ledger 判定。 | | gateway outbound poll/result | 已实现 | 支持 gateway 主动轮询和 `hardware.invoke.shell` 分发。 | -| device-pod 正式权限/profile/job | 部分实现 | profile/grant/list/status/job 持久化在 cloud-api;用户态 probe GET 已收敛为只读 job;已提供内部 gateway dispatch route 供 `hwlab-device-pod` executor 下发到 device-host-cli,无在线 gateway/device-host-cli 时返回 blocker。 | -| v0.2 登录鉴权与 admin/user 权限模型 | 部分实现 | 当前 `/auth/*` 是本地账号密码和 7 天 session;Keycloak OIDC、24 小时 Web session、CLI API key 和 `AuthPrincipal` 归一仍需按 spec-v02-auth 收敛。admin user/device-pod/grant API 和 Code Agent owner binding 已接入;生产 bootstrap 依赖 SecretRef。 | -| OpenFGA 细粒度授权与 Admin Access API | 目标状态 | 需要实现 OpenFGA client/bootstrap、`off/shadow/enforce`、`/v1/admin/access*`、tool capability check 和 runner env 过滤。 | +| device-pod 正式权限/profile/job | 部分实现 | profile/relation/list/status/job 持久化在 cloud-api;用户态 probe GET 已收敛为只读 job;已提供内部 gateway dispatch route 供 `hwlab-device-pod` executor 下发到 device-host-cli,无在线 gateway/device-host-cli 时返回 blocker。 | +| v0.2 登录鉴权与 admin/user 权限模型 | 部分实现 | 当前 `/auth/*` 是本地账号密码 bootstrap fallback 和 Web session;Keycloak OIDC、24 小时 Web session、CLI API key 和 `AuthPrincipal` 归一仍需按 spec-v02-auth 收敛。admin user/device-pod/relation API 和 Code Agent owner binding 已接入;生产 bootstrap 依赖 SecretRef。 | +| OpenFGA 细粒度授权与 Admin Access API | 目标状态 | 需要实现 OpenFGA client/bootstrap、`enforce`、`/v1/admin/access*`、tool capability check 和 runner env 过滤。 | | 账号共享 workspace authority | 已实现 | `account_workspaces` 持久化同账号 Web/CLI 共享 workspace,支持 revision 冲突可见性、账号隔离、最近 active trace 记录和 reset;同一用户不同 session/run 允许并发,互斥只在 session/thread/run 层处理。 | diff --git a/docs/reference/spec-v02-hwlab-device-pod-service.md b/docs/reference/spec-v02-hwlab-device-pod-service.md index 6e415267..657f7575 100644 --- a/docs/reference/spec-v02-hwlab-device-pod-service.md +++ b/docs/reference/spec-v02-hwlab-device-pod-service.md @@ -1,12 +1,12 @@ # v0.2 hwlab-device-pod 服务规格 -本文描述 v02 中 `hwlab-device-pod` 微服务的部署和接口实现。逻辑 device-pod、profile authority、grant 和 job 模型的权威规格见 [spec-device-pod.md](spec-device-pod.md)。 +本文描述 v02 中 `hwlab-device-pod` 微服务的部署和接口实现。逻辑 device-pod、profile authority、OpenFGA relation 和 job 模型的权威规格见 [spec-device-pod.md](spec-device-pod.md)。 ## 在系统中的职责划分 - 承接 `cloud-api -> hwlab-device-pod -> gateway/device-host-cli` 的内部执行服务位置。 -- 当前阶段只暴露 device-pod executor 边界;用户、profile、grant、用户 API key 和 job authority 都在 `hwlab-cloud-api`,不能由该 Service 伪造或兜底。 -- 普通用户和 Code Agent 不应直接调用该 Service;正式路径必须经过 `hwlab-cloud-api` 鉴权、grant/API key 和 mutating job reason 校验。 +- 当前阶段只暴露 device-pod executor 边界;用户、profile、OpenFGA relation、用户 API key 和 job authority 都在 `hwlab-cloud-api`,不能由该 Service 伪造或兜底。 +- 普通用户和 Code Agent 不应直接调用该 Service;正式路径必须经过 `hwlab-cloud-api` 鉴权、OpenFGA relation/API key 和 mutating job reason 校验。 ## 内部架构 @@ -28,7 +28,7 @@ | `POST /v1/device-pods/{devicePodId}/jobs` | 只接受带内部 token 的 `hwlab-cloud-api` 调用;创建内部 executor job 并返回 job、freshness、output/cancel URL;有可用 profile route 和 `HWLAB_CLOUD_API_INTERNAL_URL` 时通过 cloud-api gateway dispatch 下发到 device-host-cli,否则返回 `gateway_dispatch_unavailable`。 | | `GET /v1/device-pods/{devicePodId}/jobs/{jobId}`、`GET /output`、`POST /cancel` | 只接受带内部 token 的 `hwlab-cloud-api` 调用,用于查询内部 job、最大 12000 bytes 的 bounded output 和取消非终态 job;普通用户仍必须走 cloud-api 用户态 API。 | -用户态 `POST /jobs`、job output/cancel、admin profile/grant API 和正式 `device-pod-cli` REST 调用由 `hwlab-cloud-api` 实现,应以 [spec-device-pod.md](spec-device-pod.md) 为目标。`hwlab-device-pod` 不接受 CLI、浏览器或 Code Agent 直接上传 profile snapshot。 +用户态 `POST /jobs`、job output/cancel、admin profile/API key/OpenFGA relation API 和正式 `device-pod-cli` REST 调用由 `hwlab-cloud-api` 实现,应以 [spec-device-pod.md](spec-device-pod.md) 为目标。`hwlab-device-pod` 不接受 CLI、浏览器或 Code Agent 直接上传 profile snapshot。 ## 测试规格 diff --git a/docs/reference/spec-v02-openfga-authorization.md b/docs/reference/spec-v02-openfga-authorization.md index 51ffe8a9..a070898e 100644 --- a/docs/reference/spec-v02-openfga-authorization.md +++ b/docs/reference/spec-v02-openfga-authorization.md @@ -11,9 +11,9 @@ | 组件 | 职责 | | --- | --- | | Keycloak | 身份认证、注册、OIDC issuer;不直接授予 HWLAB 功能权限。 | -| `hwlab-cloud-api` | `AuthPrincipal` 恢复、OpenFGA store/model bootstrap、授权 check、授权写 API、shadow/enforce 策略、AgentRun transient env 过滤和审计摘要。 | +| `hwlab-cloud-api` | `AuthPrincipal` 恢复、OpenFGA store/model bootstrap、授权 check、授权写 API、enforce 策略、AgentRun transient env 过滤和审计摘要。 | | OpenFGA | `hwlab-v02` namespace 内部 PDP 和 relationship tuple store;只接受 cloud-api 调用,不向浏览器、CLI、AgentRun runner 或公网暴露。 | -| HWLAB v0.2 Postgres | 业务对象、用户、session、API key、兼容 grant、OpenFGA store/model 指针和迁移 ledger 的 durable source。 | +| HWLAB v0.2 Postgres | 业务对象、用户、session、API key、access_tuples 摘要、OpenFGA store/model 指针和迁移 ledger 的 durable source。 | | `hwlab-cloud-web` | Admin Access 页面和同源 API proxy;不直接调用 OpenFGA。 | | `hwlab-cli client` | Web 等价非视觉授权管理入口;通过 `19666` Cloud Web 同源 path 调 cloud-api,不直连 OpenFGA。 | | AgentRun v0.1 runner | 只消费 cloud-api 根据用户权限注入的短期工具环境;不持有跨用户共享 device-pod key、GitHub token 或 UniDesk SSH token。 | @@ -112,7 +112,7 @@ authenticate -> AuthPrincipal -> load domain object -> openfga check -> reason c | 配置 | 说明 | | --- | --- | -| `HWLAB_OPENFGA_MODE=off|shadow|enforce` | `off` 只用现有 DB/role 逻辑;`shadow` 同时 check 并记录 mismatch;`enforce` 以 OpenFGA 为准。 | +| `HWLAB_OPENFGA_MODE=enforce` | v0.2 正式运行面固定以 OpenFGA 为准;OpenFGA 不可达或写入失败时 fail closed 并返回结构化 blocker。 | | `HWLAB_OPENFGA_API_URL` | 集群内 OpenFGA Service URL。 | | `HWLAB_OPENFGA_AUTHN_TOKEN` 或 SecretRef | cloud-api 调 OpenFGA 的内部 token。 | | `HWLAB_OPENFGA_STORE_ID` / `MODEL_ID` | 可由 env 注入,也可由 cloud-api bootstrap 后写入 Postgres runtime config。 | @@ -120,9 +120,8 @@ authenticate -> AuthPrincipal -> load domain object -> openfga check -> reason c 模式语义: -- `off` 只允许本地开发或故障隔离;运行面不得把 `off` 称作细粒度授权已通过。 -- `shadow` 是导入和观测阶段默认模式:业务仍按现有 DB/role/grant 判定,但每个关键 API 输出或内部 trace 记录 OpenFGA decision、modelId、mismatch 和 fallback reason。 -- `enforce` 是目标模式:device pod、agent session 和工具能力以 OpenFGA check 为准。OpenFGA 不可达、store/model 未就绪或 check 超时时,高风险写操作 fail closed;低风险只读可以返回 degraded blocker,不得静默放行。 +- `enforce` 是唯一正式运行模式:device pod、agent session 和工具能力以 OpenFGA check 为准。OpenFGA 不可达、store/model 未就绪或 check 超时时,高风险写操作 fail closed;低风险只读可以返回 degraded blocker,不得静默放行。 +- `off` / `shadow` 不作为 v0.2 runtime 目标路径;旧文档、测试或 render 如再次把它们作为业务 allow source,应优先删除而不是兼容。 tuple 写入必须由 cloud-api admin API 统一完成,并和 Postgres domain state 保持事务级或可恢复一致: @@ -157,7 +156,7 @@ Cloud Web 新增 ActivityRail 顶层入口 `Access`,只对具备 access manage - 左栏:用户列表、搜索、role/status 筛选、Keycloak 绑定摘要。 - 中栏:选中用户的权限矩阵,按 `Device Pods`、`Agent Sessions`、`Tools`、`Platform` 分组;权限用 checkbox/toggle 表达,危险工具用显式确认。 -- 右栏:effective permission 预览、最近变更、OpenFGA mode/readiness、shadow mismatch 和保存结果。 +- 右栏:effective permission 预览、最近变更、OpenFGA mode/readiness、写入 blocker 和保存结果。 交互规则: @@ -214,7 +213,7 @@ Cloud API 在创建 AgentRun command/runner 时必须按 OpenFGA 决策装配 tr ## T5 -阅读 docs/reference/spec-v02-openfga-authorization.md,然后在 `shadow` 模式下制造一个 legacy grant 与 OpenFGA tuple 不一致的用户,访问 device pod 和 Access summary,确认业务按 legacy 结果执行但 summary/trace 显示 mismatch;切到 `enforce` 后按 OpenFGA 结果执行。 +阅读 docs/reference/spec-v02-openfga-authorization.md,然后在 `enforce` 运行面授予和撤销普通用户的 device pod relation,确认 `/v1/device-pods`、status、job 和 Access summary 都按 OpenFGA 结果执行;OpenFGA 写失败时 Admin Access API 必须返回结构化 5xx blocker,不能更新本地 access matrix。 ## T6 @@ -229,7 +228,7 @@ Cloud API 在创建 AgentRun command/runner 时必须按 OpenFGA 决策装配 tr | 规格项 | 状态 | 说明 | | --- | --- | --- | | OpenFGA 作为 v0.2 内部授权服务 | 目标状态 | 需要 GitOps resource、Postgres backend、migration 和 cloud-api readiness。 | -| Cloud API OpenFGA client/bootstrap/check/write | 目标状态 | 需要 `off/shadow/enforce`、store/model 指针、tuple write 和 structured decision。 | +| Cloud API OpenFGA client/bootstrap/check/write | 目标状态 | 需要 `enforce`、store/model 指针、tuple write 和 structured decision。 | | 细粒度 device pod / session / tool 授权 | 目标状态 | 替代旧“device pod grant 即全权限”口径。 | | Admin Access WebUI | 目标状态 | 新增 admin-only ActivityRail 页面,不直接调用 OpenFGA。 | | 同路径 CLI | 目标状态 | 新增 `client access ...`,走 Cloud Web 同源 path。 | diff --git a/docs/reference/spec-v02-services.md b/docs/reference/spec-v02-services.md index bed30f4b..adaa319d 100644 --- a/docs/reference/spec-v02-services.md +++ b/docs/reference/spec-v02-services.md @@ -6,7 +6,7 @@ 细节权威出处: -- 用户、权限、session 归属和 device pod grant:见 [spec-user-access.md](spec-user-access.md)。 +- 用户、权限、session 归属和 device pod relation:见 [spec-user-access.md](spec-user-access.md)。 - OpenFGA 细粒度授权、Admin Access 管理页和同路径 CLI:见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 - device pod profile authority、REST/job 和 gateway 执行边界:见 [spec-device-pod.md](spec-device-pod.md)。 - `v0.2` branch、namespace、GitOps、FRP、SecretRef 和发布验收:见 [spec-v02-cicd.md](spec-v02-cicd.md)。 @@ -18,7 +18,7 @@ `hwlab-v02` 是独立 runtime namespace,公网只暴露 `19666/19667`。浏览器进入 `hwlab-cloud-web`,API、agent、device 和 gateway 请求收敛到 `hwlab-cloud-api`,内部硬件与 agent 能力由专门服务承接,稳定外部服务只提供数据库、模型桥、provider 通道和 FRP 入口。 -- `hwlab-cloud-api` 是 v0.2 应用层 authority:用户身份、`admin/user`、Code Agent session owner、device pod grant、profile authority 和用户态 REST 都在这里判定。 +- `hwlab-cloud-api` 是 v0.2 应用层 authority:用户身份、`admin/user`、Code Agent session owner、OpenFGA relation、profile authority 和用户态 REST 都在这里判定。 - OpenFGA 是 `hwlab-v02` 内部稳定授权服务,只作为 cloud-api 的 PDP/relationship store;Keycloak、Cloud Web、CLI、AgentRun runner 和普通用户都不能直接调用 OpenFGA。 - `hwlab-cloud-web` 只作为用户入口和 API proxy,不拥有业务 authority;CLI 可以旁路 UI,但不能旁路 `cloud-api` 的授权。 - `hwlab-device-pod` 是正式设备业务承载点;用户态请求必须走 `cloud-api -> hwlab-device-pod -> hwlab-gateway -> device-host-cli -> hardware`。 diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index f406a550..2bca154a 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -999,12 +999,24 @@ test("cloud api access control grants visible device pods and requires device-po }, describe: () => ({ sessions: [] }) }; + const openFgaAuthorizer = createFakeOpenFgaAuthorizer({ mode: "enforce" }); + const accessController = createAccessController({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + gatewayRegistry, + openFgaAuthorizer, + now: () => "2026-05-28T00:00:00.000Z" + }); const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" }, + accessController, gatewayRegistry, now: () => "2026-05-28T00:00:00.000Z" }); @@ -1076,16 +1088,13 @@ test("cloud api access control grants visible device pods and requires device-po assert.equal(emptyList.status, 200); assert.deepEqual(emptyList.body.devicePods, []); - const grant = await postJson(port, "/v1/admin/device-pod-grants", { + const legacyGrant = await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminCookie); - assert.equal(grant.status, 201); - const bobGrant = await postJson(port, "/v1/admin/device-pod-grants", { - devicePodId: "device-pod-71-freq", - userId: bobCreate.body.user.id - }, adminCookie); - assert.equal(bobGrant.status, 201); + assert.equal(legacyGrant.status, 404); + await grantDevicePodAccess(port, adminCookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator", "job_submitter"]); + await grantDevicePodAccess(port, adminCookie, bobCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator", "job_submitter"]); const visible = await getJson(port, "/v1/device-pods", aliceCookie); assert.equal(visible.status, 200); @@ -1181,14 +1190,12 @@ test("cloud api access control grants visible device pods and requires device-po assert.equal(unsupported.status, 400); assert.equal(unsupported.body.error.code, "unsupported_device_job_intent"); - const revoke = await fetch(`http://127.0.0.1:${port}/v1/admin/device-pod-grants/device-pod-71-freq/${bobCreate.body.user.id}`, { + const legacyRevoke = await fetch(`http://127.0.0.1:${port}/v1/admin/device-pod-grants/device-pod-71-freq/${bobCreate.body.user.id}`, { method: "DELETE", headers: { cookie: adminCookie } }); - assert.equal(revoke.status, 200); - const revoked = await revoke.json(); - assert.equal(revoked.devicePodId, "device-pod-71-freq"); - assert.equal(revoked.userId, bobCreate.body.user.id); + assert.equal(legacyRevoke.status, 404); + await revokeDevicePodAccess(port, adminCookie, bobCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator", "job_submitter"]); const afterRevoke = await getJson(port, "/v1/device-pods", bobLogin.cookie); assert.equal(afterRevoke.status, 200); @@ -1199,8 +1206,15 @@ test("cloud api access control grants visible device pods and requires device-po }); test("cloud api admin access API grants, checks, and revokes device-pod and tool permissions", async () => { + const openFgaAuthorizer = createFakeOpenFgaAuthorizer({ mode: "enforce" }); + const accessController = createAccessController({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + openFgaAuthorizer, + now: () => "2026-06-04T00:00:00.000Z" + }); const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_OPENFGA_MODE: "off" }, + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + accessController, now: () => "2026-06-04T00:00:00.000Z" }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -1225,7 +1239,7 @@ test("cloud api admin access API grants, checks, and revokes device-pod and tool const summary = await getJson(port, "/v1/admin/access/summary", adminCookie); assert.equal(summary.status, 200); assert.equal(summary.body.contractVersion, "admin-access-v1"); - assert.equal(summary.body.openfga.mode, "off"); + assert.equal(summary.body.openfga.mode, "enforce"); assert.equal(summary.body.counts.users, 2); assert.equal(summary.body.counts.devicePods, 1); assert.deepEqual(summary.body.supported.devicePodRelations, ["viewer", "operator", "profile_editor", "job_submitter"]); @@ -1251,7 +1265,7 @@ test("cloud api admin access API grants, checks, and revokes device-pod and tool }, adminCookie); assert.equal(checkViewer.status, 200); assert.equal(checkViewer.body.authorization.allowed, true); - assert.equal(checkViewer.body.authorization.mode, "off"); + assert.equal(checkViewer.body.authorization.mode, "enforce"); const aliceLogin = await postJson(port, "/auth/login", { username: "access-alice", password: "alice-pass" }); assert.equal(aliceLogin.status, 200); @@ -2229,14 +2243,21 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe }); await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); const executorPort = executor.address().port; + const accessEnv = { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, + HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` + }; + const accessController = createAccessController({ + env: accessEnv, + openFgaAuthorizer: createFakeOpenFgaAuthorizer({ mode: "enforce" }), + now: () => "2026-05-28T00:00:00.000Z" + }); const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, - HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` - }, + env: accessEnv, + accessController, now: () => "2026-05-28T00:00:00.000Z" }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -2249,7 +2270,7 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe devicePodId: "device-pod-71-freq", profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie); + await grantDevicePodAccess(port, adminLogin.cookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator"]); const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie); @@ -2322,14 +2343,21 @@ test("cloud api waits longer for synchronous device-pod build verify output (HWL }); await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); const executorPort = executor.address().port; + const accessEnv = { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, + HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` + }; + const accessController = createAccessController({ + env: accessEnv, + openFgaAuthorizer: createFakeOpenFgaAuthorizer({ mode: "enforce" }), + now: () => "2026-05-28T00:00:00.000Z" + }); const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, - HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` - }, + env: accessEnv, + accessController, now: () => "2026-05-28T00:00:00.000Z" }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -2342,7 +2370,7 @@ test("cloud api waits longer for synchronous device-pod build verify output (HWL devicePodId: "device-pod-71-freq", profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie); + await grantDevicePodAccess(port, adminLogin.cookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator"]); const aliceLogin = await postJson(port, "/auth/login", { username: "slow-alice", password: "alice-pass" }); const verifyJob = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.evidence", args: { kind: "verify", expected: "axf,hex" } }, aliceLogin.cookie); @@ -2376,14 +2404,21 @@ test("cloud api bounds device-pod job output payloads", async () => { }); await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); const executorPort = executor.address().port; + const accessEnv = { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, + HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` + }; + const accessController = createAccessController({ + env: accessEnv, + openFgaAuthorizer: createFakeOpenFgaAuthorizer({ mode: "enforce" }), + now: () => "2026-05-28T00:00:00.000Z" + }); const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, - HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` - }, + env: accessEnv, + accessController, now: () => "2026-05-28T00:00:00.000Z" }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -2396,7 +2431,7 @@ test("cloud api bounds device-pod job output payloads", async () => { devicePodId: "device-pod-71-freq", profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie); + await grantDevicePodAccess(port, adminLogin.cookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator"]); const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie); @@ -2468,14 +2503,21 @@ test("cloud api routes device-pod probe GET requests through executor jobs", asy }); await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); const executorPort = executor.address().port; + const accessEnv = { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, + HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` + }; + const accessController = createAccessController({ + env: accessEnv, + openFgaAuthorizer: createFakeOpenFgaAuthorizer({ mode: "enforce" }), + now: () => "2026-05-28T00:00:00.000Z" + }); const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, - HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` - }, + env: accessEnv, + accessController, now: () => "2026-05-28T00:00:00.000Z" }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -2488,7 +2530,7 @@ test("cloud api routes device-pod probe GET requests through executor jobs", asy devicePodId: "device-pod-71-freq", profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie); + await grantDevicePodAccess(port, adminLogin.cookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer"]); const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); const chip = await getJson(port, "/v1/device-pods/device-pod-71-freq/debug-probe/chip-id", aliceLogin.cookie); @@ -2716,7 +2758,8 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent", assert.equal(setup.body.setupRequired, false); assert.deepEqual(setup.body.devicePodBootstrap, { requested: 1, initialized: 1 }); assert.equal(setup.body.devicePodsInitialized[0].devicePod.devicePodId, "device-pod-71-freq"); - assert.equal(setup.body.devicePodsInitialized[0].grant.userId, setup.body.actor.id); + assert.equal(setup.body.devicePodsInitialized[0].access.length, 4); + assert.equal(setup.body.devicePodsInitialized[0].access.every((item) => item.ok !== false), true); assert.match(setup.body.devicePodsInitialized[0].devicePod.profileHash, /^sha256:/u); assert.equal(setup.body.devicePodsInitialized[0].devicePod.profile.route.gatewaySessionId, "redacted"); assert.equal(JSON.stringify(setup.body).includes("device-pod-seed-spoof"), false); @@ -2963,6 +3006,20 @@ async function deleteJson(port, path, body = {}, cookie = null, extraHeaders = { }; } +async function grantDevicePodAccess(port, cookie, userId, devicePodId, relations) { + for (const relation of relations) { + const response = await putJson(port, `/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`, {}, cookie); + assert.equal(response.status, 201, `grant ${relation} should use Admin Access API`); + } +} + +async function revokeDevicePodAccess(port, cookie, userId, devicePodId, relations) { + for (const relation of relations) { + const response = await deleteJson(port, `/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`, {}, cookie); + assert.equal(response.status, 200, `revoke ${relation} should use Admin Access API`); + } +} + function createFakeOpenFgaAuthorizer({ mode = "enforce" } = {}) { const tuples = new Set(); function key({ userId, relation, object }) { return `user:${userId}#${relation}@${object}`; } diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index cd852e12..922da81a 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -164,13 +164,6 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, - `CREATE TABLE IF NOT EXISTS device_pod_grants ( - device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_by_admin_id TEXT NOT NULL REFERENCES users(id), - created_at TEXT NOT NULL, - PRIMARY KEY (device_pod_id, user_id) - )`, `CREATE TABLE IF NOT EXISTS device_pod_jobs ( id TEXT PRIMARY KEY, device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, @@ -427,15 +420,13 @@ class AccessController { const devicePodsInitialized = []; for (const seed of devicePodSeeds) { const pod = await this.store.upsertDevicePod({ ...seed, now }); - const grant = await this.store.grantDevicePod({ - devicePodId: pod.id, - userId: user.id, - createdByAdminId: user.id, - now - }); + const access = []; + for (const relation of ["viewer", "operator", "profile_editor", "job_submitter"]) { + access.push(await this.grantDevicePodRelation({ userId: user.id, devicePodId: pod.id, relation, admin: user })); + } devicePodsInitialized.push({ devicePod: publicDevicePod(pod, { includeAdmin: true }), - grant + access }); } const token = randomBytes(32).toString("base64url"); @@ -550,32 +541,6 @@ class AccessController { }); } - if (request.method === "POST" && url.pathname === "/v1/admin/device-pod-grants") { - const body = await jsonBody(request); - const devicePodId = requiredText(body.devicePodId, "devicePodId"); - const userId = requiredText(body.userId, "userId"); - await this.requireGrantTargets({ devicePodId, userId }); - const grant = await this.store.grantDevicePod({ - devicePodId, - userId, - createdByAdminId: auth.actor.id, - now: this.now() - }); - await this.grantDevicePodRelation({ userId, devicePodId, relation: "viewer", admin: auth.actor, mirrorLegacy: false }); - await this.grantDevicePodRelation({ userId, devicePodId, relation: "operator", admin: auth.actor, mirrorLegacy: false }); - await this.grantDevicePodRelation({ userId, devicePodId, relation: "job_submitter", admin: auth.actor, mirrorLegacy: false }); - return sendJson(response, 201, { granted: true, grant }); - } - - if (request.method === "DELETE" && url.pathname.startsWith("/v1/admin/device-pod-grants/")) { - const [, , , devicePodId, userId] = url.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part)); - await this.store.revokeDevicePodGrant({ devicePodId: requiredText(devicePodId, "devicePodId"), userId: requiredText(userId, "userId") }); - await this.revokeDevicePodRelation({ userId, devicePodId, relation: "viewer" }); - await this.revokeDevicePodRelation({ userId, devicePodId, relation: "operator" }); - await this.revokeDevicePodRelation({ userId, devicePodId, relation: "job_submitter" }); - return sendJson(response, 200, { revoked: true, devicePodId, userId }); - } - sendJson(response, 404, errorPayload("not_found", "Admin route is not implemented", 404)); } catch (error) { sendAccessError(response, error); @@ -629,6 +594,7 @@ class AccessController { const result = request.method === "PUT" ? await this.grantDevicePodRelation({ userId, devicePodId, relation, admin: auth.actor }) : await this.revokeDevicePodRelation({ userId, devicePodId, relation }); + if (result.ok === false) return sendJson(response, result.status ?? 503, accessWriteErrorPayload(result)); const user = await this.store.getUserById(userId); return sendJson(response, request.method === "PUT" ? 201 : 200, { ok: result.ok !== false, changed: true, result, access: user ? await this.userAccessMatrixPayload(user, auth.actor) : null }); } @@ -643,6 +609,7 @@ class AccessController { const result = request.method === "PUT" ? await this.grantAccessTuple({ userId, relation: "can_use", object, admin: auth.actor }) : await this.revokeAccessTuple({ userId, relation: "can_use", object }); + if (result.ok === false) return sendJson(response, result.status ?? 503, accessWriteErrorPayload(result)); return sendJson(response, request.method === "PUT" ? 201 : 200, { ok: result.ok !== false, changed: true, result, access: await this.userAccessMatrixPayload(user, auth.actor) }); } if (request.method === "POST" && url.pathname === "/v1/admin/access/check") { @@ -652,7 +619,7 @@ class AccessController { const object = requiredText(body.object, "object"); const user = await this.store.getUserById(userId); if (!user) return sendJson(response, 404, errorPayload("user_not_found", "User was not found", 404)); - const legacyAllowed = await this.legacyAllowedForRelation({ user, relation, object }); + const legacyAllowed = this.roleAllowedForRelation({ user, relation, object }); const authorization = await this.openFga.check({ actor: user, relation, object, legacyAllowed }); return sendJson(response, 200, { contractVersion: "admin-access-v1", actor: publicActor(auth.actor), subject: publicActor(user), authorization }); } @@ -723,40 +690,31 @@ class AccessController { for (const user of users.filter((item) => item.role === "admin")) await this.syncUserAdminTuple(user, user); } - async grantDevicePodRelation({ userId, devicePodId, relation, admin, mirrorLegacy = true }) { + async grantDevicePodRelation({ userId, devicePodId, relation, admin }) { const object = openFgaObject("device_pod", devicePodId); - const result = await this.grantAccessTuple({ userId, relation, object, admin }); - if (mirrorLegacy && ["viewer", "operator", "job_submitter"].includes(relation)) { - await this.store.grantDevicePod?.({ devicePodId, userId, createdByAdminId: admin.id, now: this.now() }); - } - return result; + return this.grantAccessTuple({ userId, relation, object, admin }); } async revokeDevicePodRelation({ userId, devicePodId, relation }) { const object = openFgaObject("device_pod", devicePodId); - const result = await this.revokeAccessTuple({ userId, relation, object }); - if (["viewer", "operator", "job_submitter"].includes(relation)) { - const tuples = await this.store.listAccessTuples?.({ userId, object }) ?? []; - const stillHasLegacyEquivalent = tuples.some((tuple) => ["viewer", "operator", "job_submitter"].includes(tuple.relation)); - if (!stillHasLegacyEquivalent) await this.store.revokeDevicePodGrant?.({ devicePodId, userId }); - } - return result; + return this.revokeAccessTuple({ userId, relation, object }); } async grantAccessTuple({ userId, relation, object, admin }) { - await this.store.upsertAccessTuple?.({ userId, relation, object, createdByAdminId: admin?.id ?? userId, now: this.now() }); - return this.openFga.writeTuple({ userId, relation, object }); + const result = await this.openFga.writeTuple({ userId, relation, object }); + if (result.ok !== false) await this.store.upsertAccessTuple?.({ userId, relation, object, createdByAdminId: admin?.id ?? userId, now: this.now() }); + return result; } async revokeAccessTuple({ userId, relation, object }) { - await this.store.deleteAccessTuple?.({ userId, relation, object }); - return this.openFga.deleteTuple({ userId, relation, object }); + const result = await this.openFga.deleteTuple({ userId, relation, object }); + if (result.ok !== false) await this.store.deleteAccessTuple?.({ userId, relation, object }); + return result; } - async legacyAllowedForRelation({ user, relation, object }) { + roleAllowedForRelation({ user, relation, object }) { if (user.role === "admin") return true; const parsed = parseOpenFgaObject(object); - if (parsed?.type === "device_pod") return Boolean(await this.store.hasDevicePodGrant?.({ devicePodId: parsed.id, userId: user.id })); if (parsed?.type === "tool") return false; if (parsed?.type === "system") return user.role === "admin"; return false; @@ -766,7 +724,7 @@ class AccessController { const pods = await this.store.listDevicePods?.() ?? []; const visible = []; for (const pod of pods.filter((item) => item.status === "active")) { - const legacyAllowed = actor.role === "admin" || await this.store.hasDevicePodGrant?.({ devicePodId: pod.id, userId: actor.id }); + const legacyAllowed = actor.role === "admin"; const authorization = await this.openFga.check({ actor, relation: "viewer", object: openFgaObject("device_pod", pod.id), legacyAllowed }); if (authorization.allowed) visible.push(pod); } @@ -1819,7 +1777,7 @@ class AccessController { if (!DEVICE_JOB_INTENTS.has(intent) || MUTATING_INTENTS.has(intent)) { throw Object.assign(new Error(`Device probe intent ${intent} is not supported as a read-only probe`), { statusCode: 400, code: "unsupported_device_probe_intent" }); } - const legacyAllowed = actor.role === "admin" || await this.store.hasDevicePodGrant?.({ devicePodId: pod.id, userId: actor.id }); + const legacyAllowed = actor.role === "admin"; const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation: "viewer", legacyAllowed }); if (!authorization.allowed) { return { @@ -1865,7 +1823,7 @@ class AccessController { }); } const args = normalizeObject(body.args); - const legacyAllowed = actor.role === "admin" || await this.store.hasDevicePodGrant?.({ devicePodId: pod.id, userId: actor.id }); + const legacyAllowed = actor.role === "admin"; const relation = MUTATING_INTENTS.has(intent) ? "job_submitter" : "operator"; const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation, legacyAllowed }); if (!authorization.allowed) { @@ -2122,7 +2080,6 @@ class MemoryAccessStore { this.apiKeys = new Map(); this.oidcStates = new Map(); this.devicePods = new Map(); - this.grants = new Set(); this.jobs = new Map(); this.agentSessions = new Map(); this.workspaces = new Map(); @@ -2207,13 +2164,8 @@ class MemoryAccessStore { this.devicePods.set(pod.id, pod); return pod; } - async grantDevicePod(input) { const grant = { devicePodId: input.devicePodId, userId: input.userId, createdByAdminId: input.createdByAdminId, createdAt: input.now ?? this.now() }; this.grants.add(grantKey(grant.devicePodId, grant.userId)); return grant; } - async revokeDevicePodGrant(input) { this.grants.delete(grantKey(input.devicePodId, input.userId)); } async getDevicePod(id) { return this.devicePods.get(id) ?? null; } async listDevicePods() { return [...this.devicePods.values()].sort((a, b) => String(a.id).localeCompare(String(b.id))); } - async hasDevicePodGrant(input = {}) { return this.grants.has(grantKey(input.devicePodId, input.userId)); } - async listVisibleDevicePods(actor) { return [...this.devicePods.values()].filter((pod) => pod.status === "active" && (actor.role === "admin" || this.grants.has(grantKey(pod.id, actor.id)))); } - async getVisibleDevicePod(actor, id) { return (await this.listVisibleDevicePods(actor)).find((pod) => pod.id === id) ?? null; } async getAccessConfig(key) { return this.accessConfig.get(textOr(key, "")) ?? ""; } async setAccessConfig(input = {}) { this.accessConfig.set(textOr(input.key, ""), textOr(input.value, "")); return { key: input.key, value: input.value, updatedAt: input.updatedAt ?? this.now() }; } async upsertAccessTuple(input = {}) { @@ -2368,12 +2320,8 @@ class PostgresAccessStore extends MemoryAccessStore { async createUserOidc(input) { await this.ensureSchema(); const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, auth_provider = EXCLUDED.auth_provider, keycloak_issuer = EXCLUDED.keycloak_issuer, keycloak_sub = EXCLUDED.keycloak_sub, email = EXCLUDED.email, last_login_at = EXCLUDED.last_login_at, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [id, input.username, input.displayName ?? input.username, input.role ?? "user", input.status ?? "active", null, "keycloak", input.keycloakIssuer, input.keycloakSub, input.email ?? null, now, now, now]); return pgUserWithOIDC(result.rows?.[0]); } async updateUserOidcLogin(input) { await this.ensureSchema(); const result = await this.query("UPDATE users SET email = COALESCE($2, email), last_login_at = $3, updated_at = $3 WHERE id = $1 RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [input.userId, input.email ?? null, input.lastLoginAt]); return pgUserWithOIDC(result.rows?.[0]); } async upsertDevicePod(input) { await this.ensureSchema(); const pod = await super.upsertDevicePod(input); const result = await this.query("INSERT INTO device_pods (id, name, status, profile_json, profile_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, status = EXCLUDED.status, profile_json = EXCLUDED.profile_json, profile_hash = EXCLUDED.profile_hash, updated_at = EXCLUDED.updated_at RETURNING *", [pod.id, pod.name, pod.status, stableJson(pod.profile), pod.profileHash, pod.createdAt, pod.updatedAt]); return pgDevicePod(result.rows?.[0]); } - async grantDevicePod(input) { await this.ensureSchema(); const grant = await super.grantDevicePod(input); await this.query("INSERT INTO device_pod_grants (device_pod_id, user_id, created_by_admin_id, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (device_pod_id, user_id) DO UPDATE SET created_by_admin_id = EXCLUDED.created_by_admin_id, created_at = EXCLUDED.created_at", [grant.devicePodId, grant.userId, grant.createdByAdminId, grant.createdAt]); return grant; } - async revokeDevicePodGrant(input) { await this.ensureSchema(); await this.query("DELETE FROM device_pod_grants WHERE device_pod_id = $1 AND user_id = $2", [input.devicePodId, input.userId]); } async getDevicePod(id) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pods WHERE id = $1 LIMIT 1", [id]); return pgDevicePod(result.rows?.[0]); } async listDevicePods() { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pods ORDER BY id", []); return result.rows.map(pgDevicePod); } - async hasDevicePodGrant(input = {}) { await this.ensureSchema(); const result = await this.query("SELECT 1 FROM device_pod_grants WHERE device_pod_id = $1 AND user_id = $2 LIMIT 1", [input.devicePodId, input.userId]); return Boolean(result.rows?.[0]); } - async listVisibleDevicePods(actor) { await this.ensureSchema(); const sql = actor.role === "admin" ? "SELECT * FROM device_pods WHERE status = 'active' ORDER BY id" : "SELECT p.* FROM device_pods p JOIN device_pod_grants g ON g.device_pod_id = p.id WHERE p.status = 'active' AND g.user_id = $1 ORDER BY p.id"; const result = await this.query(sql, actor.role === "admin" ? [] : [actor.id]); return result.rows.map(pgDevicePod); } async getAccessConfig(key) { await this.ensureSchema(); const result = await this.query("SELECT value FROM access_config WHERE key = $1 LIMIT 1", [key]); return textOr(result.rows?.[0]?.value, ""); } async setAccessConfig(input = {}) { await this.ensureSchema(); await this.query("INSERT INTO access_config (key, value, updated_at) VALUES ($1,$2,$3) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at", [input.key, input.value, input.updatedAt ?? this.now()]); return { key: input.key, value: input.value, updatedAt: input.updatedAt ?? this.now() }; } async upsertAccessTuple(input = {}) { await this.ensureSchema(); const tuple = { userId: input.userId, relation: input.relation, object: input.object, createdByAdminId: input.createdByAdminId, createdAt: input.now ?? this.now() }; await this.query("INSERT INTO access_tuples (user_id, relation, object, created_by_admin_id, created_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (user_id, relation, object) DO UPDATE SET created_by_admin_id = EXCLUDED.created_by_admin_id, created_at = EXCLUDED.created_at", [tuple.userId, tuple.relation, tuple.object, tuple.createdByAdminId, tuple.createdAt]); return tuple; } @@ -2583,9 +2531,8 @@ function constantSecretEquals(a, b) { const left = sha256(a); const right = sha2 function stableJson(value) { return JSON.stringify(sortJson(value)); } function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); return value; } function profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`; } -function grantKey(devicePodId, userId) { return `${devicePodId}\u0000${userId}`; } function accessTupleKey(tuple = {}) { return `${tuple.userId}\u0000${tuple.relation}\u0000${tuple.object}`; } -function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, and job authority are owned by hwlab-cloud-api; this is not fake data." }; } +function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, OpenFGA relation, and job authority are owned by hwlab-cloud-api; this is not fake data." }; } function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", firstAdminSetup: "/v1/setup/first-admin", devicePods: "/v1/device-pods" }; } function normalizeToolId(value) { return textOr(value, "").replace(/-/gu, "_"); } function _deviceJobRequiresReason(intent, args, reason) { @@ -3259,6 +3206,12 @@ function shouldUseSecureSessionCookie(request, env = process.env) { return false; } function errorPayload(code, message, status) { return { ok: false, status, error: { code, message } }; } +function accessWriteErrorPayload(result = {}) { + const error = result.error ?? {}; + const code = textOr(error.code, "openfga_write_failed"); + const message = textOr(error.message, "OpenFGA authorization write failed; permission cache was not updated"); + return { ...errorPayload(code, message, result.status ?? 503), result }; +} function sendAccessError(response, error) { const status = error?.statusCode ?? 500; sendJson(response, status, errorPayload(error?.code ?? "access_control_error", error?.message ?? "Access control request failed", status)); } function pgUser(row) { return row ? { id: row.id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.created_at, updatedAt: row.updated_at } : null; } function pgUserWithOIDC(row) { const user = pgUser(row); if (!user) return null; return { ...user, authProvider: textOr(row.auth_provider, "local"), keycloakIssuer: textOr(row.keycloak_issuer, "") || null, keycloakSub: textOr(row.keycloak_sub, "") || null, email: textOr(row.email, "") || null, lastLoginAt: textOr(row.last_login_at, "") || null }; } diff --git a/internal/cloud/openfga-authorization.test.ts b/internal/cloud/openfga-authorization.test.ts new file mode 100644 index 00000000..d0d501f7 --- /dev/null +++ b/internal/cloud/openfga-authorization.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { createOpenFgaAuthorizer } from "./openfga-authorization.ts"; + +test("OpenFGA shadow mode is normalized to enforce and does not allow legacy fallback", async () => { + const calls: Array<{ path: string; method: string }> = []; + const authorizer = createOpenFgaAuthorizer({ + env: { HWLAB_OPENFGA_MODE: "shadow", HWLAB_OPENFGA_API_URL: "http://openfga.test" }, + fetchImpl: async (url: string | URL, init: RequestInit = {}) => { + const parsed = new URL(String(url)); + calls.push({ path: parsed.pathname, method: init.method ?? "GET" }); + if (parsed.pathname === "/healthz") return jsonResponse({}, 200); + if (parsed.pathname === "/stores") return jsonResponse({ id: "store-shadow" }, 201); + if (parsed.pathname === "/stores/store-shadow/authorization-models") return jsonResponse({ authorization_model_id: "model-shadow" }, 201); + if (parsed.pathname === "/stores/store-shadow/check") return jsonResponse({ allowed: false }, 200); + return jsonResponse({ error: "unexpected path" }, 404); + } + }); + + const decision = await authorizer.check({ + actor: { id: "usr_shadow", role: "user" }, + relation: "viewer", + object: "device_pod:device-pod-71-freq", + legacyAllowed: true + }); + + assert.equal(decision.mode, "enforce"); + assert.equal(decision.allowed, false); + assert.equal(decision.fgaAllowed, false); + assert.equal(decision.decisionSource, "openfga"); + assert.deepEqual(calls.map((call) => call.path), [ + "/healthz", + "/stores", + "/stores/store-shadow/authorization-models", + "/stores/store-shadow/check" + ]); +}); + +function jsonResponse(body: unknown, status: number) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} diff --git a/internal/cloud/openfga-authorization.ts b/internal/cloud/openfga-authorization.ts index e0c555b2..b5ce66a7 100644 --- a/internal/cloud/openfga-authorization.ts +++ b/internal/cloud/openfga-authorization.ts @@ -1,7 +1,7 @@ const OPENFGA_MODEL_NAME = "hwlab-v02-openfga-authorization-v1"; const OPENFGA_CONTRACT_VERSION = "hwlab-openfga-authorization-v1"; const DEFAULT_TIMEOUT_MS = 1200; -const SUPPORTED_MODES = new Set(["off", "shadow", "enforce"]); +const SUPPORTED_MODES = new Set(["off", "enforce"]); export const HWLAB_TOOL_IDS = Object.freeze(["hwpod", "unidesk_ssh", "trans_cmd", "github_pr"]); export const DEVICE_POD_RELATIONS = Object.freeze(["viewer", "operator", "profile_editor", "job_submitter"]); @@ -71,9 +71,9 @@ export class OpenFgaAuthorizer { if (!readiness.ready) { return decision({ mode: this.mode, - allowed: this.mode === "shadow" ? Boolean(legacyAllowed) : false, + allowed: false, fgaAllowed: null, - decisionSource: this.mode === "shadow" ? "legacy-openfga-degraded" : "openfga-degraded", + decisionSource: "openfga-degraded", relation: rel, object: obj, userId: actorId, @@ -82,21 +82,6 @@ export class OpenFgaAuthorizer { } const fga = await this.checkOpenFga({ user: openFgaUser(actorId), relation: rel, object: obj, readiness }); - if (this.mode === "shadow") { - return decision({ - mode: this.mode, - allowed: Boolean(legacyAllowed), - fgaAllowed: fga.allowed, - mismatch: Boolean(legacyAllowed) !== Boolean(fga.allowed), - decisionSource: "legacy-shadow", - relation: rel, - object: obj, - userId: actorId, - storeId: readiness.storeId, - modelId: readiness.modelId, - degradedReason: fga.error ?? null - }); - } return decision({ mode: this.mode, allowed: Boolean(fga.allowed), @@ -271,6 +256,7 @@ function sanitizeTuple(tuple = {}) { function normalizeMode(value) { const mode = textOr(value, "off").toLowerCase(); + if (mode === "shadow") return "enforce"; return SUPPORTED_MODES.has(mode) ? mode : "off"; } diff --git a/internal/db/runtime-store.test.ts b/internal/db/runtime-store.test.ts index b34fd005..de9867d5 100644 --- a/internal/db/runtime-store.test.ts +++ b/internal/db/runtime-store.test.ts @@ -560,7 +560,7 @@ test("configured postgres runtime persists and queries records through query cli projectId: "prj_01J00000000000000000001000", gatewaySessionId: "gws_01J00000000000000000001000", gatewayId: "gtw_01J00000000000000000001000", - serviceId: "hwlab-gateway-simu", + serviceId: "hwlab-gateway", endpoint: "http://127.0.0.1:7101" }); await rpc("req_01J00000000000000000001002", "box.resource.register", { diff --git a/internal/db/runtime-store.ts b/internal/db/runtime-store.ts index 5bf5c14a..01003c1e 100644 --- a/internal/db/runtime-store.ts +++ b/internal/db/runtime-store.ts @@ -47,7 +47,6 @@ const postgresCountTables = Object.freeze([ "agent_sessions", "account_workspaces", "device_pods", - "device_pod_grants", "device_leases", "device_pod_jobs" ]); @@ -125,7 +124,7 @@ export class CloudRuntimeStore { const gatewaySession = pruneUndefined({ gatewaySessionId: input.gatewaySessionId || makeId("gws"), projectId: requireProtocolId(input, "projectId"), - serviceId: input.serviceId || "hwlab-gateway-simu", + serviceId: input.serviceId || "hwlab-gateway", gatewayId: input.gatewayId || makeId("gtw"), endpoint: input.endpoint, status: input.status || "connected", @@ -1768,7 +1767,6 @@ function toCountKey(table) { if (table === "agent_sessions") return "agentSessions"; if (table === "account_workspaces") return "accountWorkspaces"; if (table === "device_pods") return "devicePods"; - if (table === "device_pod_grants") return "devicePodGrants"; if (table === "device_leases") return "deviceLeases"; if (table === "device_pod_jobs") return "devicePodJobs"; return table; diff --git a/internal/db/schema.ts b/internal/db/schema.ts index 5e25a693..5bbc069a 100644 --- a/internal/db/schema.ts +++ b/internal/db/schema.ts @@ -130,12 +130,6 @@ export const CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS = Object.freeze({ "created_at", "updated_at" ]), - device_pod_grants: Object.freeze([ - "device_pod_id", - "user_id", - "created_by_admin_id", - "created_at" - ]), device_leases: Object.freeze([ "device_pod_id", "holder_session_id", diff --git a/internal/protocol/index.mjs b/internal/protocol/index.mjs index 5dba6298..cbb4b548 100644 --- a/internal/protocol/index.mjs +++ b/internal/protocol/index.mjs @@ -31,7 +31,6 @@ export const TABLES = Object.freeze([ "agent_trace_events", "evidence_records", "device_pods", - "device_pod_grants", "device_leases", "device_pod_jobs" ]); diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 5b7f2468..dc53b627 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -976,7 +976,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_USERNAME", "admin"); upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME", "HWLAB v0.2 Admin"); upsertEnvEntry(container.env, deployEnvEntry("HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH", "secretRef:hwlab-v02-bootstrap-admin/password-hash", namespace, profile)); - upsertEnv(container.env, "HWLAB_OPENFGA_MODE", "shadow"); + upsertEnv(container.env, "HWLAB_OPENFGA_MODE", "enforce"); upsertEnv(container.env, "HWLAB_OPENFGA_API_URL", "http://hwlab-openfga.hwlab-v02.svc.cluster.local:8080"); upsertEnvEntry(container.env, deployEnvEntry("HWLAB_OPENFGA_AUTHN_TOKEN", "secretRef:hwlab-v02-openfga/authn-preshared-key", namespace, profile)); upsertEnv(container.env, "HWLAB_KEYCLOAK_ISSUER", "https://auth.74-48-78-17.nip.io/realms/hwlab"); diff --git a/scripts/g14-gitops-render.test.ts b/scripts/g14-gitops-render.test.ts index 0fbaa6c5..676b0165 100644 --- a/scripts/g14-gitops-render.test.ts +++ b/scripts/g14-gitops-render.test.ts @@ -301,7 +301,7 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_KEYCLOAK_ISSUER" && entry.value === "https://auth.74-48-78-17.nip.io/realms/hwlab")); assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_KEYCLOAK_CLIENT_ID" && entry.value === "hwlab-cloud-web")); assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_KEYCLOAK_CLIENT_SECRET" && entry.valueFrom?.secretKeyRef?.name === "hwlab-cloud-web-client" && entry.valueFrom?.secretKeyRef?.key === "client-secret")); - assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_OPENFGA_MODE" && entry.value === "shadow")); + assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_OPENFGA_MODE" && entry.value === "enforce")); assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_OPENFGA_API_URL" && entry.value === "http://hwlab-openfga.hwlab-v02.svc.cluster.local:8080")); assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_OPENFGA_AUTHN_TOKEN" && entry.valueFrom?.secretKeyRef?.name === "hwlab-v02-openfga" && entry.valueFrom?.secretKeyRef?.key === "authn-preshared-key")); assert.equal(workloadsJson.items.some((item) => item.metadata?.labels?.["hwlab.pikastech.local/service-id"] === "hwlab-agent-worker"), false); diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index ff56fcff..9d8f71e8 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -66,7 +66,7 @@ test("device-pod-cli first-admin setup can seed a cloud-api device pod without l assert.deepEqual(seen[0].body.devicePod, { devicePodId: "device-pod-71-freq", name: "71-FREQ", profile }); }); -test("device-pod-cli admin wrappers call cloud-api profile and grant routes", async () => { +test("device-pod-cli admin wrapper calls only cloud-api profile routes", async () => { const seen: any[] = []; const profile = { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_admin_seed" } }; const fetchImpl = async (url, init) => { @@ -100,9 +100,9 @@ test("device-pod-cli admin wrappers call cloud-api profile and grant routes", as "admin", "grant" ], { env: adminEnv, fetchImpl, now: () => "2026-05-29T00:00:00.000Z" }); - assert.equal(grant.exitCode, 0); - assert.equal(seen[1].url, "http://cloud.test/v1/admin/device-pod-grants"); - assert.deepEqual(seen[1].body, { devicePodId: "device-pod-71-freq", userId: "usr_alice" }); + assert.equal(grant.exitCode, 1); + assert.equal(grant.payload.error.code, "unsupported_admin_command"); + assert.equal(seen.length, 1); }); test("device-pod-cli lists cloud-api authority and ignores local profile routes", async () => { diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index d821a0e2..60323ebd 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -76,25 +76,26 @@ test("hwlab-cli client access uses Cloud Web admin access routes", async () => { }); const calls: any[] = []; - const grant = await runHwlabCli(["client", "access", "grant", "usr_alice", "device-pod", "device-pod-71-freq", "viewer", "--base-url", "http://web.test"], { + const grant = await runHwlabCli(["client", "access", "device-pods", "grant", "usr_alice", "device-pod-71-freq", "--relation", "viewer", "--base-url", "http://web.test"], { cwd, fetchImpl: async (url, init) => { calls.push({ url: String(url), init, body: String(init?.body ?? "") }); - return new Response(JSON.stringify({ ok: true, changed: true, access: { user: { id: "usr_alice" }, devicePods: [], tools: {}, tuples: [] } }), { status: 201 }); + return new Response(JSON.stringify({ ok: true, changed: true, access: { user: { id: "usr_alice" }, devicePods: [], tools: {}, tuples: [], openfga: { mode: "enforce", ready: true } } }), { status: 201 }); } }); assert.equal(grant.exitCode, 0); - assert.equal(grant.payload.action, "client.access.grant"); + assert.equal(grant.payload.action, "client.access.device-pods.grant"); assert.equal(calls[0].url, "http://web.test/v1/admin/access/users/usr_alice/device-pods/device-pod-71-freq/viewer"); assert.equal(calls[0].init.method, "PUT"); assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-admin"); assert.equal(grant.payload.route.path, "/v1/admin/access/users/usr_alice/device-pods/device-pod-71-freq/viewer"); + assert.equal(grant.payload.body.openfga.mode, "enforce"); - const user = await runHwlabCli(["client", "access", "user", "usr_alice", "--base-url", "http://web.test"], { + const user = await runHwlabCli(["client", "access", "users", "inspect", "usr_alice", "--base-url", "http://web.test"], { cwd, fetchImpl: async (url, init) => { calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ contractVersion: "admin-access-v1", user: { id: "usr_alice", username: "alice" }, devicePods: [], tools: {}, tuples: [] }), { status: 200 }); + return new Response(JSON.stringify({ contractVersion: "admin-access-v1", user: { id: "usr_alice", username: "alice" }, devicePods: [], tools: {}, tuples: [], openfga: { mode: "enforce", ready: true } }), { status: 200 }); } }); assert.equal(user.exitCode, 0); @@ -102,6 +103,10 @@ test("hwlab-cli client access uses Cloud Web admin access routes", async () => { assert.equal(calls[1].init.method, "GET"); assert.equal(user.payload.route.path, "/v1/admin/access/users/usr_alice"); assert.equal(user.payload.body.user.username, "alice"); + + const legacy = await runHwlabCli(["client", "access", "grant", "usr_alice", "device-pod", "device-pod-71-freq", "viewer", "--base-url", "http://web.test"], { cwd }); + assert.equal(legacy.exitCode, 1); + assert.equal(legacy.payload.error.code, "unsupported_access_command"); }); test("hwlab-cli client request reports auth username from the selected session", async () => { diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index be80cde8..c4fde126 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -107,7 +107,6 @@ async function setupCommand({ parsed, rest, env, fetchImpl }: any) { async function adminCommand(context: any) { const subcommand = context.rest[0] || "help"; if (subcommand === "device-pod" || subcommand === "profile") return adminDevicePodCommand({ ...context, rest: context.rest.slice(1) }); - if (subcommand === "grant" || subcommand === "device-pod-grant") return adminGrantCommand({ ...context, rest: context.rest.slice(1) }); throw cliError("unsupported_admin_command", `unsupported admin command: ${subcommand}`); } @@ -121,14 +120,6 @@ async function adminDevicePodCommand({ parsed, rest, env, fetchImpl }: any) { return responsePayload(`admin.device-pod.${subcommand}`, response, { route: { method, path }, devicePodId: devicePod.devicePodId, localProfileAuthority: false }); } -async function adminGrantCommand({ parsed, rest, env, fetchImpl }: any) { - const podId = requiredText(parsed.podId ?? parsed.devicePodId ?? rest[0], "podId"); - const userId = requiredText(parsed.userId ?? rest[1], "userId"); - const body = { devicePodId: podId, userId }; - const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/v1/admin/device-pod-grants", body }); - return responsePayload("admin.device-pod-grant", response, { devicePodId: podId, userId }); -} - async function login({ parsed, env, fetchImpl }: any) { const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/auth/login", body: { username: requiredOption(parsed, env, "username", "HWLAB_USERNAME"), password: requiredOption(parsed, env, "password", "HWLAB_PASSWORD") }, auth: false }); return responsePayload("login", response, { setCookie: response.headers.get("set-cookie") ?? null }); @@ -166,7 +157,7 @@ async function bootsharpCommand(context: any) { async function profileCommand({ parsed, rest, env, fetchImpl }: any) { const subcommand = rest[0] || "list"; if (subcommand === "create") { - throw cliError("legacy_profile_create_removed", "local device-pod profile creation is removed from v0.2 formal mode; use cloud-api admin device-pod APIs", { next: ["POST /v1/admin/device-pods", "POST /v1/admin/device-pod-grants"] }); + throw cliError("legacy_profile_create_removed", "local device-pod profile creation is removed from v0.2 formal mode; use cloud-api admin device-pod and Admin Access APIs", { next: ["POST /v1/admin/device-pods", "PUT /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}"] }); } if (subcommand === "list") { const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path: "/v1/device-pods" }); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 49cfbefe..10d81052 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -117,10 +117,14 @@ function help() { "hwlab-cli client auth session", "hwlab-cli client auth profiles", "hwlab-cli client access summary", - "hwlab-cli client access users", - "hwlab-cli client access user USER_ID", - "hwlab-cli client access grant USER_ID device-pod POD_ID viewer", - "hwlab-cli client access revoke USER_ID tool hwpod", + "hwlab-cli client access users list", + "hwlab-cli client access users inspect USER_ID", + "hwlab-cli client access users set-role USER_ID --role admin|user [--status active|disabled]", + "hwlab-cli client access device-pods grant USER_ID POD_ID --relation viewer", + "hwlab-cli client access device-pods revoke USER_ID POD_ID --relation viewer", + "hwlab-cli client access tools grant USER_ID hwpod", + "hwlab-cli client access tools revoke USER_ID hwpod", + "hwlab-cli client access check --user USER_ID --relation viewer --object device_pod:POD_ID", "hwlab-cli client device-pods list", "hwlab-cli client device-pods status device-pod-71-freq", "hwlab-cli client runtime routes", @@ -212,30 +216,77 @@ async function accessCommand(context: any) { if (subcommand === "summary") { const pathName = "/v1/admin/access/summary"; const response = await requestJson({ ...context, method: "GET", path: pathName }); - return responsePayload("client.access.summary", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) }); + return responsePayload("client.access.summary", response, context, { route: route("GET", pathName), body: adminAccessBodyForCli(response.body, context.parsed) }); } if (subcommand === "users") { + return accessUsersCommand({ ...context, rest: context.rest.slice(1) }); + } + if (subcommand === "device-pods" || subcommand === "device-pod") { + return accessDevicePodsCommand({ ...context, rest: context.rest.slice(1) }); + } + if (subcommand === "tools") { + return accessToolsCommand({ ...context, rest: context.rest.slice(1) }); + } + if (subcommand === "check") { + return accessCheckCommand({ ...context, rest: context.rest.slice(1) }); + } + throw cliError("unsupported_access_command", `unsupported access command: ${subcommand}`, { subcommand }); +} + +async function accessUsersCommand(context: any) { + const subcommand = context.rest[0] || "list"; + if (subcommand === "list") { const pathName = "/v1/admin/access/users"; const response = await requestJson({ ...context, method: "GET", path: pathName }); - return responsePayload("client.access.users", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) }); + return responsePayload("client.access.users.list", response, context, { route: route("GET", pathName), body: adminAccessBodyForCli(response.body, context.parsed) }); } - if (subcommand === "user" || subcommand === "show") { + if (subcommand === "inspect") { const userId = requiredText(context.parsed.userId ?? context.rest[1], "userId"); const pathName = `/v1/admin/access/users/${encodeURIComponent(userId)}`; const response = await requestJson({ ...context, method: "GET", path: pathName }); - return responsePayload("client.access.user", response, context, { route: route("GET", pathName), userId, body: responseBodyForCli(response.body, context.parsed) }); + return responsePayload("client.access.users.inspect", response, context, { route: route("GET", pathName), userId, body: adminAccessBodyForCli(response.body, context.parsed) }); } - if (subcommand === "grant" || subcommand === "revoke") { + if (subcommand === "set-role") { const userId = requiredText(context.parsed.userId ?? context.rest[1], "userId"); - const targetKind = normalizeAccessTargetKind(context.parsed.kind ?? context.rest[2]); - const targetId = requiredText(context.parsed.targetId ?? context.rest[3], "targetId"); - const relation = requiredText(context.parsed.relation ?? context.rest[4] ?? (targetKind === "tool" ? "can-use" : ""), "relation"); - const method = subcommand === "grant" ? "PUT" : "DELETE"; - const pathName = accessMutationPath({ userId, targetKind, targetId, relation }); - const response = await requestJson({ ...context, method, path: pathName, body: {} }); - return responsePayload(`client.access.${subcommand}`, response, context, { route: route(method, pathName), userId, targetKind, targetId, relation, body: responseBodyForCli(response.body, context.parsed) }); + const role = normalizeAccessUserRole(context.parsed.role ?? context.rest[2]); + const status = context.parsed.status === undefined ? undefined : normalizeAccessUserStatus(context.parsed.status); + const pathName = `/v1/admin/access/users/${encodeURIComponent(userId)}`; + const response = await requestJson({ ...context, method: "PATCH", path: pathName, body: pruneUndefined({ role, status }) }); + return responsePayload("client.access.users.set-role", response, context, { route: route("PATCH", pathName), userId, role, status, body: adminAccessBodyForCli(response.body, context.parsed) }); } - throw cliError("unsupported_access_command", `unsupported access command: ${subcommand}`, { subcommand }); + throw cliError("unsupported_access_users_command", `unsupported access users command: ${subcommand}`, { subcommand }); +} + +async function accessDevicePodsCommand(context: any) { + const subcommand = context.rest[0] || "help"; + if (subcommand !== "grant" && subcommand !== "revoke") throw cliError("unsupported_access_device_pods_command", `unsupported access device-pods command: ${subcommand}`, { subcommand }); + const userId = requiredText(context.parsed.userId ?? context.rest[1], "userId"); + const devicePodId = requiredText(context.parsed.devicePodId ?? context.parsed.podId ?? context.rest[2], "devicePodId"); + const relation = normalizeDevicePodRelation(context.parsed.relation ?? context.rest[3]); + const method = subcommand === "grant" ? "PUT" : "DELETE"; + const pathName = `/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`; + const response = await requestJson({ ...context, method, path: pathName, body: {} }); + return responsePayload(`client.access.device-pods.${subcommand}`, response, context, { route: route(method, pathName), userId, devicePodId, relation, body: adminAccessBodyForCli(response.body, context.parsed) }); +} + +async function accessToolsCommand(context: any) { + const subcommand = context.rest[0] || "help"; + if (subcommand !== "grant" && subcommand !== "revoke") throw cliError("unsupported_access_tools_command", `unsupported access tools command: ${subcommand}`, { subcommand }); + const userId = requiredText(context.parsed.userId ?? context.rest[1], "userId"); + const toolId = normalizeToolId(context.parsed.toolId ?? context.rest[2]); + const method = subcommand === "grant" ? "PUT" : "DELETE"; + const pathName = `/v1/admin/access/users/${encodeURIComponent(userId)}/tools/${encodeURIComponent(toolId)}/can-use`; + const response = await requestJson({ ...context, method, path: pathName, body: {} }); + return responsePayload(`client.access.tools.${subcommand}`, response, context, { route: route(method, pathName), userId, toolId, relation: "can_use", body: adminAccessBodyForCli(response.body, context.parsed) }); +} + +async function accessCheckCommand(context: any) { + const userId = requiredText(context.parsed.user ?? context.parsed.userId ?? context.parsed.actorId ?? context.rest[0], "userId"); + const relation = requiredText(context.parsed.relation ?? context.rest[1], "relation"); + const object = requiredText(context.parsed.object ?? context.rest[2], "object"); + const pathName = "/v1/admin/access/check"; + const response = await requestJson({ ...context, method: "POST", path: pathName, body: { userId, relation, object } }); + return responsePayload("client.access.check", response, context, { route: route("POST", pathName), userId, relation, object, body: adminAccessBodyForCli(response.body, context.parsed) }); } function accessHelp() { @@ -245,12 +296,14 @@ function accessHelp() { sameOrigin: true, commands: [ "summary", - "users", - "user USER_ID", - "grant USER_ID device-pod POD_ID viewer|operator|profile_editor|job_submitter", - "revoke USER_ID device-pod POD_ID viewer|operator|profile_editor|job_submitter", - "grant USER_ID tool hwpod|unidesk_ssh|trans_cmd|github_pr", - "revoke USER_ID tool hwpod|unidesk_ssh|trans_cmd|github_pr" + "users list", + "users inspect USER_ID", + "users set-role USER_ID --role admin|user [--status active|disabled]", + "device-pods grant USER_ID POD_ID --relation viewer|operator|profile_editor|job_submitter", + "device-pods revoke USER_ID POD_ID --relation viewer|operator|profile_editor|job_submitter", + "tools grant USER_ID hwpod|unidesk_ssh|trans_cmd|github_pr", + "tools revoke USER_ID hwpod|unidesk_ssh|trans_cmd|github_pr", + "check --user USER_ID --relation REL --object TYPE:ID" ], routes: { summary: route("GET", "/v1/admin/access/summary"), @@ -259,22 +312,6 @@ function accessHelp() { }); } -function normalizeAccessTargetKind(value: unknown) { - const kind = text(value).replace(/_/gu, "-"); - if (["device-pod", "device-pods", "pod"].includes(kind)) return "device-pod"; - if (kind === "tool" || kind === "tools") return "tool"; - throw cliError("invalid_access_target_kind", "access target kind must be device-pod or tool", { kind }); -} - -function accessMutationPath({ userId, targetKind, targetId, relation }: { userId: string; targetKind: string; targetId: string; relation: string }) { - if (targetKind === "device-pod") { - const normalizedRelation = normalizeDevicePodRelation(relation); - return `/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(targetId)}/${encodeURIComponent(normalizedRelation)}`; - } - const toolId = normalizeToolId(targetId); - return `/v1/admin/access/users/${encodeURIComponent(userId)}/tools/${encodeURIComponent(toolId)}/can-use`; -} - function normalizeDevicePodRelation(value: unknown) { const relation = text(value).replace(/-/gu, "_"); if (["viewer", "operator", "profile_editor", "job_submitter"].includes(relation)) return relation; @@ -287,6 +324,18 @@ function normalizeToolId(value: unknown) { throw cliError("invalid_tool_id", "tool must be hwpod, unidesk_ssh, trans_cmd, or github_pr", { toolId }); } +function normalizeAccessUserRole(value: unknown) { + const role = text(value); + if (role === "admin" || role === "user") return role; + throw cliError("invalid_access_user_role", "role must be admin or user", { role }); +} + +function normalizeAccessUserStatus(value: unknown) { + const status = text(value); + if (status === "active" || status === "disabled") return status; + throw cliError("invalid_access_user_status", "status must be active or disabled", { status }); +} + async function authStatus(context: any) { const endpoint = runtimeEndpoint(context.parsed, context.env, "web"); const localSession = sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })); @@ -2804,6 +2853,83 @@ function responseBodyForCli(body: any, parsed: ParsedArgs) { return parsed.full === true ? body : compactApiBody(body); } +function adminAccessBodyForCli(body: any, parsed: ParsedArgs) { + if (parsed.full === true) return body; + const access = body?.access && typeof body.access === "object" ? body.access : null; + const source = access ?? body; + const users = Array.isArray(source?.users) ? source.users : []; + const devicePods = Array.isArray(source?.devicePods) ? source.devicePods : []; + const tuples = Array.isArray(source?.tuples) ? source.tuples : []; + const openfga = source?.openfga ?? body?.openfga; + return pruneUndefined({ + actor: compactApiBody(source?.actor ?? body?.actor), + subject: compactApiBody(body?.subject), + user: compactApiBody(source?.user ?? body?.user), + contractVersion: text(source?.contractVersion ?? body?.contractVersion) || undefined, + openfga: openfga ? pruneUndefined({ + mode: text(openfga.mode), + ready: openfga.ready === true, + status: text(openfga.status), + configured: openfga.configured === true, + storeId: text(openfga.storeId), + modelId: text(openfga.modelId), + degradedReason: text(openfga.degradedReason), + valuesRedacted: openfga.valuesRedacted === true + }) : undefined, + counts: source?.counts, + supported: source?.supported, + users: users.length > 0 ? users.map(adminAccessUserSummaryForCli) : undefined, + userCount: users.length || undefined, + devicePods: devicePods.length > 0 ? devicePods.map(adminAccessDevicePodForCli) : undefined, + devicePodCount: devicePods.length || undefined, + tools: source?.tools, + tupleCount: tuples.length || source?.tupleCount, + tuples: tuples.length > 0 ? tuples.map(adminAccessTupleForCli) : undefined, + authorization: body?.authorization ? pruneUndefined({ + mode: text(body.authorization.mode), + allowed: body.authorization.allowed === true, + fgaAllowed: body.authorization.fgaAllowed ?? undefined, + mismatch: body.authorization.mismatch === true, + decisionSource: text(body.authorization.decisionSource), + relation: text(body.authorization.relation), + object: text(body.authorization.object), + degradedReason: text(body.authorization.degradedReason), + valuesRedacted: body.authorization.valuesRedacted === true + }) : undefined, + result: body?.result ? compactApiBody(body.result) : undefined, + error: body?.error ? compactApiBody(body.error) : undefined, + changed: body?.changed === true ? true : undefined, + updated: body?.updated === true ? true : undefined, + ok: body?.ok, + fullBodyAvailable: true + }); +} + +function adminAccessUserSummaryForCli(value: any) { + return pruneUndefined({ + user: compactApiBody(value?.user), + tupleCount: value?.tupleCount, + devicePodCount: value?.devicePodCount, + tools: value?.tools + }); +} + +function adminAccessDevicePodForCli(value: any) { + return pruneUndefined({ + devicePod: compactApiBody(value?.devicePod), + relations: value?.relations + }); +} + +function adminAccessTupleForCli(value: any) { + return pruneUndefined({ + userId: text(value?.userId), + relation: text(value?.relation), + object: text(value?.object), + createdAt: text(value?.createdAt) + }); +} + function traceBodyForCli(body: any, parsed: ParsedArgs) { if (text(parsed.render) === "web") return webTraceRenderBody(body, parsed); return responseBodyForCli(body, parsed); @@ -2922,6 +3048,10 @@ function compactApiBody(body: any): any { assistantText: assistantText(body), reply: compactReply(body.reply ?? body.message), id: body.id, + username: body.username, + displayName: body.displayName, + role: body.role, + status: body.status, eventId: body.eventId, jobId: body.jobId, name: body.name,