# v0.2 登录与鉴权规格

本文是 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、OpenFGA relation 和资源授权矩阵见 [spec-user-access.md](spec-user-access.md)；本文只定义“如何登录、如何恢复 actor、如何把请求归一成 actor”。

## 设计目标

- Web 用户通过 Keycloak OIDC 登录或注册，HWLAB 不再把本地账号密码表单作为目标登录体验。
- Web 使用 `hwlab-cloud-api` 发行的 httpOnly `hwlab_session`，每个 session token 最长 24 小时；第一版不做复杂 refresh token 管理，但必须可撤销、可重新登录轮换。
- CLI 是纯 CLI 体验，不打开浏览器、不跳转 Web、不做 device-code flow，也不直接消费 Keycloak access token；标准凭据是环境变量 `HWLAB_API_KEY`。
- AgentRun runner 内的 `hwpod` 也必须使用同一类用户 API key 认证，映射到发起 Code Agent session 的 `users.id`；不存在可恢复用户 actor 的共享系统 key。
- 每个用户在首次登录后自动拥有一个默认 API key；用户也可以在 Web 中创建、查看、失效或重新生成 API key。
- API key 长效有效，除非用户或管理员手动 revoke/regenerate；它不跟 Web session 的 24 小时过期绑定。
- Keycloak 只做身份认证和账号注册；HWLAB 的 `users.role`、`users.status`、OpenFGA relation 和 Code Agent owner 仍是应用层授权 source of truth。
- HWPOD 工具能力只由 `users.role/status`、Code Agent session owner 和 OpenFGA tool capability 控制；不存在“对所有硬件目标授权”的共享 runner key。

## 系统边界

| 组件 | 职责 |
| --- | --- |
| Keycloak | 独立 `keycloak` namespace 中的身份提供方，提供公网 HTTPS OIDC issuer、登录页、注册页和用户 subject。 |
| Keycloak Postgres | Keycloak 专用数据库，短期可与 Keycloak 同在 `keycloak` namespace；不复用 HWLAB v0.2 业务 Postgres。 |
| `hwlab-cloud-api` | OIDC callback、session token、API key、用户映射、`AuthPrincipal` 和应用层授权收口。 |
| `hwlab-cloud-web` | 浏览器 UI 和同源代理；未登录时通过 `/auth/oidc/login` 引导到 Keycloak，不持有 Keycloak client secret，不直接访问 Postgres。 |
| `hwlab-cli` / AgentRun `hwpod` | 读取 `HWLAB_API_KEY`，向 Cloud Web 同源 API 或 Cloud API 发送 `Authorization: Bearer hwl_live_...`，恢复到同一个用户 actor。 |
| `hwlab-edge-proxy` / FRP | 只转发 cookie/header 和 HTTP 流量，不注入伪 actor，不做业务授权。 |
| HWLAB v0.2 Postgres | 保存 HWLAB 用户映射、session、API key、resource grant 和 runtime durable state。 |

Keycloak 部署不进入 `hwlab-v02` namespace。`hwlab-v02` 只把 Keycloak 当成外部 OIDC issuer 消费，使用 issuer URL、client id 和 client secret SecretRef 完成 callback 校验。

## 公网 HTTPS 与 issuer

Keycloak 和 HWLAB 浏览器入口都必须使用公网 HTTPS；OIDC redirect URI、cookie `Secure` policy 和浏览器登录都依赖这一点。短期全自动方案使用主 server Caddy + Let's Encrypt + nip.io 域名：Keycloak 通过 FRP 暴露到 `auth.74-48-78-17.nip.io`，HWLAB Cloud Web 通过 Caddy 反向代理到 v0.2 Cloud Web FRP 入口。

目标公网入口：

```text
Keycloak issuer: https://auth.74-48-78-17.nip.io/realms/hwlab
HWLAB Web/API origin: https://hwlab.74-48-78-17.nip.io
```

约束：

