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