diff --git a/AGENTS.md b/AGENTS.md index 8984711a..8cbe10f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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)。 diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index eaf57070..b202dbc6 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -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 diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index d8610ce0..292d0d82 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -147,7 +147,7 @@ CREATE TABLE IF NOT EXISTS device_pods ( 历史版本中的 `profile_ref`、`gateway_ref` 或 `device_pod_json` 可以在迁移时折叠进 `profile_json`。第一阶段不新增 `device_pod_profile_revisions`;需要审计版本、回滚或多环境批准时再引入 profile revision 表。 -`device_pod_grants` 仍只表达用户是否拥有完整使用权;它不保存 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/.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 实现口径 diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 4459d5b9..489f2b43 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -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 pod;device-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 hash;Postgres、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 authority;OpenFGA 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 和 jobs;Device Pod 强副作用 job 已接入 reason 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 | | Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidence;trace/result cache 也按 owner/admin 限制访问。 | -| 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 权限。 | diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 2f75f4cc..86b0cd95 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -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 ` 读取 Cloud Web 的 `/v1/agent/chat/inspect`,输出 trace 所属 `conversationId/sessionId/threadId`、session 状态和 `retryOf` 建议;`client agent send --from-trace ` 只能作为显式复现该 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 API;small 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 `、`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` 字段。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index b1d81615..c7f0c12b 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -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/revision;Cloud 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 天 session;Keycloak OIDC、24 小时 Web session、CLI API key 和 `AuthPrincipal` 归一仍需按 spec-v02-auth 收敛。admin user/device-pod/grant API 和 Code Agent owner binding 已接入;生产 bootstrap 依赖 SecretRef。 | +| OpenFGA 细粒度授权与 Admin Access API | 目标状态 | 需要实现 OpenFGA client/bootstrap、`off/shadow/enforce`、`/v1/admin/access*`、tool capability check 和 runner env 过滤。 | | 账号共享 workspace authority | 已实现 | `account_workspaces` 持久化同账号 Web/CLI 共享 workspace,支持 revision 冲突可见性、账号隔离、最近 active trace 记录和 reset;同一用户不同 session/run 允许并发,互斥只在 session/thread/run 层处理。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index fca872ed..bcc7389d 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -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 Web;runner 使用映射到发起用户的 `HWLAB_API_KEY` 直连 Cloud API,Cloud 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 ` 必须看到同一 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-`,1Gi,StorageClass 走 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。 | diff --git a/docs/reference/spec-v02-openfga-authorization.md b/docs/reference/spec-v02-openfga-authorization.md new file mode 100644 index 00000000..51ffe8a9 --- /dev/null +++ b/docs/reference/spec-v02-openfga-authorization.md @@ -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 migrate;migration 失败时 cloud-api OpenFGA readiness 必须 degraded,不能进入 enforce。 +- OpenFGA 鉴权使用 preshared key 或等价内部服务 token SecretRef;Secret 值不得出现在 ConfigMap、日志、issue、trace 或 CLI 默认输出。 +- Readiness 使用 OpenFGA health endpoint;cloud-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: access_manager system:hwlab` 或 `admin system:hwlab` | +| 查看 device pod | `viewer device_pod:` | +| 提交 device pod job / 使用 `hwpod` | `operator` 或 `job_submitter device_pod:`,并要求 `can_use tool:hwpod` | +| 修改 device pod profile | `profile_editor device_pod:` 或 `admin system:hwlab` | +| 查看自己的 Code Agent session | `viewer agent_session:` | +| 继续/取消 Code Agent session | `operator agent_session:` | +| 注入 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:` 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:#owner@user:`;取消/归档不删除 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 写工具都必须独立授权。 | + diff --git a/docs/reference/spec-v02-services.md b/docs/reference/spec-v02-services.md index 77c51c2b..bed30f4b 100644 --- a/docs/reference/spec-v02-services.md +++ b/docs/reference/spec-v02-services.md @@ -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 store;Keycloak、Cloud Web、CLI、AgentRun runner 和普通用户都不能直接调用 OpenFGA。 - `hwlab-cloud-web` 只作为用户入口和 API proxy,不拥有业务 authority;CLI 可以旁路 UI,但不能旁路 `cloud-api` 的授权。 - `hwlab-device-pod` 是正式设备业务承载点;用户态请求必须走 `cloud-api -> hwlab-device-pod -> hwlab-gateway -> device-host-cli -> hardware`。 - 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 规格区提供顶级索引。 | diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index eef9686e..f406a550 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -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); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 657d80fe..cd852e12 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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; } diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 7463d4de..6fa7ffb9 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -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 }) { diff --git a/internal/cloud/openfga-authorization.ts b/internal/cloud/openfga-authorization.ts new file mode 100644 index 00000000..e0c555b2 --- /dev/null +++ b/internal/cloud/openfga-authorization.ts @@ -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); + } +} + diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 33c0e336..34528e21 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -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: { diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 9ba05e65..5b7f2468 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -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 })); diff --git a/scripts/g14-gitops-render.test.ts b/scripts/g14-gitops-render.test.ts index b82661fe..0fbaa6c5 100644 --- a/scripts/g14-gitops-render.test.ts +++ b/scripts/g14-gitops-render.test.ts @@ -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" }); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 3918afd7..d821a0e2 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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[] = []; diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index b6da0439..49cfbefe 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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() })); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 3a450f86..89bafc9d 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -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 = { workspace: "工作台", skills: "Skills", + access: "授权管理", gate: "内部复核", help: "使用说明", settings: "设置" @@ -168,6 +170,7 @@ export function App(): ReactElement {
{route === "workspace" ? 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" ? : null} + {route === "access" ? : null} {route === "gate" ? : null} {route === "help" ? : null} {route === "settings" ? : 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"; } diff --git a/web/hwlab-cloud-web/src/components/access/AccessView.tsx b/web/hwlab-cloud-web/src/components/access/AccessView.tsx new file mode 100644 index 00000000..4cfc3539 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/access/AccessView.tsx @@ -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(initialState); + + async function load(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 ( +
+
+
+

管理员

授权管理

+
{statusLabel}
+
+ {state.error ?

{state.error}

: null} +
+ + +
+
+

{selectedUser?.displayName || selectedUser?.username || "未选择用户"}

{selectedUser?.id ?? "-"}
+
+ + +
+
+
tuples {tupleCount}device pods {activeDevicePodCount}
+
+ + {RELATIONS.map((relation) => )} + + {state.detailLoading ? : null} + {!state.detailLoading && (state.matrix?.devicePods.length ?? 0) === 0 ? : null} + {!state.detailLoading ? state.matrix?.devicePods.map((row) => ( + + + + {RELATIONS.map((relation) => { + const key = `pod:${selectedUser?.id ?? ""}:${row.devicePod.devicePodId}:${relation.id}`; + return ; + })} + + )) : null} + +
Device PodTarget{relation.label}
正在读取授权矩阵。
无 Device Pod。
{row.devicePod.name ?? row.devicePod.devicePodId}{row.devicePod.devicePodId}{row.devicePod.targetId ?? shortToken(row.devicePod.profileHash, 12)} void toggleDevicePod(row.devicePod.devicePodId, relation.id, checked)} />
+
+
+ + +
+
+
+ ); +} + +function UserButton({ item, active, onSelect }: { item: AdminAccessUserSummary; active: boolean; onSelect(): void }): ReactElement { + return ( + + ); +} + +function PermissionSwitch({ checked, disabled, busy, label, onChange }: { checked: boolean; disabled: boolean; busy: boolean; label: string; onChange(checked: boolean): void }): ReactElement { + return ; +} + +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"; +} diff --git a/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx b/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx index 57733f74..463f9051 100644 --- a/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx +++ b/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx @@ -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 ( diff --git a/web/hwlab-cloud-web/src/services/api/client.ts b/web/hwlab-cloud-web/src/services/api/client.ts index 9e01a88d..ea5173f8 100644 --- a/web/hwlab-cloud-web/src/services/api/client.ts +++ b/web/hwlab-cloud-web/src/services/api/client.ts @@ -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> => fetchJson("/v1/device-pods", { timeoutMs: 12000, timeoutName: "Device Pods" }), devicePodStatus: (devicePodId: string): Promise> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/status`, { timeoutMs: 12000, timeoutName: "Device Pod status" }), devicePodEvents: (devicePodId: string): Promise> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/events?limit=120`, { timeoutMs: 12000, timeoutName: "Device Pod events" }), + adminAccessSummary: (): Promise> => fetchJson("/v1/admin/access/summary", { timeoutMs: 12000, timeoutName: "admin access summary" }), + adminAccessUsers: (): Promise> => fetchJson("/v1/admin/access/users", { timeoutMs: 12000, timeoutName: "admin access users" }), + adminAccessUser: (userId: string): Promise> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { timeoutMs: 12000, timeoutName: "admin access user" }), + updateAdminAccessUser: (userId: string, payload: { role?: AccessUserRole; status?: AccessUserStatus }): Promise> => 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> => 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> => 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> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }), updateWorkspace: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }), selectConversation: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }), diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 91fecb1d..5410c389 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -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; } } diff --git a/web/hwlab-cloud-web/src/types/domain.ts b/web/hwlab-cloud-web/src/types/domain.ts index eeb2ecc0..fea35f51 100644 --- a/web/hwlab-cloud-web/src/types/domain.ts +++ b/web/hwlab-cloud-web/src/types/domain.ts @@ -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>; + devicePodCount?: number; +} + +export interface AdminAccessDevicePodRow { + devicePod: DevicePodItem; + relations: Record; +} + +export interface AdminAccessMatrixResponse { + contractVersion?: string; + actor?: AuthUser | null; + user: AuthUser; + openfga?: OpenFgaStatus; + devicePods: AdminAccessDevicePodRow[]; + tools: Record; + 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; + user?: AuthUser; +} + export interface DevicePodStatusResponse { status?: string; serviceId?: string;