- Keycloak admin console、account console、OIDC discovery 和 JWKS 都必须走 HTTPS；不要用裸 HTTP issuer。
- `hwlab-cloud-api` 只记录 issuer、client id、redirect URI 和 SecretRef 名称，不在日志、issue 或 ConfigMap 中输出 client secret。
- Keycloak 的 `hostname`、proxy header 和 frontend URL 必须与公网 issuer 一致，避免 callback 后 token issuer mismatch。
- 浏览器和 CLI 默认使用 `https://hwlab.74-48-78-17.nip.io`；`http://74.48.78.17:19666/19667` 只作为 legacy/debug 入口，不作为 OIDC callback、cookie 或用户文档默认入口。
- Keycloak OIDC client 的 redirect URI 必须指向 HWLAB callback：`https://hwlab.74-48-78-17.nip.io/auth/oidc/callback`。`https://auth.74-48-78-17.nip.io` 只作为 Keycloak issuer/authorization host，不能配置成 HWLAB callback 目标。
- `https://hwlab.74-48-78-17.nip.io/auth/*` 由 Cloud Web 同源代理到 Cloud API；Cloud Web 不读取 client secret，token exchange 只在 Cloud API 内完成。

## Keycloak 当前部署基线

v0.2 的 Keycloak 部署已经具备公网 HTTPS 原生后台管理入口和 `hwlab` realm OIDC issuer。后续 HWLAB 接入工作应把 Keycloak 当成已存在的外部 OIDC provider 消费，不再把 Keycloak 基础部署、Postgres 或 HTTPS 暴露混入 Cloud Web/Cloud API 接入任务。

部署拓扑：

```text
browser
  -> https://auth.74-48-78-17.nip.io
  -> master Caddy :443
  -> master frps remotePort 28443
  -> G14 keycloak-frpc
  -> keycloak.keycloak.svc.cluster.local:8080
```

运行基线：

- Kubernetes namespace 固定为 `keycloak`，与 `hwlab-v02` 解耦。
- Keycloak Deployment 使用 `quay.io/keycloak/keycloak:25.0`，Postgres 使用 `postgres:16-alpine`，FRP client 使用 `fatedier/frpc:v0.68.1`。
- `keycloak` Deployment、`keycloak-frpc` Deployment 和 `keycloak-postgres` StatefulSet 必须 Ready；`keycloak-bootstrap-realm` Job 必须已成功完成。
- 公网管理入口是 `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`，不自动拥有 HWPOD 工具能力。
- `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 鉴权方式。

管理员凭据边界：

- `master` realm 的 `admin` 用户只用于 Keycloak 原生后台和自动化 bootstrap，不是 HWLAB 应用管理员账号。
- Kubernetes Secret `keycloak-admin/password` 是 Keycloak 初始管理员和后续自动化 Job 获取 admin token 的凭据来源；如果人工在 Keycloak UI 里轮换 admin 密码，必须同步更新该 Secret，否则重跑 bootstrap/admin REST Job 会继续使用旧密码并失败。
- 不要把 admin password、client secret、session token 或完整 API key 写入 issue、长期参考、ConfigMap、日志或 trace。临时取密脚本使用后必须删除。
- `hwlab-cloud-web-client` Secret 只保存 Keycloak OIDC client secret；Cloud Web 不直接读取它，只有 `hwlab-cloud-api` 在 callback/token exchange 中使用。

部署硬化项：

- Keycloak 当前已能通过公网 HTTPS issuer 和 forwarded headers 正常工作；后续 render 可显式固定 `--hostname=https://auth.74-48-78-17.nip.io` 和必要的 `--hostname-admin`，减少代理链路变化导致的 issuer/callback 歧义。
- 小范围测试可先使用 bootstrap 创建的 client secret；进入更大范围公网使用前应轮换为随机 secret，并同时更新 Keycloak client 与 `hwlab-cloud-web-client` Secret。

## 注册策略

短期小范围测试允许 Keycloak 开启自助注册，并且不要求邮箱或手机校验。该策略只有在以下条件同时满足时成立：

- 新注册用户默认只映射为 HWLAB `user` 角色，不自动成为 `admin`。
- 新用户没有 HWPOD 工具能力，也不能通过 Code Agent 使用 HWPOD，直到 `admin` 在 HWLAB 中授权。
- Keycloak 账号状态必须能被管理员禁用；HWLAB `users.status='disabled'` 也必须能独立阻断 session 和 API key。
- 如果公网注册出现垃圾账号或撞库迹象，第一优先级是关闭 Keycloak self-registration 或增加邀请码/管理员审核；不要把防滥用逻辑塞进 HWPOD 工具授权。

邮箱和手机字段可以作为 profile 信息保存，但第一版不要求验证。后续如果进入更大范围公网使用，应至少补充邮箱验证、注册限流或邀请制中的一种。

## 身份映射

Keycloak subject 不直接当业务授权 ID。`hwlab-cloud-api` 在 OIDC callback 中按 `issuer + sub` 查找或创建 HWLAB `users` 记录，然后所有业务授权都使用 `users.id`。

