Merge pull request #894 from pikasTech/feat/v02-openfga-access-admin

feat: add v02 OpenFGA access management
This commit is contained in:
Lyon
2026-06-05 09:42:16 +08:00
committed by GitHub
24 changed files with 1932 additions and 79 deletions
+1
View File
@@ -71,6 +71,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
- v0.2 服务规格总览、保留服务清单、稳定外部服务边界和废弃范围:[docs/reference/spec-v02-services.md](docs/reference/spec-v02-services.md)`hwlab-gateway-simu``hwlab-box-simu``hwlab-patch-panel` 已废弃,不再保留 spec。
- v0.2 登录与鉴权规格、Keycloak OIDC、Web session 和 CLI API key[docs/reference/spec-v02-auth.md](docs/reference/spec-v02-auth.md)。
- v0.2 用户和权限管理规格、code agent session 归属和 device pod 授权:[docs/reference/spec-user-access.md](docs/reference/spec-user-access.md)。
- v0.2 OpenFGA 细粒度授权、Admin Access 管理页和同路径 CLI 规格:[docs/reference/spec-v02-openfga-authorization.md](docs/reference/spec-v02-openfga-authorization.md)。
- Device Pod 正式规格、profile 服务端权威、REST/job 和多 devicePodId 服务口径:[docs/reference/spec-device-pod.md](docs/reference/spec-device-pod.md)。
- v0.2 CI/CD 加法 lane、`v0.2-gitops``hwlab-v02``19666/19667` 规格:[docs/reference/spec-v02-cicd.md](docs/reference/spec-v02-cicd.md)。
- v0.2 `hwlab-cloud-api` API 核心服务规格:[docs/reference/spec-v02-hwlab-cloud-api.md](docs/reference/spec-v02-hwlab-cloud-api.md)。
+3 -3
View File
@@ -41,10 +41,10 @@ For M3 hardware proof, the required runtime participants are:
能力;正式 profile authority、REST/job 和服务部署规格见
[spec-device-pod.md](spec-device-pod.md)。
`v0.2` 用户和权限管理规格只保留 `admin``user` 两类角色:code agent
session 归属于创建用户,device pod `admin` 管理并按用户授权,授权即全权限
`v0.2` 用户和权限管理规格只保留 `admin``user` 两类基础角色:code agent
session 归属于创建用户,device pod 和工具能力由 Cloud API 通过 OpenFGA 按用户、对象和 relation 授权
Kubernetes 只做运行时隔离和资源兜底,不承载最终用户权限。权威口径见
[spec-user-access.md](spec-user-access.md)。
[spec-user-access.md](spec-user-access.md) 和 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)
## M3 Trusted Loop
+4 -4
View File
@@ -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` 仍只表达用户是否拥有完整使用权;它不保存 profile,也不 capability。
`device_pod_grants` 只作为旧全权限 grant 的迁移兼容表;目标状态的 device pod 可见、操作、profile 修改和 job 提交权限由 OpenFGA relation 表达。它不保存 profile,也不再作为细粒度 capability authority
## REST API
@@ -240,21 +240,21 @@ manages: many devicePodId
- 单个服务内 job 队列和 freshness 监控互相影响;
- 不同设备需要不同 host network、USB、Secret 或资源 request
- 设备数量增长到单实例状态管理明显吃力;
- 强隔离需求超过应用层 grant 和内部服务凭据能覆盖的范围。
- 强隔离需求超过应用层 OpenFGA relation 和内部服务凭据能覆盖的范围。
普通用户和 code agent session Pod 不应直接调用 `hwlab-device-pod` Service。正式路径是 `code agent -> cloud-api -> hwlab-device-pod`
## 验收标准
- `device-pod-cli` 在正式模式下不读取 `.device-pod/<devicePodId>.json` 作为权威 profile,只向 cloud-api 提交 `devicePodId`、intent 和 args。
- 普通用户无授权时不能看到或使用任何 device pod;授权后拥有对应 device pod 的完整使用权
- 普通用户无授权时不能看到或使用任何 device pod;拥有 `viewer` 只能看到摘要,拥有 `operator/job_submitter` 才能提交对应 job,拥有 `profile_editor` 才能修改 profile
- code agent 不能通过修改本地文件改变 gateway session、resource、host workspace、probe UID 或 UART port。
- `hwlab-device-pod` 不接受无内部服务凭据的 profile snapshot 或 job 请求。
- `hwlab-device-pod` 一个实例可以列出并执行多个 `devicePodId` 的状态/job。
- cloud-api legacy compatibility entry 只能返回 blocked authority payload,不得合成 fake device pod 数据或作为正式 device-pod DEV-LIVE 证据。
- 强副作用 job 必须有 `reason`;正式路径只使用 Web session/cookie 或映射到具体用户的 `HWLAB_API_KEY` 做身份授权。
- AgentRun runner 访问 device-pod 必须使用 cloud-api 组装的用户 `HWLAB_API_KEY`,该 key 恢复为发起 Code Agent session 的 owner 用户;不得使用跨用户共享、对所有正式 device-pod 授权的 system key。
- 撤销 device pod grant 必须同时影响该用户通过 Web session、CLI API key 和 AgentRun `hwpod` 的可见性与使用权;revoke API key 后 CLI 和 runner 内旧 key 都必须失效。
- 撤销 device pod relation 必须同时影响该用户通过 Web session、CLI API key 和 AgentRun `hwpod` 的可见性与使用权;revoke API key 后 CLI 和 runner 内旧 key 都必须失效。
## CLI 实现口径
+44 -39
View File
@@ -1,18 +1,18 @@
# v0.2 用户和权限管理规格
本文是 HWLAB `v0.2` 用户和权限管理的规格说明。目标是用最少概念支持真实用户使用 code agent session 和管理员分配 device pod 权限,同时避免把用户体系、Kubernetes 租户、设备授权、硬件证据链和审计系统混成一套复杂门禁。
本文是 HWLAB `v0.2` 用户和权限管理的规格说明。目标是用最少概念支持真实用户使用 code agent session,并让管理员能按用户独立调整 device pod、Code Agent session 和工具功能权限,同时避免把用户体系、Kubernetes 租户、设备授权、硬件证据链和审计系统混成一套复杂门禁。
本规格与 [spec-device-pod.md](spec-device-pod.md) 配套:用户和权限规格定义谁可以看见、创建和使用 device poddevice-pod 规格定义 profile authority、REST/job 和硬件执行边界。
登录入口、Keycloak OIDC、Web session、CLI API key 和 `AuthPrincipal` 归一见 [spec-v02-auth.md](spec-v02-auth.md)。本文只定义认证完成后的角色、资源归属、device pod grant 和 code agent owner 授权;正式用户鉴权只有 Web session 与 CLI/API key 两类,除非明确标注为 bootstrap/legacy,不再把本地账号密码登录或 device-pod 系统 key 作为目标登录体验。
登录入口、Keycloak OIDC、Web session、CLI API key 和 `AuthPrincipal` 归一见 [spec-v02-auth.md](spec-v02-auth.md)。OpenFGA、Admin Access WebUI 和同路径 CLI 细节见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。本文只定义认证完成后的角色、资源归属、device pod capability、tool capability 和 code agent owner 授权;正式用户鉴权只有 Web session 与 CLI/API key 两类,除非明确标注为 bootstrap/legacy,不再把本地账号密码登录或 device-pod 系统 key 作为目标登录体验。
实施跟踪见 [pikasTech/HWLAB#531](https://github.com/pikasTech/HWLAB/issues/531),原 `docs/plan/v02-multi-user-migration.md` 迁移计划全文已迁入该 issue 评论。
## 在系统中的职责划分
用户和权限管理不是独立微服务,权威实现收敛在 `hwlab-cloud-api`:它消费 [spec-v02-auth.md](spec-v02-auth.md) 产出的 `AuthPrincipal`,负责角色、device pod grant、用户 API key 和 code agent session owner 校验。`hwlab-cloud-web` 只提供浏览器 UI 和同源代理;`hwlab-device-pod` 只执行设备语义;AgentRun v0.1 只消费 cloud-api 入的 actor/session/device 权限上下文,不成为 HWLAB 用户权限 authority。
用户和权限管理不是独立微服务,权威实现收敛在 `hwlab-cloud-api`:它消费 [spec-v02-auth.md](spec-v02-auth.md) 产出的 `AuthPrincipal`,负责角色、OpenFGA check/write、用户 API key、device pod capability、tool capability 和 code agent session owner 校验。`hwlab-cloud-web` 只提供浏览器 UI 和同源代理;`hwlab-device-pod` 只执行设备语义;AgentRun v0.1 只消费 cloud-api 按用户权限注入的 actor/session/device/tool 上下文,不成为 HWLAB 用户权限 authority。
Postgres 是该规格的数据持久化边界。Kubernetes namespace、ServiceAccount、Service 直连和 gateway route 都不能替代用户权限模型;普通用户不获得 kubeconfig、内部 Service 直连能力或长期 Secret。
Postgres 是用户、session、业务对象和迁移 ledger 的持久化边界;OpenFGA 是细粒度授权关系与授权判定边界。Kubernetes namespace、ServiceAccount、Service 直连和 gateway route 都不能替代用户权限模型;普通用户不获得 kubeconfig、内部 Service 直连能力、OpenFGA token 或长期 Secret。
`v0.2` 本地 bootstrap 管理员账号固定为 `admin`,默认登录密码固定为 `hwlab2026`,只作为空库初始化和 legacy fallback。Keycloak 接入后的目标 Web 登录以 OIDC 为准,CLI 以 `HWLAB_API_KEY` 为准。
运行时仍只通过 `hwlab-v02-bootstrap-admin/password-hash` SecretRef 注入本地 fallback password hashPostgres、API 响应、日志、CLI session 和文档不得保存或输出 password hash、session token 原文或 Secret 值。
@@ -22,8 +22,9 @@ Postgres 是该规格的数据持久化边界。Kubernetes namespace、ServiceAc
- 只保留两类角色:`admin``user`
- `code agent session` 直接归属于创建它的用户;普通用户只能查看、继续和取消自己的 session。
- `device pod``admin` 管理;普通用户只有在被 `admin` 授权后才能看到和使用对应 device pod。
- device pod 授权不拆分 `read``operate` 或 capability;授权关系存在即代表该用户拥有该 device pod 的完整使用权
- `device pod``admin` 或被授予 `profile_editor` 的用户管理;普通用户只有在被授权后才能看到、操作或提交对应 device pod job
- device pod 授权`viewer``operator``profile_editor``job_submitter` 等 relation 表达;旧 `device_pod_grants` 只作为迁移兼容和 shadow 对照,不再代表目标状态的完整使用权。
- 工具能力必须独立授权,例如 `hwpod``unidesk_ssh``trans_cmd` 和 GitHub 写工具;拥有 Code Agent session 不等于拥有这些工具。
- MVP 不新增产品级 `audit_events` 用户审计表,也不把用户权限依赖到 audit。现有硬件 trace/evidence/audit 字段属于硬件闭环证据,不是多用户权限模型的一部分。
- 强副作用 device-pod job 只额外要求业务 `reason`;设备互斥由 executor、gateway 和硬件 host 串行化或返回 blocker,不进入用户权限模型。
- 普通用户不获得 Kubernetes 用户、kubeconfig、namespace 管理权或直接访问 device pod Service 的权限;所有用户权限判断在 cloud-api 应用层完成。
@@ -38,8 +39,8 @@ Postgres 是该规格的数据持久化边界。Kubernetes namespace、ServiceAc
| 项目隔离 | `projects``project_members` | 不引入 |
| Code Agent 会话 | `ownerUserId + projectId + sessionId` | `owner_user_id + session id` |
| Device Pod 管理 | 平台管理员和设备管理员分工 | `admin` 统一管理 |
| Device Pod 授权 | 可按 group/project/user 授权 | 只按具体 `user_id` 授权 |
| 设备权限粒度 | `device.view``io.read``io.write``debug.reset` 等 capability | 授权即全权限 |
| Device Pod 授权 | 可按 group/project 授权 | 第一版只按具体 `user_id` 授权relation 由 OpenFGA 表达 |
| 设备权限粒度 | `io.read``io.write` 等硬件寄存器级 capability | `viewer``operator``profile_editor``job_submitter` 等产品级 relation |
| Viewer | 单独只读角色 | 不引入 |
| Audit | 独立用户审计表 | 不引入 |
| Lease | 权限和互斥可能混用 | 只作为设备互斥锁 |
@@ -126,7 +127,7 @@ CREATE TABLE IF NOT EXISTS device_pods (
### `device_pod_grants`
普通用户对 device pod 的授权关系;存在即全权限
迁移期普通用户对 device pod 的兼容授权关系。目标状态以 OpenFGA tuple 为准;该表只用于从旧全权限 grant 导入 `operator/job_submitter`、在 `shadow` 模式下对照 mismatch,或在 OpenFGA `off` 的本地开发中保持 legacy fallback
```sql
CREATE TABLE IF NOT EXISTS device_pod_grants (
@@ -138,23 +139,24 @@ CREATE TABLE IF NOT EXISTS device_pod_grants (
);
```
-包含 `capability``scope``expires_at`
- 撤销授权就是删除对应行;撤销后该用户通过普通 session/cookie 不再看到或使用该 device pod
- AgentRun runner 不使用跨用户共享的 device-pod 系统 key。runner 内 `hwpod` 只能使用映射到 Code Agent session owner 的 `HWLAB_API_KEY`,并按同一用户的 `device_pod_grants` 授权。
-新增 `capability``scope``expires_at` 字段;细粒度 relation 不回写到该表,统一写 OpenFGA
- 在 OpenFGA `enforce` 模式下,撤销授权就是删除对应 tuple;该表若仍有 legacy 行也不能放行请求
- AgentRun runner 不使用跨用户共享的 device-pod 系统 key。runner 内 `hwpod` 只能使用映射到 Code Agent session owner 的 `HWLAB_API_KEY`,并按同一用户的 OpenFGA device pod relation 与 tool capability 授权。
## 权限矩阵
| 操作 | `admin` | `user` |
| --- | --- | --- |
| 管理用户 | 可以 | 不可以 |
| 管理用户和权限 | 可以;应镜像为 `system:hwlab#admin``access_manager` | 只有被授予 `access_manager`可以 |
| 创建自己的 code agent session | 可以 | 可以 |
| 查看、继续、取消自己的 code agent session | 可以 | 可以 |
| 查看、取消别人的 code agent session | 可以 | 不可以 |
| 创建、更新、删除 device pod | 可以 | 不可以 |
| 给用户授权或撤销 device pod | 可以 | 不可以 |
| 查看 device pod | 可以查看全部 | 只能查看被授权的 device pod |
| 使用 device pod 的 workspace/debug/io 能力 | 可以使用全部 | 只能使用被授权的 device pod |
| 提交强副作用 device-pod job | 必须填写 reason | 被授权后必须填写 reason |
| 查看、继续、取消自己的 code agent session | 可以 | 拥有该 session 的 `viewer/operator`可以 |
| 查看、取消别人的 code agent session | 可以 | 需要该 session 的显式 relation |
| 创建、更新、删除 device pod | 可以 | 需要目标 device pod 的 `profile_editor` |
| 给用户授权或撤销 device pod/tool | 可以 | 需要 `access_manager` |
| 查看 device pod | 可以查看全部 | 需要目标 device pod `viewer` 或更高 relation |
| 使用 device pod 的 workspace/debug/io 能力 | 可以使用全部 | 需要 `tool:hwpod#can_use` 且目标 device pod 具备 `operator/job_submitter` |
| 提交强副作用 device-pod job | 必须填写 reason | 被授权后必须填写 reason |
| 调用 UniDesk SSH / trans cmd / GitHub 写工具 | 可以,但仍受工具边界约束 | 需要对应 `tool:*#can_use` |
## 请求链路
@@ -200,16 +202,16 @@ browser admin UI
### admin 授权 device pod
```text
browser admin UI
-> cloud-api POST /v1/admin/device-pod-grants
browser admin Access UI
-> cloud-api PUT /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}
-> authenticate admin
-> validate users.status='active'
-> validate device_pods.status='active'
-> upsert device_pod_grants(device_pod_id, user_id)
-> return grant summary
-> write OpenFGA tuple
-> return effective permission matrix
```
撤销授权走 `DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}`,删除后该用户通过普通 session/cookie 不再能看到或使用该 device pod
撤销授权走 `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 表
### 用户列出 device pod
@@ -217,8 +219,8 @@ browser admin UI
browser or code agent tool
-> cloud-api GET /v1/device-pods
-> authenticate actor
-> if admin: list all active device_pods
-> if user: inner join device_pod_grants by actor.id
-> if admin/access manager: list all active device_pods
-> if user: check OpenFGA viewer/operator relation for each active device_pod
-> return visible device pod summaries
```
@@ -245,19 +247,19 @@ code agent turn
-> cloud-api device operation route
-> authenticate actor from owning session or user API key in runner
-> verify agent_sessions.owner_user_id == actor.id
-> authorize device_pod_grants/admin
-> authorize OpenFGA tool:hwpod and device_pod relation
-> require reason for mutating operations
-> cloud-api -> hwlab-device-pod internal Service
-> gateway/device-host-cli/hardware path
```
code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device pod Service。device pod 服务只信任来自 cloud-api 的内部调用,不做最终用户权限判断。
code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device pod Service。device pod 服务只信任来自 cloud-api 的内部调用,不做最终用户权限判断。Cloud API 给 AgentRun runner 注入 `hwpod`、UniDesk SSH、`trans_cmd` 或 GitHub 写工具前,必须先按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 检查对应 `tool:*#can_use`
## 内部架构
`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`device_pod_grants` 表示普通用户对 device pod 的完整使用权。不得再引入对所有正式 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 authorityOpenFGA tuple 表示用户对 device pod、agent session 和工具的细粒度能力;`device_pod_grants` 只作为旧全权限 grant 的迁移兼容表。不得再引入对所有正式 device pod 授权的共享用户 key`HWLAB_DEVICE_POD_API_KEY` 如仍存在,只能作为 cloud-api 到 device-pod 的内部服务凭据。
## API 接口说明
@@ -272,17 +274,18 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device
| `POST /auth/logout` | 设置 `revoked_at`,撤销当前 browser session。 |
| `POST /v1/admin/users` | admin 创建用户,响应不得返回 `password_hash` 或 token。 |
| `POST /v1/admin/device-pods``PUT /v1/admin/device-pods/{devicePodId}` | admin 管理 device pod profile authority。 |
| `POST /v1/admin/device-pod-grants``DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}` | admin 授权或撤销普通用户使用 device pod。 |
| `GET /v1/device-pods` 和 device-pod 操作 API | 按 actor role 和 grant 过滤可见/可用 device pod。 |
| `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 原文。
## 微服务设计
v0.2 不新增独立用户管理微服务。Keycloak 是独立身份提供方,不是 HWLAB 应用层授权服务;用户映射、session/API key 消费、device pod grant 和 code agent owner 校验全部放在 `hwlab-cloud-api` 内,理由是:
v0.2 不新增独立用户管理微服务。Keycloak 是独立身份提供方,不是 HWLAB 应用层授权服务;OpenFGA 是内部授权 PDP,不对用户暴露独立 API;用户映射、session/API key 消费、OpenFGA check/write、device pod relation、tool capability 和 code agent owner 校验全部放在 `hwlab-cloud-api` 内,理由是:
- 当前权限模型只有 `admin/user`、session owner 和 device grant 三类判断,拆服务会增加网络、部署、Secret、迁移和一致性成本。
- 当前权限入口必须和 `/v1/agent/*``/v1/device-pods/*`、AgentRun transient env 注入和 runtime store 保持强一致,拆出新的 HWLAB 用户微服务会增加网络、部署、Secret、迁移和一致性成本。
- cloud-api 已经是 `/v1/agent/*``/v1/device-pods/*` 和 runtime store 的统一入口,最适合做应用层授权收口。
- 后续若出现组织、计费、批量用户导入或跨产品用户中心,再把 cloud-api 内的 user/auth 模块抽成 `hwlab-user-api`;抽服务前接口和表结构仍以本文和 [spec-v02-auth.md](spec-v02-auth.md) 为准。
@@ -290,8 +293,9 @@ v0.2 不新增独立用户管理微服务。Keycloak 是独立身份提供方,
| 服务 | v0.2 职责 |
| --- | --- |
| `hwlab-cloud-web` | Keycloak 登录入口、普通用户工作台、API key 管理入口和 admin 用户/授权 UI;浏览器 `/auth/*` 由 cloud-web 代理到 cloud-api。 |
| `hwlab-cloud-api` | 用户映射、Web session/API key 消费、授权、device grant、code agent owner 校验和对 device pod 的受控转发。 |
| `hwlab-cloud-web` | Keycloak 登录入口、普通用户工作台、API key 管理入口和 Admin Access 授权 UI;浏览器 `/auth/*` `/v1/admin/access*` 由 cloud-web 代理到 cloud-api。 |
| `hwlab-cloud-api` | 用户映射、Web session/API key 消费、OpenFGA 授权、device relation、tool capability、code agent owner 校验和对 device pod 的受控转发。 |
| OpenFGA | `hwlab-v02` 内部稳定授权服务,只接受 cloud-api 调用,不向普通用户或公网暴露。 |
| AgentRun v0.1 runner | 执行 code agent session;接收 cloud-api 提供的 owner/session/device 上下文方便观测,但不作为最终权限 authority。 |
| `hwlab-device-pod` | 暴露设备语义 API;不保存用户权限,不直接面向浏览器或普通用户 session Pod。 |
| `hwlab-edge-proxy` | 公网/FRP 入口和 HTTP 转发;不做业务权限,只转发 cookie/header,不注入伪 actor。 |
@@ -307,7 +311,7 @@ Kubernetes 只做运行时隔离和资源兜底,不承载 HWLAB 用户权限
- code agent worker、session Pod/PVC/Job 必须带稳定 label,例如 `hwlab.pikastech.local/owner-user-id``hwlab.pikastech.local/session-id`
- device pod 工作负载必须带 `hwlab.pikastech.local/device-pod-id` label,并通过 Service 暴露稳定内部地址。
- code agent 到 device pod 的访问应收敛到 `code agent -> cloud-api -> device-pod`,避免普通 session Pod 直接调用 device pod Service 绕过应用层授权。
- Keycloak 按 [spec-v02-auth.md](spec-v02-auth.md) 作为独立 `keycloak` namespace 的外部身份源接入;Kubernetes 租户隔离第一轮仍不引入 Dex、oauth2-proxy、OpenFGA、Capsule、vCluster、Kyverno 或 service mesh。
- Keycloak 按 [spec-v02-auth.md](spec-v02-auth.md) 作为独立 `keycloak` namespace 的外部身份源接入;OpenFGA 按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 部署在 `hwlab-v02` namespace 作为内部授权服务;Kubernetes 租户隔离第一轮仍不引入 Dex、oauth2-proxy、Capsule、vCluster、Kyverno 或 service mesh。
当前态、差距和迁移步骤已迁入 [pikasTech/HWLAB#531](https://github.com/pikasTech/HWLAB/issues/531) 评论。
@@ -319,7 +323,7 @@ Kubernetes 只做运行时隔离和资源兜底,不承载 HWLAB 用户权限
## T2
阅读 docs/reference/spec-user-access.md,然后用 cli 手动测试以下内容:用 admin 创建普通用户 device pod grant,再用普通用户列出 `/v1/device-pods`,确认只能看到被授权 device pod;撤销授权后同一用户不能再看到或使用该 device pod。
阅读 docs/reference/spec-user-access.md 和 docs/reference/spec-v02-openfga-authorization.md,然后用 cli 手动测试以下内容:用 admin 普通用户授予某个 device pod `viewer` 但不授予 `operator/job_submitter`,确认普通用户只能看到 device pod 摘要,提交 job 返回 403;授予 `operator/job_submitter` 后 job 可提交;撤销 relation 后同一用户不能再看到或使用该 device pod。
## T3
@@ -332,5 +336,6 @@ 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 和 jobsDevice Pod 强副作用 job 已接入 reason 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 |
| Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidencetrace/result cache 也按 owner/admin 限制访问。 |
| device pod 授权模型 | 部分实现 | cloud-api 已实现 admin profile/grant、普通用户可见性和 job 持久化;无在线 gateway/device-host-cli 时返回 blocker。 |
| 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。 |
| 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 |
+14 -1
View File
@@ -27,7 +27,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb
## 在系统中的职责划分
- 提供 WEB 等价的非视觉业务入口:登录鉴权、显式 Code Agent session 管理、Device Pod 看板、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。
- 提供 WEB 等价的非视觉业务入口:登录鉴权、显式 Code Agent session 管理、Device Pod 看板、Admin Access 授权管理、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。
- 只走 Cloud Web 同源 API surface;正式运行时由 `HWLAB_RUNTIME_*` 装配出当前 lane 的 Web/API endpoint,失败时必须 fail closed,不能静默退回 legacy DEV 入口。
- Web/CLI 路径一致性优先于继续 Web 修复。Cloud Web 暴露 Code Agent、AgentRun、continuation、steer、trace/result 或 provider 问题后,必须先能用 runtime namespace/lane 装配出的 `bun tools/hwlab-cli/bin/hwlab-cli.ts client agent send/result/trace/inspect/steer ...` 对同一 Cloud Web origin、同一 `/v1/agent/chat*`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。Cloud API 只用于显式 admin/setup/gateway 诊断,不得替代 WEB 同源路径验收。
- 从 Web trace 回放 Code Agent 问题时,优先用 `client agent inspect --trace-id <traceId>` 读取 Cloud Web 的 `/v1/agent/chat/inspect`,输出 trace 所属 `conversationId/sessionId/threadId`、session 状态和 `retryOf` 建议;`client agent send --from-trace <traceId>` 只能作为显式复现该 trace 所属 session 的入口,不能自动创建、滚动或替换 session。inspect 缺失或 session 已 failed/stale 时,CLI 必须返回结构化 blocker 和显式新建 session 建议。
@@ -36,6 +36,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb
- `client runtime routes` 必须按当前运行 profile/lane 的数据生成 UniDesk `pod:` route;实现不得硬编码 `dev``v0.2``v0.3`、namespace 或 catalog path。新增版本只允许通过 `deploy.json.lanes[profile]` 声明 namespace、artifact catalog 和 service overrides,不为每个版本新增代码分支。
- 运行时不做内部证明型校验、旧健康诊断或重断言;CI/CD 只保留能证明代码可构建、语法正确和最小冒烟可用的校验。功能正确性通过 `hwlab-cli client` 短连接真实业务 E2E 暴露和修复。
- 专用子命令覆盖高频用户工作台;`client request METHOD /path` 覆盖 WEB 同源代理允许的其他非视觉 API。`client request` 只接受以 `/` 开头的 Cloud Web 相对路径,禁止绝对 URL,避免绕过 Cloud Web 直接打内部服务。
- `client access ...` 是 Admin Access 页面的同路径 CLI,不直连 OpenFGA,不手动传 OpenFGA token,不把 `19667` Cloud API 当作 Web 等价验收路径。所有授权读写都必须输出 runtimeEndpoint、route、actor、mode、decision 和 effective matrix 摘要。
- `client gateway pressure` 是 device-pod/gateway 高频故障的真实业务传输压测入口;必须覆盖 small stdout、大 stdout、长单行 stdout、stderr flood、结构化 timeout 和超出 gateway inflight 上限的并发请求。所有场景必须返回 JSON、HTTP/route/traceId/requestId、字节数、truncated 标记、sha256 和 bounded preview;失败必须明确是 `http_*``stdout_not_truncated``stderr_not_truncated``timeout_not_observed``structured_gateway_busy` 等可定位原因,禁止无输出、长时间黑洞或只靠 shell pipe 截断。
- 输出默认是 JSON;任何失败都要有 `ok:false``action``status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`
- `device-pod-cli`/`hwpod` 在 AgentRun runner 中是设备 API 标准短入口,必须自动使用装配的 `HWLAB_RUNTIME_API_URL` 直达 `hwlab-cloud-api`,并使用映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;不能把 Cloud Web 同源代理当作设备 API 通道,也不能手动传 URL、session token 或历史 `HWLAB_DEVICE_POD_API_KEY``job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head``grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。
@@ -73,6 +74,13 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb
| `hwlab-cli client auth session` | `GET /auth/session` | legacy cookie session 恢复或 Web session debug。 |
| `hwlab-cli client auth profiles` | 本地状态读取 | 列出同一 base URL 下的本地 profile state,用于账号切换可见性。 |
| `hwlab-cli client auth logout` | `POST /auth/logout` | 撤销 server session 并清理本地 cookie。 |
| `hwlab-cli client access summary` | `GET /v1/admin/access/summary` | Admin Access 总览,显示 OpenFGA mode/readiness/store/model、用户/tool/device-pod 数量和 mismatch 摘要。 |
| `hwlab-cli client access users list` | `GET /v1/admin/access/users` | 列出用户、role/status、Keycloak 绑定摘要和 effective capability 摘要。 |
| `hwlab-cli client access users inspect USER` | `GET /v1/admin/access/users/{userId}` | 查看单个用户的 device pod、agent session 和 tool 权限矩阵。 |
| `hwlab-cli client access users set-role USER --role admin|user` | `PATCH /v1/admin/access/users/{userId}` | 更新用户 role/status,并同步 OpenFGA admin tuple。 |
| `hwlab-cli client access device-pods grant/revoke USER POD --relation REL` | `PUT/DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}` | 授予或撤销 device pod `viewer/operator/profile_editor/job_submitter` 等 relation。 |
| `hwlab-cli client access tools grant/revoke USER TOOL` | `PUT/DELETE /v1/admin/access/users/{userId}/tools/{toolId}/can-use` | 授予或撤销 `hwpod``unidesk_ssh``trans_cmd`、GitHub 写工具等 capability。 |
| `hwlab-cli client access check --user USER --relation REL --object OBJECT` | `POST /v1/admin/access/check` | 管理员调试单次 authorization check,输出 decision 和 redacted actor/object。 |
| `hwlab-cli client device-pods list` | `GET /v1/device-pods` | 对应右侧 Device Pod 列表。 |
| `hwlab-cli client device-pods status POD` | `GET /v1/device-pods/{pod}/status` | 对应 Device Pod summary/status。 |
| `hwlab-cli client device-pods events POD` | `GET /v1/device-pods/{pod}/events` | 对应纯文本事件流。 |
@@ -152,12 +160,17 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 或当前 v0.2 worktree 用 cli 手动测试以下内容:在 runtime endpoint locked 环境下运行 `bun tools/hwlab-cli/bin/hwlab-cli.ts client gateway pressure --gateway-session-id gws_D601_F103 --large-bytes 131072 --parallel 8 --request-timeout-ms 60000 --timeout-scenario-ms 1000 --no-auth --full`。确认 gateway CLI 自动定位当前 lane 的 Cloud APIsmall stdout、大 stdout、长单行 stdout、stderr flood、timeout 和并发超容量全部返回结构化 JSON;大输出应显示 `stdoutBytes``stderrBytes``stdoutTruncated``stderrTruncated`、sha256 和 preview,超出 `maxInflightRequests` 的请求必须显示 `structured_gateway_busy`,不能出现无输出或卡死。
## T8
阅读 docs/reference/spec-v02-hwlab-cli.md 和 docs/reference/spec-v02-openfga-authorization.md,然后在 runtime endpoint locked 环境下运行 `client access summary``client access users list``client access users inspect <user>``client access device-pods grant/revoke ...``client access tools grant/revoke ...``client access check ...`。确认所有命令都走 Cloud Web 同源 `19666` path,输出 JSON、route、actor、OpenFGA mode/decision 和 effective matrix,不输出 OpenFGA token、完整 API key 或 Secret 值。
## 规格的实现情况
| 规格项 | 状态 | 说明 |
| --- | --- | --- |
| 固定 repo 短连接 client | 目标状态 | `hwlab-cli``G14:/root/hwlab-v02` 或当前 v0.2 worktree 直接用 Bun 运行,不作为 runtime service。 |
| WEB 等价 API client | 目标状态 | `client` 子命令覆盖 Cloud Web 非视觉业务面。 |
| Admin Access 同路径 CLI | 目标状态 | `client access ...` 覆盖 OpenFGA summary、user matrix、grant/revoke、tool capability 和 check,必须走 Cloud Web 同源 path。 |
| 显式 Code Agent session 管理 | 目标状态 | `client agent session create|select|status|list``send` 前置;普通 `send` 不自动创建或滚动 session。 |
| WEB composer 状态机等价 | 目标状态 | `client agent composer status|submit` 复用 Web composer policy,但必须显示 sessionRequired/sessionUsable,不能自动创建或滚动 session。 |
| JSON-RPC 同源 API | 目标状态 | `client rpc` 自动补齐 Web JSON-RPC envelope 的 `meta` 字段。 |
+18 -3
View File
@@ -4,15 +4,17 @@
## 在系统中的职责划分
- 承担 runtime health、DB readiness、登录鉴权、`AuthPrincipal`、用户/session/API key 权限、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、device-pod authority/job 和 live build inventory。
-`hwlab-cloud-web`、Code Agent session、device-pod 用户态操作和 gateway outbound poll 的唯一应用层收口点;普通用户不直接访问内部 `hwlab-device-pod` Service。
- 承担 runtime health、DB readiness、登录鉴权、`AuthPrincipal`OpenFGA 授权 check/write、用户/session/API key 权限、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、device-pod authority/job 和 live build inventory。
-`hwlab-cloud-web`、Code Agent session、device-pod 用户态操作、Admin Access API、AgentRun 工具注入和 gateway outbound poll 的唯一应用层收口点;普通用户不直接访问内部 `hwlab-device-pod` Service 或 OpenFGA Service。
- 读取 `hwlab-cloud-api-v02-db/database-url``hwlab-v02-code-agent-provider/openai-api-key``hwlab-v02-code-agent-codex-auth/auth.json` 等 v02 独立 SecretRef;用户 API key 存在 Postgres `api_keys`,不得再以 `hwlab-v02-device-pod-api-key/api-key` 这类跨用户 SecretRef 授权 device-pod。文档和日志只允许记录 SecretRef 名称、key、字节数或哈希指纹,不记录值。
- 读取 OpenFGA URL、auth token、store/model 指针和 mode 时只能通过 env/SecretRef/Postgres runtime config`/health/live``/v1/admin/access/summary` 只输出 readiness、mode、storeId/modelId 摘要和 degraded reason,不输出 token、Postgres URL 或 tuple secret。
## 内部架构
- `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/access-control.ts` 负责 `/auth/*`、OIDC callback、Web session、用户 API key、admin/user、device pod profile/grant、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、`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/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。
@@ -25,6 +27,7 @@
- Code Agent 不允许存在 turn/session/conversation 总时长 timeout;只允许无新 app-server 响应、无 notification、无 assistant/tool/event activity 的 idle timeout。AgentRun command 失败、provider 失败或 idle timeout 只终结当前 command,并按 session policy 标记当前 session 状态;系统不得自动滚动到新 session。后续消息要么继续同一个 usable session/thread,要么由用户显式创建或选择另一个 session。
- runner pod 被删、runner Job 被重建或旧 lease 失效后,同一 HWLAB session 的恢复判定必须基于同一个 `sessionId``threadId``providerProfile/backendProfile`、AgentRun `SessionRef` 和 PVC。replacement run/job 只能作为恢复执行壳;Cloud API 仍要把它映射为同一业务 session 的后续 turn,并在 trace/result 中分离业务 session identity 与执行 run/job identity。长期目标仍是在 runner reuse window 内复用同一 AgentRun run/runner。
- Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*``HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和映射到当前 session owner 的 `HWLAB_API_KEY``transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value;`HWLAB_RUNTIME_API_URL` 必须指向当前 namespace 内的 `hwlab-cloud-api` Service`HWLAB_RUNTIME_WEB_URL` 才指向 `hwlab-cloud-web``HWLAB_API_KEY` 必须对应当前 `agent_sessions.owner_user_id`,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他无关 credential;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 Secret 值。
- AgentRun runner 工具环境注入必须经过 OpenFGA tool capability check。用户没有 `tool:hwpod#can_use` 时不注入可用 `HWLAB_API_KEY``hwpod`;没有 `tool:unidesk_ssh#can_use` 时不注入 UniDesk SSH token、route alias 或相关 helper;没有 `tool:trans_cmd#can_use` 时不暴露 trans cmd 透传入口;GitHub issue/PR 写工具也必须独立授权。工具授权不绕过 UniDesk route/operation 边界。
- 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。
- `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。
@@ -43,6 +46,8 @@
| `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/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 同步。 |
| `GET /v1/m3/status``POST /v1/m3/io` | M3 只读/受控 IO 入口;写操作必须有明确 approval。 |
@@ -58,6 +63,7 @@
需要验证 Cloud Web 时必须读取 `services[]``serviceId=hwlab-cloud-web` 的行,并区分 `build.createdAt``image.tag/digest``commit.id``revision``build.liveMetadataMatch``/health/live` 只能证明当前服务 health/revisionCloud Web 顶部 build chip 和详情弹窗展示的构建时间、env image 和实际 runtime commit 以 `/v1/live-builds` 的对应 service row 为准。
登录鉴权 API 的最终规格见 [spec-v02-auth.md](spec-v02-auth.md);用户、权限、device-pod 管理 API 的最终规格见 [spec-user-access.md](spec-user-access.md) 和 [spec-device-pod.md](spec-device-pod.md)。
OpenFGA、Access API、工具能力和 shadow/enforce 策略的最终规格见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。
## 测试规格
@@ -81,6 +87,14 @@
阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:未登录访问 `/v1/device-pods` 必须返回认证错误;登录后访问 device-pod list/status 时必须显示 `contractVersion=device-pod-authority-v1``fake=false` 来源,不得出现 fake fallback。
## T4
阅读 docs/reference/spec-v02-hwlab-cloud-api.md 和 docs/reference/spec-v02-openfga-authorization.md,然后用 cli 手动测试以下内容:用 admin 调用 `GET /v1/admin/access/summary``POST /v1/admin/access/check`,确认 OpenFGA mode/readiness/store/model/decision 可见且 Secret 不泄漏;普通用户调用 admin access API 必须返回 403。
## T5
阅读 docs/reference/spec-v02-hwlab-cloud-api.md 和 docs/reference/spec-v02-openfga-authorization.md,然后用 cli 手动测试以下内容:撤销某用户 `tool:unidesk_ssh``tool:hwpod` 后创建 Code Agent session,确认 runner transient env 和 trace 摘要不再注入对应工具能力,强行调用返回结构化 authorization blocker。
## 规格的实现情况
| 规格项 | 状态 | 说明 |
@@ -92,5 +106,6 @@
| 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 天 sessionKeycloak 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 过滤。 |
| 账号共享 workspace authority | 已实现 | `account_workspaces` 持久化同账号 Web/CLI 共享 workspace,支持 revision 冲突可见性、账号隔离、最近 active trace 记录和 reset;同一用户不同 session/run 允许并发,互斥只在 session/thread/run 层处理。 |
@@ -9,6 +9,7 @@
- 为浏览器提供同源代理,避免前端直接跨域调用内部 ClusterIP。
- Web 登录按 [spec-v02-auth.md](spec-v02-auth.md) 走 Keycloak OIDC;未登录用户进入 Keycloak 登录/注册,callback 后由 cloud-api 发行 24 小时 `hwlab_session`。本地账号密码表单和自动 admin 登录只允许作为 legacy/bootstrap fallback,不是目标体验。
- Cloud Web 提供 API key 管理入口,让用户查看默认 API key、创建新 key、revoke 或 regenerate;浏览器日常请求仍使用 Web session,不要求用户手动输入 API key。
- Cloud Web 提供 admin-only Access 页面,让管理员按用户管理 role/status、device pod relation、Code Agent session 可见性和工具 capability;页面只调用 cloud-api `/v1/admin/access*` 同源 API,不直接访问 OpenFGA、Postgres、Kubernetes 或 Keycloak admin API。
- Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result、device-pod list/status 和 device-pod job 问题,必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现;不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。
- Cloud Web 只承担浏览器 UI 和 `hwlab-cli client` 的同源代理。AgentRun runner 内的 `hwpod` 不走 Cloud Webrunner 使用映射到发起用户的 `HWLAB_API_KEY` 直连 Cloud APICloud Web 不保留 device-pod lease 路由。
- 浏览器启动后必须从 `GET /v1/workbench/workspace` hydrate 账号 workspace;同一个账号在多个浏览器标签页、多个浏览器或 CLI profile 中应看到同一个 `workspaceId`、selected conversation/session/thread、provider profile 和 active trace。浏览器 localStorage 只能作为短期缓存,并必须绑定 actor,不能作为 workspace authority。
@@ -81,6 +82,7 @@
- `internal/dev-entrypoint/http.mjs` 提供静态服务、health 和 HTTP proxy 基础能力。
- `internal/dev-entrypoint/cloud-web-routes.mjs` 定义可代理到 cloud-api 的同源 API route 和认证边界。
- `web/hwlab-cloud-web/auth.ts` 管理工作台登录态、Keycloak redirect/callback 状态和 API key 管理 UI 调用;真正的登录鉴权和用户权限 authority 仍应收敛到 cloud-api。
- `web/hwlab-cloud-web/views/access` 或等价模块实现 Admin Access 页面:ActivityRail 顶层入口、用户列表、权限矩阵、pending diff、OpenFGA readiness/mismatch 展示和保存结果。页面必须复用同源 fetch client 和 auth blocker,不新增直连 OpenFGA client。
## API 接口说明
@@ -91,6 +93,7 @@
| `GET /help` | 返回可用 route 摘要。 |
| `GET /auth/oidc/login``GET /auth/oidc/callback``GET /auth/session``POST /auth/logout` | 同源代理到 cloud-api 的 Keycloak/Web session 入口;登录鉴权最终规格见 [spec-v02-auth.md](spec-v02-auth.md)。 |
| `GET/POST /v1/api-keys...` | 同源代理到 cloud-api 的 API key 管理入口;短期测试允许当前用户重复查看默认 key 明文。 |
| `GET/PATCH/PUT/DELETE /v1/admin/access...` | 同源代理到 cloud-api 的 Admin Access API;用于 Access 页面读取 summary/user matrix、授予/撤销 device pod relation、tool capability 和 role/status。 |
| `GET /v1``GET /v1/...` | 同源代理到 `hwlab-cloud-api`;公开的 Code Agent result/trace 轮询按 route policy 处理。 |
| `GET/PATCH /v1/workbench/workspace...` | 同源代理到 cloud-api 的账号 workspace authority,用于 Web/CLI 共享工作区和 revision 冲突保护。 |
| `POST/GET/PATCH /v1/agent/sessions...` | 同源代理到 cloud-api 的显式 Code Agent session 生命周期入口;Web 不在普通 send 中隐式创建 session。 |
@@ -156,6 +159,10 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验,
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:确认 trace 渲染相关单测覆盖 request、setup、commandExecution、assistant markdown 和 completion row;该测试必须能在无浏览器、无 Playwright、无公网、无真实 provider 的环境中执行。
## T6
阅读 docs/reference/spec-v02-hwlab-cloud-web.md 和 docs/reference/spec-v02-openfga-authorization.md,然后用 cli 手动测试以下内容:登录 admin 后打开 Cloud Web Access 页面,确认 ActivityRail 显示 Access 入口,页面加载 `/v1/admin/access/summary` 和用户权限矩阵;普通用户访问同一路由必须显示 authorization blocker。通过页面授予/撤销一次 device pod relation 后,`hwlab-cli client access users inspect <user>` 必须看到同一 effective matrix。
## Session state 持久化与 eviction reset
HWLAB v0.2 Code Agent chat turn 默认走 `sessionStorage=persistent`:每次 turn 由 `code-agent-agentrun-adapter.ts` 在创建 run 之前显式调 `POST /api/v1/sessions`,让 AgentRun v0.1 同步创建 RWO PVC`agentrun-v01-session-<sessionId>`1GiStorageClass 走 env `AGENTRUN_SESSION_STORAGE_CLASS` 默认 `local-path`)。runner Job manifest 渲染时多挂一个 `agentrun-sessions` volume + `AGENTRUN_SESSION_PVC_NAME` / `_NAMESPACE` / `_MOUNT_PATH` / `AGENTRUN_CODEX_ROLLOUT_SUBDIR` env,使 codex app-server 自己把 rollout JSONL 写进 PVC,跨 runner pod 重建天然 `thread/resume:completed`
@@ -194,5 +201,6 @@ runner pod 或 runner Job 丢失但 PVC 仍存在时,下一轮必须继续使
| Code Agent 无锁 composer | 已实现 | Web/CLI 共享 composer policy;运行中输入框保持可编辑并自动走 steer。 |
| 账号 workspace hydrate/sync | 已实现 | 启动读取 `account_workspaces`Code Agent 请求携带 workspace revision,终态再同步 workspace。 |
| device-pod 面板 | 未完全实现 | 当前主要消费 fake/只读 device-pod payload。 |
| Admin Access 授权页面 | 目标状态 | 新增 admin-only ActivityRail 页面,管理 OpenFGA relation/tool capability/role status,并与 CLI `client access` 共用同一路径。 |
| 完整多用户 admin/user UI | 未完全实现 | 登录态存在;Keycloak/Web session/API key 需按 spec-v02-auth 收敛,权限 authority 仍需按 spec-user-access 收敛到 cloud-api。 |
@@ -0,0 +1,237 @@
# v0.2 OpenFGA 授权与 Admin Access 管理规格
本文是 HWLAB `v0.2` 细粒度资源授权、OpenFGA 接入、管理员 Access WebUI 和同路径 CLI 的长期规格。Keycloak 只回答“用户是谁”;OpenFGA 回答“该用户能不能对该对象做该动作”;`hwlab-cloud-api` 仍是唯一应用层 enforcement point 和授权写入口。
本规格补充 [spec-v02-auth.md](spec-v02-auth.md) 和 [spec-user-access.md](spec-user-access.md):前者定义 Keycloak/Web session/API key 到 `AuthPrincipal` 的认证归一;后者定义用户、session owner、device pod 和工具能力的业务权限口径;本文定义 OpenFGA 如何在 `hwlab-v02` namespace 内以 Kubernetes 原生方式落地,并如何被 Cloud Web 与 `hwlab-cli client` 管理和验证。
实施跟踪 issue 必须记录 spec、GitOps、cloud-api、Cloud Web、CLI、测试、PR/CI/CD 和原入口验收进展。过程记录写 issue 评论,本文只保留稳定目标和验收口径。
## 在系统中的职责划分
| 组件 | 职责 |
| --- | --- |
| Keycloak | 身份认证、注册、OIDC issuer;不直接授予 HWLAB 功能权限。 |
| `hwlab-cloud-api` | `AuthPrincipal` 恢复、OpenFGA store/model bootstrap、授权 check、授权写 API、shadow/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-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。 |
OpenFGA 是 HWLAB 应用层授权基础设施,不是 Kubernetes RBAC、ServiceAccount、NetworkPolicy 或 Keycloak realm role 的替代。普通用户不获得 kubeconfig、内部 Service 直连、OpenFGA token 或 Keycloak admin 权限。
## Kubernetes 和 GitOps 落点
OpenFGA 固定部署在 `hwlab-v02` namespace,作为稳定外部服务进入 v0.2 GitOps desired state
- `deploy/gitops/g14/runtime-v02/openfga.yaml` 声明 `Deployment``Service`、migration `Job` 或 init/migrate step、SecretRef 和 health/readiness。
- `deploy/gitops/g14/runtime-v02/kustomization.yaml` 必须引用 `openfga.yaml`render 脚本如果有 service inventory 或 artifact catalog,也必须加入 OpenFGA 的外部服务记录。
- OpenFGA Service 只允许 ClusterIP,默认 DNS 为 `hwlab-openfga.hwlab-v02.svc.cluster.local`;不得配置 FRP、公网 Ingress 或 Cloud Web 直连。
- 数据存储使用 Postgres backend;禁止 production/stable runtime 使用 memory backend。可复用 `hwlab-v02-postgres` 实例,但必须使用独立 database/schema/role 和独立 SecretRef,避免和 cloud-api migration ledger 混写。
- 首次部署和版本升级必须执行 OpenFGA migratemigration 失败时 cloud-api OpenFGA readiness 必须 degraded,不能进入 enforce。
- OpenFGA 鉴权使用 preshared key 或等价内部服务 token SecretRefSecret 值不得出现在 ConfigMap、日志、issue、trace 或 CLI 默认输出。
- Readiness 使用 OpenFGA health endpointcloud-api `/health/live` 汇总 OpenFGA endpoint、store/model status、mode 和 degraded reason,但不输出 token。
OpenFGA 官方 Helm chart 可以作为 YAML 来源,但 `hwlab-v02` desired state 仍以 repo 内 GitOps render 后的 YAML 为准。任何 OpenFGA 镜像、SecretRef、Postgres URL 或 migration 变更都属于运行面/权限高风险变更,必须走 PR、CI/CD 和原入口验收。
## 授权模型
对象命名必须稳定、可从业务 ID 直接推导,并在日志/trace 中可脱敏展示:
| 对象 | 例子 | 说明 |
| --- | --- | --- |
| 用户 | `user:usr_123` | HWLAB `users.id`,不是 Keycloak `sub`。 |
| 系统 | `system:hwlab` | 平台级 admin、access 管理和全局工具授权。 |
| Device Pod | `device_pod:device-pod-71-freq` | 服务端 profile authority 对象。 |
| Code Agent session | `agent_session:ags_123` | 会话 owner/collaborator/viewer 判定。 |
| 工具 | `tool:hwpod``tool:unidesk_ssh``tool:github_pr``tool:trans_cmd` | AgentRun runner 可注入或可调用的功能能力。 |
目标关系模型:
```fga
model
schema 1.1
type user
type system
relations
define admin: [user]
define access_manager: admin
define can_manage_users: admin
define can_manage_device_pods: admin
define can_manage_tools: admin
type device_pod
relations
define owner: [user]
define viewer: [user] or owner or admin from system
define operator: [user] or owner or admin from system
define profile_editor: [user] or owner or admin from system
define job_submitter: [user] or operator
define admin: admin from system
type agent_session
relations
define owner: [user]
define viewer: [user] or owner or admin from system
define operator: [user] or owner or admin from system
define admin: admin from system
type tool
relations
define can_use: [user] or admin from system
define admin: admin from system
```
实现可以在第一版用等价模型名称和 relation 名称,但必须保持以下能力语义:
| 功能 | Check |
| --- | --- |
| 管理用户和权限 | `user:<id> access_manager system:hwlab``admin system:hwlab` |
| 查看 device pod | `viewer device_pod:<id>` |
| 提交 device pod job / 使用 `hwpod` | `operator``job_submitter device_pod:<id>`,并要求 `can_use tool:hwpod` |
| 修改 device pod profile | `profile_editor device_pod:<id>``admin system:hwlab` |
| 查看自己的 Code Agent session | `viewer agent_session:<id>` |
| 继续/取消 Code Agent session | `operator agent_session:<id>` |
| 注入 UniDesk SSH 透传能力 | `can_use tool:unidesk_ssh` |
| 允许 trans 透传 cmd | `can_use tool:trans_cmd`,且仍受 UniDesk route/operation 边界限制 |
| GitHub PR/issue 写操作工具 | `can_use tool:github_pr` 或更细工具对象 |
`admin/user` 仍保留为 UX 和 bootstrap 角色,但 runtime 授权必须逐步迁移到 OpenFGA check。`users.role='admin'` 必须镜像为 `system:hwlab#admin@user:<id>` tuple;降级 admin 时必须同步删除 tuple。Keycloak realm role、group、claim 不能直接变成 OpenFGA tuple,除非由 HWLAB admin API 明确写入。
## Cloud API 授权策略
`hwlab-cloud-api` 是唯一 policy enforcement point。所有用户态请求按以下顺序处理:
```text
authenticate -> AuthPrincipal -> load domain object -> openfga check -> reason check for mutating device jobs -> execute
```
配置项:
| 配置 | 说明 |
| --- | --- |
| `HWLAB_OPENFGA_MODE=off|shadow|enforce` | `off` 只用现有 DB/role 逻辑;`shadow` 同时 check 并记录 mismatch`enforce` 以 OpenFGA 为准。 |
| `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。 |
| `HWLAB_OPENFGA_TIMEOUT_MS` | 单次 check/write 超时;超时必须结构化返回。 |
模式语义:
- `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,不得静默放行。
tuple 写入必须由 cloud-api admin API 统一完成,并和 Postgres domain state 保持事务级或可恢复一致:
- 用户创建、禁用、角色提升/降级时同步写 `users``system:hwlab` tuples。
- device pod 创建/更新/删除时同步 `device_pods` 和相关 tuples;删除对象时清理 tuple 或标记不可用。
- Code Agent session 创建时写 `agent_session:<id>#owner@user:<owner>`;取消/归档不删除 owner tuple,便于审计和 trace 回放。
- API key `scopes_json` 只能作为用户权限的收窄条件,不能授予超过 OpenFGA 的能力。
## Admin Access API
Cloud Web 和 CLI 只能通过 cloud-api 的 admin API 管理授权。第一版 API surface
| 接口 | 说明 |
| --- | --- |
| `GET /v1/admin/access/summary` | 返回 mode、OpenFGA readiness、storeId/modelId、user/tool/device-pod 数量和最近 mismatch 摘要。 |
| `GET /v1/admin/access/users` | 列出用户、role/status、Keycloak 绑定摘要、API key 数量和 effective capability 摘要。 |
| `GET /v1/admin/access/users/{userId}` | 返回单个用户的 device pod、agent session 和 tool 权限矩阵。 |
| `PATCH /v1/admin/access/users/{userId}` | 更新用户 `role/status`,并同步 OpenFGA admin tuple。 |
| `PUT /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}` | 授予 `viewer/operator/profile_editor/job_submitter` 等 relation。 |
| `DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}` | 撤销指定 device pod relation。 |
| `PUT /v1/admin/access/users/{userId}/tools/{toolId}/can-use` | 授予工具能力,例如 `hwpod``unidesk_ssh``github_pr``trans_cmd`。 |
| `DELETE /v1/admin/access/users/{userId}/tools/{toolId}/can-use` | 撤销工具能力。 |
| `POST /v1/admin/access/check` | 管理员调试单次 authorization check;响应必须标明 actor/object/relation/mode,但不得泄漏 token。 |
所有 write API 必须要求当前 actor 具备 `access_manager system:hwlab` 或 admin tuple;普通 `user` 不可调用。响应必须包含结构化 `authorization` 字段:`mode``allowed``decisionSource``storeId``modelId``relation``object` 和 redacted actor。
## Admin Access WebUI
Cloud Web 新增 ActivityRail 顶层入口 `Access`,只对具备 access manager/admin 权限的用户显示。它不是 Keycloak 管理后台,也不放在 Settings 子页里。
页面布局:
- 左栏:用户列表、搜索、role/status 筛选、Keycloak 绑定摘要。
- 中栏:选中用户的权限矩阵,按 `Device Pods``Agent Sessions``Tools``Platform` 分组;权限用 checkbox/toggle 表达,危险工具用显式确认。
- 右栏:effective permission 预览、最近变更、OpenFGA mode/readiness、shadow mismatch 和保存结果。
交互规则:
- 初始加载先调用 `GET /v1/admin/access/summary` 和用户列表;OpenFGA degraded 时页面必须显示 blocker,不允许静默按旧 grant 表展示为通过。
- 修改权限时先在本地形成 pending diff;保存后逐项调用对应 PATCH/PUT/DELETE API,并刷新 effective matrix。
- 页面不暴露 OpenFGA tuple 原文作为主要操作面;需要排障时只在详情中显示 redacted object/relation。
- 普通用户或未登录用户访问 Access route 时显示授权 blocker 或跳登录,不渲染空管理表。
## 同路径 CLI
`hwlab-cli client` 必须提供与 WebUI 同一 API surface 的非视觉入口,默认由 runtime namespace/lane 解析到 `19666` Cloud Web origin
| CLI | Web/API 等价 |
| --- | --- |
| `client access summary` | `GET /v1/admin/access/summary` |
| `client access users list` | `GET /v1/admin/access/users` |
| `client access users inspect USER` | `GET /v1/admin/access/users/{userId}` |
| `client access users set-role USER --role admin|user` | `PATCH /v1/admin/access/users/{userId}` |
| `client access device-pods grant USER POD --relation viewer|operator|profile_editor|job_submitter` | `PUT /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}` |
| `client access device-pods revoke USER POD --relation ...` | `DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}` |
| `client access tools grant USER TOOL` | `PUT /v1/admin/access/users/{userId}/tools/{toolId}/can-use` |
| `client access tools revoke USER TOOL` | `DELETE /v1/admin/access/users/{userId}/tools/{toolId}/can-use` |
| `client access check --user USER --relation REL --object OBJECT` | `POST /v1/admin/access/check` |
CLI 输出必须是 JSON,包含 `runtimeEndpoint`、HTTP route、actor 摘要、mode、decision 和变更后的 effective matrix 摘要。它不能直接调用 OpenFGA API,不能手动传 OpenFGA token,不能把 `--base-url 19667` 作为 WebUI 等价验收路径。
## AgentRun 工具能力边界
Cloud API 在创建 AgentRun command/runner 时必须按 OpenFGA 决策装配 transient env 和工具说明:
- 用户没有 `can_use tool:hwpod` 时,不注入 `HWLAB_API_KEY``hwpod`,也不在 prompt/tools 中声明 device pod 操作可用。
- 用户没有目标 device pod 的 `operator`/`job_submitter` 时,即使拥有 `tool:hwpod`,具体 device pod job 也必须被 cloud-api 拒绝。
- 用户没有 `can_use tool:unidesk_ssh` 时,不注入 UniDesk SSH client token、workspace route 或相关 alias。
- `tool:trans_cmd` 只代表允许通过受控 UniDesk route 调用透传命令;它不绕过 UniDesk CLI 的 route/operation 安全边界,不允许 pod 内任意 Secret 读取或 Kubernetes 写操作。
- GitHub issue/PR 写入能力必须单独由工具对象授权;拥有 Code Agent session 不等于拥有 GitHub 写权限。
## 测试规格
## T1
阅读 docs/reference/spec-v02-openfga-authorization.md,然后检查 `deploy/gitops/g14/runtime-v02` 和渲染结果,确认 OpenFGA 以 ClusterIP-only 服务部署在 `hwlab-v02`,使用 Postgres backend、migration job/step 和 SecretRef`kustomization.yaml` 引用 OpenFGA 资源,且没有 FRP/Ingress 公网暴露。
## T2
阅读 docs/reference/spec-v02-openfga-authorization.md,然后访问 `GET /health/live``GET /v1/admin/access/summary`,确认响应显示 `openfga.mode`、readiness、storeId/modelId 摘要和 degraded reason;响应不得包含 OpenFGA token、Postgres URL 或 Secret 值。
## T3
阅读 docs/reference/spec-v02-openfga-authorization.md,然后用 admin 通过 CLI 或 WebUI 给普通用户授予某个 device pod 的 `viewer` 但不授予 `operator`,确认该用户可以看到 device pod 摘要,但提交 device job 返回 403;再授予 `operator/job_submitter` 后 job 可提交。
## T4
阅读 docs/reference/spec-v02-openfga-authorization.md,然后撤销普通用户的 `tool:hwpod``tool:unidesk_ssh`,创建新的 Code Agent session 并检查 trace/runner env 摘要,确认对应工具 alias/env 未注入;尝试调用时返回结构化 authorization blocker。
## T5
阅读 docs/reference/spec-v02-openfga-authorization.md,然后在 `shadow` 模式下制造一个 legacy grant 与 OpenFGA tuple 不一致的用户,访问 device pod 和 Access summary,确认业务按 legacy 结果执行但 summary/trace 显示 mismatch;切到 `enforce` 后按 OpenFGA 结果执行。
## T6
阅读 docs/reference/spec-v02-openfga-authorization.md,然后从浏览器打开 Access 页面,确认只有 admin/access manager 可见 ActivityRail 入口;普通用户访问 route 显示授权 blocker。保存一次权限变更后,CLI `client access users inspect USER` 必须看到同一 effective matrix。
## T7
阅读 docs/reference/spec-v02-openfga-authorization.md,然后在 runtime endpoint locked 环境运行 `hwlab-cli client access summary/users/check/grant/revoke`,确认全部走 Cloud Web 同源 `19666` path,输出 JSON、route、actor、mode、decision 和 effective matrix;不手动传 OpenFGA URL/token,不直连 `19667` 作为最终 Web 等价验收。
## 规格的实现情况
| 规格项 | 状态 | 说明 |
| --- | --- | --- |
| 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。 |
| 细粒度 device pod / session / tool 授权 | 目标状态 | 替代旧“device pod grant 即全权限”口径。 |
| Admin Access WebUI | 目标状态 | 新增 admin-only ActivityRail 页面,不直接调用 OpenFGA。 |
| 同路径 CLI | 目标状态 | 新增 `client access ...`,走 Cloud Web 同源 path。 |
| AgentRun 工具注入按用户权限过滤 | 目标状态 | `hwpod``unidesk_ssh``trans_cmd`、GitHub 写工具都必须独立授权。 |
+11
View File
@@ -7,6 +7,7 @@
细节权威出处:
- 用户、权限、session 归属和 device pod grant:见 [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)。
- Code Agent provider 真实聊天验收:见 [code-agent-chat-readiness.md](code-agent-chat-readiness.md)。
@@ -18,6 +19,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 都在这里判定。
- OpenFGA 是 `hwlab-v02` 内部稳定授权服务,只作为 cloud-api 的 PDP/relationship storeKeycloak、Cloud Web、CLI、AgentRun runner 和普通用户都不能直接调用 OpenFGA。
- `hwlab-cloud-web` 只作为用户入口和 API proxy,不拥有业务 authorityCLI 可以旁路 UI,但不能旁路 `cloud-api` 的授权。
- `hwlab-device-pod` 是正式设备业务承载点;用户态请求必须走 `cloud-api -> hwlab-device-pod -> hwlab-gateway -> device-host-cli -> hardware`
- Code Agent session 归属、鉴权、trace 和用户态 API 收敛在 `hwlab-cloud-api`;执行调度接入 AgentRun v0.1 共享基础设施。`hwlab-agent-mgr``hwlab-agent-worker` 和 repo-owned codex-stdio supervisor 不是 v0.2 runtime service matrix,不再生成 Deployment、Job template、Service、artifact 或 GitOps desired state。
@@ -47,6 +49,12 @@ cloud-web or device-pod-cli or code agent tool
-> Keil / pyOCD / UART / target
```
```text
hwlab-cloud-api
-> OpenFGA
-> Postgres
```
```text
hwlab-cloud-api
-> codex-api profile
@@ -80,6 +88,7 @@ origin/v0.2
| 浏览器工作台 | `http://74.48.78.17:19666/` | [spec-v02-hwlab-cloud-web.md](spec-v02-hwlab-cloud-web.md) |
| API/live 公网入口 | `http://74.48.78.17:19667/health/live` 和同源 API | [spec-v02-hwlab-edge-proxy.md](spec-v02-hwlab-edge-proxy.md)、[spec-v02-hwlab-cloud-api.md](spec-v02-hwlab-cloud-api.md) |
| 用户、session、授权 | `/auth/*``/v1/admin/*``/v1/agent/chat*` | [spec-user-access.md](spec-user-access.md)、[spec-v02-hwlab-cloud-api.md](spec-v02-hwlab-cloud-api.md) |
| OpenFGA 授权管理 | `/v1/admin/access*``hwlab-cli client access ...`、Cloud Web Access 页面 | [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) |
| Device Pod | `/v1/device-pods*`、正式 job/admin API | [spec-device-pod.md](spec-device-pod.md)、[spec-v02-hwlab-device-pod-service.md](spec-v02-hwlab-device-pod-service.md) |
| Gateway transport | `cloud-api /v1/gateway/poll``/v1/gateway/result`、gateway `/status` | [spec-v02-hwlab-gateway.md](spec-v02-hwlab-gateway.md) |
| Code Agent provider | `deepseek``codex-api` provider profile | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md)、[spec-v02-codex-api-forwarder.md](spec-v02-codex-api-forwarder.md) |
@@ -117,6 +126,7 @@ origin/v0.2
| Moon Bridge | 外部稳定服务 | 保留 | 否 | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md) |
| `frpc` / `frps` | 外部稳定服务 | 保留 | 否 | [spec-v02-frpc.md](spec-v02-frpc.md) |
| Postgres | 外部稳定服务 | 保留 | 否 | [spec-v02-postgres.md](spec-v02-postgres.md) |
| OpenFGA | 外部稳定服务 / 内部授权 PDP | 新增并保留,ClusterIP-only | 否 | [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) |
| Argo CD / Tekton / BuildKit / registry | 外部稳定服务 | 保留 | 否 | [spec-v02-cicd.md](spec-v02-cicd.md)、[g14-gitops-cicd.md](g14-gitops-cicd.md) |
| Codex CLI | 外部工具/runtime | 保留 | 否 | [code-agent-chat-readiness.md](code-agent-chat-readiness.md) |
| Keil / pyOCD / UART 工具 | 外部或主机侧工具 | 保留 | 否 | [spec-device-pod.md](spec-device-pod.md) |
@@ -200,6 +210,7 @@ hwlab-agent-worker
| v0.2 总体依赖方向 | 已实现 | 本文定义浏览器、API、device、provider 和 CI/CD 链路。 |
| 保留服务均有 spec | 已实现 | 本文服务总表列出当前保留服务和 spec 文件。 |
| 稳定外部服务纳入 spec | 已实现 | Postgres、Codex API forwarder/hyueapi、DeepSeek/Moon Bridge、FRP 已独立成文或交叉引用权威规格。 |
| OpenFGA 授权服务纳入 spec | 目标状态 | 需要按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 完成 GitOps、cloud-api、WebUI 和 CLI。 |
| `simu`、接线盘、router、tunnel-client 裁撤 | 已实现 | 本文记录裁撤口径;v0.2 render、artifact catalog、Tekton build service set 和 cloud-api 运行时 env 不再包含裁撤对象。 |
| Bun + TypeScript 迁移边界 | 已实现 | 本文区分第一阶段、后续、暂不迁移和裁撤集合。 |
| spec 作为开发和测试权威 | 已实现 | AGENTS.md 规格区提供顶级索引。 |
+185
View File
@@ -1198,6 +1198,166 @@ 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 server = createCloudApiServer({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_OPENFGA_MODE: "off" },
now: () => "2026-06-04T00:00:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const setup = await postJson(port, "/v1/setup/first-admin", { username: "access-admin", password: "admin-pass" });
assert.equal(setup.status, 201);
const adminCookie = setup.cookie;
const alice = await postJson(port, "/v1/admin/users", { username: "access-alice", password: "alice-pass", displayName: "Access Alice" }, adminCookie);
assert.equal(alice.status, 201);
const userId = alice.body.user.id;
const pod = await postJson(port, "/v1/admin/device-pods", {
devicePodId: "device-pod-access-api",
name: "Access API Pod",
profile: { schemaVersion: 1, target: { id: "target-access-api" }, route: { gatewaySessionId: "gws_access_api" } }
}, adminCookie);
assert.equal(pod.status, 201);
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.counts.users, 2);
assert.equal(summary.body.counts.devicePods, 1);
assert.deepEqual(summary.body.supported.devicePodRelations, ["viewer", "operator", "profile_editor", "job_submitter"]);
const users = await getJson(port, "/v1/admin/access/users", adminCookie);
assert.equal(users.status, 200);
assert.ok(users.body.users.some((item) => item.user.id === userId));
const grantViewer = await putJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-access-api/viewer`, {}, adminCookie);
assert.equal(grantViewer.status, 201);
const matrixAfterViewer = grantViewer.body.access.devicePods.find((item) => item.devicePod.devicePodId === "device-pod-access-api");
assert.equal(matrixAfterViewer.relations.viewer, true);
assert.equal(matrixAfterViewer.relations.operator, false);
const grantTool = await putJson(port, `/v1/admin/access/users/${userId}/tools/hwpod/can-use`, {}, adminCookie);
assert.equal(grantTool.status, 201);
assert.equal(grantTool.body.access.tools.hwpod, true);
const checkViewer = await postJson(port, "/v1/admin/access/check", {
userId,
relation: "viewer",
object: "device_pod:device-pod-access-api"
}, adminCookie);
assert.equal(checkViewer.status, 200);
assert.equal(checkViewer.body.authorization.allowed, true);
assert.equal(checkViewer.body.authorization.mode, "off");
const aliceLogin = await postJson(port, "/auth/login", { username: "access-alice", password: "alice-pass" });
assert.equal(aliceLogin.status, 200);
const visible = await getJson(port, "/v1/device-pods", aliceLogin.cookie);
assert.equal(visible.status, 200);
assert.deepEqual(visible.body.devicePods.map((item) => item.devicePodId), ["device-pod-access-api"]);
const revokeTool = await deleteJson(port, `/v1/admin/access/users/${userId}/tools/hwpod/can-use`, {}, adminCookie);
assert.equal(revokeTool.status, 200);
assert.equal(revokeTool.body.access.tools.hwpod, false);
const revokeViewer = await deleteJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-access-api/viewer`, {}, adminCookie);
assert.equal(revokeViewer.status, 200);
const matrixAfterRevoke = revokeViewer.body.access.devicePods.find((item) => item.devicePod.devicePodId === "device-pod-access-api");
assert.equal(matrixAfterRevoke.relations.viewer, false);
const hidden = await getJson(port, "/v1/device-pods", aliceLogin.cookie);
assert.equal(hidden.status, 200);
assert.deepEqual(hidden.body.devicePods, []);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("cloud api enforce mode uses OpenFGA relations for device-pod visibility and jobs", async () => {
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"
},
openFgaAuthorizer,
now: () => "2026-06-04T00:00:00.000Z"
});
const server = createCloudApiServer({
env: {
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
},
accessController,
now: () => "2026-06-04T00:00:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
assert.equal(adminLogin.status, 200);
const adminCookie = adminLogin.cookie;
const alice = await postJson(port, "/v1/admin/users", { username: "enforce-alice", password: "alice-pass" }, adminCookie);
assert.equal(alice.status, 201);
const userId = alice.body.user.id;
const pod = await postJson(port, "/v1/admin/device-pods", {
devicePodId: "device-pod-enforce",
profile: { schemaVersion: 1, target: { id: "target-enforce" }, route: { gatewaySessionId: "gws_enforce" } }
}, adminCookie);
assert.equal(pod.status, 201);
const viewer = await putJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-enforce/viewer`, {}, adminCookie);
assert.equal(viewer.status, 201);
assert.equal(openFgaAuthorizer.tuples.has(`user:${userId}#viewer@device_pod:device-pod-enforce`), true);
const profileCheck = await postJson(port, "/v1/admin/access/check", {
userId,
relation: "profile_editor",
object: "device_pod:device-pod-enforce"
}, adminCookie);
assert.equal(profileCheck.status, 200);
assert.equal(profileCheck.body.authorization.allowed, false);
assert.equal(profileCheck.body.authorization.mode, "enforce");
const aliceLogin = await postJson(port, "/auth/login", { username: "enforce-alice", password: "alice-pass" });
assert.equal(aliceLogin.status, 200);
const visible = await getJson(port, "/v1/device-pods", aliceLogin.cookie);
assert.equal(visible.status, 200);
assert.deepEqual(visible.body.devicePods.map((item) => item.devicePodId), ["device-pod-enforce"]);
const deniedJob = await postJson(port, "/v1/device-pods/device-pod-enforce/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie);
assert.equal(deniedJob.status, 403);
assert.equal(deniedJob.body.error.code, "device_pod_authorization_denied");
assert.match(deniedJob.body.blocker.summary, /relation operator/u);
const operator = await putJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-enforce/operator`, {}, adminCookie);
assert.equal(operator.status, 201);
const allowedJob = await postJson(port, "/v1/device-pods/device-pod-enforce/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie);
assert.equal(allowedJob.status, 409);
assert.equal(allowedJob.body.blocker.code, "device_pod_executor_unavailable");
const profileGrant = await putJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-enforce/profile_editor`, {}, adminCookie);
assert.equal(profileGrant.status, 201);
const profileAllowed = await postJson(port, "/v1/admin/access/check", {
userId,
relation: "profile_editor",
object: "device_pod:device-pod-enforce"
}, adminCookie);
assert.equal(profileAllowed.status, 200);
assert.equal(profileAllowed.body.authorization.allowed, true);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("cloud api stores and restores Code Agent conversations by authenticated account", async () => {
const server = createCloudApiServer({
env: {
@@ -2803,6 +2963,31 @@ async function deleteJson(port, path, body = {}, cookie = null, extraHeaders = {
};
}
function createFakeOpenFgaAuthorizer({ mode = "enforce" } = {}) {
const tuples = new Set();
function key({ userId, relation, object }) { return `user:${userId}#${relation}@${object}`; }
return {
mode,
tuples,
async describe() {
return { contractVersion: "openfga-authorization-v1", mode, configured: true, ready: true, storeId: "fake-store", modelId: "fake-model", tupleCount: tuples.size };
},
async check({ actor, userId, relation, object, legacyAllowed = false }) {
const subject = actor?.id ?? userId;
const allowed = actor?.role === "admin" || tuples.has(key({ userId: subject, relation, object }));
return { contractVersion: "openfga-authorization-v1", mode, user: `user:${subject}`, relation, object, allowed, legacyAllowed, decisionSource: "fake-openfga", degraded: false };
},
async writeTuple({ userId, relation, object }) {
tuples.add(key({ userId, relation, object }));
return { ok: true, mode, written: 1, deleted: 0 };
},
async deleteTuple({ userId, relation, object }) {
tuples.delete(key({ userId, relation, object }));
return { ok: true, mode, written: 0, deleted: 1 };
}
};
}
async function requestJson(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
+348 -5
View File
@@ -7,6 +7,13 @@ import {
syncAgentRunChatResult
} from "./code-agent-agentrun-adapter.ts";
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import {
DEVICE_POD_RELATIONS,
HWLAB_TOOL_IDS,
createOpenFgaAuthorizer,
openFgaObject,
parseOpenFgaObject
} from "./openfga-authorization.ts";
import { getHeader, readBody, sendJson, sendRedirect, truthyFlag } from "./server-http-utils.ts";
const SESSION_COOKIE = "hwlab_session";
@@ -179,7 +186,22 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT
)`
)`,
`CREATE TABLE IF NOT EXISTS access_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS access_tuples (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
relation TEXT NOT NULL,
object TEXT NOT NULL,
created_by_admin_id TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, relation, object)
)`,
`CREATE INDEX IF NOT EXISTS idx_access_tuples_user ON access_tuples(user_id, object)`,
`CREATE INDEX IF NOT EXISTS idx_access_tuples_object ON access_tuples(object, relation)`
]);
const MUTATING_INTENTS = new Set([
@@ -225,7 +247,7 @@ function accessStoreForRuntime(runtimeStore, options = {}) {
}
class AccessController {
constructor({ store, env = process.env, gatewayRegistry = null, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, devicePodExecutorUrl = env.HWLAB_DEVICE_POD_URL, devicePodExecutorTimeoutMs = 1200, devicePodSyncTimeoutMs = env.HWLAB_DEVICE_POD_SYNC_TIMEOUT_MS ?? 120000, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED) } = {}) {
constructor({ store, env = process.env, gatewayRegistry = null, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, devicePodExecutorUrl = env.HWLAB_DEVICE_POD_URL, devicePodExecutorTimeoutMs = 1200, devicePodSyncTimeoutMs = env.HWLAB_DEVICE_POD_SYNC_TIMEOUT_MS ?? 120000, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED), openFgaAuthorizer = null } = {}) {
this.store = store;
this.env = env;
this.gatewayRegistry = gatewayRegistry;
@@ -240,6 +262,7 @@ class AccessController {
this.devicePodSyncTimeoutMs = Number.parseInt(String(devicePodSyncTimeoutMs), 10) || 120000;
this.now = now;
this.required = required;
this.openFga = openFgaAuthorizer ?? createOpenFgaAuthorizer({ env, fetchImpl, now, configStore: store });
this.bootstrapAttempted = false;
}
@@ -490,6 +513,10 @@ class AccessController {
return sendJson(response, 403, errorPayload("admin_required", "Only admin users can call v0.2 admin APIs", 403));
}
if (url.pathname === "/v1/admin/access" || url.pathname.startsWith("/v1/admin/access/")) {
return this.handleAdminAccessRoute(request, response, url, auth);
}
if (request.method === "POST" && url.pathname === "/v1/admin/users") {
const body = await jsonBody(request);
const user = await this.store.createUser({
@@ -500,6 +527,7 @@ class AccessController {
passwordHash: hashPassword(requiredText(body.password, "password")),
now: this.now()
});
await this.syncUserAdminTuple(user, auth.actor);
return sendJson(response, 201, { created: true, user: redactedUser(user) });
}
@@ -533,12 +561,18 @@ class AccessController {
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 });
}
@@ -548,6 +582,206 @@ class AccessController {
}
}
async handleAdminAccessRoute(request, response, url, auth) {
if (request.method === "GET" && url.pathname === "/v1/admin/access/summary") {
const payload = await this.adminAccessSummary(auth.actor);
return sendJson(response, 200, payload);
}
if (request.method === "GET" && url.pathname === "/v1/admin/access/users") {
const users = await this.store.listUsers?.() ?? [];
const summaries = [];
for (const user of users) summaries.push(await this.userAccessSummary(user));
return sendJson(response, 200, {
contractVersion: "admin-access-v1",
actor: publicActor(auth.actor),
openfga: await this.openFga.describe(),
users: summaries,
count: summaries.length
});
}
const userMatch = url.pathname.match(/^\/v1\/admin\/access\/users\/([^/]+)$/u);
if (request.method === "GET" && userMatch) {
const user = await this.store.getUserById(decodeURIComponent(userMatch[1]));
if (!user) return sendJson(response, 404, errorPayload("user_not_found", "User was not found", 404));
return sendJson(response, 200, await this.userAccessMatrixPayload(user, auth.actor));
}
if ((request.method === "PATCH" || request.method === "PUT") && userMatch) {
const userId = decodeURIComponent(userMatch[1]);
const body = await jsonBody(request);
const existing = await this.store.getUserById(userId);
if (!existing) return sendJson(response, 404, errorPayload("user_not_found", "User was not found", 404));
const updated = await this.store.updateUserAccess?.({
userId,
role: body.role === "admin" ? "admin" : body.role === "user" ? "user" : existing.role,
status: body.status === "disabled" ? "disabled" : body.status === "active" ? "active" : existing.status,
now: this.now()
}) ?? existing;
await this.syncUserAdminTuple(updated, auth.actor);
return sendJson(response, 200, { updated: true, user: redactedUser(updated), access: await this.userAccessMatrixPayload(updated, auth.actor) });
}
const devicePodRelationMatch = url.pathname.match(/^\/v1\/admin\/access\/users\/([^/]+)\/device-pods\/([^/]+)\/([^/]+)$/u);
if (devicePodRelationMatch && (request.method === "PUT" || request.method === "DELETE")) {
const userId = decodeURIComponent(devicePodRelationMatch[1]);
const devicePodId = decodeURIComponent(devicePodRelationMatch[2]);
const relation = decodeURIComponent(devicePodRelationMatch[3]);
if (!DEVICE_POD_RELATIONS.includes(relation)) return sendJson(response, 400, errorPayload("invalid_device_pod_relation", "Device pod relation is not supported", 400));
await this.requireGrantTargets({ devicePodId, userId });
const result = request.method === "PUT"
? await this.grantDevicePodRelation({ userId, devicePodId, relation, admin: auth.actor })
: await this.revokeDevicePodRelation({ userId, devicePodId, relation });
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 });
}
const toolMatch = url.pathname.match(/^\/v1\/admin\/access\/users\/([^/]+)\/tools\/([^/]+)\/can-use$/u);
if (toolMatch && (request.method === "PUT" || request.method === "DELETE")) {
const userId = decodeURIComponent(toolMatch[1]);
const toolId = normalizeToolId(decodeURIComponent(toolMatch[2]));
if (!HWLAB_TOOL_IDS.includes(toolId)) return sendJson(response, 400, errorPayload("invalid_tool_id", "Tool id is not supported", 400));
const user = await this.store.getUserById(userId);
if (!user) return sendJson(response, 404, errorPayload("user_not_found", "User was not found", 404));
const object = openFgaObject("tool", toolId);
const result = request.method === "PUT"
? await this.grantAccessTuple({ userId, relation: "can_use", object, admin: auth.actor })
: await this.revokeAccessTuple({ userId, relation: "can_use", object });
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") {
const body = await jsonBody(request);
const userId = requiredText(body.userId ?? body.user ?? body.actorId, "userId");
const relation = requiredText(body.relation, "relation");
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 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 });
}
return sendJson(response, 404, errorPayload("not_found", "Admin Access route is not implemented", 404));
}
async adminAccessSummary(actor) {
const [users, pods, tuples, openfga] = await Promise.all([
this.store.listUsers?.() ?? [],
this.store.listDevicePods?.() ?? [],
this.store.listAccessTuples?.() ?? [],
this.openFga.describe()
]);
const toolTupleCount = tuples.filter((tuple) => parseOpenFgaObject(tuple.object)?.type === "tool").length;
const devicePodTupleCount = tuples.filter((tuple) => parseOpenFgaObject(tuple.object)?.type === "device_pod").length;
return {
contractVersion: "admin-access-v1",
actor: publicActor(actor),
openfga,
counts: {
users: users.length,
devicePods: pods.length,
tuples: tuples.length,
toolTuples: toolTupleCount,
devicePodTuples: devicePodTupleCount
},
supported: {
devicePodRelations: DEVICE_POD_RELATIONS,
toolIds: HWLAB_TOOL_IDS
}
};
}
async userAccessSummary(user) {
const tuples = await this.store.listAccessTuples?.({ userId: user.id }) ?? [];
return {
user: redactedUser(user),
tupleCount: tuples.length,
tools: toolsFromTuples(tuples),
devicePodCount: devicePodMatrixFromTuples(tuples).length
};
}
async userAccessMatrixPayload(user, actor) {
const [tuples, pods, openfga] = await Promise.all([
this.store.listAccessTuples?.({ userId: user.id }) ?? [],
this.store.listDevicePods?.() ?? [],
this.openFga.describe()
]);
const devicePods = pods.map((pod) => {
const relations = Object.fromEntries(DEVICE_POD_RELATIONS.map((relation) => [relation, tuples.some((tuple) => tuple.object === openFgaObject("device_pod", pod.id) && tuple.relation === relation)]));
return { devicePod: publicDevicePod(pod, { includeAdmin: true }), relations };
});
const tools = Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, tuples.some((tuple) => tuple.object === openFgaObject("tool", toolId) && tuple.relation === "can_use")]));
return { contractVersion: "admin-access-v1", actor: publicActor(actor), user: redactedUser(user), openfga, devicePods, tools, tuples: tuples.map(publicAccessTuple) };
}
async syncUserAdminTuple(user, admin) {
if (!user) return null;
const object = openFgaObject("system", "hwlab");
return user.role === "admin"
? this.grantAccessTuple({ userId: user.id, relation: "admin", object, admin: admin ?? user })
: this.revokeAccessTuple({ userId: user.id, relation: "admin", object });
}
async syncExistingAdminTuples() {
const users = await this.store.listUsers?.() ?? [];
for (const user of users.filter((item) => item.role === "admin")) await this.syncUserAdminTuple(user, user);
}
async grantDevicePodRelation({ userId, devicePodId, relation, admin, mirrorLegacy = true }) {
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;
}
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;
}
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 });
}
async revokeAccessTuple({ userId, relation, object }) {
await this.store.deleteAccessTuple?.({ userId, relation, object });
return this.openFga.deleteTuple({ userId, relation, object });
}
async legacyAllowedForRelation({ 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;
}
async listVisibleDevicePods(actor) {
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 authorization = await this.openFga.check({ actor, relation: "viewer", object: openFgaObject("device_pod", pod.id), legacyAllowed });
if (authorization.allowed) visible.push(pod);
}
return visible;
}
async getVisibleDevicePod(actor, id) {
return (await this.listVisibleDevicePods(actor)).find((pod) => pod.id === id) ?? null;
}
async authorizeDevicePodOperation({ actor, pod, relation, legacyAllowed = false }) {
const authorization = await this.openFga.check({ actor, relation, object: openFgaObject("device_pod", pod.id), legacyAllowed: actor.role === "admin" || legacyAllowed });
return authorization;
}
async handleDevicePodRoute(request, response, url) {
try {
await this.ensureBootstrap();
@@ -555,7 +789,7 @@ class AccessController {
if (!auth.ok) return sendJson(response, auth.status, auth);
if (request.method === "GET" && url.pathname === "/v1/device-pods") {
const pods = await this.store.listVisibleDevicePods(auth.actor);
const pods = await this.listVisibleDevicePods(auth.actor);
return sendJson(response, 200, {
serviceId: CLOUD_API_SERVICE_ID,
contractVersion: "device-pod-authority-v1",
@@ -570,7 +804,7 @@ class AccessController {
const parsed = parseDevicePodPath(url.pathname);
if (!parsed) return sendJson(response, 404, errorPayload("not_found", "Device Pod route is not implemented", 404));
const pod = await this.store.getVisibleDevicePod(auth.actor, parsed.devicePodId);
const pod = await this.getVisibleDevicePod(auth.actor, parsed.devicePodId);
if (!pod) {
const existing = await this.store.getDevicePod(parsed.devicePodId);
if (existing?.status === "active") {
@@ -843,6 +1077,20 @@ class AccessController {
};
}
async codeAgentToolCapabilitiesForOwner(ownerUserId) {
await this.ensureBootstrap();
if (this.openFga.mode === "off") return null;
const actor = await this.store.getUserById(ownerUserId);
if (!actor || actor.status !== "active") return defaultDeniedToolCapabilities();
const entries = {};
for (const toolId of HWLAB_TOOL_IDS) {
const legacyAllowed = false;
const authorization = await this.openFga.check({ actor, relation: "can_use", object: openFgaObject("tool", toolId), legacyAllowed });
entries[toolId] = { allowed: authorization.allowed, authorization };
}
return { contractVersion: "admin-access-v1", actor: publicActor(actor), tools: entries, valuesRedacted: true };
}
async bootstrapAdminActor() {
return await this.store.findUserByUsername(this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin")
?? await this.store.getUserById(this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin");
@@ -861,6 +1109,7 @@ class AccessController {
passwordHash,
now: this.now()
});
await this.syncExistingAdminTuples();
return;
}
if (!passwordHash) return;
@@ -873,6 +1122,7 @@ class AccessController {
passwordHash,
now: this.now()
});
await this.syncExistingAdminTuples();
}
async setupRequired() {
@@ -1569,6 +1819,14 @@ 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 authorization = await this.authorizeDevicePodOperation({ actor, pod, relation: "viewer", legacyAllowed });
if (!authorization.allowed) {
return {
job: await this.createBlockedAuthorizationDevicePodJob({ pod, actor, intent, args, reason: "", authorization }),
httpStatus: 403
};
}
const traceId = `trc_devicepod_${randomUUID()}`;
const operationId = `op_devicepod_${randomUUID()}`;
const now = this.now();
@@ -1606,10 +1864,22 @@ class AccessController {
blocker: deviceJobBlocked("device_job_reason_required", `Device job intent ${intent} requires reason`, false)
});
}
const args = normalizeObject(body.args);
const legacyAllowed = actor.role === "admin" || await this.store.hasDevicePodGrant?.({ devicePodId: pod.id, userId: actor.id });
const relation = MUTATING_INTENTS.has(intent) ? "job_submitter" : "operator";
const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation, legacyAllowed });
if (!authorization.allowed) {
return this.rejectDevicePodJob(response, pod, actor, {
httpStatus: 403,
intent,
args,
reason,
blocker: authorizationBlocker("device_pod_authorization_denied", `Device job intent ${intent} is not authorized for relation ${relation}`, authorization)
});
}
const traceId = `trc_devicepod_${randomUUID()}`;
const operationId = `op_devicepod_${randomUUID()}`;
const now = this.now();
const args = normalizeObject(body.args);
const job = await this.store.createDevicePodJob({
id: `job_devicepod_${randomUUID()}`,
devicePodId: pod.id,
@@ -1657,6 +1927,26 @@ class AccessController {
});
}
async createBlockedAuthorizationDevicePodJob({ pod, actor, intent, args = {}, reason = "", authorization }) {
const now = this.now();
const blocker = authorizationBlocker("device_pod_authorization_denied", `Device Pod ${pod.id} is not authorized for the current actor`, authorization);
return this.store.createDevicePodJob({
id: `job_devicepod_${randomUUID()}`,
devicePodId: pod.id,
ownerUserId: actor.id,
status: "blocked",
intent,
args,
reason,
traceId: `trc_devicepod_${randomUUID()}`,
operationId: `op_devicepod_${randomUUID()}`,
output: { error: blocker.summary },
blocker,
now,
completedAt: now
});
}
async dispatchDevicePodExecutorJob({ job, pod, actor }) {
const target = `${this.devicePodExecutorUrl}/v1/device-pods/${encodeURIComponent(pod.id)}/jobs`;
try {
@@ -1836,11 +2126,21 @@ class MemoryAccessStore {
this.jobs = new Map();
this.agentSessions = new Map();
this.workspaces = new Map();
this.accessConfig = new Map();
this.accessTuples = new Map();
}
async countUsers() { return this.users.size; }
async getUserById(id) { return this.users.get(id) ?? null; }
async listUsers() { return [...this.users.values()].sort((a, b) => String(a.username).localeCompare(String(b.username))); }
async findUserByUsername(username) { return [...this.users.values()].find((user) => user.username === username) ?? null; }
async updateUserAccess(input = {}) {
const user = this.users.get(textOr(input.userId, ""));
if (!user) return null;
const next = { ...user, role: input.role ?? user.role, status: input.status ?? user.status, updatedAt: input.now ?? this.now() };
this.users.set(next.id, next);
return next;
}
async syncBootstrapAdminPassword(input) {
const existing = await this.findUserByUsername(input.username) ?? await this.getUserById(input.id);
if (!existing || existing.role !== "admin") return null;
@@ -1910,8 +2210,24 @@ class MemoryAccessStore {
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 = {}) {
const tuple = { userId: input.userId, relation: input.relation, object: input.object, createdByAdminId: input.createdByAdminId, createdAt: input.now ?? this.now() };
this.accessTuples.set(accessTupleKey(tuple), tuple);
return tuple;
}
async deleteAccessTuple(input = {}) { this.accessTuples.delete(accessTupleKey(input)); }
async listAccessTuples(input = {}) {
return [...this.accessTuples.values()]
.filter((tuple) => !input.userId || tuple.userId === input.userId)
.filter((tuple) => !input.object || tuple.object === input.object)
.sort((a, b) => `${a.object}:${a.relation}`.localeCompare(`${b.object}:${b.relation}`));
}
async createDevicePodJob(input) { const job = normalizeJob(input); this.jobs.set(job.id, job); return job; }
async updateDevicePodJob(devicePodId, jobId, patch) { const job = this.jobs.get(jobId); if (!job || job.devicePodId !== devicePodId) return null; const next = { ...job, ...patch, updatedAt: patch.updatedAt ?? this.now() }; this.jobs.set(jobId, next); return next; }
async getDevicePodJob(devicePodId, jobId) { const job = this.jobs.get(jobId); return job?.devicePodId === devicePodId ? job : null; }
@@ -2005,7 +2321,9 @@ class PostgresAccessStore extends MemoryAccessStore {
async countUsers() { await this.ensureSchema(); const result = await this.query("SELECT COUNT(*)::int AS count FROM users", []); return Number(result.rows?.[0]?.count ?? 0); }
async getUserById(id) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE id = $1 LIMIT 1", [id]); return pgUser(result.rows?.[0]); }
async listUsers() { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users ORDER BY username", []); return result.rows.map(pgUser); }
async findUserByUsername(username) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE username = $1 LIMIT 1", [username]); return pgUser(result.rows?.[0]); }
async updateUserAccess(input = {}) { await this.ensureSchema(); const result = await this.query("UPDATE users SET role = $2, status = $3, updated_at = $4 WHERE id = $1 RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [input.userId, input.role, input.status, input.now ?? this.now()]); return pgUser(result.rows?.[0]); }
async syncBootstrapAdminPassword(input) {
await this.ensureSchema();
const result = await this.query("UPDATE users SET password_hash = $3, updated_at = $4 WHERE role = 'admin' AND (username = $1 OR id = $2) RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [input.username, input.id, input.passwordHash, input.now ?? this.now()]);
@@ -2053,7 +2371,14 @@ class PostgresAccessStore extends MemoryAccessStore {
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; }
async deleteAccessTuple(input = {}) { await this.ensureSchema(); await this.query("DELETE FROM access_tuples WHERE user_id = $1 AND relation = $2 AND object = $3", [input.userId, input.relation, input.object]); }
async listAccessTuples(input = {}) { await this.ensureSchema(); const params = []; const clauses = []; if (input.userId) { params.push(input.userId); clauses.push(`user_id = $${params.length}`); } if (input.object) { params.push(input.object); clauses.push(`object = $${params.length}`); } const sql = `SELECT user_id, relation, object, created_by_admin_id, created_at FROM access_tuples ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY object, relation, user_id`; const result = await this.query(sql, params); return result.rows.map(pgAccessTuple); }
async createDevicePodJob(input) { await this.ensureSchema(); const job = normalizeJob(input); await this.query("INSERT INTO device_pod_jobs (id, device_pod_id, owner_user_id, status, intent, args_json, reason, trace_id, operation_id, output_json, blocker_json, created_at, updated_at, completed_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)", jobParams(job)); return job; }
async updateDevicePodJob(devicePodId, jobId, patch) { await this.ensureSchema(); const current = await this.getDevicePodJob(devicePodId, jobId); if (!current) return null; const next = { ...current, ...patch, updatedAt: patch.updatedAt ?? this.now() }; await this.query("UPDATE device_pod_jobs SET status=$3, output_json=$4, blocker_json=$5, updated_at=$6, completed_at=$7 WHERE device_pod_id=$1 AND id=$2", [devicePodId, jobId, next.status, stableJson(next.output ?? {}), stableJson(next.blocker ?? {}), next.updatedAt, next.completedAt]); return next; }
async getDevicePodJob(devicePodId, jobId) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 AND id = $2 LIMIT 1", [devicePodId, jobId]); return pgJob(result.rows?.[0]); }
@@ -2259,8 +2584,10 @@ 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 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) {
if (!MUTATING_INTENTS.has(intent)) return false;
if (reason) return false;
@@ -2270,6 +2597,7 @@ function _deviceJobRequiresReason(intent, args, reason) {
return true;
}
function deviceJobBlocked(code, summary, retryable) { return { code, layer: "device-pod", retryable, summary, userMessage: summary }; }
function authorizationBlocker(code, summary, authorization) { return { code, layer: "authorization", retryable: authorization?.degradedReason ? true : false, summary, userMessage: summary, authorization }; }
function devicePodExecutorBlocker(summary) { return { code: "device_pod_executor_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但内部执行服务当前不可用。" }; }
function gatewayDispatchBlocker(summary) { return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但当前没有可用 gateway/device-host-cli 执行通道。" }; }
function normalizeDeviceJobStatus(status, httpStatus) { const text = textOr(status, ""); return ["queued", "running", "completed", "failed", "blocked", "canceled"].includes(text) ? text : httpStatus >= 400 ? "blocked" : "running"; }
@@ -2333,6 +2661,20 @@ function publicApiKey(key, { includeSecret = false } = {}) {
function redactedUser(user) { return publicActor(user); }
function publicSession(session) { return { id: session.id, userId: session.userId, createdAt: session.createdAt, lastSeenAt: session.lastSeenAt, expiresAt: session.expiresAt, revoked: Boolean(session.revokedAt) }; }
function publicDevicePod(pod, { includeAdmin = false } = {}) { return { devicePodId: pod.id, name: pod.name, status: pod.status, targetId: targetIdFromProfile(pod.profile), profileHash: pod.profileHash, profile: redactedProfile(pod.profile), createdAt: pod.createdAt, updatedAt: pod.updatedAt, blocker: null, ...(includeAdmin ? { admin: { profileStored: true, routeStored: Boolean(pod.profile.route) } } : {}) }; }
function publicAccessTuple(tuple) { return tuple ? { userId: tuple.userId, relation: tuple.relation, object: tuple.object, createdByAdminId: tuple.createdByAdminId, createdAt: tuple.createdAt } : null; }
function toolsFromTuples(tuples = []) { return Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, tuples.some((tuple) => tuple.object === openFgaObject("tool", toolId) && tuple.relation === "can_use")])); }
function defaultDeniedToolCapabilities() { return { contractVersion: "admin-access-v1", actor: null, tools: Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, { allowed: false, authorization: { contractVersion: "openfga-authorization-v1", mode: "enforce", allowed: false, decisionSource: "missing-owner", degraded: false } }])), valuesRedacted: true }; }
function devicePodMatrixFromTuples(tuples = []) {
const byPod = new Map();
for (const tuple of tuples) {
const parsed = parseOpenFgaObject(tuple.object);
if (parsed?.type !== "device_pod") continue;
const current = byPod.get(parsed.id) ?? Object.fromEntries(DEVICE_POD_RELATIONS.map((relation) => [relation, false]));
if (DEVICE_POD_RELATIONS.includes(tuple.relation)) current[tuple.relation] = true;
byPod.set(parsed.id, current);
}
return [...byPod.entries()].map(([devicePodId, relations]) => ({ devicePodId, relations })).sort((a, b) => a.devicePodId.localeCompare(b.devicePodId));
}
function redactedProfile(profile = {}) { return { schemaVersion: profile.schemaVersion ?? null, target: { id: profile.target?.id ?? null }, projectWorkspace: { projectPath: profile.projectWorkspace?.projectPath ?? null, targetName: profile.projectWorkspace?.targetName ?? null, hexPath: profile.projectWorkspace?.hexPath ?? null }, debugInterface: { type: profile.debugInterface?.type ?? null }, ioInterface: { uartCount: Array.isArray(profile.ioInterface?.uart) ? profile.ioInterface.uart.length : 0 }, route: { configured: Boolean(profile.route?.gatewaySessionId), gatewaySessionId: "redacted", resourceId: profile.route?.resourceId ? "redacted" : null, capabilityId: profile.route?.capabilityId ? "redacted" : null } }; }
function targetIdFromProfile(profile = {}) { return profile.target?.id ?? profile.targetId ?? null; }
function terminalJobStatus(status) { return ["completed", "failed", "blocked", "canceled"].includes(status); }
@@ -2926,6 +3268,7 @@ function pgDevicePod(row) { return { id: row.id, name: row.name, status: row.sta
function pgJob(row) { return row ? { id: row.id, devicePodId: row.device_pod_id, ownerUserId: row.owner_user_id, status: row.status, intent: row.intent, args: parseJson(row.args_json, {}), reason: row.reason, traceId: row.trace_id, operationId: row.operation_id, output: parseJson(row.output_json, {}), blocker: normalizeBlocker(parseJson(row.blocker_json, null)), createdAt: row.created_at, updatedAt: row.updated_at, completedAt: row.completed_at } : null; }
function pgAgentSession(row) { return row ? { id: row.id, projectId: row.project_id, agentId: row.agent_id, status: row.status, startedAt: row.started_at, endedAt: row.ended_at, ownerUserId: row.owner_user_id, conversationId: row.conversation_id, threadId: row.thread_id, lastTraceId: row.last_trace_id, session: parseJson(row.session_json, {}), updatedAt: row.updated_at } : null; }
function pgWorkspace(row) { return row ? { id: row.id, ownerUserId: row.owner_user_id, projectId: row.project_id, name: row.name, status: row.status, isDefault: row.is_default !== false, selectedConversationId: row.selected_conversation_id, selectedAgentSessionId: row.selected_agent_session_id, selectedDevicePodId: row.selected_device_pod_id, activeTraceId: row.active_trace_id, providerProfile: row.provider_profile, workspace: parseJson(row.workspace_json, {}), revision: Number(row.revision ?? 1), updatedBySessionId: row.updated_by_session_id, updatedByClient: row.updated_by_client, createdAt: row.created_at, updatedAt: row.updated_at } : null; }
function pgAccessTuple(row) { return row ? { userId: row.user_id, relation: row.relation, object: row.object, createdByAdminId: row.created_by_admin_id, createdAt: row.created_at } : null; }
function jobParams(job) { return [job.id, job.devicePodId, job.ownerUserId, job.status, job.intent, stableJson(job.args), job.reason, job.traceId, job.operationId, stableJson(job.output), stableJson(job.blocker ?? {}), job.createdAt, job.updatedAt, job.completedAt]; }
function parseJson(value, fallback) { if (!value) return fallback; if (typeof value === "object") return value; try { return JSON.parse(String(value)); } catch { return fallback; } }
function normalizeBlocker(value) { return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? value : null; }
+38 -15
View File
@@ -246,6 +246,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI
const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000);
const startedAt = nowIso(options.now);
const ownerApiKey = await resolveOwnerApiKey({ params, options, now: () => nowIso(options.now) });
const toolCapabilities = await resolveToolCapabilities({ params, options });
traceStore.ensure(traceId, agentRunTraceMeta(env, params));
traceStore.append(traceId, {
@@ -282,7 +283,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI
});
let runnerJob = null;
try {
const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey });
const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities });
runnerJob = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(reusable.mapping.runId)}/runner-jobs`, {
method: "POST",
body: runnerJobInput,
@@ -362,7 +363,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI
}
throw error;
}
const runInput = buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId });
const runInput = buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId, toolCapabilities });
run = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs", { method: "POST", body: runInput, timeoutMs });
const runId = requiredString(run?.id, "run.id");
traceStore.append(traceId, {
@@ -380,7 +381,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI
message: "AgentRun command " + commandId + " created; hwlab-cloud-api will start a runner Job explicitly without relying on scheduler automation.",
runId, commandId, backendProfile, waitingFor: "agentrun-runner-job-create", valuesPrinted: false,
});
const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey });
const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities });
try {
runnerJob = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs/" + encodeURIComponent(runId) + "/runner-jobs", { method: "POST", body: runnerJobInput, timeoutMs });
} catch (error) {
@@ -705,10 +706,11 @@ function newSessionIdAfterEviction(baseSessionId, traceId) {
const profile = String(traceId).replace(/[^A-Za-z0-9]/gu, "").slice(0, 12) || "fresh";
return baseSessionId + "-reset-" + profile;
}
function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId }) {
function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId, toolCapabilities = null }) {
const commitId = fullSourceCommit(env);
const threadId = safeOpaqueId(params.threadId);
const hwlabProjectId = firstNonEmpty(params.projectId);
const toolAliases = filteredResourceToolAliases(toolCapabilities);
const resourceBundleRef = commitId
? {
kind: "git",
@@ -716,7 +718,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
commitId,
submodules: false,
lfs: false,
toolAliases: HWLAB_RESOURCE_TOOL_ALIASES,
toolAliases,
promptRefs: HWLAB_RESOURCE_PROMPT_REFS,
skillRefs: HWLAB_RESOURCE_SKILL_REFS
}
@@ -760,7 +762,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
secretScope: {
allowCredentialEcho: false,
providerCredentials: [providerCredentialSecretRef(backendProfile, env)],
toolCredentials: agentRunToolCredentials(env)
toolCredentials: agentRunToolCredentials(env, toolCapabilities)
}
},
traceSink: {
@@ -828,9 +830,10 @@ async function resolveOwnerApiKey({ params, options, now }) {
if (!key) return "";
return text(key.displaySecret ?? "");
}
function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey }) {
function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities = null }) {
const baseTransient = buildAgentRunTransientEnv(env);
const transientEnv = ownerApiKey
const hwpodAllowed = toolCapabilityAllowed(toolCapabilities, "hwpod");
const transientEnv = ownerApiKey && hwpodAllowed
? baseTransient
.filter((entry) => entry.name !== "HWLAB_DEVICE_POD_API_KEY")
.concat([{ name: "HWLAB_API_KEY", value: ownerApiKey, sensitive: true }])
@@ -866,25 +869,45 @@ function buildAgentRunTransientEnv(env = process.env) {
return entries;
}
function agentRunToolCredentials(env = process.env) {
function agentRunToolCredentials(env = process.env, toolCapabilities = null) {
const namespace = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TOOL_SECRET_NAMESPACE, env.HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE, DEFAULT_RUNNER_NAMESPACE);
const githubSecretName = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME, "agentrun-v01-tool-github-pr");
const githubSecretKey = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_KEY, "GH_TOKEN");
const unideskSecretName = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME, "agentrun-v01-tool-unidesk-ssh");
return [
{
const credentials = [];
if (toolCapabilityAllowed(toolCapabilities, "github_pr")) credentials.push({
tool: "github",
purpose: "pull-request",
secretRef: { namespace, name: githubSecretName, keys: [githubSecretKey] },
projection: { kind: "env", envName: githubSecretKey, secretKey: githubSecretKey }
},
{
});
if (toolCapabilityAllowed(toolCapabilities, "unidesk_ssh")) credentials.push({
tool: "unidesk-ssh",
purpose: "ssh-passthrough",
secretRef: { namespace, name: unideskSecretName, keys: ["UNIDESK_SSH_CLIENT_TOKEN"] },
projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" }
}
];
});
return credentials;
}
async function resolveToolCapabilities({ params = {}, options = {} } = {}) {
const ownerUserId = text(params.ownerUserId);
const accessController = options.accessController;
if (!ownerUserId || typeof accessController?.codeAgentToolCapabilitiesForOwner !== "function") return null;
return await accessController.codeAgentToolCapabilitiesForOwner(ownerUserId);
}
function filteredResourceToolAliases(toolCapabilities = null) {
return HWLAB_RESOURCE_TOOL_ALIASES.filter((alias) => {
if (alias.name === "hwpod") return toolCapabilityAllowed(toolCapabilities, "hwpod");
if (alias.name === "unidesk-ssh") return toolCapabilityAllowed(toolCapabilities, "unidesk_ssh") || toolCapabilityAllowed(toolCapabilities, "trans_cmd");
return true;
});
}
function toolCapabilityAllowed(toolCapabilities = null, toolId) {
if (!toolCapabilities || !toolCapabilities.tools) return true;
return toolCapabilities.tools?.[toolId]?.allowed === true;
}
async function resolveReusableAgentRun({ params = {}, options = {}, env = process.env, managerUrl, fetchImpl, timeoutMs, backendProfile, traceId, traceStore }) {
+322
View File
@@ -0,0 +1,322 @@
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"]);
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"]);
export function createOpenFgaAuthorizer(options = {}) {
return new OpenFgaAuthorizer(options);
}
export function openFgaUser(userId) {
return `user:${sanitizeObjectId(userId)}`;
}
export function openFgaObject(type, id) {
return `${sanitizeType(type)}:${sanitizeObjectId(id)}`;
}
export function parseOpenFgaObject(object) {
const text = textOr(object, "");
const index = text.indexOf(":");
if (index <= 0 || index === text.length - 1) return null;
return { type: text.slice(0, index), id: text.slice(index + 1) };
}
export class OpenFgaAuthorizer {
constructor({ env = process.env, fetchImpl = fetch, now = () => new Date().toISOString(), configStore = null } = {}) {
this.env = env;
this.fetchImpl = fetchImpl;
this.now = now;
this.configStore = configStore;
this.mode = normalizeMode(env.HWLAB_OPENFGA_MODE);
this.apiUrl = normalizeBaseUrl(env.HWLAB_OPENFGA_API_URL);
this.token = textOr(env.HWLAB_OPENFGA_AUTHN_TOKEN ?? env.OPENFGA_API_TOKEN, "");
this.storeName = textOr(env.HWLAB_OPENFGA_STORE_NAME, "hwlab-v02");
this.timeoutMs = boundedTimeout(env.HWLAB_OPENFGA_TIMEOUT_MS);
this.bootstrapEnabled = textOr(env.HWLAB_OPENFGA_BOOTSTRAP, "1") !== "0";
this.storeId = textOr(env.HWLAB_OPENFGA_STORE_ID ?? env.FGA_STORE_ID, "");
this.modelId = textOr(env.HWLAB_OPENFGA_MODEL_ID ?? env.FGA_MODEL_ID, "");
this.readyCache = null;
}
async describe({ refresh = false } = {}) {
const readiness = await this.ensureReady({ refresh });
return {
contractVersion: OPENFGA_CONTRACT_VERSION,
mode: this.mode,
configured: Boolean(this.apiUrl),
ready: readiness.ready,
status: readiness.ready ? "ready" : this.mode === "off" ? "disabled" : "degraded",
apiUrlConfigured: Boolean(this.apiUrl),
storeName: this.storeName,
storeId: redactedId(readiness.storeId || this.storeId),
modelId: redactedId(readiness.modelId || this.modelId),
degradedReason: readiness.degradedReason,
valuesRedacted: true
};
}
async check({ actor = null, userId = "", relation = "", object = "", legacyAllowed = false } = {}) {
const actorId = textOr(userId, actor?.id ?? "");
const rel = textOr(relation, "");
const obj = textOr(object, "");
if (!actorId || !rel || !obj) return decision({ mode: this.mode, allowed: false, decisionSource: "invalid-request", relation: rel, object: obj, userId: actorId });
if (actor?.role === "admin") return decision({ mode: this.mode, allowed: true, decisionSource: "role-admin", relation: rel, object: obj, userId: actorId });
if (this.mode === "off") return decision({ mode: this.mode, allowed: Boolean(legacyAllowed), decisionSource: "legacy", relation: rel, object: obj, userId: actorId });
const readiness = await this.ensureReady();
if (!readiness.ready) {
return decision({
mode: this.mode,
allowed: this.mode === "shadow" ? Boolean(legacyAllowed) : false,
fgaAllowed: null,
decisionSource: this.mode === "shadow" ? "legacy-openfga-degraded" : "openfga-degraded",
relation: rel,
object: obj,
userId: actorId,
degradedReason: readiness.degradedReason
});
}
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),
fgaAllowed: fga.allowed,
decisionSource: "openfga",
relation: rel,
object: obj,
userId: actorId,
storeId: readiness.storeId,
modelId: readiness.modelId,
degradedReason: fga.error ?? null
});
}
async writeTuple({ userId, relation, object } = {}) {
return this.writeTuples({ writes: [{ user: openFgaUser(userId), relation: textOr(relation, ""), object: textOr(object, "") }] });
}
async deleteTuple({ userId, relation, object } = {}) {
return this.writeTuples({ deletes: [{ user: openFgaUser(userId), relation: textOr(relation, ""), object: textOr(object, "") }] });
}
async writeTuples({ writes = [], deletes = [] } = {}) {
const sanitizedWrites = writes.map(sanitizeTuple).filter(Boolean);
const sanitizedDeletes = deletes.map(sanitizeTuple).filter(Boolean);
if (this.mode === "off") {
return { ok: true, mode: this.mode, written: sanitizedWrites.length, deleted: sanitizedDeletes.length, decisionSource: "openfga-off" };
}
const readiness = await this.ensureReady();
if (!readiness.ready) {
return { ok: false, mode: this.mode, status: 503, error: { code: "openfga_degraded", message: readiness.degradedReason }, written: 0, deleted: 0 };
}
const body = {
...(sanitizedWrites.length > 0 ? { writes: { tuple_keys: sanitizedWrites, on_duplicate: "ignore" } } : {}),
...(sanitizedDeletes.length > 0 ? { deletes: { tuple_keys: sanitizedDeletes } } : {}),
authorization_model_id: readiness.modelId
};
const result = await this.request(`/stores/${encodeURIComponent(readiness.storeId)}/write`, { method: "POST", body });
return {
ok: result.ok,
mode: this.mode,
status: result.status,
written: sanitizedWrites.length,
deleted: sanitizedDeletes.length,
storeId: redactedId(readiness.storeId),
modelId: redactedId(readiness.modelId),
error: result.ok ? null : { code: "openfga_write_failed", message: result.message }
};
}
async ensureReady({ refresh = false } = {}) {
if (this.mode === "off") return { ready: false, storeId: this.storeId, modelId: this.modelId, degradedReason: "openfga_disabled" };
if (!this.apiUrl) return { ready: false, storeId: "", modelId: "", degradedReason: "openfga_api_url_missing" };
if (this.readyCache && !refresh) return this.readyCache;
const health = await this.request("/healthz", { method: "GET", allowNotJson: true });
if (!health.ok) return this.cacheReady({ ready: false, storeId: this.storeId, modelId: this.modelId, degradedReason: `openfga_health_failed:${health.status}` });
let storeId = this.storeId || await this.getConfigValue("openfga.storeId");
if (!storeId && this.bootstrapEnabled) {
const created = await this.request("/stores", { method: "POST", body: { name: this.storeName } });
if (created.ok) {
storeId = textOr(created.body?.id, "");
if (storeId) await this.setConfigValue("openfga.storeId", storeId);
}
}
if (!storeId) return this.cacheReady({ ready: false, storeId: "", modelId: "", degradedReason: "openfga_store_id_missing" });
this.storeId = storeId;
let modelId = this.modelId || await this.getConfigValue("openfga.modelId");
if (!modelId && this.bootstrapEnabled) {
const written = await this.request(`/stores/${encodeURIComponent(storeId)}/authorization-models`, { method: "POST", body: HWLAB_AUTHORIZATION_MODEL });
if (written.ok) {
modelId = textOr(written.body?.authorization_model_id ?? written.body?.id, "");
if (modelId) await this.setConfigValue("openfga.modelId", modelId);
} else {
return this.cacheReady({ ready: false, storeId, modelId: "", degradedReason: `openfga_model_write_failed:${written.status}` });
}
}
if (!modelId) return this.cacheReady({ ready: false, storeId, modelId: "", degradedReason: "openfga_model_id_missing" });
this.modelId = modelId;
return this.cacheReady({ ready: true, storeId, modelId, degradedReason: null });
}
cacheReady(value) {
this.readyCache = value;
return value;
}
async checkOpenFga({ user, relation, object, readiness }) {
const result = await this.request(`/stores/${encodeURIComponent(readiness.storeId)}/check`, {
method: "POST",
body: { tuple_key: { user, relation, object }, authorization_model_id: readiness.modelId }
});
if (!result.ok) return { allowed: false, error: `openfga_check_failed:${result.status}` };
return { allowed: result.body?.allowed === true, error: null };
}
async request(path, { method = "GET", body = null, allowNotJson = false } = {}) {
const headers = { accept: "application/json" };
if (body !== null) headers["content-type"] = "application/json";
if (this.token) headers.authorization = `Bearer ${this.token}`;
try {
const response = await fetchWithTimeout(this.fetchImpl, `${this.apiUrl}${path}`, {
method,
headers,
...(body !== null ? { body: JSON.stringify(body) } : {})
}, this.timeoutMs);
const text = await response.text();
const json = allowNotJson ? parseJson(text, {}) : parseJson(text, {});
return { ok: response.status < 400, status: response.status, body: json, message: textOr(json?.message ?? json?.error_description ?? json?.error, text) };
} catch (error) {
return { ok: false, status: 0, body: {}, message: error?.message ?? String(error) };
}
}
async getConfigValue(key) {
return textOr(await this.configStore?.getAccessConfig?.(key), "");
}
async setConfigValue(key, value) {
await this.configStore?.setAccessConfig?.({ key, value, updatedAt: this.now() });
}
}
const HWLAB_AUTHORIZATION_MODEL = Object.freeze({
schema_version: "1.1",
type_definitions: [
{ type: "user" },
directTypeDefinition("system", ["admin", "access_manager", "can_manage_users", "can_manage_device_pods", "can_manage_tools"]),
directTypeDefinition("device_pod", DEVICE_POD_RELATIONS),
directTypeDefinition("agent_session", ["owner", "viewer", "operator"]),
directTypeDefinition("tool", ["can_use"])
]
});
function directTypeDefinition(type, relations) {
return {
type,
relations: Object.fromEntries(relations.map((relation) => [relation, { this: {} }])),
metadata: {
relations: Object.fromEntries(relations.map((relation) => [relation, { directly_related_user_types: [{ type: "user" }] }]))
}
};
}
function decision(input = {}) {
return {
contractVersion: OPENFGA_CONTRACT_VERSION,
mode: input.mode,
allowed: input.allowed === true,
fgaAllowed: input.fgaAllowed ?? null,
mismatch: input.mismatch === true,
decisionSource: textOr(input.decisionSource, "unknown"),
userId: textOr(input.userId, ""),
relation: textOr(input.relation, ""),
object: textOr(input.object, ""),
storeId: redactedId(input.storeId),
modelId: redactedId(input.modelId),
degradedReason: input.degradedReason ?? null,
valuesRedacted: true
};
}
function sanitizeTuple(tuple = {}) {
const user = textOr(tuple.user, "");
const relation = textOr(tuple.relation, "");
const object = textOr(tuple.object, "");
if (!user || !relation || !object) return null;
return { user, relation, object };
}
function normalizeMode(value) {
const mode = textOr(value, "off").toLowerCase();
return SUPPORTED_MODES.has(mode) ? mode : "off";
}
function sanitizeType(value) {
return textOr(value, "object").replace(/[^A-Za-z0-9_]/gu, "_");
}
function sanitizeObjectId(value) {
return textOr(value, "unknown").replace(/[^A-Za-z0-9_.:-]/gu, "_");
}
function normalizeBaseUrl(value) {
const text = textOr(value, "").replace(/\/+$/u, "");
return /^https?:\/\//u.test(text) ? text : "";
}
function boundedTimeout(value) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS, 100), 10000);
}
function redactedId(value) {
const text = textOr(value, "");
if (!text) return null;
if (text.length <= 12) return text;
return `${text.slice(0, 8)}...${text.slice(-4)}`;
}
function textOr(value, fallback) {
const text = String(value ?? "").trim();
return text || fallback;
}
function parseJson(value, fallback) {
if (!value) return fallback;
if (typeof value === "object") return value;
try { return JSON.parse(String(value)); } catch { return fallback; }
}
async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchImpl(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
+92
View File
@@ -9,6 +9,7 @@ import { createCloudApiServer } from "./server.ts";
import { validateCodeAgentChatSchema } from "./code-agent-chat.ts";
import { createCodexStdioSessionManager } from "./codex-stdio-session.ts";
import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { submitAgentRunChatTurn } from "./code-agent-agentrun-adapter.ts";
import {
codexStdioChatFixture,
codexStdioReadyFixture,
@@ -44,6 +45,97 @@ function testAgentSessionRecord(input = {}) {
};
}
test("AgentRun adapter filters resource tools and credentials through access capabilities", async () => {
const calls = [];
const agentRunServer = createHttpServer(async (request, response) => {
const url = new URL(request.url || "/", "http://127.0.0.1");
const chunks = [];
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
calls.push({ method: request.method, path: url.pathname, body });
const send = (data) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_access_tools" })}\n`);
};
if (request.method === "POST" && url.pathname === "/api/v1/sessions") return send({ ok: true });
if (request.method === "POST" && url.pathname === "/api/v1/runs") return send({ id: "run_access_tools", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef });
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_access_tools/commands") return send({ id: "cmd_access_tools", runId: "run_access_tools", state: "pending", type: "turn", seq: 1 });
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_access_tools/runner-jobs") return send({
action: "create-kubernetes-job",
runId: "run_access_tools",
commandId: body.commandId,
attemptId: "attempt_access_tools",
runnerId: "runner_access_tools",
namespace: "agentrun-v01",
jobName: "agentrun-v01-runner-access-tools"
});
response.writeHead(404, { "content-type": "application/json" });
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
});
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
try {
const { port } = agentRunServer.address();
const traceStore = createCodeAgentTraceStore();
const result = await submitAgentRunChatTurn({
traceId: "trc_access_tool_filter",
traceStore,
params: {
message: "tool filter smoke",
ownerUserId: "usr_tool_limited",
projectId: "prj_device_pod_workbench",
conversationId: "cnv_access_tools",
sessionId: "ses_access_tools"
},
options: {
env: {
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
AGENTRUN_MGR_URL: `http://127.0.0.1:${port}`,
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567",
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek",
HWLAB_RUNTIME_API_URL: "http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667",
HWLAB_RUNTIME_WEB_URL: "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080",
HWLAB_RUNTIME_NAMESPACE: "hwlab-v02",
HWLAB_RUNTIME_LANE: "v02",
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1",
UNIDESK_MAIN_SERVER_IP: "http://74.48.78.17:18081"
},
accessController: {
async codeAgentToolCapabilitiesForOwner(ownerUserId) {
assert.equal(ownerUserId, "usr_tool_limited");
return {
contractVersion: "admin-access-v1",
tools: {
hwpod: { allowed: false },
unidesk_ssh: { allowed: false },
trans_cmd: { allowed: false },
github_pr: { allowed: false }
}
};
},
store: {
async findActiveDefaultApiKeyForUser(userId) {
assert.equal(userId, "usr_tool_limited");
return { displaySecret: "hwl_live_owner_secret" };
}
}
}
}
});
assert.equal(result.status, "running");
const createRun = calls.find((call) => call.method === "POST" && call.path === "/api/v1/runs");
assert.deepEqual(createRun.body.resourceBundleRef.toolAliases, []);
assert.deepEqual(createRun.body.executionPolicy.secretScope.toolCredentials, []);
const runnerJob = calls.find((call) => call.method === "POST" && call.path === "/api/v1/runs/run_access_tools/runner-jobs");
assert.equal(runnerJob.body.transientEnv.some((entry) => entry.name === "HWLAB_API_KEY"), false);
} finally {
await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve())));
}
});
test("cloud api /v1/agent/chat parse and params errors use structured blocker envelope", async () => {
const server = createCloudApiServer({
env: {
+94 -1
View File
@@ -976,6 +976,9 @@ 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_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");
upsertEnv(container.env, "HWLAB_KEYCLOAK_CLIENT_ID", "hwlab-cloud-web");
upsertEnvEntry(container.env, deployEnvEntry("HWLAB_KEYCLOAK_CLIENT_SECRET", "secretRef:hwlab-cloud-web-client/client-secret", namespace, profile));
@@ -4522,10 +4525,99 @@ function v02PostgresManifest({ migrationSql, source }) {
};
}
function v02OpenFgaManifest({ source }) {
const namespace = "hwlab-v02";
const name = "hwlab-openfga";
const image = "127.0.0.1:5000/hwlab/openfga:v1.17.0";
const labels = {
"app.kubernetes.io/name": name,
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/component": "authorization",
"hwlab.pikastech.local/environment": "v02",
"hwlab.pikastech.local/gitops-target": "v02",
"hwlab.pikastech.local/profile": "v02",
"hwlab.pikastech.local/service-id": name,
"hwlab.pikastech.local/source-commit": source.full
};
const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/g14-gitops-render.mjs" };
const selector = { "app.kubernetes.io/name": name };
const env = [
{ name: "OPENFGA_DATASTORE_ENGINE", value: "postgres" },
{ name: "OPENFGA_DATASTORE_URI", valueFrom: { secretKeyRef: { name: "hwlab-v02-openfga", key: "datastore-uri" } } },
{ name: "OPENFGA_AUTHN_METHOD", value: "preshared" },
{ name: "OPENFGA_AUTHN_PRESHARED_KEYS", valueFrom: { secretKeyRef: { name: "hwlab-v02-openfga", key: "authn-preshared-key" } } },
{ name: "OPENFGA_PLAYGROUND_ENABLED", value: "false" },
{ name: "OPENFGA_HTTP_ADDR", value: "0.0.0.0:8080" },
{ name: "OPENFGA_GRPC_ADDR", value: "0.0.0.0:8081" }
];
const templateLabels = { ...labels, ...selector };
return {
apiVersion: "v1",
kind: "List",
items: [
{
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: `${name}-migrate`,
namespace,
labels,
annotations: {
...annotations,
"argocd.argoproj.io/hook": "PreSync",
"argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded"
}
},
spec: {
backoffLimit: 3,
template: {
metadata: { labels: templateLabels, annotations },
spec: {
restartPolicy: "OnFailure",
containers: [{ name: "openfga-migrate", image, imagePullPolicy: "IfNotPresent", args: ["migrate"], env }]
}
}
}
},
{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name, namespace, labels, annotations },
spec: {
replicas: 1,
selector: { matchLabels: selector },
template: {
metadata: { labels: templateLabels, annotations },
spec: {
containers: [{
name: "openfga",
image,
imagePullPolicy: "IfNotPresent",
args: ["run"],
env,
ports: [{ name: "http", containerPort: 8080 }, { name: "grpc", containerPort: 8081 }],
readinessProbe: { httpGet: { path: "/healthz", port: "http" }, initialDelaySeconds: 3, periodSeconds: 10 },
livenessProbe: { httpGet: { path: "/healthz", port: "http" }, initialDelaySeconds: 15, periodSeconds: 20 },
resources: { requests: { cpu: "50m", memory: "128Mi" }, limits: { cpu: "500m", memory: "512Mi" } }
}]
}
}
}
},
{
apiVersion: "v1",
kind: "Service",
metadata: { name, namespace, labels, annotations },
spec: { type: "ClusterIP", selector, ports: [{ name: "http", port: 8080, targetPort: "http" }, { name: "grpc", port: 8081, targetPort: "grpc" }] }
}
]
};
}
function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev" } = {}) {
const resources = ["namespace.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "deepseek-proxy.yaml"];
if (profile !== "v02") resources.splice(1, 0, "code-agent-codex-config.yaml");
if (profile === "v02") resources.push("postgres.yaml", "observability.yaml");
if (profile === "v02") resources.push("postgres.yaml", "openfga.yaml", "observability.yaml");
if (includeDeviceAgent) resources.push("device-agent-71-freq.yaml");
resources.push("g14-frpc.yaml");
return {
@@ -4662,6 +4754,7 @@ async function plannedFiles(args) {
putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
if (profile === "v02") putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ migrationSql, source }));
if (profile === "v02") putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ source }));
if (profile === "v02") putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ namespace, labels: profileLabels, annotations, metricsSidecarScript }));
if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages }));
putJson(`${runtimePath}/g14-frpc.yaml`, g14FrpcManifest({ profile, source }));
+28 -2
View File
@@ -301,8 +301,13 @@ 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_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);
assert.doesNotMatch(await readFile(path.join(outDir, "runtime-v02", "kustomization.yaml"), "utf8"), /code-agent-codex-config/u);
const kustomization = await readFile(path.join(outDir, "runtime-v02", "kustomization.yaml"), "utf8");
assert.doesNotMatch(kustomization, /code-agent-codex-config/u);
assert.match(kustomization, /openfga\.yaml/u);
const workloadTemplates = workloadsJson.items.filter((item) => item.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/source-commit"]);
assert.ok(workloadTemplates.length > 0, "expected rendered pod-template source labels");
assert.equal(devicePod.spec.template.metadata.labels["hwlab.pikastech.local/source-commit"], sourceRevision);
@@ -323,7 +328,7 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
assert.match(application, /git-mirror-http\.devops-infra\.svc\.cluster\.local/u);
assert.doesNotMatch(application, /git@github\.com:pikasTech\/HWLAB\.git/u);
for (const generatedFile of ["deepseek-proxy.yaml", "postgres.yaml", "g14-frpc.yaml"]) {
for (const generatedFile of ["deepseek-proxy.yaml", "postgres.yaml", "openfga.yaml", "g14-frpc.yaml"]) {
const content = await readFile(path.join(outDir, "runtime-v02", generatedFile), "utf8");
assert.match(content, /"hwlab\.pikastech\.local\/source-commit"/u);
}
@@ -349,6 +354,27 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
assert.equal(postgresMigrationAnnotation.startsWith(postgresMigrationLabel), true);
assert.equal(postgresStatefulSet.spec.template.metadata.labels["hwlab.pikastech.local/source-commit"], undefined);
const openfga = JSON.parse(await readFile(path.join(outDir, "runtime-v02", "openfga.yaml"), "utf8"));
const openfgaDeployment = openfga.items.find((item) => item.kind === "Deployment" && item.metadata?.name === "hwlab-openfga");
const openfgaService = openfga.items.find((item) => item.kind === "Service" && item.metadata?.name === "hwlab-openfga");
const openfgaMigrate = openfga.items.find((item) => item.kind === "Job" && item.metadata?.name === "hwlab-openfga-migrate");
assert.ok(openfgaDeployment, "expected v02 OpenFGA deployment");
assert.ok(openfgaService, "expected v02 OpenFGA ClusterIP service");
assert.ok(openfgaMigrate, "expected v02 OpenFGA migration job");
assert.equal(openfgaService.spec.type, "ClusterIP");
assert.equal(openfga.items.some((item) => item.kind === "Ingress"), false);
assert.equal(openfgaDeployment.spec.template.spec.containers[0].image, "127.0.0.1:5000/hwlab/openfga:v1.17.0");
assert.deepEqual(openfgaDeployment.spec.template.spec.containers[0].args, ["run"]);
assert.deepEqual(openfgaMigrate.spec.template.spec.containers[0].args, ["migrate"]);
const openfgaEnv = openfgaDeployment.spec.template.spec.containers[0].env;
assert.ok(openfgaEnv.some((entry) => entry.name === "OPENFGA_DATASTORE_ENGINE" && entry.value === "postgres"));
assert.ok(openfgaEnv.some((entry) => entry.name === "OPENFGA_DATASTORE_URI" && entry.valueFrom?.secretKeyRef?.name === "hwlab-v02-openfga" && entry.valueFrom?.secretKeyRef?.key === "datastore-uri"));
assert.ok(openfgaEnv.some((entry) => entry.name === "OPENFGA_AUTHN_METHOD" && entry.value === "preshared"));
assert.ok(openfgaEnv.some((entry) => entry.name === "OPENFGA_AUTHN_PRESHARED_KEYS" && entry.valueFrom?.secretKeyRef?.name === "hwlab-v02-openfga" && entry.valueFrom?.secretKeyRef?.key === "authn-preshared-key"));
assert.ok(openfgaEnv.some((entry) => entry.name === "OPENFGA_PLAYGROUND_ENABLED" && entry.value === "false"));
assert.equal(openfgaDeployment.spec.template.spec.containers[0].readinessProbe.httpGet.path, "/healthz");
assert.equal(openfgaDeployment.spec.template.metadata.labels["hwlab.pikastech.local/source-commit"], sourceRevision);
await assert.rejects(() => readFile(path.join(outDir, "tekton-v02", "control-plane-reconciler.yaml"), "utf8"), { code: "ENOENT" });
await assert.rejects(() => readFile(path.join(outDir, "tekton-v02", "poller.yaml"), "utf8"), { code: "ENOENT" });
+36
View File
@@ -68,6 +68,42 @@ test("hwlab-cli client device-pods uses saved cookie and same Web API paths", as
assert.equal(result.payload.body.fullBodyAvailable, true);
});
test("hwlab-cli client access uses Cloud Web admin access routes", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-access-"));
await runHwlabCli(["client", "auth", "login", "--base-url", "http://web.test", "--username", "admin", "--password", "pw"], {
cwd,
fetchImpl: async () => new Response(JSON.stringify({ authenticated: true, user: { id: "usr_admin", username: "admin", role: "admin" } }), { status: 200, headers: { "set-cookie": "hwlab_session=session-admin; Path=/" } })
});
const calls: any[] = [];
const grant = await runHwlabCli(["client", "access", "grant", "usr_alice", "device-pod", "device-pod-71-freq", "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 });
}
});
assert.equal(grant.exitCode, 0);
assert.equal(grant.payload.action, "client.access.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");
const user = await runHwlabCli(["client", "access", "user", "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 });
}
});
assert.equal(user.exitCode, 0);
assert.equal(calls[1].url, "http://web.test/v1/admin/access/users/usr_alice");
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");
});
test("hwlab-cli client request reports auth username from the selected session", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-profile-user-"));
const calls: any[] = [];
+88
View File
@@ -68,6 +68,7 @@ async function clientCommand(context: any) {
if (groupHelp) return groupHelp;
}
if (group === "auth") return authCommand(next);
if (group === "access") return accessCommand(next);
if (group === "device-pods" || group === "device-pod") return devicePodsCommand(next);
if (group === "runtime") return runtimeCommand(next);
if (group === "gateway") return gatewayCommand(next);
@@ -82,6 +83,7 @@ async function clientCommand(context: any) {
function clientSubcommandHelp(group: string) {
if (group === "auth") return authHelp();
if (group === "access") return accessHelp();
if (group === "device-pods" || group === "device-pod") return devicePodsHelp();
if (group === "runtime") return runtimeHelp();
if (group === "gateway") return gatewayHelp();
@@ -114,6 +116,11 @@ function help() {
"hwlab-cli client auth status",
"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 device-pods list",
"hwlab-cli client device-pods status device-pod-71-freq",
"hwlab-cli client runtime routes",
@@ -199,6 +206,87 @@ function authHelp() {
});
}
async function accessCommand(context: any) {
const subcommand = context.rest[0] || "summary";
if (wantsHelp(context)) return accessHelp();
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) });
}
if (subcommand === "users") {
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) });
}
if (subcommand === "user" || subcommand === "show") {
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) });
}
if (subcommand === "grant" || subcommand === "revoke") {
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) });
}
throw cliError("unsupported_access_command", `unsupported access command: ${subcommand}`, { subcommand });
}
function accessHelp() {
return ok("client.access.help", {
serviceRuntime: false,
imagePublished: false,
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"
],
routes: {
summary: route("GET", "/v1/admin/access/summary"),
users: route("GET", "/v1/admin/access/users")
}
});
}
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;
throw cliError("invalid_device_pod_relation", "device-pod relation must be viewer, operator, profile_editor, or job_submitter", { relation });
}
function normalizeToolId(value: unknown) {
const toolId = text(value).replace(/-/gu, "_");
if (["hwpod", "unidesk_ssh", "trans_cmd", "github_pr"].includes(toolId)) return toolId;
throw cliError("invalid_tool_id", "tool must be hwpod, unidesk_ssh, trans_cmd, or github_pr", { toolId });
}
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() }));
+5 -1
View File
@@ -12,6 +12,7 @@ import { recordDraft } from "./components/command-bar/DraftsList";
import { ConversationPanel } from "./components/conversation/ConversationPanel";
import { DevicePodSidebar } from "./components/device-pod/DevicePodSidebar";
import { ActivityRail } from "./components/layout/ActivityRail";
import { AccessView } from "./components/access/AccessView";
import { SessionSidebar } from "./components/sessions/SessionSidebar";
import { SettingsView } from "./components/settings/SettingsView";
import { SkillsView } from "./components/skills/SkillsView";
@@ -33,6 +34,7 @@ const RIGHT_SIDEBAR_MAX_WIDTH = 680;
const routeLabels: Record<RouteId, string> = {
workspace: "工作台",
skills: "Skills",
access: "授权管理",
gate: "内部复核",
help: "使用说明",
settings: "设置"
@@ -168,6 +170,7 @@ export function App(): ReactElement {
<section className="center-workspace">
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} onCopySession={() => copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null}
{route === "skills" ? <SkillsView /> : null}
{route === "access" ? <AccessView /> : null}
{route === "gate" ? <GateView /> : null}
{route === "help" ? <HelpView help={help} /> : null}
{route === "settings" ? <SettingsView providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onLogout={auth.logout} /> : null}
@@ -266,10 +269,11 @@ function HelpView({ help }: { help: string }): ReactElement {
function routeFromLocation(): RouteId {
const hash = window.location.hash.replace(/^#\/?/u, "");
if (hash === "skills" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash;
if (hash === "skills" || hash === "access" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash;
const path = window.location.pathname.replace(/\/+$/u, "") || "/";
if (path === "/help") return "help";
if (path === "/skills") return "skills";
if (path === "/access" || path === "/admin/access") return "access";
if (path === "/gate" || path === "/diagnostics/gate") return "gate";
return "workspace";
}
@@ -0,0 +1,214 @@
import type { ReactElement } from "react";
import { useEffect, useMemo, useState } from "react";
import { api } from "../../services/api/client";
import type { AccessDevicePodRelation, AccessToolId, AccessUserRole, AccessUserStatus, AdminAccessMatrixResponse, AdminAccessSummaryResponse, AdminAccessUserSummary, Tone } from "../../types/domain";
import { shortToken } from "../../utils";
import { StateTag } from "../shared/StateTag";
interface AccessState {
loading: boolean;
detailLoading: boolean;
savingKey: string | null;
users: AdminAccessUserSummary[];
selectedUserId: string | null;
summary: AdminAccessSummaryResponse | null;
matrix: AdminAccessMatrixResponse | null;
error: string | null;
}
const RELATIONS: Array<{ id: AccessDevicePodRelation; label: string; title: string }> = [
{ id: "viewer", label: "查看", title: "viewer" },
{ id: "operator", label: "操作", title: "operator" },
{ id: "profile_editor", label: "Profile", title: "profile_editor" },
{ id: "job_submitter", label: "提交", title: "job_submitter" }
];
const TOOLS: Array<{ id: AccessToolId; label: string; title: string }> = [
{ id: "hwpod", label: "hwpod", title: "Device Pod CLI" },
{ id: "unidesk_ssh", label: "UniDesk SSH", title: "UniDesk SSH passthrough" },
{ id: "trans_cmd", label: "trans cmd", title: "trans passthrough command" },
{ id: "github_pr", label: "GitHub PR", title: "GitHub PR tool" }
];
const initialState: AccessState = { loading: false, detailLoading: false, savingKey: null, users: [], selectedUserId: null, summary: null, matrix: null, error: null };
export function AccessView(): ReactElement {
const [state, setState] = useState<AccessState>(initialState);
async function load(): Promise<void> {
setState((current) => ({ ...current, loading: true, error: null }));
const [summary, users] = await Promise.all([api.adminAccessSummary(), api.adminAccessUsers()]);
if (!summary.ok || !users.ok) {
setState((current) => ({ ...current, loading: false, summary: summary.ok ? summary.data : current.summary, users: users.ok ? users.data?.users ?? [] : current.users, error: summary.error ?? users.error ?? "授权管理不可用" }));
return;
}
const nextUsers = users.data?.users ?? [];
const selectedUserId = state.selectedUserId && nextUsers.some((item) => item.user.id === state.selectedUserId)
? state.selectedUserId
: nextUsers[0]?.user.id ?? null;
setState((current) => ({ ...current, loading: false, summary: summary.data, users: nextUsers, selectedUserId, error: null }));
if (selectedUserId) await loadUser(selectedUserId);
}
async function loadUser(userId: string): Promise<void> {
if (!userId) return;
setState((current) => ({ ...current, selectedUserId: userId, detailLoading: true, error: null }));
const response = await api.adminAccessUser(userId);
setState((current) => ({ ...current, detailLoading: false, matrix: response.ok ? response.data : current.matrix, error: response.ok ? null : response.error ?? "用户授权矩阵不可用" }));
}
async function updateUser(patch: { role?: AccessUserRole; status?: AccessUserStatus }): Promise<void> {
const userId = state.matrix?.user.id ?? state.selectedUserId;
if (!userId || state.savingKey) return;
setState((current) => ({ ...current, savingKey: `user:${userId}`, error: null }));
const response = await api.updateAdminAccessUser(userId, patch);
setState((current) => ({
...current,
savingKey: null,
matrix: response.ok ? response.data?.access ?? current.matrix : current.matrix,
users: response.ok && response.data?.access ? mergeUserSummaries(current.users, response.data.access) : current.users,
error: response.ok ? null : response.error ?? "用户状态更新失败"
}));
}
async function toggleDevicePod(devicePodId: string, relation: AccessDevicePodRelation, allowed: boolean): Promise<void> {
const userId = state.matrix?.user.id ?? state.selectedUserId;
if (!userId || state.savingKey) return;
const savingKey = `pod:${userId}:${devicePodId}:${relation}`;
setState((current) => ({ ...current, savingKey, error: null }));
const response = await api.setAdminAccessDevicePod(userId, devicePodId, relation, allowed);
setState((current) => ({
...current,
savingKey: null,
matrix: response.ok ? response.data?.access ?? current.matrix : current.matrix,
users: response.ok && response.data?.access ? mergeUserSummaries(current.users, response.data.access) : current.users,
error: response.ok ? null : response.error ?? "Device Pod 授权更新失败"
}));
}
async function toggleTool(toolId: AccessToolId, allowed: boolean): Promise<void> {
const userId = state.matrix?.user.id ?? state.selectedUserId;
if (!userId || state.savingKey) return;
const savingKey = `tool:${userId}:${toolId}`;
setState((current) => ({ ...current, savingKey, error: null }));
const response = await api.setAdminAccessTool(userId, toolId, allowed);
setState((current) => ({
...current,
savingKey: null,
matrix: response.ok ? response.data?.access ?? current.matrix : current.matrix,
users: response.ok && response.data?.access ? mergeUserSummaries(current.users, response.data.access) : current.users,
error: response.ok ? null : response.error ?? "工具授权更新失败"
}));
}
useEffect(() => { void load(); }, []);
const selectedUser = state.matrix?.user ?? state.users.find((item) => item.user.id === state.selectedUserId)?.user ?? null;
const statusTone = state.error ? "blocked" : state.loading || state.detailLoading ? "pending" : "source";
const statusLabel = state.error ? "阻塞" : state.loading || state.detailLoading ? "加载中" : `${state.users.length} 个用户`;
const openFga = state.matrix?.openfga ?? state.summary?.openfga ?? null;
const openFgaTone = openFgaStatusTone(openFga?.mode, openFga?.ready, openFga?.degraded);
const tupleCount = state.matrix?.tuples?.length ?? selectedSummary(state.users, state.selectedUserId)?.tupleCount ?? 0;
const activeDevicePodCount = useMemo(() => state.matrix?.devicePods.filter((row) => RELATIONS.some((relation) => row.relations[relation.id] === true)).length ?? 0, [state.matrix]);
return (
<section className="view access-view" id="access" data-view="access" aria-labelledby="access-title">
<div className="workspace-panel access-panel">
<div className="panel-title-row">
<div><p className="eyebrow"></p><h2 id="access-title"></h2></div>
<div className="panel-title-actions"><StateTag id="access-status" tone={statusTone}>{statusLabel}</StateTag><button className="command-button secondary" id="access-refresh" type="button" disabled={state.loading} onClick={() => void load()}>{state.loading ? "刷新中" : "刷新"}</button></div>
</div>
{state.error ? <p className="access-error" role="alert">{state.error}</p> : null}
<div className="access-layout">
<aside className="access-user-panel" aria-label="用户列表">
<div className="access-section-head"><h3></h3><span className="mono">{state.users.length}</span></div>
<div className="access-user-list" id="access-user-list" aria-live="polite">
{state.users.length === 0 ? <p className="access-empty"></p> : state.users.map((item) => <UserButton key={item.user.id ?? item.user.username} item={item} active={item.user.id === state.selectedUserId} onSelect={() => item.user.id ? void loadUser(item.user.id) : undefined} />)}
</div>
</aside>
<section className="access-matrix-panel" aria-label="Device Pod 授权矩阵">
<div className="access-section-head">
<div><h3>{selectedUser?.displayName || selectedUser?.username || "未选择用户"}</h3><span className="mono wrap">{selectedUser?.id ?? "-"}</span></div>
<div className="access-user-controls">
<label><span></span><select value={selectedUser?.role ?? "user"} disabled={!selectedUser || Boolean(state.savingKey)} onChange={(event) => void updateUser({ role: event.currentTarget.value as AccessUserRole })}><option value="user"></option><option value="admin"></option></select></label>
<label><span></span><select value={selectedUser?.status ?? "active"} disabled={!selectedUser || Boolean(state.savingKey)} onChange={(event) => void updateUser({ status: event.currentTarget.value as AccessUserStatus })}><option value="active"></option><option value="disabled"></option></select></label>
</div>
</div>
<div className="access-metrics" aria-label="当前用户授权摘要"><span>tuples <strong>{tupleCount}</strong></span><span>device pods <strong>{activeDevicePodCount}</strong></span></div>
<div className="access-table-wrap">
<table className="access-table" aria-describedby="access-status">
<thead><tr><th>Device Pod</th><th>Target</th>{RELATIONS.map((relation) => <th key={relation.id} title={relation.title}>{relation.label}</th>)}</tr></thead>
<tbody>
{state.detailLoading ? <tr><td colSpan={6}></td></tr> : null}
{!state.detailLoading && (state.matrix?.devicePods.length ?? 0) === 0 ? <tr><td colSpan={6}> Device Pod</td></tr> : null}
{!state.detailLoading ? state.matrix?.devicePods.map((row) => (
<tr key={row.devicePod.devicePodId}>
<td><strong>{row.devicePod.name ?? row.devicePod.devicePodId}</strong><span className="mono wrap">{row.devicePod.devicePodId}</span></td>
<td className="mono wrap">{row.devicePod.targetId ?? shortToken(row.devicePod.profileHash, 12)}</td>
{RELATIONS.map((relation) => {
const key = `pod:${selectedUser?.id ?? ""}:${row.devicePod.devicePodId}:${relation.id}`;
return <td key={relation.id}><PermissionSwitch checked={row.relations[relation.id] === true} disabled={!selectedUser || Boolean(state.savingKey && state.savingKey !== key)} busy={state.savingKey === key} label={`${relation.title} ${row.devicePod.devicePodId}`} onChange={(checked) => void toggleDevicePod(row.devicePod.devicePodId, relation.id, checked)} /></td>;
})}
</tr>
)) : null}
</tbody>
</table>
</div>
</section>
<aside className="access-tool-panel" aria-label="工具授权和 OpenFGA 状态">
<section className="access-status-panel" aria-label="OpenFGA 状态">
<div className="access-section-head"><h3>OpenFGA</h3><StateTag tone={openFgaTone}>{openFga?.mode ?? "unknown"}</StateTag></div>
<dl className="access-openfga-grid">
<div><dt>ready</dt><dd>{String(openFga?.ready ?? false)}</dd></div>
<div><dt>store</dt><dd className="mono wrap">{shortToken(openFga?.storeId, 18)}</dd></div>
<div><dt>model</dt><dd className="mono wrap">{shortToken(openFga?.modelId, 18)}</dd></div>
</dl>
</section>
<section className="access-tools" aria-label="工具授权">
<div className="access-section-head"><h3></h3><span className="mono">{TOOLS.length}</span></div>
<div className="access-tool-list">
{TOOLS.map((tool) => {
const key = `tool:${selectedUser?.id ?? ""}:${tool.id}`;
return <div className="access-tool-row" key={tool.id}><div><strong>{tool.label}</strong><span>{tool.title}</span></div><PermissionSwitch checked={state.matrix?.tools?.[tool.id] === true} disabled={!selectedUser || Boolean(state.savingKey && state.savingKey !== key)} busy={state.savingKey === key} label={`${tool.title} can_use`} onChange={(checked) => void toggleTool(tool.id, checked)} /></div>;
})}
</div>
</section>
</aside>
</div>
</div>
</section>
);
}
function UserButton({ item, active, onSelect }: { item: AdminAccessUserSummary; active: boolean; onSelect(): void }): ReactElement {
return (
<button className={`access-user-row${active ? " active" : ""}`} type="button" aria-current={active ? "page" : undefined} onClick={onSelect}>
<strong>{item.user.displayName || item.user.username || item.user.id}</strong>
<span className="mono wrap">{item.user.username ?? item.user.id}</span>
<span>{item.user.role ?? "user"} / {item.user.status ?? "active"} / {item.devicePodCount ?? 0} pods</span>
</button>
);
}
function PermissionSwitch({ checked, disabled, busy, label, onChange }: { checked: boolean; disabled: boolean; busy: boolean; label: string; onChange(checked: boolean): void }): ReactElement {
return <label className={`access-switch${busy ? " busy" : ""}`} title={label}><input type="checkbox" checked={checked} disabled={disabled || busy} aria-label={label} onChange={(event) => onChange(event.currentTarget.checked)} /><span aria-hidden="true" /></label>;
}
function selectedSummary(users: AdminAccessUserSummary[], userId: string | null): AdminAccessUserSummary | null {
return users.find((item) => item.user.id === userId) ?? null;
}
function mergeUserSummaries(users: AdminAccessUserSummary[], access: AdminAccessMatrixResponse): AdminAccessUserSummary[] {
return users.map((item) => item.user.id === access.user.id ? { ...item, user: access.user, tupleCount: access.tuples?.length ?? item.tupleCount, tools: access.tools, devicePodCount: access.devicePods.filter((row) => RELATIONS.some((relation) => row.relations[relation.id] === true)).length } : item);
}
function openFgaStatusTone(mode: string | undefined, ready: boolean | undefined, degraded: boolean | undefined): Tone {
if (degraded) return "blocked";
if (mode === "enforce" && ready) return "ok";
if (mode === "shadow") return "warn";
if (mode === "off") return "source";
return ready ? "ok" : "pending";
}
@@ -9,16 +9,17 @@ interface ActivityRailProps {
const routes: Array<{ id: RouteId; label: string; title: string }> = [
{ id: "workspace", label: "工作台", title: "工作台" },
{ id: "skills", label: "Skills", title: "Skills" },
{ id: "access", label: "授权", title: "授权管理" },
{ id: "gate", label: "内部复核", title: "内部复核" },
{ id: "help", label: "使用说明", title: "使用说明" },
{ id: "settings", label: "设置", title: "设置" }
];
const settingsRoute = routes[4];
const settingsRoute = routes[5];
export function ActivityRail({ route, onRoute }: ActivityRailProps): ReactElement {
return (
<aside className="activity-rail" id="activity-rail" aria-label="工作台活动栏">
{routes.slice(0, 4).map((item) => <RailButton key={item.id} item={item} active={route === item.id} onRoute={onRoute} />)}
{routes.slice(0, 5).map((item) => <RailButton key={item.id} item={item} active={route === item.id} onRoute={onRoute} />)}
<span className="rail-spacer" aria-hidden="true" />
{settingsRoute ? <RailButton item={settingsRoute} active={route === "settings"} onRoute={onRoute} /> : null}
</aside>
@@ -1,6 +1,14 @@
import type {
AgentChatResponse,
AgentChatResultResponse,
AccessDevicePodRelation,
AccessToolId,
AccessUserRole,
AccessUserStatus,
AdminAccessMatrixResponse,
AdminAccessMutationResponse,
AdminAccessSummaryResponse,
AdminAccessUsersResponse,
ApiResult,
ConversationRecord,
DevicePodEventsResponse,
@@ -203,6 +211,12 @@ export const api = {
devicePods: (): Promise<ApiResult<DevicePodListResponse>> => fetchJson("/v1/device-pods", { timeoutMs: 12000, timeoutName: "Device Pods" }),
devicePodStatus: (devicePodId: string): Promise<ApiResult<DevicePodStatusResponse>> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/status`, { timeoutMs: 12000, timeoutName: "Device Pod status" }),
devicePodEvents: (devicePodId: string): Promise<ApiResult<DevicePodEventsResponse>> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/events?limit=120`, { timeoutMs: 12000, timeoutName: "Device Pod events" }),
adminAccessSummary: (): Promise<ApiResult<AdminAccessSummaryResponse>> => fetchJson("/v1/admin/access/summary", { timeoutMs: 12000, timeoutName: "admin access summary" }),
adminAccessUsers: (): Promise<ApiResult<AdminAccessUsersResponse>> => fetchJson("/v1/admin/access/users", { timeoutMs: 12000, timeoutName: "admin access users" }),
adminAccessUser: (userId: string): Promise<ApiResult<AdminAccessMatrixResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { timeoutMs: 12000, timeoutName: "admin access user" }),
updateAdminAccessUser: (userId: string, payload: { role?: AccessUserRole; status?: AccessUserStatus }): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin access user update" }),
setAdminAccessDevicePod: (userId: string, devicePodId: string, relation: AccessDevicePodRelation, allowed: boolean): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access device-pod" }),
setAdminAccessTool: (userId: string, toolId: AccessToolId, allowed: boolean): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/tools/${encodeURIComponent(toolId)}/can-use`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access tool" }),
workspace: (projectId: string): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }),
updateWorkspace: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }),
selectConversation: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }),
+47 -2
View File
@@ -59,7 +59,7 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.workbench-shell.is-right-sidebar-collapsed .workspace-body { grid-template-columns: var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) 0 0; }
.workbench-shell.is-left-sidebar-collapsed.is-right-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(0, 1fr) 0 0; }
.workspace-body.is-route-without-sidebars { grid-template-columns: minmax(0, 1fr); }
.activity-rail { min-width: 0; display: grid; grid-template-rows: repeat(4, minmax(54px, auto)) 1fr minmax(54px, auto); gap: 6px; padding: 10px 8px; background: var(--rail); overflow-x: hidden; }
.activity-rail { min-width: 0; display: grid; grid-template-rows: repeat(5, minmax(54px, auto)) 1fr minmax(54px, auto); gap: 6px; padding: 10px 8px; background: var(--rail); overflow-x: hidden; }
.rail-button { min-width: 0; min-height: 44px; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; padding: 8px 6px; background: transparent; color: #eef4ed; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rail-button.active { color: #1d2b23; background: var(--rail-active); border-color: var(--rail-active); }
.rail-spacer { min-height: 8px; }
@@ -304,7 +304,7 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.device-detail-dialog { max-width: min(720px, 92vw); border: 1px solid var(--line); border-radius: 8px; padding: 0; }
.device-detail-dialog form { display: grid; gap: 10px; padding: 16px; }
.skills-panel, .settings-panel, .help-panel { padding: 14px; }
.skills-panel, .settings-panel, .help-panel, .access-panel { padding: 14px; }
.settings-content { display: grid; align-content: start; gap: 14px; }
.settings-group { display: grid; grid-template-columns: repeat(3, minmax(150px, 220px)); gap: 10px; align-items: end; }
.skills-layout { min-height: 0; display: grid; grid-template-columns: 280px minmax(220px, 1fr) minmax(260px, 1.2fr); gap: 12px; }
@@ -319,6 +319,47 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.skill-prompt-row { display: inline-flex; gap: 6px; border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.wrap { overflow-wrap: anywhere; }
.access-view { display: grid; gap: 12px; }
.access-panel { min-height: calc(100dvh - 72px); display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 12px; overflow: hidden; }
.access-error { margin: 0; border: 1px solid #e7a1a1; border-radius: 6px; padding: 8px 10px; background: #fff3f3; color: var(--bad); font-size: 13px; font-weight: 700; }
.access-layout { min-width: 0; min-height: 0; display: grid; grid-template-columns: minmax(220px, 280px) minmax(520px, 1fr) minmax(240px, 320px); gap: 12px; overflow: hidden; }
.access-user-panel, .access-matrix-panel, .access-tool-panel, .access-status-panel, .access-tools { min-width: 0; display: grid; align-content: start; gap: 10px; }
.access-user-panel, .access-matrix-panel, .access-tool-panel { min-height: 0; overflow: hidden; }
.access-section-head { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.access-section-head h3 { margin: 0; font-size: 15px; }
.access-section-head span, .access-metrics, .access-tool-row span, .access-user-row span { color: var(--muted); font-size: 12px; }
.access-user-list, .access-tool-list { min-height: 0; display: grid; align-content: start; gap: 8px; overflow: auto; }
.access-user-list { max-height: calc(100dvh - 156px); }
.access-user-row { min-width: 0; display: grid; gap: 3px; text-align: left; border: 1px solid var(--line); border-radius: 6px; background: #fff; padding: 9px; }
.access-user-row.active { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
.access-user-row strong, .access-tool-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.access-user-controls { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; }
.access-user-controls label { display: grid; gap: 3px; color: var(--muted); font-size: 12px; font-weight: 800; }
.access-user-controls select { min-width: 104px; border: 1px solid var(--line); border-radius: 6px; background: #fff; padding: 6px 8px; }
.access-metrics { display: flex; flex-wrap: wrap; gap: 10px; }
.access-metrics strong { color: var(--ink); }
.access-table-wrap { min-height: 0; overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
.access-table { width: 100%; min-width: 720px; border-collapse: collapse; }
.access-table th, .access-table td { border-bottom: 1px solid var(--line); padding: 8px; text-align: left; vertical-align: middle; }
.access-table th:not(:first-child):not(:nth-child(2)), .access-table td:not(:first-child):not(:nth-child(2)) { text-align: center; width: 86px; }
.access-table td:first-child { display: grid; gap: 2px; }
.access-openfga-grid { display: grid; gap: 6px; margin: 0; }
.access-openfga-grid div { min-width: 0; display: grid; gap: 2px; border: 1px solid var(--line); border-radius: 6px; background: #fff; padding: 7px 8px; }
.access-openfga-grid dt { color: var(--muted); font-size: 11px; font-weight: 900; text-transform: uppercase; }
.access-openfga-grid dd { margin: 0; font-size: 12px; overflow-wrap: anywhere; }
.access-status-panel, .access-tools { border: 1px solid var(--line); border-radius: 8px; background: #fbfcf8; padding: 10px; }
.access-tool-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 6px; background: #fff; padding: 8px; }
.access-tool-row > div { min-width: 0; display: grid; gap: 2px; }
.access-empty { margin: 0; color: var(--muted); font-size: 13px; }
.access-switch { width: 42px; height: 24px; display: inline-grid; align-items: center; justify-items: start; cursor: pointer; }
.access-switch input { position: absolute; opacity: 0; pointer-events: none; }
.access-switch span { width: 42px; height: 24px; border: 1px solid var(--line); border-radius: 999px; background: #dfe6e0; position: relative; transition: background 120ms ease, border-color 120ms ease; }
.access-switch span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.24); transition: transform 120ms ease; }
.access-switch input:checked + span { border-color: var(--accent); background: #b9dfd4; }
.access-switch input:checked + span::after { transform: translateX(18px); }
.access-switch input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
.access-switch input:disabled + span { opacity: 0.55; }
.access-switch.busy span { background: #fff3d8; border-color: #e4bd68; }
.gate-view { display: grid; gap: 12px; }
.gate-controls { display: flex; flex-wrap: wrap; gap: 8px; }
.gate-table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
@@ -368,4 +409,8 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.device-pod-meta { display: none; }
.settings-group { grid-template-columns: minmax(0, 1fr); }
.skills-layout { grid-template-columns: minmax(0, 1fr); }
.access-panel { min-height: auto; overflow: visible; }
.access-layout { grid-template-columns: minmax(0, 1fr); overflow: visible; }
.access-user-list { max-height: none; }
.access-table { min-width: 640px; }
}
+78 -1
View File
@@ -2,14 +2,17 @@ import type { LiveBuildsPayload } from "./live-builds";
export type { LiveBuildBuild, LiveBuildCommit, LiveBuildImage, LiveBuildRecord, LiveBuildsPayload } from "./live-builds";
export type Tone = "ok" | "source" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run";
export type RouteId = "workspace" | "skills" | "gate" | "help" | "settings";
export type RouteId = "workspace" | "skills" | "access" | "gate" | "help" | "settings";
export type AuthState = "checking" | "login" | "authenticated";
export type ProviderProfile = "deepseek" | "codex-api" | "minimax-m3";
export interface AuthUser {
id?: string;
username?: string;
displayName?: string;
name?: string;
role?: AccessUserRole;
status?: AccessUserStatus;
}
export interface AuthSession {
@@ -273,6 +276,7 @@ export interface DevicePodSummary {
export interface DevicePodItem {
devicePodId: string;
name?: string;
targetId?: string;
profileId?: string;
status?: string;
@@ -287,6 +291,79 @@ export interface DevicePodListResponse {
[key: string]: unknown;
}
export type AccessUserRole = "admin" | "user";
export type AccessUserStatus = "active" | "disabled";
export type AccessDevicePodRelation = "viewer" | "operator" | "profile_editor" | "job_submitter";
export type AccessToolId = "hwpod" | "unidesk_ssh" | "trans_cmd" | "github_pr";
export interface OpenFgaStatus {
contractVersion?: string;
mode?: "off" | "shadow" | "enforce" | string;
configured?: boolean;
ready?: boolean;
degraded?: boolean;
storeId?: string | null;
modelId?: string | null;
tupleCount?: number;
error?: string;
[key: string]: unknown;
}
export interface AdminAccessTuple {
userId?: string;
relation?: string;
object?: string;
createdByAdminId?: string;
createdAt?: string;
}
export interface AdminAccessUserSummary {
user: AuthUser;
tupleCount?: number;
tools?: Partial<Record<AccessToolId, boolean>>;
devicePodCount?: number;
}
export interface AdminAccessDevicePodRow {
devicePod: DevicePodItem;
relations: Record<AccessDevicePodRelation, boolean>;
}
export interface AdminAccessMatrixResponse {
contractVersion?: string;
actor?: AuthUser | null;
user: AuthUser;
openfga?: OpenFgaStatus;
devicePods: AdminAccessDevicePodRow[];
tools: Record<AccessToolId, boolean>;
tuples?: AdminAccessTuple[];
}
export interface AdminAccessSummaryResponse {
contractVersion?: string;
actor?: AuthUser | null;
openfga?: OpenFgaStatus;
counts?: { users?: number; devicePods?: number; tuples?: number; toolTuples?: number; devicePodTuples?: number; [key: string]: unknown };
supported?: { devicePodRelations?: AccessDevicePodRelation[]; toolIds?: AccessToolId[]; [key: string]: unknown };
}
export interface AdminAccessUsersResponse {
contractVersion?: string;
actor?: AuthUser | null;
openfga?: OpenFgaStatus;
users?: AdminAccessUserSummary[];
count?: number;
}
export interface AdminAccessMutationResponse {
ok?: boolean;
changed?: boolean;
updated?: boolean;
access?: AdminAccessMatrixResponse | null;
result?: Record<string, unknown>;
user?: AuthUser;
}
export interface DevicePodStatusResponse {
status?: string;
serviceId?: string;