推荐扩展 `users`：

```sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'local';
ALTER TABLE users ADD COLUMN IF NOT EXISTS keycloak_issuer TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS keycloak_sub TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_keycloak_subject
  ON users(keycloak_issuer, keycloak_sub)
  WHERE keycloak_issuer IS NOT NULL AND keycloak_sub IS NOT NULL;
```

映射规则：

- 首次 OIDC 登录：按 `issuer + sub` 不存在则创建 `users`，默认 `role='user'`、`status='active'`。
- 已存在本地 bootstrap admin 时，可以由 admin 在 HWLAB 内手动绑定 Keycloak subject 或提升 Keycloak 用户为 `admin`；Keycloak 注册本身不得自动提权。
- Keycloak profile 更新只同步 `username/display_name/email` 这类展示信息，不覆盖 HWLAB `role/status`。
- `disabled` 用户不能创建 Web session，不能通过 API key 鉴权，也不能继续 Code Agent session。

## AuthPrincipal

所有用户态请求最终归一成 `AuthPrincipal`。业务模块只消费 `AuthPrincipal`，不直接判断 Keycloak token、cookie 或 API key 原文。

```ts
type AuthPrincipal = {
  userId: string;
  username: string;
  displayName: string;
  role: "admin" | "user";
  status: "active" | "disabled";
  authMethod: "web-session" | "api-key";
  sessionId?: string;
  apiKeyId?: string;
  keycloakIssuer?: string;
  keycloakSub?: string;
  scopes: string[];
};
```

正式用户态请求只接受两类凭据：

1. `Authorization: Bearer hwl_live_...` 或 `HWLAB_API_KEY` 映射出的 header 是用户 API key，适用于 CLI 和 AgentRun runner 内 `hwpod`。
2. `hwlab_session` cookie 是 Web session。

本地 `/auth/login` 只用于空库 bootstrap 和 legacy/debug；它发行的 session 也必须走同一个 `hwlab_session` cookie，不再通过 `Authorization: Bearer <session-token>` 恢复用户。用户 API、CLI、AgentRun runner 或浏览器文档中不得出现跨用户共享系统 key。

## Web 登录流程

```text
browser -> Cloud Web /
  -> no valid hwlab_session
  -> GET /auth/oidc/login?returnTo=/
  -> 302 Keycloak login/register
  -> Keycloak callback with code
  -> hwlab-cloud-api exchanges authorization code with confidential client
  -> validate signed id_token via Keycloak JWKS, iss/aud/azp/exp/iat/nonce/sub
  -> fetch userinfo only as profile enrichment and require userinfo.sub == id_token.sub
  -> upsert users by issuer+sub
  -> ensure default API key exists for the user
  -> create user_sessions token hash, expires_at <= now + 24h
  -> Set-Cookie hwlab_session=httpOnly; Secure; SameSite=Lax
  -> redirect returnTo
```

Web 页面不要求用户输入或复制 API key。浏览器请求通过 `hwlab_session` 恢复 `AuthPrincipal`；需要 CLI 时，用户在 Web 的 API key 页面生成或重新生成自己的 key。

Session 规则：

- 每次 OIDC 登录或 callback 成功都发行新的 session token，数据库只保存 token hash。
- 单个 session token 最长 24 小时；过期、用户 disabled 或 logout 后必须返回 `401 auth_session_invalid`。
- `/auth/logout` 撤销 HWLAB session，并可选择跳转 Keycloak logout；即使 Keycloak 全局会话仍有效，HWLAB 已撤销的 session 也不能继续使用。
- 第一版不实现长期 refresh token 存储，也不把 Keycloak access token 或 refresh token 写入 HWLAB 数据库；用户过期后重新走 Keycloak 登录。
- `returnTo` 只允许同站相对路径，外站 URL、scheme-relative URL、反斜杠或控制字符必须回退到 `/workbench`。

## CLI API key 流程

CLI 和 AgentRun `hwpod` 标准认证方式：

```bash
export HWLAB_API_KEY=hwl_live_xxx
bun tools/hwlab-cli/bin/hwlab-cli.ts client auth whoami
```

约束：

- CLI 不跳转浏览器，不依赖 Web cookie，不要求 username/password 交互。
- `hwlab-cli` 默认且唯一从 `HWLAB_API_KEY` 读取 key，并发送 `Authorization: Bearer <key>`；`Authorization: Bearer` 是 HTTP 协议 header，不是第二个用户配置来源。
- `HWLAB_API_KEY` 是 CLI 和 AgentRun/HWPOD runner 唯一用户 API key 环境变量；不得支持或新增 `API_KEY`、`HWLAB_BEARER_TOKEN`、`--api-key`、`--bearer-token` 等别名。发现旧别名时必须返回结构化 `unsupported_api_key_source`，不能静默忽略或降级到 Web session。
- 受保护的 CLI 业务命令默认只接受 API key；`.state/hwlab-cli/session.json`、profile cookie 和 `client auth login` 产生的 Web session 不参与默认请求鉴权。Web session 只允许用于 `client auth session --web-session`、`client auth logout --web-session` 或显式 `--cookie`/`HWLAB_SESSION_COOKIE` 的浏览器同路径诊断，CLI 输出必须用 `authMethod=api-key|web-session` 和 `requiredAuthMethod=api-key|web-session` 暴露实际边界。
- AgentRun runner 的 transient env 只允许注入映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`；可以使用该用户默认 key，也可以使用同一 `api_keys` 表中为该用户创建的 runner 专用 key，但绝不能使用跨用户共享系统 key 或 Keycloak token。
- `client auth status` 必须显示 endpoint、是否检测到 `HWLAB_API_KEY`、key prefix 和用户摘要；不得输出完整 API key。
- `client auth whoami` 或 `client request GET /v1/users/me` 必须能用 API key 返回与 Web 同一用户的 `AuthPrincipal` 摘要。
- `client auth login --username ...` 和本地 cookie profile 属于 legacy 兼容入口；目标体验不再把它作为一等 CLI 登录。
- `hwpod` 在 runner 中必须自动使用 `HWLAB_RUNTIME_API_URL` 和 `HWLAB_API_KEY` 直连 `hwlab-cloud-api`，并让 cloud-api 按该 key 对应用户的 role/grant 做授权。
- v0.2 运行面允许配置一个 master server 管理员 API key：SecretRef 固定为 `hwlab-v02-master-server-admin-api-key/api-key`，Cloud API 通过 `HWLAB_BOOTSTRAP_ADMIN_API_KEY` 和固定 id `key_master_server_admin` 同步到 bootstrap admin `usr_v02_admin`。该 key 归属 HWLAB 管理员账号，scope 摘要为 `admin`、`system:hwlab`、`tool:*`，并在 bootstrap 时授予当前 `HWLAB_TOOL_IDS` 的 `can_use` capability。完整 key 只保存在受控 Secret 和 master server 本地 0600 配置文件中，不进入 Git、issue、长期文档、日志、trace 或 CLI 默认输出。

API key 数据结构：

```sql
CREATE TABLE IF NOT EXISTS api_keys (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  name TEXT NOT NULL DEFAULT 'Default API key',
  key_prefix TEXT NOT NULL,
  key_hash TEXT,
  display_secret TEXT,
  scopes_json TEXT NOT NULL DEFAULT '[]',
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
  created_at TEXT NOT NULL,
  last_used_at TEXT,
  revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id, status, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
```

目标状态只长期保存 `key_hash` 和 `key_prefix`，完整 API key 只在创建或 regenerate 响应中显示一次；用户丢失 key 时通过 regenerate 获取新 key。`display_secret` 只允许作为迁移期开发字段存在，不能作为生产化长期口径；完整 key 不得写入日志、issue、ConfigMap、Kubernetes Secret、trace 或 CLI 默认输出。

API key 行为：

- 首次 OIDC 登录后自动创建默认 key；如果默认 key 被 revoke，用户可在 Web 中重新生成。
- 默认 key、手动创建的 key 和 AgentRun runner 使用的 key 都继承该用户的 `role/status/grant`，不额外绕过权限矩阵。
- revoke 后同一 key 立刻返回 `401 api_key_invalid`，不能等 Web session 过期。
- 每次 API key 成功认证应更新 `last_used_at`，但不得记录完整 key。

## API 接口

| 接口 | 说明 |
| --- | --- |
| `GET /auth/oidc/login?returnTo=...` | 发起 Keycloak OIDC 登录，保存 state/nonce 并 302 到 Keycloak。 |
| `GET /auth/oidc/callback` | 校验 code、issuer、nonce 和 state，映射用户，发行 24 小时 Web session。 |
| `GET /auth/session` | 从 Web cookie 恢复 actor；API key 用户摘要使用 `/v1/users/me`。 |
| `POST /auth/logout` | revoke 当前 Web session 并清 cookie。 |
| `GET /v1/users/me` | 返回当前 `AuthPrincipal` 的脱敏摘要。 |
| `GET /v1/api-keys` | 列出当前用户 API key metadata；目标状态不返回完整 key。 |
| `GET /v1/api-keys/default` | 返回或创建默认 API key；目标状态只在新建时返回一次完整 key。 |
| `POST /v1/api-keys` | 创建一个用户 API key。 |
| `POST /v1/api-keys/{id}/regenerate` | 重新生成指定 API key，旧 secret 立即失效。 |
| `DELETE /v1/api-keys/{id}` | revoke 指定 API key。 |

所有 API key 管理接口都要求当前 `AuthPrincipal` 是同一用户本人或 `admin`；普通用户不能查看或生成其他人的 key。常规列表响应只返回 `key_prefix`、`id`、`name`、`status`、`last_used_at`，完整 key 只在创建或 regenerate 的一次性响应中出现。

## 与资源授权的关系

登录和认证只回答“请求是谁”。资源授权仍由 [spec-user-access.md](spec-user-access.md) 定义：

- `admin` 可以管理用户、工具 capability，并跨用户查看或取消 Code Agent session。
- `user` 只能访问自己的 Code Agent session 和被授权的工具能力。
- Web session、CLI API key 和 AgentRun runner 内 `hwpod` API key 得到同一个 `users.id` 时，应看到相同 OpenFGA tool capability 和账号 workspace。
- Keycloak realm role、group 或 claim 不直接决定 HWLAB 工具权限；最多作为创建/绑定用户时的输入线索。

## 测试规格

## T1

阅读 docs/reference/spec-v02-auth.md，然后用 CLI 或 curl 验证 `https://auth.74-48-78-17.nip.io/realms/hwlab/.well-known/openid-configuration` 可访问，issuer 与配置完全一致，JWKS URL 为 HTTPS，响应不包含 admin password、client secret 或数据库连接串。

## T2

阅读 docs/reference/spec-v02-auth.md，然后从浏览器访问 `https://hwlab.74-48-78-17.nip.io/`。未登录时必须进入 Keycloak 登录/注册；注册新用户不要求邮箱或手机验证，callback 后回到 Workbench，`GET /auth/session` 返回 active `user` actor 和 24 小时内过期的 session，不返回 session token 原文。

## T3

阅读 docs/reference/spec-v02-auth.md，然后登录 Web 后打开 API key 管理入口，确认可以创建或 regenerate 用户 API key；完整 key 只在创建或 regenerate 响应中显示一次。用 `HWLAB_API_KEY=<key> bun tools/hwlab-cli/bin/hwlab-cli.ts client auth whoami` 验证 CLI 无浏览器跳转即可恢复同一用户。

## T4

阅读 docs/reference/spec-v02-auth.md，然后 revoke 或 regenerate API key，再用旧 `HWLAB_API_KEY` 请求 `/v1/users/me` 或 `/v1/hwpod-node-ops`，必须返回 `401 api_key_invalid` 或等价鉴权 blocker；同一用户重新登录 Web session 不应恢复旧 key。

## T5

阅读 docs/reference/spec-v02-auth.md，然后分别使用 Web session 和同一用户的 CLI API key 创建 Code Agent session、提交 HWPOD node-ops smoke 和读取自己的 trace/result，确认授权结果一致；另一个普通用户的 API key 不能读取该 session，admin 可以跨用户查看。

## T6

阅读 docs/reference/spec-v02-auth.md，然后检查 cloud-api 日志、trace、CLI 默认输出和 issue 复现材料，确认不出现 Keycloak client secret、session token、完整 API key 或 Postgres URL 原文；AgentRun runner 中只能出现映射到当前 owner 的用户 API key。

## 规格的实现情况

| 规格项 | 状态 | 说明 |
| --- | --- | --- |
| Keycloak 独立 namespace 与公网 HTTPS issuer | 部署已完成 | `keycloak` namespace、Caddy/FRP HTTPS、`hwlab` issuer、admin console 和 bootstrap Job 已形成部署基线；后续只按本文件继续硬化。 |
| Keycloak 自助注册且不强制邮箱/手机验证 | Keycloak 侧已完成 | 只适合小范围测试；HWLAB 应用层仍必须默认 `user`、无 HWPOD 工具能力。 |
| 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，无跨用户共享系统 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` 与 tool capability 授权 | 部分实现 | 现有 cloud-api 已有本地用户、session、OpenFGA tool capability 和 Code Agent owner 绑定；资源授权继续按 spec-user-access 收敛。 |

## Keycloak 部署纪律

G14 v0.2 固定使用 `quay.io/keycloak/keycloak:25.0`（实际启动版本 Keycloak 25.0.6）。该镜像的初始管理员入口是 `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD`；`KC_BOOTSTRAP_ADMIN_USERNAME/PASSWORD` 属于更新版本文档口径，不作为 v0.2 当前镜像的部署入口。`master` realm 保持 Keycloak 自身初始化结果，不导入或覆盖；业务 realm 使用管理员 token 通过 Admin REST API 创建和更新。

`--import-realm` 只用于导入非 `master` 的预制 realm 文件，且启动导入遇到已存在 realm 会跳过。需要覆盖已存在 realm 时按 Keycloak import/export 文档停服务后运行离线 import，不能在运行中的生产 Deployment 上把 `master` realm 当作可覆盖配置文件反复灌入。

### 禁止直接修改 keycloak 数据库

所有 keycloak admin 操作必须通过 `kc.sh` CLI、Keycloak admin REST API 或 `--import-realm` 启动导入完成。**禁止**对 `keycloak` namespace 里的 Postgres 库直接执行 `INSERT / UPDATE / DELETE` 修改 `user_entity`、`credential`、`realm`、`client`、`client_scope`、`client_scope_attributes`、`keycloak_role`、`user_role_mapping` 等表。直接改库会导致：

- 破坏 Keycloak 25 的 `pbkdf2-sha256` JSON credential 格式，登录时报 `Unrecognized token 'pbkdf2'` 反序列化失败
- 绕过 Keycloak 启动时缓存的 `client_scope_attributes`（如 `include.in.token.scope`），pod 重启后修改失效
- 跳过 Liquibase changelog，未来 keycloak 升级时迁移冲突
- 在多副本或滚动重启场景下造成各副本行为不一致

### 正确做法

| 场景 | 推荐做法 | 禁止做法 |
| --- | --- | --- |
| 第一次引导管理员 | `KEYCLOAK_ADMIN=admin` + `KEYCLOAK_ADMIN_PASSWORD` 引用 `keycloak-admin` Secret，让 Keycloak 在首次启动时自行创建 `master` 管理员 | `psql` 直插 `user_entity` + `credential`，或导入自制 `master-realm.json` 覆盖 Keycloak 内置 `master` realm |
| 创建 `hwlab` realm + `hwlab-cloud-web` OIDC client | `keycloak-bootstrap-realm` Job 用 `kcadm.sh` / Admin REST API 幂等 create-or-update realm/client，redirect URI 对齐 `hwlab-cloud-api` callback | `psql` 直插，或依赖运行中的 `--import-realm` 覆盖已有 realm |
| 修改 realm/client/user/role | 优先通过 Keycloak Admin REST API 或 `kcadm.sh`；批量离线导入必须停服务后按 import/export 文档执行，并先确认是否会覆盖目标 realm | `psql` UPDATE 后靠重启碰运气 |
| 调试 admin token 没 `realm_access.roles` | `kubectl -n keycloak exec ... -- /opt/keycloak/bin/kc.sh show-config` 查 attribute 实际值；用 `curl /admin/realms/master/client-scopes/<id>` 走 admin REST 验 attribute 状态 | `psql` UPDATE 然后重启 keycloak pod 试运气 |

### 历史教训

#788 收口过程中曾用 `psql` 直插 `user_entity` / `credential` / `user_role_mapping` 临时绕开 Keycloak 初始化问题，结果：

1. 直插的 `pbkdf2-sha256` 字符串 hash 在 Keycloak 25 反序列化时抛 `Unrecognized token 'pbkdf2'`
2. SQL UPDATE `client_scope_attributes.include.in.token.scope = 'true'` 后，access_token 仍然不包含 `realm_access.roles`——Keycloak 在启动时缓存 attribute，SQL 修改要重启 pod 才生效
3. `client_scope_client.default_scope = true` 的 SQL 修改同样需要重启才生效
4. master realm admin role 复合 19 个子 role，手工插 `user_role_mapping` 后仍然有缺失

所有这些都是直接改库的副作用。后续所有 Keycloak 部署必须走镜像支持的初始管理员 env、Admin REST API、`kcadm.sh` 或明确停服的离线 import，**禁止 SQL 直改**。
