diff --git a/docs/release/k8s-secret-redaction.md b/docs/release/k8s-secret-redaction.md new file mode 100644 index 00000000..c3b82bdc --- /dev/null +++ b/docs/release/k8s-secret-redaction.md @@ -0,0 +1,145 @@ +# K8s Secret 脱敏与 reveal 契约(SEC-25) + +## 结论 + +- `list`、`detail`、`detail/yaml` 三个接口**默认不返回任何 Secret 值**,只返回键名和值的字节数。 +- 明文只能通过新接口 `GET /api/k8s/secret/:cluster_id/:namespace/:name/reveal` 获取。每次调用都会同步写一条审计(`cl_system_audit_logs`),审计只含谁、何时、哪个集群/命名空间/Secret、哪些 key,**不含值**。审计写入失败时不返回明文。 +- `kubectl.kubernetes.io/last-applied-configuration` 注解可能含整份明文,list/detail 的 `annotations` 和 YAML 中都不再返回它。 +- 本次改动不涉及数据库表结构变化(`K8sSecret` 不参与 AutoMigrate,`cl_system_apis`、`cl_system_audit_logs` 的结构未改)。 + +## 1. list / detail:字段变化 + +| 字段 | 改造前 | 改造后 | +| --- | --- | --- | +| `data` | `{key: base64(值)}` | `{key: ""}`:键都保留,值一律为空字符串 | +| `string_data` | `{key: 明文}`(通常为空) | `{key: ""}`:键都保留,值一律为空字符串 | +| `data_keys` | 无 | **新增**。`[{key, size}]` 按 key 升序,`size` 为 base64 解码后的值字节数 | +| `string_data_keys` | 无 | **新增**。`[{key, size}]` 按 key 升序,`size` 为字符串的 UTF-8 字节数 | +| `redacted` | 无 | **新增**。固定为 `true`,表示值已被移除 | +| `annotations` | 原样返回 | 不再返回 `kubectl.kubernetes.io/last-applied-configuration`,其余注解照常返回 | +| `data_count` / `size` | 键数 / 总大小 | 不变 | + +前端兼容说明:`data` 仍是对象,键集合没变,已有的“按 key 列表展示”逻辑无需修改。如果前端原来会对值做 base64 解码,现在得到的是空字符串,请改为展示 `data_keys[].size` 并提供“查看”按钮调用 reveal。 + +示例(`GET /api/k8s/secret/1/list` 中的单个 item,或 `GET /api/k8s/secret/1/ops/app-secret/detail` 返回的 `data`): + +```json +{ + "code": 0, + "data": { + "name": "app-secret", + "namespace": "ops", + "cluster_id": 1, + "uid": "00000000-0000-0000-0000-000000000000", + "type": "Opaque", + "data": { "db-user": "", "db-pass": "" }, + "string_data": { "note": "" }, + "data_keys": [ + { "key": "db-pass", "size": 16 }, + { "key": "db-user", "size": 5 } + ], + "string_data_keys": [ + { "key": "note", "size": 9 } + ], + "redacted": true, + "labels": { "app": "demo" }, + "annotations": { "owner": "team-a" }, + "immutable": false, + "data_count": 3, + "size": "30 B", + "age": "240h0m0s", + "created_at": "2026-10-01T00:00:00Z" + }, + "message": "请求成功" +} +``` + +## 2. detail/yaml:字段变化 + +- `data` 和 `stringData` 下每个键的值都替换为占位符 ``,键保留。这个占位符不是合法的 base64,不可能被当作真实值写回集群。 +- `metadata.annotations` 中的 `last-applied-configuration` 被移除(改造前已经如此),`resourceVersion`、`uid`、`managedFields` 等系统字段的清理方式不变。 + +```json +{ + "code": 0, + "data": { + "yaml": "data:\n db-pass: \n db-user: \nmetadata:\n annotations:\n owner: team-a\n labels:\n app: demo\n name: app-secret\n namespace: ops\nstringData:\n note: \ntype: Opaque\n" + }, + "message": "请求成功" +} +``` + +## 3. 新接口:reveal + +`GET /api/k8s/secret/:cluster_id/:namespace/:name/reveal[?keys=k1&keys=k2]` + +- `keys` 可选,可以重复传,也可以用逗号分隔。为空时返回全部键;指定的键不存在时报错,同样会写审计。同一个键同时出现在 `data` 和 `stringData` 中时,两处的值都会返回,`keys` 中该键只出现一次。 +- `data` 的编码与改造前 detail 一致(base64),前端原有的解码逻辑可以直接复用。 +- 响应头带 `Cache-Control: no-store`、`Pragma: no-cache`。 +- 同步审计写入失败时返回 **HTTP 500**(`code=1`,`message` 为“写入审计失败,已拒绝返回Secret明文”),不含任何值。其他失败(Secret 不存在、键不存在等)沿用项目惯例:HTTP 200、`code=1`。 +- 权限码:接口目录(`cl_system_apis`)中的名称为 `k8s:secret:reveal`,路径为 `/api/k8s/secret/:cluster_id/:namespace/:name/reveal`,方法为 GET。**只有显式授予这一条才生效**:授权记录的 `path` 必须与上面的路由模板逐字相等、方法为 GET。`/api/k8s/*`、`/*`、`/api/k8s/*/reveal` 这类通配符授权,`/api/k8s/secret/:cluster_id/:namespace/:name/:action`、`/api/k8s/secret/:a/:b/:c/reveal` 这类参数化授权(不含 `*`,参数名或段不同),具体请求路径(如 `/api/k8s/secret/1/ops/app/reveal`),以及 detail 权限,都不覆盖 reveal。用户名为 `admin` 的账号与其他接口一样直接放行。 + +```json +{ + "code": 0, + "data": { + "cluster_id": 1, + "namespace": "ops", + "name": "app-secret", + "type": "Opaque", + "data": { "db-pass": "", "db-user": "" }, + "string_data": null, + "keys": ["db-pass", "db-user"] + }, + "message": "请求成功" +} +``` + +### 审计记录 + +由 reveal 接口在 service 层同步写入,不依赖审计中间件(中间件对 GET 请求不记录,也从不记录请求体或响应体)。 + +| 列 | 值 | +| --- | --- | +| `user_id` | 操作者 ID | +| `ip_address` / `user_agent` / `trace_id` | 请求元数据(表结构要求 IP 非空) | +| `http_method` / `endpoint` | `GET` / 实际请求路径 | +| `status_code` | 本次 reveal 的实际结果:成功 `200`;指定的键不存在 `400`;读取 Secret 失败时取 K8s API 返回的状态码(如 `404`、`403`),无法识别时 `500` | +| `operation_type` | `REVEAL`(已加入 `GetAuditTypes`) | +| `target_type` / `target_id` | `k8s_secret` / `集群ID/命名空间/名称` | +| `request_body` | 见下方 JSON,只含元数据和键名 | +| `response_body` | 始终为空 | +| `error_msg` | 失败原因(不含值) | +| `created_at` | 写入时间 | + +```json +{ + "action": "k8s_secret_reveal", + "user_id": 7, + "username": "alice", + "cluster_id": 1, + "namespace": "ops", + "name": "app-secret", + "keys": ["db-pass", "db-user"], + "success": true, + "revealed_at": "2026-10-23T15:00:00Z" +} +``` + +## 4. 回写保护(保证前端改造前不丢数据) + +- `PUT .../update`:如果请求 `data` 中某个键的值为空,而集群中该键当前有非空值,就保留原值。这样前端把脱敏后的详情原样回填提交时,真实值不会被清空。要删除一个键,请在请求里去掉它。`string_data` 按同样规则处理:某个键的值为空、而集群中该键当前有非空值时,这个键不会作为 `stringData` 提交(否则 K8s 合并 `stringData` 时会把 `data` 中的值覆盖成空),而是在 `data` 中保留原值;如果请求 `data` 对同名键给了非空新值,以请求的新值为准。集群中不存在的键按原样写入。副作用是:通过表单无法把一个已有非空值的键改成空值(`data` 和 `string_data` 都不行),需要时请改用 YAML 更新,在 YAML 中写 `""`。请求没有携带 `last-applied-configuration` 注解时,保留集群中的原注解。 +- **SEC-29 同批次调整(SEC-25 P3)**: + - `data` 缺省与空对象的区别:请求**没有 `data` 字段**或为 `"data": null` 时,视为“不修改 data”,**保留集群现有的全部 `data` 键和值**(改造前会把 `data` 整体清空);请求带 **`"data": {}`** 时表示**显式清空**,集群中的 `data` 键全部删除。 + - `string_data` 空串的恢复范围收窄:某个键在 `string_data` 中为空串、集群中有非空原值时,**只有请求 `data` 也包含该键**(或请求根本没带 `data`,即整体保留)才恢复原值;请求 `data` 不包含该键时,该键视为已被删除,不会被恢复,也不会被写成空值(从 `stringData` 中移除,更新后集群中不再有该键)。 + - 示例:集群 `data` 为 `{a, b}`,请求 `{"data": {"a": ""}, "string_data": {"a": "", "b": ""}}` → 更新后 `a` 保留原值、`b` 被删除;请求 `{"string_data": {"a": ""}}`(无 `data`)→ `a`、`b` 都保留;请求 `{"data": {}}` → `a`、`b` 都删除。 +- `PUT .../update/yaml`:值仍为 `` 的键保持集群中的原值,用户只需修改需要变更的键。占位符对应的键在集群中不存在时拒绝更新。 +- `POST .../create/yaml`:YAML 中含 `` 时拒绝创建。 + +## 5. 前端待办 + +“查看值”“复制”“编辑”三个入口需要明文时,改为先调用 reveal(可以按 `keys` 只取需要的键),不再读取 detail/yaml 中的值。 + +## 6. 回滚 + +本次没有数据库迁移,回退提交即可恢复旧行为。回退后,reveal 接口在 `cl_system_apis` 中登记的那一行会留下,但不会产生影响;如需清理,删除 `name = 'k8s:secret:reveal'` 的记录及其在 `cl_system_role_apis` 中的关联即可。 diff --git a/docs/release/k8s-serviceaccount-token.md b/docs/release/k8s-serviceaccount-token.md new file mode 100644 index 00000000..d0891dc8 --- /dev/null +++ b/docs/release/k8s-serviceaccount-token.md @@ -0,0 +1,235 @@ +# K8s ServiceAccount token 接口加固(SEC-29) + +## 结论 + +- `GET /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` 只返回**属于该 SA 的** token Secret:类型必须为 `kubernetes.io/service-account-token`,注解 `kubernetes.io/service-account.name` 必须等于 SA 名称,若有注解 `kubernetes.io/service-account.uid` 则必须等于 SA 的 UID,命名空间一致且 `token` 非空。仅凭 `sa.secrets` 引用挂进来的其他 SA 的 token Secret(借挂)一律不返回。 +- 创建/更新 SA 时请求里的 `secrets` 引用**被忽略并记 Warn 日志**(只记 Secret 名称),不再写入集群。 +- GET 与 POST(TokenRequest)`/token` 都改为**只认显式授权**:必须按完整路由模板 + 方法授权,`/api/k8s/*`、`/*`、带 `:param` 的模板、具体请求路径都不覆盖。admin 照常放行。 +- 两个接口每次调用都**同步**写一条审计,只含元数据、不含 token;审计写入失败时**不返回 token**(HTTP 500)。响应头带 `Cache-Control: no-store`、`Pragma: no-cache`。 +- 不涉及数据库表结构变化,没有 DDL。启动时只会向 `cl_system_apis` 补登/改名权限行(见第 3 节)。**该启动迁移需要用户批准后才能上线**(见 3.6)。 +- `PUT .../update/yaml`:YAML 带 `metadata.resourceVersion` 时按乐观锁更新,版本过期返回 **HTTP 409**;不带时保持冲突重试(见 1.4)。 + +## 1. 接口契约变化 + +### 1.1 GET `/token`(读取 token Secret) + +| 场景 | 改造前 | 改造后 | +| --- | --- | --- | +| SA 自己的 token Secret | 返回 token | 不变 | +| `sa.secrets` 引用了其他 SA 的 token Secret | 返回其中第一个 token(越权) | 跳过;只剩借挂的 Secret 时返回 `code=1`、`msg` 含“没有属于自己的token Secret” | +| 注解 name 相同但 uid 不同(同名 SA 删除重建后残留) | 返回旧 token | 跳过 | +| 无 uid 注解 | 返回 | 只校验 name,返回 | +| 审计写入失败 | 无审计 | HTTP 500,不返回 token | + +返回结构 `{token, expiration_seconds, created_at, expiration_time}` 不变。 + +注意:Kubernetes 1.24 及以上的集群(`LegacyServiceAccountTokenNoAutoGeneration` 默认开启)通常**不会**再为 SA 自动生成 token Secret,`sa.secrets` 一般为空,GET `/token` 基本拿不到结果(返回 `code=1`,提示没有属于自己的 token Secret)。除非手工创建了带 `kubernetes.io/service-account.name` 注解的 `kubernetes.io/service-account-token` Secret,否则**主要入口是 POST(TokenRequest)**签发短期 token。 + +### 1.2 POST `/token`(TokenRequest) + +- 请求体 `{"expiration_seconds": 600}`;`cluster_id`、`namespace`、`service_account_name` 以 URL 为准,请求体里的同名字段会被 URL 值覆盖。 +- `expiration_seconds`(秒): + + | 请求值 | 结果 | + | --- | --- | + | 不传 / `null` / `0` | 按默认 **3600** 签发 | + | `600` ~ `3600`(含边界) | 按请求值签发 | + | `< 600`(含负数)或 `> 3600` | **HTTP 400**,`code=1`,`msg` 说明取值范围;**不调用 K8s、不签发 token**;同步写一条审计(`status_code=400`、`success=false`、`expiration_seconds` 为请求值) | + | 非整数(如字符串) | 绑定失败,HTTP 400(与其他接口的绑定错误一致,此时不进入 service、不写 token 审计) | + + 下限 600 是 K8s TokenRequest 允许的最小值;**上限 3600 是本平台自己的策略,不是 K8s 的限制**(K8s 本身允许更长的有效期,最大值由 apiserver 的 `--service-account-max-token-expiration` 决定)。超出范围返回 **HTTP 400**,这一行为已确认可以接受。校验在调用 K8s 之前完成。响应中的 `expiration_seconds` 为实际签发使用的值(不传时为 3600)。 +- 响应中的 `expiration_time` 固定为 **UTC**,格式 `2006-01-02T15:04:05Z`:K8s 返回的过期时间先 `.UTC()` 再格式化(改造前直接格式化,非 UTC 时区的时间会被错误地加上 `Z` 后缀)。 +- K8s 返回错误时审计记录真实状态码(403/404/…)。 +- **审计写入失败时不返回 token(HTTP 500)。但 token 已由 K8s 签发**,TokenRequest 签发的 token 无法单独吊销,会在 `expiration_seconds` 到期前保持有效(调用方拿不到它)。由于有效期上限为 3600,这类“签发了但没交出去”的 token **最多有效 1 小时**。如需立即失效,只能删除并重建该 SA。 + +### 1.3 创建 / 更新 SA 的 `secrets` 字段 + +| 接口 | 改造后行为 | +| --- | --- | +| `POST .../create`、`POST .../create/yaml` | 请求里的 `secrets` 被清空后再创建,`image_pull_secrets` 不受影响 | +| `PUT .../update` | 忽略请求里的 `secrets`,保留集群中该 SA 现有的 `secrets` 引用(原来 Update 是整体替换,不传会清空控制器维护的引用) | +| `PUT .../update/yaml` | 同上;另外 YAML 中的 `metadata.name` / `metadata.namespace` 被强制改为 URL 中的值,不能借 YAML 改到其他 SA | + +为什么是“忽略 + 日志”而不是“拒绝”:`secrets` 字段在现有 API 契约和前端类型(`k8s_serviceaccount.ts` 的 `secrets?: string[]`)中都存在,并且 detail YAML 里带有集群维护的 `secrets`,前端/用户把 YAML 原样回传时如果直接拒绝会把正常的编辑请求打失败。忽略后既堵住了“把别人的 token Secret 挂到自己 SA 上”的路径,又不破坏现有调用。日志关键字:`忽略请求中的ServiceAccount secrets引用`(仅含 Secret 名称)。 + +### 1.4 更新 SA:YAML 带版本走乐观锁,其余冲突重试(`PUT .../update`、`PUT .../update/yaml`) + +**YAML 带 `metadata.resourceVersion` 时(严格乐观锁)**: + +1. 按 URL 中的 `namespace` / `name` Get 当前对象作为基础(保留不可编辑字段和集群维护的 `secrets` 引用,`name` / `namespace` 以 URL 为准); +2. 只应用 4 个可编辑字段(同下文第 2 步); +3. 把 `resourceVersion` 设为 **YAML 中提交的值**,只 Update **一次**; +4. 版本已过期时 API Server 返回 Conflict,**不重试**,接口返回 **HTTP 409**、`code=1`,`msg` 形如 `ServiceAccount ops/app 已被修改(提交的 resourceVersion=4 已过期),请重新获取 YAML 后再提交`。 + +409 的映射方式沿用仓库已有的错误映射风格:service 返回带类型的业务错误 `base.BusinessError{Code: constants.ErrK8sResourceConflict}`(与有效期越界返回 `BusinessError{constants.ErrInvalidParam}` → HTTP 400 同一套机制),handler 通过 `respondBusinessError`(`internal/k8s/api/secret.go`,从原 `handleAuditedRequest` 中抽出)用 `errors.As` + `errors.Is` 识别,调用新增的 `base.ConflictError`(HTTP 409 + `code=1`,与 `base.BadRequestError` 同构)。其他错误仍是项目约定的 HTTP 200 + `code=1`。 + +**YAML 不带 `resourceVersion`,以及 JSON 更新(`PUT .../update`)**:保持冲突重试。两条路径都使用 `k8s.io/client-go/util/retry` 的 `retry.RetryOnConflict(retry.DefaultRetry, ...)`(`client-go` 原有依赖,`go.mod`/`go.sum` 未改动): + +1. 每次尝试先按 URL 中的 `namespace` / `name` **Get 最新版本**(带最新 `resourceVersion`); +2. 只把请求中允许修改的字段**整体覆盖**到最新版本上:`labels`、`annotations`、`automount_service_account_token`、`image_pull_secrets`; +3. `secrets` 引用保留集群最新版本中的值(请求/YAML 中的 `secrets` 忽略并记 Warn 日志),`name` / `namespace` 以 URL 为准; +4. Update;返回 409 Conflict 时回到第 1 步。 + +| 场景 | 改造前 | 改造后 | +| --- | --- | --- | +| 读取与写入之间 SA 被并发修改(如 token controller 追加了 `secrets` 引用) | 用旧快照整体替换,可能抹掉刚写入的引用 | 冲突后重新读取最新版本再应用字段,保留最新的 `secrets` 引用 | +| 连续冲突超过 `retry.DefaultRetry`(共 5 次尝试,间隔约 10ms) | — | 返回 `code=1`,`msg` 形如 `更新ServiceAccount失败: Operation cannot be fulfilled ...` | +| SA 不存在 | 报“ServiceAccount不存在” | 不变;不会调用 Update | +| YAML 中带 `metadata.resourceVersion` | 透传给 K8s,旧版本 YAML 直接失败(HTTP 200 + `code=1`) | **乐观锁**:版本一致则更新成功;版本过期只 Update 一次并返回 **HTTP 409**,不重试 | +| YAML 中不带 `metadata.resourceVersion` | 用读取时的快照整体替换 | 以最新版本为基础、冲突重试(对允许修改的字段是“后写覆盖”) | +| YAML 中上述 4 个字段以外的内容(`ownerReferences`、`finalizers`、`uid` 等) | 随整体替换写入 | **不生效**,保留集群现有值 | +| JSON 中未传 `labels` / `annotations` / `image_pull_secrets` / `automount_service_account_token` | 整体替换为空 | 不变(仍是整体覆盖为空),与原契约一致 | + +“JSON 更新时没传的字段会被清空”是**改造前就存在的行为**,本次不改,留给 SEC-30 处理。manager 中旧的 `UpdateServiceAccount`(整体替换)方法同样保留,留给 SEC-30。 + +日志关键字:失败时 `更新ServiceAccount失败` / `按resourceVersion更新ServiceAccount失败`(manager),忽略 secrets 时同 1.3。 + +## 2. 权限码与授权 + +| 方法 | 路由(`cl_system_apis.path`) | `method` | 权限码(`cl_system_apis.name`) | +| --- | --- | --- | --- | +| GET | `/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` | 1 | `k8s:serviceaccount:token` | +| POST | `/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` | 2 | `k8s:serviceaccount:token:create` | + +- `cl_system_apis.name` 有唯一索引,GET 与 POST 不能共用一个名字,所以 POST 用 `:create` 后缀。 +- 授权按 `path + method` 精确匹配:GET 授权不覆盖 POST,反之亦然。 +- 以下授权都**不再**放行 `/token`:`/api/k8s/*`、`/*`、`/api/k8s/serviceaccount/:cluster_id/:namespace/:name/:action`、换了参数名的模板、具体路径如 `/api/k8s/serviceaccount/1/ops/app/token`、中间通配 `/api/k8s/*/token`。 + +## 3. 启动时的权限目录迁移(无 DDL) + +**本节描述的启动迁移需要用户批准后才能上线。** 它在服务每次启动时由 `bootstrapPermissionCatalog`(`internal/app/permissions.go`)自动执行,没有单独的开关;批准前不要把包含本改动的版本部署到已有数据的环境。 + +### 3.1 涉及的表和行 + +只涉及 `cl_system_apis` 一张表(`model.Api`),不改表结构、不动其他表、不授予任何角色(不写 `cl_role_apis` / `cl_user_apis`)。可能被写入的只有下面三行(`method`:1=GET,2=POST): + +| `path` | `method` | 目标 `name` | 目标 `description` | +| --- | --- | --- | --- | +| `/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` | 1 | `k8s:serviceaccount:token` | `读取 K8s ServiceAccount token Secret(SEC-29,每次调用写审计;仅显式授权生效,通配符授权不覆盖)` | +| `/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` | 2 | `k8s:serviceaccount:token:create` | `通过 TokenRequest 签发 K8s ServiceAccount token(SEC-29,每次调用写审计;仅显式授权生效,通配符授权不覆盖)` | +| `/api/k8s/secret/:cluster_id/:namespace/:name/reveal`(SEC-25) | 1 | `k8s:secret:reveal` | `查看 K8s Secret 明文(SEC-25,…)` | + +其他路由仍按原逻辑只补登缺失的行(自动名 + 说明 `" "`),本次没有改变。 + +### 3.2 两种操作与条件 + +**(a) 插入(行不存在时)**:按 `(path, method)` 判断该路由在表中没有未删除的行(查询走 GORM 软删除过滤 `deleted_at = 0`,已软删除的行不计入,与原逻辑一致)时,按上表的 `name` / `description` 插入,`version='v1'`、`category=2`、`is_public=0`。插入使用 `INSERT ... ON CONFLICT DO NOTHING`:若 `name` 已被其他行占用(唯一索引 `idx_name_del` = `name + deleted_at`),插入被静默跳过。插入后会核对这三个权限码,**缺行时记一条 Warn**:`敏感接口权限码未登记到接口目录(插入因唯一键冲突被跳过),请人工核对 cl_system_apis`,字段只有 `permission=<权限码>`,不影响启动。 + +**(b) 改名(行已存在时)**:已部署实例在 SEC-29 之前就登记过 `/token` 的 GET/POST 行,名称是自动生成的。只有同时满足以下条件才更新: + +- `id` = 按 `(path, method)` 查到的那一行; +- `name` 仍等于自动生成值 `" ") 前 4 字节 hex>"`,对 `/token` 两行分别为 `GET GetServiceAccountToken-fm af783601`、`POST CreateServiceAccountToken 8ff0d051`(由代码计算,上线前请与库中实际值核对); +- `description` 仍等于自动生成值 `" "`,即 `GET /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` / `POST /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token`。 + +等价 SQL(**仅说明语义,由程序通过 GORM 执行,请勿手工执行**): + +```sql +UPDATE cl_system_apis + SET name = '<目标 name>', description = '<目标 description>', updated_at = <当前时间> + WHERE id = ? AND name = '<自动名>' AND description = ' ' AND deleted_at = 0; +``` + +只改 `name`、`description` 两个字段(GORM 会同时刷新 `updated_at`),`path`、`method`、`version`、`category`、`is_public` 和角色关联都不动。 + +- 管理员改过名称或说明(任一项)的行**整行不动**,记一条 Info 日志 `敏感接口权限行的说明已被管理员修改,保留原名称与说明`。这类行保留自动名,不影响授权(授权只看 `path + method`)。 +- 改名失败(例如目标权限码已被其他行占用触发唯一索引冲突)只记 Warn 日志,不影响启动。 + +### 3.3 幂等性 + +- 插入以 `(path, method)` 是否已有行为前提,再加 `ON CONFLICT DO NOTHING`,重复启动不会产生重复行。 +- 改名的 WHERE 带“名称、说明都仍是自动值”的条件,改过一次后条件不再成立,再次启动是空操作;多个实例同时启动时,同一行最多被改一次,其余实例的 UPDATE 影响 0 行。 +- 缺行 Warn 只针对本次实际尝试插入的权限码;已存在(包括被管理员改名)的行不会每次启动都告警。 + +### 3.4 回滚 SQL(草稿,只写在文档里,绝不执行) + +以下 SQL **只是草稿**,本次开发和验证过程中没有执行过,也不应由程序或流水线执行。如确需把改过名的两行恢复为自动名,由 DBA 在批准后先核对 `id` 和当前值再手工执行: + +```sql +-- 草稿:仅供审阅,未执行。执行前请先 SELECT 确认行与值。 +-- SELECT id, name, path, method, description FROM cl_system_apis +-- WHERE path = '/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' AND deleted_at = 0; + +UPDATE cl_system_apis + SET name = 'GET GetServiceAccountToken-fm af783601', + description = 'GET /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' + WHERE path = '/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' + AND method = 1 AND name = 'k8s:serviceaccount:token' AND deleted_at = 0; + +UPDATE cl_system_apis + SET name = 'POST CreateServiceAccountToken 8ff0d051', + description = 'POST /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' + WHERE path = '/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' + AND method = 2 AND name = 'k8s:serviceaccount:token:create' AND deleted_at = 0; +``` + +注意:新实例上这两行是 SEC-29 直接插入的(没有“原自动名”),回滚代码后保留即可;恢复名称不是回滚的必要步骤(见第 6 节)。不建议删除这些行:删除会让已经按这两行显式授权的角色失去授权关联。 + +### 3.5 不会做的事 + +- 不会自动授予任何角色; +- 不会修改管理员维护过的名称、说明; +- 不会删除任何行,也不会改 `path` / `method`。 + +### 3.6 上线审批 + +该迁移会在启动时写生产库的 `cl_system_apis`,**需要用户批准后才能上线**。审批时请确认:3.2 中的两个自动名与库中实际值一致;是否接受“管理员只改过说明的行保留自动名”;是否需要按 3.4 准备回滚。 + +## 4. 审计字段 + +写入 `cl_system_audit_logs`,同步写入(不经异步队列): + +| 字段 | GET | POST | +| --- | --- | --- | +| `operation_type` | `TOKEN_REVEAL` | `TOKEN_CREATE` | +| `http_method` | `GET` | `POST` | +| `target_type` | `k8s_serviceaccount_token` | 同左 | +| `target_id` | `//` | 同左 | +| `status_code` | 200 / 404 / K8s 真实状态码 | 200 / 400(有效期越界)/ K8s 真实状态码 | +| `user_id`、`ip_address`、`user_agent`、`trace_id`、`endpoint`、`duration`、`error_msg` | 照常 | 照常 | +| `response_body` | 空 | 空 | + +`request_body` 为 JSON 元数据(示例值均为虚构): + +```json +{ + "action": "k8s_serviceaccount_token_reveal", + "user_id": 7, + "username": "operator", + "cluster_id": 1, + "namespace": "ops", + "service_account": "app", + "service_account_uid": "00000000-0000-0000-0000-000000000000", + "secret_name": "app-token-xxxxx", + "success": true, + "issued_at": "2026-01-01T00:00:00Z" +} +``` + +POST 的 `action` 为 `k8s_serviceaccount_token_create`,不含 `secret_name`,含 `expiration_seconds`(生效值:不传时记 3600;越界被拒时记请求值)。**任何审计字段都不含 token。** 请求失败(SA 不存在、无合法 token Secret、有效期越界、K8s 拒绝)也会写审计,`success=false`。通用审计中间件对 GET 不记录;对 POST 另记一条 `CREATE` 元数据记录,不记录请求体和响应体。 + +## 5. 兼容性影响 + +- **只靠通配符授权(`/api/k8s/*`、`/*` 等)的非 admin 角色将失去 GET/POST `/token`**,返回 403。需要时按第 2 节为角色显式授权对应的 path + method。 +- 依赖“借挂 token Secret”拿 token 的用法失效(这正是要堵的越权路径)。 +- 创建/更新时传 `secrets` 不再生效,但请求不会失败。 +- 更新 SA 改为“读最新版本 + 只应用允许修改的字段”:YAML 中 4 个可编辑字段以外的内容不再生效;YAML 带 `resourceVersion` 时为乐观锁,版本过期返回 **HTTP 409**(原来是 HTTP 200 + `code=1`),不带时冲突重试(见 1.4)。调用方需要能处理 409(提示用户重新获取 YAML)。 +- JSON 更新时没传的字段会被清空:原有行为,本次未改,留给 SEC-30。 +- `expiration_time` 统一为 UTC 字符串。 +- `/token` 响应不可缓存(`no-store`)。 +- POST `/token` 的 `expiration_seconds` 超出 600~3600 时由原来的“透传给 K8s”改为 HTTP 400;不传时由“交给 K8s 默认”改为由平台显式传 3600(K8s API 默认值同为 3600,实际效果不变)。 +- 审计库不可用时 `/token` 返回 HTTP 500。 + +## 6. 回滚 + +- 回滚代码即可,无需数据库回滚(3.4 的 SQL 只是可选的名称恢复草稿,不是必要步骤)。 +- 回滚后 `cl_system_apis` 中残留的行:`k8s:serviceaccount:token`(GET)和 `k8s:serviceaccount:token:create`(POST),或在已部署实例上是被改名后的原行。旧代码启动时按 `(path, method)` 判断已存在,**不会改回自动名称,也不会新增重复行**,残留名称不影响授权。如需恢复原名称,可由 DBA 手动改 `name`;保留不动也无副作用。 +- 回滚后通配符授权重新覆盖 `/token`,借挂 token Secret 重新可被读出,审计和 no-store 一并消失。为 SEC-29 新增的显式授权行在旧代码下仍然有效(旧代码同样接受精确授权)。 +- 回滚期间已写入的 `TOKEN_REVEAL` / `TOKEN_CREATE` 审计记录保留,旧代码不读取也不受影响。 +- 回滚后 YAML 更新不再返回 409(恢复为改造前行为)。 + +## 7. 同批次的 SEC-25 语义变化(Secret 更新) + +详见 `docs/release/k8s-secret-redaction.md` 第 4 节,摘要: + +- `PUT /api/k8s/secret/.../update` 的 `string_data` 中值为空串的键,**只有当请求 `data` 也包含该键时**才恢复集群原值;请求 `data` 不包含该键时,该键被**删除**(不再因为 `string_data` 里残留的空串被恢复)。 +- 请求**没有 `data` 字段**(或 `"data": null`)时,**保留**集群现有的 `data`(原来会把 `data` 整体清空);请求带 **`"data": {}`**(空对象)表示**显式删除全部 `data` 键**。 diff --git a/internal/app/permissions.go b/internal/app/permissions.go index eb2cd5bd..cab0f620 100644 --- a/internal/app/permissions.go +++ b/internal/app/permissions.go @@ -6,28 +6,69 @@ import ( _ "embed" "encoding/json" "fmt" + "sort" "strings" "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/gin-gonic/gin" + "go.uber.org/zap" "gorm.io/gorm/clause" ) //go:embed aiops_permissions.json var aiopsPermissionCatalog []byte +// namedPermission 需要稳定权限名和说明的接口(敏感接口),其余接口按路由自动生成名称 +type namedPermission struct { + Name string + Description string +} + +// namedPermissions 以 "METHOD 路由" 为键登记的权限码 +var namedPermissions = map[string]namedPermission{ + "GET " + model.K8sSecretRevealRoute: { + Name: model.K8sSecretRevealPermissionName, + Description: "查看 K8s Secret 明文(SEC-25,每次调用写审计;仅显式授权生效,通配符授权不覆盖)", + }, + "GET " + model.K8sServiceAccountTokenRoute: { + Name: model.K8sServiceAccountTokenPermissionName, + Description: "读取 K8s ServiceAccount token Secret(SEC-29,每次调用写审计;仅显式授权生效,通配符授权不覆盖)", + }, + "POST " + model.K8sServiceAccountTokenRoute: { + Name: model.K8sServiceAccountTokenCreatePermissionName, + Description: "通过 TokenRequest 签发 K8s ServiceAccount token(SEC-29,每次调用写审计;仅显式授权生效,通配符授权不覆盖)", + }, +} + +// autoPermissionName 未登记权限码的接口按路由自动生成的名称 +func autoPermissionName(route gin.RouteInfo) string { + digest := sha256.Sum256([]byte(route.Method + " " + route.Path)) + handler := route.Handler[strings.LastIndex(route.Handler, ".")+1:] + if len(handler) > 25 { + handler = handler[:25] + } + return fmt.Sprintf("%s %s %x", route.Method, handler, digest[:4]) +} + +// autoPermissionDescription 未登记权限码的接口自动生成的说明:" " +func autoPermissionDescription(route gin.RouteInfo) string { + return route.Method + " " + route.Path +} + // bootstrapPermissionCatalog 补充可发现接口,不授予角色或覆盖管理员维护的元数据 func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { methods := map[string]int8{"GET": 1, "POST": 2, "PUT": 3, "DELETE": 4} public := map[string]bool{"/api/user/login": true, "/api/user/logout": true, "/api/user/refresh_token": true, "/api/not_auth/getBindIps": true, "/api/not_auth/getTreeNodeBindIps": true} var existing []model.Api - if err := a.db.WithContext(ctx).Select("path", "method").Find(&existing).Error; err != nil { + if err := a.db.WithContext(ctx).Select("id", "name", "path", "method", "description").Find(&existing).Error; err != nil { return err } seen := make(map[string]bool, len(existing)) + existingByKey := make(map[string]model.Api, len(existing)) key := func(method int8, path string) string { return fmt.Sprintf("%d %s", method, path) } for _, api := range existing { seen[key(api.Method, api.Path)] = true + existingByKey[key(api.Method, api.Path)] = api } routes := a.server.Routes() for _, route := range routes { @@ -48,19 +89,96 @@ func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { pending := make([]model.Api, 0) for _, route := range routes { method, ok := methods[route.Method] - if !ok || !strings.HasPrefix(route.Path, "/api/") || public[route.Path] || seen[key(method, route.Path)] { + if !ok || !strings.HasPrefix(route.Path, "/api/") || public[route.Path] { continue } - digest := sha256.Sum256([]byte(route.Method + " " + route.Path)) - handler := route.Handler[strings.LastIndex(route.Handler, ".")+1:] - if len(handler) > 25 { - handler = handler[:25] + if seen[key(method, route.Path)] { + a.renameAutoNamedPermission(ctx, route, existingByKey[key(method, route.Path)]) + continue + } + entry := model.Api{Name: autoPermissionName(route), Path: route.Path, Method: method, Description: autoPermissionDescription(route), Version: "v1", Category: 2, IsPublic: 0} + if named, ok := namedPermissions[route.Method+" "+route.Path]; ok { + entry.Name, entry.Description = named.Name, named.Description } - pending = append(pending, model.Api{Name: fmt.Sprintf("%s %s %x", route.Method, handler, digest[:4]), Path: route.Path, Method: method, Description: route.Method + " " + route.Path, Version: "v1", Category: 2, IsPublic: 0}) + pending = append(pending, entry) seen[key(method, route.Path)] = true } if len(pending) == 0 { return nil } - return a.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(pending, 100).Error + if err := a.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(pending, 100).Error; err != nil { + return err + } + a.warnMissingNamedPermissions(ctx, pending) + return nil +} + +// warnMissingNamedPermissions OnConflict DoNothing 遇到唯一键冲突(例如权限码已被其他接口或已软删除的行占用)会静默跳过插入。 +// 插入后逐个核对本次尝试登记的权限码:表中不存在 name + path + method 均一致的行时记一条 Warn(只记权限码),不影响启动。 +func (a *App) warnMissingNamedPermissions(ctx context.Context, pending []model.Api) { + expected := make(map[string]model.Api) + for _, entry := range pending { + for _, named := range namedPermissions { + if entry.Name == named.Name { + expected[entry.Name] = entry + } + } + } + if len(expected) == 0 { + return + } + names := make([]string, 0, len(expected)) + for name := range expected { + names = append(names, name) + } + sort.Strings(names) + var rows []model.Api + if err := a.db.WithContext(ctx).Select("id", "name", "path", "method").Where("name IN ?", names).Find(&rows).Error; err != nil { + if a.logger != nil { + a.logger.Warn("核对敏感接口权限码失败", zap.Strings("permissions", names), zap.Error(err)) + } + return + } + present := make(map[string]bool, len(rows)) + for _, row := range rows { + if want, ok := expected[row.Name]; ok && row.Path == want.Path && row.Method == want.Method { + present[row.Name] = true + } + } + for _, name := range names { + if !present[name] && a.logger != nil { + a.logger.Warn("敏感接口权限码未登记到接口目录(插入因唯一键冲突被跳过),请人工核对 cl_system_apis", + zap.String("permission", name)) + } + } +} + +// renameAutoNamedPermission 已登记过的敏感接口(如 SEC-29 之前就存在的 SA token 路由)在目录中仍是自动生成的 +// 名称与说明时,改为稳定的权限码与说明。只在名称和说明都仍等于自动生成值时更新 +// (WHERE id = ? AND name = <自动名> AND description = " "),管理员改过名称或说明(任一项)的行整行不动; +// 更新失败(例如权限码已被其他记录占用)只记日志,不影响启动。 +func (a *App) renameAutoNamedPermission(ctx context.Context, route gin.RouteInfo, row model.Api) { + named, ok := namedPermissions[route.Method+" "+route.Path] + if !ok || row.ID == 0 || row.Name == named.Name { + return + } + auto := autoPermissionName(route) + autoDescription := autoPermissionDescription(route) + if row.Name != auto { + return + } + if row.Description != autoDescription { + if a.logger != nil { + a.logger.Info("敏感接口权限行的说明已被管理员修改,保留原名称与说明", + zap.String("path", route.Path), zap.String("method", route.Method), zap.String("permission", named.Name)) + } + return + } + err := a.db.WithContext(ctx).Model(&model.Api{}). + Where("id = ? AND name = ? AND description = ?", row.ID, auto, autoDescription). + Updates(map[string]interface{}{"name": named.Name, "description": named.Description}).Error + if err != nil && a.logger != nil { + a.logger.Warn("更新敏感接口权限名失败,保留原名称", + zap.String("path", route.Path), zap.String("method", route.Method), zap.Error(err)) + } } diff --git a/internal/app/permissions_named_test.go b/internal/app/permissions_named_test.go new file mode 100644 index 00000000..99f0c4ca --- /dev/null +++ b/internal/app/permissions_named_test.go @@ -0,0 +1,46 @@ +package app + +import ( + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" +) + +func TestSecretRevealIsRegisteredAsNamedPermission(t *testing.T) { + named, ok := namedPermissions["GET "+model.K8sSecretRevealRoute] + if !ok { + t.Fatal("reveal 接口未登记到接口目录") + } + if named.Name != model.K8sSecretRevealPermissionName || named.Description == "" { + t.Fatalf("权限码不符: %+v", named) + } + // cl_system_apis.name 为 varchar(50) + if len(named.Name) > 50 { + t.Fatal("权限名超过 50 字符") + } +} + +func TestServiceAccountTokenIsRegisteredAsNamedPermission(t *testing.T) { + names := map[string]bool{} + for _, method := range []string{"GET", "POST"} { + named, ok := namedPermissions[method+" "+model.K8sServiceAccountTokenRoute] + if !ok { + t.Fatalf("%s token 接口未登记到接口目录", method) + } + if named.Description == "" || len(named.Name) > 50 { + t.Fatalf("权限码不符: %+v", named) + } + names[named.Name] = true + } + if !names[model.K8sServiceAccountTokenPermissionName] || !names[model.K8sServiceAccountTokenCreatePermissionName] { + t.Fatalf("权限码不符: %v", names) + } + // cl_system_apis.name 有唯一索引,所有登记的权限码不能重复 + seen := map[string]bool{} + for _, named := range namedPermissions { + if seen[named.Name] { + t.Fatalf("权限码重复: %s", named.Name) + } + seen[named.Name] = true + } +} diff --git a/internal/app/permissions_test.go b/internal/app/permissions_test.go index 94822bea..62b0fbc5 100644 --- a/internal/app/permissions_test.go +++ b/internal/app/permissions_test.go @@ -8,6 +8,9 @@ import ( "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/gin-gonic/gin" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -84,3 +87,207 @@ func TestAIProxyCatalogSupportsSeparateAssistantAndWorkflowGrants(t *testing.T) } } } + +func TestPermissionCatalogNamesSecretReveal(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + defer pool.Close() + if err := db.AutoMigrate(&model.Api{}); err != nil { + t.Fatal(err) + } + engine := gin.New() + engine.GET(model.K8sSecretRevealRoute, func(c *gin.Context) { c.Status(200) }) + app := &App{db: db, server: engine} + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + var row model.Api + if err := db.Where("path = ? AND method = ?", model.K8sSecretRevealRoute, 1).First(&row).Error; err != nil { + t.Fatal(err) + } + if row.Name != model.K8sSecretRevealPermissionName { + t.Fatalf("权限名=%q", row.Name) + } +} + +func TestPermissionCatalogNamesServiceAccountToken(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + defer pool.Close() + if err := db.AutoMigrate(&model.Api{}); err != nil { + t.Fatal(err) + } + engine := gin.New() + handler := func(c *gin.Context) { c.Status(200) } + engine.GET(model.K8sServiceAccountTokenRoute, handler) + engine.POST(model.K8sServiceAccountTokenRoute, handler) + app := &App{db: db, server: engine} + for i := 0; i < 2; i++ { + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + } + for method, want := range map[int8]string{1: model.K8sServiceAccountTokenPermissionName, 2: model.K8sServiceAccountTokenCreatePermissionName} { + var rows []model.Api + db.Where("path = ? AND method = ?", model.K8sServiceAccountTokenRoute, method).Find(&rows) + if len(rows) != 1 || rows[0].Name != want { + t.Fatalf("method=%d 权限目录不符: %+v", method, rows) + } + } +} + +// 升级场景:SEC-29 之前 token 路由已按自动名称登记过;自动名称改为权限码,管理员改过的名称保持不变 +func TestPermissionCatalogRenamesAutoNamedServiceAccountToken(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + defer pool.Close() + if err := db.AutoMigrate(&model.Api{}); err != nil { + t.Fatal(err) + } + engine := gin.New() + handler := func(c *gin.Context) { c.Status(200) } + engine.GET(model.K8sServiceAccountTokenRoute, handler) + engine.POST(model.K8sServiceAccountTokenRoute, handler) + var getRoute gin.RouteInfo + for _, r := range engine.Routes() { + if r.Method == "GET" { + getRoute = r + } + } + legacy := []model.Api{ + {Name: autoPermissionName(getRoute), Path: model.K8sServiceAccountTokenRoute, Method: 1, Description: "GET " + model.K8sServiceAccountTokenRoute, Version: "v1", Category: 2}, + {Name: "admin custom token name", Path: model.K8sServiceAccountTokenRoute, Method: 2, Description: "custom metadata", Version: "v1", Category: 2}, + } + if err := db.Create(&legacy).Error; err != nil { + t.Fatal(err) + } + app := &App{db: db, server: engine} + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + var count int64 + db.Model(&model.Api{}).Count(&count) + var get, post model.Api + db.First(&get, legacy[0].ID) + db.First(&post, legacy[1].ID) + if count != 2 || get.Name != model.K8sServiceAccountTokenPermissionName || post.Name != "admin custom token name" || post.Description != "custom metadata" { + t.Fatalf("升级后权限目录不符: count=%d get=%q post=%q/%q", count, get.Name, post.Name, post.Description) + } + if get.Description != namedPermissions["GET "+model.K8sServiceAccountTokenRoute].Description { + t.Fatalf("自动命名的行应同时更新为登记的说明: %q", get.Description) + } +} + +func newPermissionTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + t.Cleanup(func() { pool.Close() }) + if err := db.AutoMigrate(&model.Api{}); err != nil { + t.Fatal(err) + } + return db +} + +func newTokenRouteEngine() (*gin.Engine, map[string]gin.RouteInfo) { + engine := gin.New() + handler := func(c *gin.Context) { c.Status(200) } + engine.GET(model.K8sServiceAccountTokenRoute, handler) + engine.POST(model.K8sServiceAccountTokenRoute, handler) + routes := map[string]gin.RouteInfo{} + for _, r := range engine.Routes() { + routes[r.Method] = r + } + return engine, routes +} + +// SEC-29 P2:管理员只改了说明(名称仍是自动名)时,启动迁移不能覆盖该行的名称和说明 +func TestPermissionCatalogKeepsAdminEditedDescription(t *testing.T) { + db := newPermissionTestDB(t) + engine, routes := newTokenRouteEngine() + legacy := []model.Api{ + // 管理员只改了说明 + {Name: autoPermissionName(routes["GET"]), Path: model.K8sServiceAccountTokenRoute, Method: 1, Description: "运维自定义说明", Version: "v1", Category: 2}, + // 完全是自动生成的行,应被改名 + {Name: autoPermissionName(routes["POST"]), Path: model.K8sServiceAccountTokenRoute, Method: 2, Description: "POST " + model.K8sServiceAccountTokenRoute, Version: "v1", Category: 2}, + } + if err := db.Create(&legacy).Error; err != nil { + t.Fatal(err) + } + app := &App{db: db, server: engine, logger: zap.NewNop()} + for i := 0; i < 2; i++ { + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + } + var get, post model.Api + db.First(&get, legacy[0].ID) + db.First(&post, legacy[1].ID) + if get.Name != autoPermissionName(routes["GET"]) || get.Description != "运维自定义说明" { + t.Fatalf("管理员改过说明的行不应被覆盖: %q/%q", get.Name, get.Description) + } + wantPost := namedPermissions["POST "+model.K8sServiceAccountTokenRoute] + if post.Name != wantPost.Name || post.Description != wantPost.Description { + t.Fatalf("自动命名的行应改为权限码与登记说明: %q/%q", post.Name, post.Description) + } + var count int64 + db.Model(&model.Api{}).Count(&count) + if count != 2 { + t.Fatalf("不应新增行: %d", count) + } +} + +// SEC-29 P3-1:权限码被其他行占用导致 OnConflict DoNothing 静默跳过时,记 Warn(只含权限码) +func TestPermissionCatalogWarnsWhenNamedInsertSkipped(t *testing.T) { + db := newPermissionTestDB(t) + engine, _ := newTokenRouteEngine() + occupant := model.Api{Name: model.K8sServiceAccountTokenPermissionName, Path: "/api/other/occupant", Method: 1, Description: "occupant", Version: "v1", Category: 2} + if err := db.Create(&occupant).Error; err != nil { + t.Fatal(err) + } + core, logs := observer.New(zapcore.InfoLevel) + app := &App{db: db, server: engine, logger: zap.New(core)} + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + var rows []model.Api + db.Where("path = ?", model.K8sServiceAccountTokenRoute).Find(&rows) + if len(rows) != 1 || rows[0].Name != model.K8sServiceAccountTokenCreatePermissionName { + t.Fatalf("前提:GET 行应因权限码冲突被跳过,只插入 POST 行: %+v", rows) + } + warns := logs.FilterLevelExact(zapcore.WarnLevel).All() + if len(warns) != 1 { + t.Fatalf("应只记一条 Warn: %+v", warns) + } + fields := warns[0].ContextMap() + if len(fields) != 1 || fields["permission"] != model.K8sServiceAccountTokenPermissionName { + t.Fatalf("Warn 只应包含缺失的权限码: %+v", fields) + } + + // 全部登记成功时不记 Warn + db2 := newPermissionTestDB(t) + core2, logs2 := observer.New(zapcore.InfoLevel) + app2 := &App{db: db2, server: engine, logger: zap.New(core2)} + if err := app2.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + if n := logs2.FilterLevelExact(zapcore.WarnLevel).Len(); n != 0 { + t.Fatalf("权限码均已登记时不应告警: %d", n) + } +} diff --git a/internal/constants/k8s.go b/internal/constants/k8s.go index fda8a7d2..07a86d69 100644 --- a/internal/constants/k8s.go +++ b/internal/constants/k8s.go @@ -51,4 +51,7 @@ var ( ErrK8sGetPodLogs = errors.New("k8s get pod logs failed") ErrK8sPortForward = errors.New("k8s port forward failed") ErrorK8sIngressHealthFailed = errors.New("k8s ingress check health failed") + + // ErrK8sResourceConflict 乐观锁冲突(提交的 resourceVersion 已过期),handler 映射为 HTTP 409 + ErrK8sResourceConflict = errors.New("k8s resource conflict") ) diff --git a/internal/k8s/api/secret.go b/internal/k8s/api/secret.go index 4871f9e9..3f785066 100644 --- a/internal/k8s/api/secret.go +++ b/internal/k8s/api/secret.go @@ -26,6 +26,9 @@ package api import ( + "errors" + + "github.com/GoSimplicity/AI-CloudOps/internal/constants" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/GoSimplicity/AI-CloudOps/pkg/base" @@ -50,6 +53,7 @@ func (h *K8sSecretHandler) RegisterRouters(server *gin.Engine) { k8sGroup.GET("/secret/:cluster_id/list", h.GetSecretList) k8sGroup.GET("/secret/:cluster_id/:namespace/:name/detail", h.GetSecret) k8sGroup.GET("/secret/:cluster_id/:namespace/:name/detail/yaml", h.GetSecretYAML) + k8sGroup.GET("/secret/:cluster_id/:namespace/:name/reveal", h.RevealSecret) k8sGroup.POST("/secret/:cluster_id/create", h.CreateSecret) k8sGroup.POST("/secret/:cluster_id/create/yaml", h.CreateSecretByYaml) k8sGroup.PUT("/secret/:cluster_id/:namespace/:name/update", h.UpdateSecret) @@ -255,3 +259,109 @@ func (h *K8sSecretHandler) UpdateSecretByYaml(ctx *gin.Context) { return nil, h.secretService.UpdateSecretByYaml(ctx.Request.Context(), &req) }) } + +// RevealSecret 返回 Secret 明文(SEC-25)。list/detail/yaml 默认脱敏,明文只能经此接口获取, +// 每次调用都会写一条审计(只含操作者、集群、命名空间、名称与键名)。 +func (h *K8sSecretHandler) RevealSecret(ctx *gin.Context) { + var req model.RevealSecretReq + + clusterID, err := base.GetCustomParamID(ctx, "cluster_id") + if err != nil { + base.BadRequestError(ctx, err.Error()) + return + } + + namespace, err := base.GetParamCustomName(ctx, "namespace") + if err != nil { + base.BadRequestError(ctx, err.Error()) + return + } + + name, err := base.GetParamCustomName(ctx, "name") + if err != nil { + base.BadRequestError(ctx, err.Error()) + return + } + + req.ClusterID = clusterID + req.Namespace = namespace + req.Name = name + + user := base.GetUserInfoFromGinContext(ctx) + actor := model.SecretRevealActor{ + UserID: user.UserID, + Username: user.Username, + IP: user.IP, + UserAgent: user.UserAgent, + TraceID: ctx.GetHeader("X-Trace-ID"), + Endpoint: ctx.Request.URL.Path, + } + + // 明文响应禁止被浏览器或中间代理缓存 + ctx.Header("Cache-Control", "no-store") + ctx.Header("Pragma", "no-cache") + + handleAuditedRequest(ctx, &req, func() (interface{}, error) { + return h.secretService.RevealSecret(ctx.Request.Context(), &req, actor) + }) +} + +// handleAuditedRequest 与 base.HandleRequest 相同,但同步审计写入失败(service.ErrAuditWriteFailed)时 +// 返回 HTTP 500 而不是 200+code=1,使网关/监控与审计告警能看到真实的失败状态; +// service 返回的参数错误(BusinessError{constants.ErrInvalidParam})返回 HTTP 400。响应中不含任何明文。 +func handleAuditedRequest(ctx *gin.Context, req interface{}, action func() (interface{}, error)) { + if req != nil { + if err := base.BindRequest(ctx, req); err != nil { + base.BadRequestWithDetails(ctx, err.Error(), "绑定数据失败") + return + } + } + data, err := action() + if err != nil { + if errors.Is(err, service.ErrAuditWriteFailed) { + base.InternalServerErrorWithDetails(ctx, map[string]interface{}{}, err.Error()) + return + } + respondBusinessError(ctx, err) + return + } + base.SuccessWithData(ctx, data) +} + +// handleMappedRequest 与 base.HandleRequest 相同,但按 respondBusinessError 把带类型的业务错误映射为对应 HTTP 状态码 +func handleMappedRequest(ctx *gin.Context, req interface{}, action func() (interface{}, error)) { + if req != nil { + if err := base.BindRequest(ctx, req); err != nil { + base.BadRequestWithDetails(ctx, err.Error(), "绑定数据失败") + return + } + } + data, err := action() + if err != nil { + respondBusinessError(ctx, err) + return + } + if data != nil { + base.SuccessWithData(ctx, data) + } else { + base.Success(ctx) + } +} + +// respondBusinessError 业务错误映射:BusinessError{constants.ErrInvalidParam} → HTTP 400; +// BusinessError{constants.ErrK8sResourceConflict}(乐观锁冲突)→ HTTP 409;其余保持项目约定的 HTTP 200 + code=1 +func respondBusinessError(ctx *gin.Context, err error) { + var bizErr *base.BusinessError + if errors.As(err, &bizErr) { + switch { + case errors.Is(bizErr.Code, constants.ErrInvalidParam): + // 参数错误(如 TokenRequest 有效期越界)按项目约定返回 HTTP 400 + code=1 + base.BadRequestError(ctx, err.Error()) + return + case errors.Is(bizErr.Code, constants.ErrK8sResourceConflict): + base.ConflictError(ctx, err.Error()) + return + } + } + base.ErrorWithMessage(ctx, err.Error()) +} diff --git a/internal/k8s/api/secret_test.go b/internal/k8s/api/secret_test.go new file mode 100644 index 00000000..e59e0294 --- /dev/null +++ b/internal/k8s/api/secret_test.go @@ -0,0 +1,161 @@ +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" + "github.com/GoSimplicity/AI-CloudOps/internal/middleware" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const handlerFixtureValue = "plain-handler-fixture-777" + +type handlerSecretManager struct { + manager.SecretManager +} + +func (handlerSecretManager) GetSecret(_ context.Context, _ int, namespace, name string) (*corev1.Secret, error) { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{"alpha": []byte(handlerFixtureValue)}, + }, nil +} + +// recordingAudit 同时记录 service 的同步审计与中间件的异步审计 +type recordingAudit struct { + systemService.AuditService + mu sync.Mutex + sync []*model.CreateAuditLogRequest + async []*model.CreateAuditLogRequest +} + +func (a *recordingAudit) CreateAuditLog(_ context.Context, req *model.CreateAuditLogRequest) error { + a.mu.Lock() + defer a.mu.Unlock() + a.sync = append(a.sync, req) + return nil +} + +func (a *recordingAudit) CreateAuditLogAsync(_ context.Context, req *model.CreateAuditLogRequest) { + a.mu.Lock() + defer a.mu.Unlock() + a.async = append(a.async, req) +} + +func TestRevealRouteMatchesPermissionCode(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + NewK8sSecretHandler(nil).RegisterRouters(engine) + found := false + for _, r := range engine.Routes() { + if r.Method == "GET" && r.Path == model.K8sSecretRevealRoute { + found = true + } + } + if !found { + t.Fatalf("未注册 reveal 路由 %s", model.K8sSecretRevealRoute) + } +} + +func TestRevealEndpointReturnsPlaintextAndAuditsWithoutValues(t *testing.T) { + gin.SetMode(gin.TestMode) + audit := &recordingAudit{} + svc := service.NewSecretService(handlerSecretManager{}, audit, zap.NewNop()) + + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}) + c.Next() + }) + // 与线上一致挂载审计中间件,确认它不会把响应体(明文)写入审计 + engine.Use(middleware.NewAuditLogMiddleware(audit, zap.NewNop()).AuditLog()) + NewK8sSecretHandler(svc).RegisterRouters(engine) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/k8s/secret/3/ops/app/reveal", nil)) + if rec.Code != 200 { + t.Fatalf("status=%d", rec.Code) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatal("reveal 响应应禁止缓存") + } + var resp struct { + Code int `json:"code"` + Data model.K8sSecretReveal `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if string(resp.Data.Data["alpha"]) != handlerFixtureValue || resp.Data.Namespace != "ops" || resp.Data.Name != "app" || resp.Data.ClusterID != 3 { + t.Fatalf("reveal 响应不符: %s", rec.Body.String()) + } + + audit.mu.Lock() + defer audit.mu.Unlock() + if len(audit.sync) != 1 { + t.Fatalf("reveal 应写 1 条审计,实际 %d", len(audit.sync)) + } + all := append(append([]*model.CreateAuditLogRequest{}, audit.sync...), audit.async...) + raw, _ := json.Marshal(all) + if strings.Contains(string(raw), handlerFixtureValue) || strings.Contains(string(raw), base64.StdEncoding.EncodeToString([]byte(handlerFixtureValue))) { + t.Fatal("审计记录中出现了明文") + } + for _, l := range all { + if len(l.ResponseBody) != 0 { + t.Fatal("审计不应记录响应体") + } + } + got := audit.sync[0] + if got.UserID != 9 || got.TargetID != "3/ops/app" || got.Endpoint != "/api/k8s/secret/3/ops/app/reveal" { + t.Fatalf("审计元数据不符: %+v", got) + } + var body model.SecretRevealAuditRecord + if err := json.Unmarshal(got.RequestBody, &body); err != nil || body.Username != "bob" || strings.Join(body.Keys, ",") != "alpha" { + t.Fatalf("审计内容不符: %s", got.RequestBody) + } +} + +type failingAudit struct { + systemService.AuditService +} + +func (failingAudit) CreateAuditLog(context.Context, *model.CreateAuditLogRequest) error { + return errors.New("audit db unavailable") +} + +// 审计写入失败:HTTP 500、不返回明文、仍带 no-store +func TestRevealEndpointAuditFailureReturns500WithoutPlaintext(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := service.NewSecretService(handlerSecretManager{}, failingAudit{}, zap.NewNop()) + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}); c.Next() }) + NewK8sSecretHandler(svc).RegisterRouters(engine) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/k8s/secret/3/ops/app/reveal", nil)) + if rec.Code != 500 { + t.Fatalf("审计失败应返回 500, got %d", rec.Code) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatal("失败响应也应禁止缓存") + } + body := rec.Body.String() + if strings.Contains(body, handlerFixtureValue) || strings.Contains(body, base64.StdEncoding.EncodeToString([]byte(handlerFixtureValue))) { + t.Fatal("审计失败时响应中出现了明文") + } +} diff --git a/internal/k8s/api/serviceaccount.go b/internal/k8s/api/serviceaccount.go index e1a03e6f..56e46ab9 100644 --- a/internal/k8s/api/serviceaccount.go +++ b/internal/k8s/api/serviceaccount.go @@ -251,7 +251,8 @@ func (h *K8sServiceAccountHandler) UpdateServiceAccountYaml(ctx *gin.Context) { req.Namespace = namespace req.Name = name - base.HandleRequest(ctx, &req, func() (interface{}, error) { + // YAML 带 resourceVersion 时走乐观锁,版本冲突返回 HTTP 409 + handleMappedRequest(ctx, &req, func() (interface{}, error) { return nil, h.serviceAccountService.UpdateServiceAccountYaml(ctx.Request.Context(), &req) }) } @@ -281,8 +282,12 @@ func (h *K8sServiceAccountHandler) GetServiceAccountToken(ctx *gin.Context) { req.Namespace = namespace req.Name = name - base.HandleRequest(ctx, &req, func() (interface{}, error) { - return h.serviceAccountService.GetServiceAccountToken(ctx.Request.Context(), &req) + actor := sensitiveReadActor(ctx) + // token 响应禁止被浏览器或中间代理缓存 + setNoStore(ctx) + + handleAuditedRequest(ctx, &req, func() (interface{}, error) { + return h.serviceAccountService.GetServiceAccountToken(ctx.Request.Context(), &req, actor) }) } @@ -311,7 +316,29 @@ func (h *K8sServiceAccountHandler) CreateServiceAccountToken(ctx *gin.Context) { req.Namespace = namespace req.ServiceAccountName = name - base.HandleRequest(ctx, &req, func() (interface{}, error) { - return h.serviceAccountService.CreateServiceAccountToken(ctx.Request.Context(), &req) + actor := sensitiveReadActor(ctx) + setNoStore(ctx) + + handleAuditedRequest(ctx, &req, func() (interface{}, error) { + return h.serviceAccountService.CreateServiceAccountToken(ctx.Request.Context(), &req, actor) }) } + +// sensitiveReadActor 从登录态中提取敏感接口(Secret reveal、SA token)审计所需的操作者信息 +func sensitiveReadActor(ctx *gin.Context) model.SecretRevealActor { + user := base.GetUserInfoFromGinContext(ctx) + return model.SecretRevealActor{ + UserID: user.UserID, + Username: user.Username, + IP: user.IP, + UserAgent: user.UserAgent, + TraceID: ctx.GetHeader("X-Trace-ID"), + Endpoint: ctx.Request.URL.Path, + } +} + +// setNoStore 敏感响应禁止被浏览器或中间代理缓存 +func setNoStore(ctx *gin.Context) { + ctx.Header("Cache-Control", "no-store") + ctx.Header("Pragma", "no-cache") +} diff --git a/internal/k8s/api/serviceaccount_route_test.go b/internal/k8s/api/serviceaccount_route_test.go new file mode 100644 index 00000000..afada2f8 --- /dev/null +++ b/internal/k8s/api/serviceaccount_route_test.go @@ -0,0 +1,129 @@ +package api + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" + "github.com/GoSimplicity/AI-CloudOps/internal/middleware" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemservice "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" +) + +type routeGrantRoles struct { + systemservice.RoleService + apis []*model.Api +} + +func (r routeGrantRoles) GetUserRoles(context.Context, int) (*model.ListResp[*model.Role], error) { + return &model.ListResp[*model.Role]{Items: []*model.Role{{Status: 1, Apis: r.apis}}}, nil +} + +// routeProbeSAManager 记录 token 相关的 manager 调用:handler 被执行时必然会调用其中之一 +type routeProbeSAManager struct { + *handlerSAManager + reveals, issues int +} + +func (m *routeProbeSAManager) GetServiceAccount(ctx context.Context, clusterID int, namespace, name string) (*corev1.ServiceAccount, error) { + m.reveals++ + return m.handlerSAManager.GetServiceAccount(ctx, clusterID, namespace, name) +} + +func (m *routeProbeSAManager) CreateServiceAccountToken(ctx context.Context, clusterID int, namespace, name string, tr *authv1.TokenRequest) (*authv1.TokenRequest, error) { + m.issues++ + return m.handlerSAManager.CreateServiceAccountToken(ctx, clusterID, namespace, name, tr) +} + +// newSARouteAuthEngine 与生产一致:gin.New()(RedirectTrailingSlash=true、RedirectFixedPath=false)+ 认证中间件 + 真实路由注册 +// (不测 RedirectFixedPath=true:gin v1.10.0 的 findCaseInsensitivePath 在本路由树上会 panic,生产也未开启) +func newSARouteAuthEngine(mgr *routeProbeSAManager, apis []*model.Api) *gin.Engine { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}); c.Next() }) + engine.Use(middleware.NewAuthMiddleware(routeGrantRoles{apis: apis}).CheckAuth()) + NewK8sServiceAccountHandler(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())).RegisterRouters(engine) + return engine +} + +// serveFollowingRedirects 发请求并跟随最多 3 次重定向(模拟客户端自动跟随),返回最终状态码 +func serveFollowingRedirects(engine *gin.Engine, method, path string) int { + for i := 0; i < 4; i++ { + req := httptest.NewRequest(method, path, strings.NewReader(`{"expiration_seconds":600}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code < 300 || rec.Code >= 400 { + return rec.Code + } + loc := rec.Header().Get("Location") + if loc == "" { + return rec.Code + } + path = loc + } + return -1 +} + +// SEC-29 P3-4:只有通配/参数化授权(没有显式授予 token 权限码)时,末尾斜杠、大小写变体、重复斜杠等路径 +// 都不能执行 k8s:serviceaccount:token(GET)或 k8s:serviceaccount:token:create(POST)的 handler; +// 即使跟随 gin 的末尾斜杠重定向,落到规范路径后仍被显式授权检查拦截。 +func TestServiceAccountTokenRoutesTrailingSlashAndCaseCannotBypassPermission(t *testing.T) { + gin.SetMode(gin.TestMode) + wildcardOnly := []*model.Api{ + {Path: "/api/k8s/*", Method: 1}, {Path: "/api/k8s/*", Method: 2}, + {Path: "/*", Method: 1}, {Path: "/*", Method: 2}, + {Path: "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/:action", Method: 1}, + {Path: "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/:action", Method: 2}, + } + variants := []string{ + "/api/k8s/serviceaccount/3/ops/app/token", + "/api/k8s/serviceaccount/3/ops/app/token/", + "/api/k8s/serviceaccount/3/ops/app/token//", + "/api/k8s/serviceaccount/3/ops/app//token", + "/api/k8s/serviceaccount/3/ops/app/Token", + "/api/k8s/serviceaccount/3/ops/app/TOKEN", + "/api/k8s/serviceaccount/3/ops/app/TOKEN/", + "/api/k8s/ServiceAccount/3/ops/app/token", + "/API/K8S/SERVICEACCOUNT/3/ops/app/token", + "/api/k8s/serviceaccount/3/ops/app/token/.", + "/api/k8s/serviceaccount/3/ops/app/./token", + } + for _, method := range []string{"GET", "POST"} { + for _, path := range variants { + mgr := &routeProbeSAManager{handlerSAManager: &handlerSAManager{}} + code := serveFollowingRedirects(newSARouteAuthEngine(mgr, wildcardOnly), method, path) + if mgr.reveals != 0 || mgr.issues != 0 || (code >= 200 && code < 300) { + t.Fatalf("%s %s 绕过了 token 显式授权: status=%d reveals=%d issues=%d", method, path, code, mgr.reveals, mgr.issues) + } + } + } + + // 只显式授予 GET(k8s:serviceaccount:token)时,POST 的任何路径变体都不能签发 token + getOnly := append([]*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 1}}, wildcardOnly...) + for _, path := range variants { + mgr := &routeProbeSAManager{handlerSAManager: &handlerSAManager{}} + code := serveFollowingRedirects(newSARouteAuthEngine(mgr, getOnly), "POST", path) + if mgr.issues != 0 || (code >= 200 && code < 300) { + t.Fatalf("POST %s 未授予 token:create 却签发了 token: status=%d", path, code) + } + } + + // 对照:显式授权后规范路径可以正常访问(证明上面的拒绝来自授权检查而非路由缺失) + for _, tt := range []struct { + method string + code int8 + }{{"GET", 1}, {"POST", 2}} { + mgr := &routeProbeSAManager{handlerSAManager: &handlerSAManager{}} + engine := newSARouteAuthEngine(mgr, []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: tt.code}}) + if code := serveFollowingRedirects(engine, tt.method, "/api/k8s/serviceaccount/3/ops/app/token"); code != 200 || mgr.reveals+mgr.issues != 1 { + t.Fatalf("%s 显式授权后应可访问: status=%d", tt.method, code) + } + } +} diff --git a/internal/k8s/api/serviceaccount_test.go b/internal/k8s/api/serviceaccount_test.go new file mode 100644 index 00000000..174f622b --- /dev/null +++ b/internal/k8s/api/serviceaccount_test.go @@ -0,0 +1,212 @@ +package api + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" + "github.com/GoSimplicity/AI-CloudOps/internal/middleware" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// 测试用 token(非真实凭据) +const ( + handlerOwnToken = "fake-handler-sa-token-01" + handlerIssuedToken = "fake-handler-sa-token-02" +) + +type handlerSAManager struct { + manager.ServiceAccountManager + tokenTarget string + tokenReq *authv1.TokenRequest +} + +func (handlerSAManager) GetServiceAccount(_ context.Context, _ int, namespace, name string) (*corev1.ServiceAccount, error) { + return &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID("uid-" + name)}}, nil +} + +func (handlerSAManager) GetServiceAccountSecrets(_ context.Context, _ int, sa *corev1.ServiceAccount) ([]corev1.Secret, error) { + namespace, name := sa.Namespace, sa.Name + return []corev1.Secret{{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-token", Namespace: namespace, Annotations: map[string]string{ + corev1.ServiceAccountNameKey: name, corev1.ServiceAccountUIDKey: "uid-" + name, + }}, + Type: corev1.SecretTypeServiceAccountToken, + Data: map[string][]byte{corev1.ServiceAccountTokenKey: []byte(handlerOwnToken)}, + }}, nil +} + +func (m *handlerSAManager) CreateServiceAccountToken(_ context.Context, _ int, _, name string, tr *authv1.TokenRequest) (*authv1.TokenRequest, error) { + m.tokenTarget = name + m.tokenReq = tr.DeepCopy() + out := tr.DeepCopy() + out.Status.Token = handlerIssuedToken + return out, nil +} + +func TestServiceAccountTokenRoutesMatchPermissionCode(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + NewK8sServiceAccountHandler(nil).RegisterRouters(engine) + found := map[string]bool{} + for _, r := range engine.Routes() { + if r.Path == model.K8sServiceAccountTokenRoute { + found[r.Method] = true + } + } + if !found["GET"] || !found["POST"] { + t.Fatalf("token 路由与权限码路径不一致: %v", found) + } +} + +func newSATokenEngine(audit service.ServiceAccountService) *gin.Engine { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}); c.Next() }) + NewK8sServiceAccountHandler(audit).RegisterRouters(engine) + return engine +} + +func TestServiceAccountTokenEndpointsNoStoreAndAuditWithoutToken(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + method, body, token, op string + }{ + {"GET", "", handlerOwnToken, model.ServiceAccountTokenRevealAuditOperation}, + // 请求体中的 service_account_name 不能覆盖 URL 中的 SA + {"POST", `{"expiration_seconds":600,"service_account_name":"admin"}`, handlerIssuedToken, model.ServiceAccountTokenCreateAuditOperation}, + } { + t.Run(tt.method, func(t *testing.T) { + audit := &recordingAudit{} + mgr := &handlerSAManager{} + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}); c.Next() }) + engine.Use(middleware.NewAuditLogMiddleware(audit, zap.NewNop()).AuditLog()) + NewK8sServiceAccountHandler(service.NewServiceAccountService(mgr, audit, zap.NewNop())).RegisterRouters(engine) + + var req = httptest.NewRequest(tt.method, "/api/k8s/serviceaccount/3/ops/app/token", strings.NewReader(tt.body)) + if tt.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != 200 || rec.Header().Get("Cache-Control") != "no-store" || rec.Header().Get("Pragma") != "no-cache" { + t.Fatalf("status=%d headers=%v body=%s", rec.Code, rec.Header(), rec.Body.String()) + } + var resp struct { + Code int `json:"code"` + Data model.ServiceAccountTokenInfo `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp.Data.Token != tt.token { + t.Fatalf("响应不符: %s", rec.Body.String()) + } + if tt.method == "POST" && mgr.tokenTarget != "app" { + t.Fatalf("TokenRequest 目标应为 URL 中的 SA, got %q", mgr.tokenTarget) + } + + audit.mu.Lock() + defer audit.mu.Unlock() + if len(audit.sync) != 1 || audit.sync[0].OperationType != tt.op || audit.sync[0].TargetID != "3/ops/app" || + audit.sync[0].Endpoint != "/api/k8s/serviceaccount/3/ops/app/token" || audit.sync[0].UserID != 9 { + t.Fatalf("同步审计不符: %+v", audit.sync) + } + raw, _ := json.Marshal(append(append([]*model.CreateAuditLogRequest{}, audit.sync...), audit.async...)) + if strings.Contains(string(raw), tt.token) { + t.Fatal("审计记录中出现了 token") + } + for _, l := range audit.async { + if len(l.ResponseBody) != 0 { + t.Fatal("审计中间件不应记录响应体") + } + } + }) + } +} + +func TestServiceAccountTokenEndpointsAuditFailureReturns500WithoutToken(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, method := range []string{"GET", "POST"} { + t.Run(method, func(t *testing.T) { + engine := newSATokenEngine(service.NewServiceAccountService(&handlerSAManager{}, failingAudit{}, zap.NewNop())) + req := httptest.NewRequest(method, "/api/k8s/serviceaccount/3/ops/app/token", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != 500 { + t.Fatalf("审计失败应返回 500, got %d", rec.Code) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatal("失败响应也应禁止缓存") + } + body := rec.Body.String() + if strings.Contains(body, handlerOwnToken) || strings.Contains(body, handlerIssuedToken) { + t.Fatal("审计失败时响应中出现了 token") + } + }) + } +} + +func TestServiceAccountTokenCreateExpirationValidation(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name, body string + wantHTTP int + wantExp int64 // 传给 K8s 的有效期;0 表示不应调用 K8s + }{ + {"不传", `{}`, 200, 3600}, + {"传0", `{"expiration_seconds":0}`, 200, 3600}, + {"599", `{"expiration_seconds":599}`, 400, 0}, + {"600", `{"expiration_seconds":600}`, 200, 600}, + {"3600", `{"expiration_seconds":3600}`, 200, 3600}, + {"3601", `{"expiration_seconds":3601}`, 400, 0}, + {"负数", `{"expiration_seconds":-1}`, 400, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + audit := &recordingAudit{} + mgr := &handlerSAManager{} + engine := newSATokenEngine(service.NewServiceAccountService(mgr, audit, zap.NewNop())) + req := httptest.NewRequest("POST", "/api/k8s/serviceaccount/3/ops/app/token", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != tt.wantHTTP || rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d headers=%v body=%s", rec.Code, rec.Header(), rec.Body.String()) + } + var resp struct { + Code int `json:"code"` + Data model.ServiceAccountTokenInfo `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + audit.mu.Lock() + defer audit.mu.Unlock() + if len(audit.sync) != 1 || audit.sync[0].StatusCode != tt.wantHTTP { + t.Fatalf("应写一条状态码为 %d 的审计: %+v", tt.wantHTTP, audit.sync) + } + if tt.wantExp == 0 { + if resp.Code != 1 || resp.Data.Token != "" || strings.Contains(rec.Body.String(), handlerIssuedToken) { + t.Fatalf("越界应返回参数错误且不含 token: %s", rec.Body.String()) + } + if mgr.tokenTarget != "" || mgr.tokenReq != nil { + t.Fatal("越界时不应调用 K8s TokenRequest") + } + return + } + if resp.Code != 0 || resp.Data.Token != handlerIssuedToken || mgr.tokenReq == nil || + mgr.tokenReq.Spec.ExpirationSeconds == nil || *mgr.tokenReq.Spec.ExpirationSeconds != tt.wantExp { + t.Fatalf("应以 %d 秒签发: %s %+v", tt.wantExp, rec.Body.String(), mgr.tokenReq) + } + }) + } +} diff --git a/internal/k8s/api/serviceaccount_update_yaml_test.go b/internal/k8s/api/serviceaccount_update_yaml_test.go new file mode 100644 index 00000000..cfda34d7 --- /dev/null +++ b/internal/k8s/api/serviceaccount_update_yaml_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "strings" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" + k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// yamlUpdateSAManager 模拟 manager 层的两种 YAML 更新路径 +type yamlUpdateSAManager struct { + *handlerSAManager + strictErr error + strictCalls, retries int + strictRV string +} + +func (m *yamlUpdateSAManager) UpdateServiceAccountWithResourceVersion(_ context.Context, _ int, _, _, rv string, _ k8sutils.ServiceAccountMutateFunc) error { + m.strictCalls++ + m.strictRV = rv + return m.strictErr +} + +func (m *yamlUpdateSAManager) UpdateServiceAccountWithRetry(context.Context, int, string, string, k8sutils.ServiceAccountMutateFunc) error { + m.retries++ + return nil +} + +func putSAYaml(engine *gin.Engine, yaml string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]interface{}{"cluster_id": 3, "namespace": "ops", "name": "app", "yaml_content": yaml}) + req := httptest.NewRequest("PUT", "/api/k8s/serviceaccount/3/ops/app/update/yaml", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + return rec +} + +// SEC-29(A):YAML 带过期 resourceVersion → HTTP 409 + code=1(respondBusinessError 映射 BusinessError{ErrK8sResourceConflict}); +// 版本正确 → 200;不带版本 → 走 RetryOnConflict 路径 +func TestUpdateServiceAccountYamlConflictMapsTo409(t *testing.T) { + gin.SetMode(gin.TestMode) + withRV := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n resourceVersion: \"4\"\n" + conflict := apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", errors.New("the object has been modified")) + + mgr := &yamlUpdateSAManager{handlerSAManager: &handlerSAManager{}, strictErr: conflict} + rec := putSAYaml(newSATokenEngine(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())), withRV) + var resp struct { + Code int `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if rec.Code != 409 || resp.Code != 1 || !strings.Contains(resp.Message, "resourceVersion") { + t.Fatalf("版本冲突应返回 HTTP 409 + code=1: status=%d body=%s", rec.Code, rec.Body.String()) + } + if mgr.strictCalls != 1 || mgr.retries != 0 || mgr.strictRV != "4" { + t.Fatalf("应只走一次乐观锁更新、不重试: strict=%d retry=%d rv=%q", mgr.strictCalls, mgr.retries, mgr.strictRV) + } + + mgr = &yamlUpdateSAManager{handlerSAManager: &handlerSAManager{}} + if rec := putSAYaml(newSATokenEngine(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())), withRV); rec.Code != 200 || mgr.strictCalls != 1 { + t.Fatalf("版本正确应返回 200: status=%d body=%s", rec.Code, rec.Body.String()) + } + + mgr = &yamlUpdateSAManager{handlerSAManager: &handlerSAManager{}} + noRV := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n" + if rec := putSAYaml(newSATokenEngine(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())), noRV); rec.Code != 200 || mgr.retries != 1 || mgr.strictCalls != 0 { + t.Fatalf("不带 resourceVersion 应走重试路径: status=%d strict=%d retry=%d", rec.Code, mgr.strictCalls, mgr.retries) + } + + // 其他错误保持项目约定:HTTP 200 + code=1 + mgr = &yamlUpdateSAManager{handlerSAManager: &handlerSAManager{}, strictErr: errors.New("boom")} + rec = putSAYaml(newSATokenEngine(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())), withRV) + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if rec.Code != 200 || resp.Code != 1 { + t.Fatalf("非冲突错误应保持 200 + code=1: status=%d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/k8s/manager/serviceaccount_manager.go b/internal/k8s/manager/serviceaccount_manager.go index 5ec1f1be..4257bf38 100644 --- a/internal/k8s/manager/serviceaccount_manager.go +++ b/internal/k8s/manager/serviceaccount_manager.go @@ -2,8 +2,10 @@ package manager import ( "context" + "fmt" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/client" + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" "go.uber.org/zap" authv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" @@ -18,12 +20,18 @@ type ServiceAccountManager interface { GetServiceAccount(ctx context.Context, clusterID int, namespace, name string) (*corev1.ServiceAccount, error) GetServiceAccountList(ctx context.Context, clusterID int, namespace string, listOptions metav1.ListOptions) (*corev1.ServiceAccountList, error) UpdateServiceAccount(ctx context.Context, clusterID int, namespace string, sa *corev1.ServiceAccount) error + // UpdateServiceAccountWithRetry 冲突时重试的更新:每次先 Get 最新版本,经 mutate 应用允许修改的字段后 Update; + // name/namespace 与 secrets 引用始终以集群最新版本为准 + UpdateServiceAccountWithRetry(ctx context.Context, clusterID int, namespace, name string, mutate utils.ServiceAccountMutateFunc) error + // UpdateServiceAccountWithResourceVersion 严格乐观锁更新:以调用方提交的 resourceVersion 只 Update 一次,冲突直接返回 Conflict,不重试 + UpdateServiceAccountWithResourceVersion(ctx context.Context, clusterID int, namespace, name, resourceVersion string, mutate utils.ServiceAccountMutateFunc) error DeleteServiceAccount(ctx context.Context, clusterID int, namespace, name string, deleteOptions metav1.DeleteOptions) error // 高级功能 PatchServiceAccount(ctx context.Context, clusterID int, namespace, name string, data []byte, patchType string) (*corev1.ServiceAccount, error) - GetServiceAccountSecrets(ctx context.Context, clusterID int, namespace, name string) ([]corev1.Secret, error) + // GetServiceAccountSecrets 读取 sa.secrets 引用的 Secret;sa 由调用方传入(复用已读取的对象,不再重复 Get) + GetServiceAccountSecrets(ctx context.Context, clusterID int, sa *corev1.ServiceAccount) ([]corev1.Secret, error) GetServiceAccountTokens(ctx context.Context, clusterID int, namespace, name string) ([]string, error) CreateServiceAccountToken(ctx context.Context, clusterID int, namespace, name string, tokenRequest *authv1.TokenRequest) (*authv1.TokenRequest, error) BindServiceAccountToRole(ctx context.Context, clusterID int, namespace, saName, roleName string) error @@ -144,6 +152,59 @@ func (m *serviceAccountManager) UpdateServiceAccount(ctx context.Context, cluste return nil } +func (m *serviceAccountManager) UpdateServiceAccountWithRetry(ctx context.Context, clusterID int, namespace, name string, mutate utils.ServiceAccountMutateFunc) error { + kubeClient, err := m.client.GetKubeClient(clusterID) + if err != nil { + m.logger.Error("获取Kubernetes客户端失败", + zap.Int("clusterID", clusterID), + zap.Error(err)) + return err + } + + if _, err := utils.UpdateServiceAccountWithRetry(ctx, kubeClient.CoreV1().ServiceAccounts(namespace), name, mutate); err != nil { + m.logger.Error("更新ServiceAccount失败", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("name", name), + zap.Error(err)) + return err + } + + m.logger.Info("成功更新ServiceAccount", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("name", name)) + + return nil +} + +func (m *serviceAccountManager) UpdateServiceAccountWithResourceVersion(ctx context.Context, clusterID int, namespace, name, resourceVersion string, mutate utils.ServiceAccountMutateFunc) error { + kubeClient, err := m.client.GetKubeClient(clusterID) + if err != nil { + m.logger.Error("获取Kubernetes客户端失败", + zap.Int("clusterID", clusterID), + zap.Error(err)) + return err + } + + if _, err := utils.UpdateServiceAccountWithResourceVersion(ctx, kubeClient.CoreV1().ServiceAccounts(namespace), name, resourceVersion, mutate); err != nil { + m.logger.Error("按resourceVersion更新ServiceAccount失败", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("name", name), + zap.String("resourceVersion", resourceVersion), + zap.Error(err)) + return err + } + + m.logger.Info("成功更新ServiceAccount", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("name", name)) + + return nil +} + func (m *serviceAccountManager) DeleteServiceAccount(ctx context.Context, clusterID int, namespace, name string, deleteOptions metav1.DeleteOptions) error { kubeClient, err := m.client.GetKubeClient(clusterID) if err != nil { @@ -201,11 +262,11 @@ func (m *serviceAccountManager) PatchServiceAccount(ctx context.Context, cluster return sa, nil } -func (m *serviceAccountManager) GetServiceAccountSecrets(ctx context.Context, clusterID int, namespace, name string) ([]corev1.Secret, error) { - sa, err := m.GetServiceAccount(ctx, clusterID, namespace, name) - if err != nil { - return nil, err +func (m *serviceAccountManager) GetServiceAccountSecrets(ctx context.Context, clusterID int, sa *corev1.ServiceAccount) ([]corev1.Secret, error) { + if sa == nil { + return nil, fmt.Errorf("ServiceAccount不能为空") } + namespace, name := sa.Namespace, sa.Name kubeClient, err := m.client.GetKubeClient(clusterID) if err != nil { @@ -239,18 +300,30 @@ func (m *serviceAccountManager) GetServiceAccountSecrets(ctx context.Context, cl } func (m *serviceAccountManager) GetServiceAccountTokens(ctx context.Context, clusterID int, namespace, name string) ([]string, error) { - secrets, err := m.GetServiceAccountSecrets(ctx, clusterID, namespace, name) + sa, err := m.GetServiceAccount(ctx, clusterID, namespace, name) if err != nil { return nil, err } + secrets, err := m.GetServiceAccountSecrets(ctx, clusterID, sa) + if err != nil { + return nil, err + } + + // SEC-29:只返回注解指向本 SA(名称 + UID)的 token,sa.secrets 中借挂的其他 SA token 一律忽略 var tokens []string - for _, secret := range secrets { - if secret.Type == corev1.SecretTypeServiceAccountToken { - if token, exists := secret.Data["token"]; exists { - tokens = append(tokens, string(token)) + for i := range secrets { + if !utils.IsServiceAccountTokenSecretFor(&secrets[i], sa) { + if secrets[i].Type == corev1.SecretTypeServiceAccountToken { + m.logger.Warn("忽略不属于该ServiceAccount的token Secret", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("serviceAccountName", name), + zap.String("secretName", secrets[i].Name)) } + continue } + tokens = append(tokens, string(secrets[i].Data[corev1.ServiceAccountTokenKey])) } m.logger.Debug("获取ServiceAccount的Token", diff --git a/internal/k8s/service/secret_service.go b/internal/k8s/service/secret_service.go index a203f1f1..e5bc6f67 100644 --- a/internal/k8s/service/secret_service.go +++ b/internal/k8s/service/secret_service.go @@ -27,7 +27,11 @@ package service import ( "context" + "encoding/json" + stderrors "errors" "fmt" + "net/http" + "sort" "strings" "time" @@ -35,6 +39,7 @@ import ( "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "go.uber.org/zap" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -51,17 +56,21 @@ type SecretService interface { UpdateSecretByYaml(ctx context.Context, req *model.UpdateSecretByYamlReq) error DeleteSecret(ctx context.Context, req *model.DeleteSecretReq) error GetSecretYAML(ctx context.Context, req *model.GetSecretYamlReq) (*model.K8sYaml, error) + // RevealSecret 返回 Secret 明文;每次调用都会写一条审计(只含元数据与键名,不含值) + RevealSecret(ctx context.Context, req *model.RevealSecretReq, actor model.SecretRevealActor) (*model.K8sSecretReveal, error) } // secretService Secret服务实现 type secretService struct { secretManager manager.SecretManager + auditService systemService.AuditService logger *zap.Logger } -func NewSecretService(secretManager manager.SecretManager, logger *zap.Logger) SecretService { +func NewSecretService(secretManager manager.SecretManager, auditService systemService.AuditService, logger *zap.Logger) SecretService { return &secretService{ secretManager: secretManager, + auditService: auditService, logger: logger, } } @@ -189,21 +198,25 @@ func (s *secretService) convertToK8sSecret(secret *corev1.Secret, clusterID int) } return &model.K8sSecret{ - Name: secret.Name, - Namespace: secret.Namespace, - ClusterID: clusterID, - UID: string(secret.UID), - Type: model.K8sSecretType(secret.Type), - Data: model.BinaryDataMap(secret.Data), - StringData: secret.StringData, // 通常为空,因为K8s会转换为Data - Labels: secret.Labels, - Annotations: secret.Annotations, - Immutable: immutable, - DataCount: dataCount, - Size: size, - Age: time.Since(secret.CreationTimestamp.Time).String(), - CreatedAt: secret.CreationTimestamp.Time, - RawSecret: secret, + Name: secret.Name, + Namespace: secret.Namespace, + ClusterID: clusterID, + UID: string(secret.UID), + Type: model.K8sSecretType(secret.Type), + // SEC-25:列表/详情默认脱敏,只返回键与值长度;明文走 reveal 接口 + Data: k8sutils.RedactSecretData(secret.Data), + StringData: k8sutils.RedactSecretStringData(secret.StringData), // 通常为空,因为K8s会转换为Data + DataKeys: k8sutils.SecretDataKeyInfos(secret.Data), + StringDataKeys: k8sutils.SecretStringDataKeyInfos(secret.StringData), + Redacted: true, + Labels: secret.Labels, + Annotations: k8sutils.RedactSecretAnnotations(secret.Annotations), + Immutable: immutable, + DataCount: dataCount, + Size: size, + Age: time.Since(secret.CreationTimestamp.Time).String(), + CreatedAt: secret.CreationTimestamp.Time, + RawSecret: secret, } } @@ -275,10 +288,11 @@ func (s *secretService) UpdateSecret(ctx context.Context, req *model.UpdateSecre updatedSecret := existingSecret.DeepCopy() - updatedSecret.Data = map[string][]byte(req.Data) - updatedSecret.StringData = req.StringData + // SEC-25:请求未携带 data(nil)时保留集群现有 data;携带空对象 {} 表示删除全部 data 键 + updatedSecret.Data = preserveRedactedData(map[string][]byte(req.Data), existingSecret.Data) + updatedSecret.Data, updatedSecret.StringData = preserveRedactedStringData(req.StringData, map[string][]byte(req.Data), updatedSecret.Data, existingSecret) updatedSecret.Labels = req.Labels - updatedSecret.Annotations = req.Annotations + updatedSecret.Annotations = preserveLastApplied(req.Annotations, existingSecret.Annotations) // Type和Immutable字段在创建后通常不能修改,保持原值 // 如果需要修改,K8s会返回错误 @@ -363,6 +377,13 @@ func (s *secretService) CreateSecretByYaml(ctx context.Context, req *model.Creat return fmt.Errorf("请求参数不能为空") } + // SEC-25:脱敏占位符不是真实值,禁止用于创建 + if hasPlaceholder, err := k8sutils.SecretYAMLHasRedactedPlaceholder(req.YAML); err != nil { + return err + } else if hasPlaceholder { + return fmt.Errorf("YAML 中包含脱敏占位符 %s,请填写真实值后再创建", k8sutils.SecretRedactedPlaceholder) + } + sec, err := k8sutils.YAMLToSecret(req.YAML) if err != nil { return fmt.Errorf("解析YAML失败: %w", err) @@ -404,8 +425,24 @@ func (s *secretService) UpdateSecretByYaml(ctx context.Context, req *model.Updat zap.String("namespace", req.Namespace), zap.String("name", req.Name)) + // 获取现有资源以获取ResourceVersion(用于乐观锁,避免并发冲突),并用于恢复脱敏占位符 + existing, err := s.secretManager.GetSecret(ctx, req.ClusterID, req.Namespace, req.Name) + if err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("Secret不存在: %s/%s", req.Namespace, req.Name) + } + s.logger.Error("获取现有Secret失败", zap.Error(err)) + return fmt.Errorf("获取现有Secret失败: %w", err) + } + + // SEC-25:详情 YAML 已脱敏,仍为占位符的键保持集群中的原值,避免占位符被写回 + restoredYAML, err := k8sutils.RestoreRedactedSecretYAML(req.YAML, existing) + if err != nil { + return err + } + // 解析YAML - sec, err := k8sutils.YAMLToSecret(req.YAML) + sec, err := k8sutils.YAMLToSecret(restoredYAML) if err != nil { s.logger.Error("解析YAML失败", zap.Error(err)) return fmt.Errorf("解析YAML失败: %w", err) @@ -415,16 +452,6 @@ func (s *secretService) UpdateSecretByYaml(ctx context.Context, req *model.Updat sec.Namespace = req.Namespace sec.Name = req.Name - // 获取现有资源以获取ResourceVersion(用于乐观锁,避免并发冲突) - existing, err := s.secretManager.GetSecret(ctx, req.ClusterID, req.Namespace, req.Name) - if err != nil { - if errors.IsNotFound(err) { - return fmt.Errorf("Secret不存在: %s/%s", req.Namespace, req.Name) - } - s.logger.Error("获取现有Secret失败", zap.Error(err)) - return fmt.Errorf("获取现有Secret失败: %w", err) - } - // 保留ResourceVersion和UID等关键元数据 sec.ResourceVersion = existing.ResourceVersion sec.UID = existing.UID @@ -445,3 +472,273 @@ func (s *secretService) UpdateSecretByYaml(ctx context.Context, req *model.Updat return nil } + +// preserveRedactedData 兼容脱敏后的详情回填:请求中值为空、而集群中该键已有非空值时保留原值。 +// 详情接口只返回空值,前端若直接回填提交,不能因此把真实值清空。 +// 如需删除某个键,请在请求中去掉该键。 +// +// SEC-25 P3-2:requested 为 nil(请求未携带 data 字段或为 null)时视为“不修改 data”,返回集群现有 data 的副本; +// requested 为非 nil 的空 map(请求携带 "data": {})时视为显式清空,返回空 map,集群中的 data 键全部删除。 +func preserveRedactedData(requested, existing map[string][]byte) map[string][]byte { + if requested == nil { + if existing == nil { + return nil + } + kept := make(map[string][]byte, len(existing)) + for k, v := range existing { + kept[k] = append([]byte(nil), v...) + } + return kept + } + out := make(map[string][]byte, len(requested)) + for k, v := range requested { + if len(v) == 0 { + if old, ok := existing[k]; ok && len(old) > 0 { + out[k] = old + continue + } + } + out[k] = v + } + return out +} + +// preserveRedactedStringData 与 data 相同按“空值保留原值”处理 stringData。 +// K8s 写入时 stringData 会覆盖 data 中的同名键,若把脱敏回填的空字符串原样提交,会把 data 中的真实值清空。 +// 因此:stringData 中值为空、而集群中该键已有非空值(data 优先,其次 stringData)时, +// 从 stringData 中移除该键,并在 data 中保留原值(请求 data 对同名键给了非空新值时以请求为准)。 +// 集群中不存在或原值为空的键按原样写入。返回处理后的 data 与 stringData。 +// +// SEC-25 P3-1:只有请求的 data 同样“包含”该键时才恢复原值——requestedData 为 nil(未携带 data,整体保留) +// 或 requestedData 中有该键。请求 data 非 nil 且不含该键时,表示该键已被用户删除:从 stringData 中移除, +// 也不写回 data,更新后该键被删除(不会因为 stringData 中残留的空串被恢复,也不会被写成空值)。 +func preserveRedactedStringData(requested map[string]string, requestedData, data map[string][]byte, existing *corev1.Secret) (map[string][]byte, map[string]string) { + if requested == nil { + return data, nil + } + var existingData map[string][]byte + var existingStringData map[string]string + if existing != nil { + existingData, existingStringData = existing.Data, existing.StringData + } + out := make(map[string]string, len(requested)) + for k, v := range requested { + if v == "" { + old, ok := existingData[k] + if !ok || len(old) == 0 { + if s, sok := existingStringData[k]; sok && s != "" { + old, ok = []byte(s), true + } + } + if ok && len(old) > 0 { + if _, inRequest := requestedData[k]; requestedData != nil && !inRequest { + delete(data, k) + continue + } + if data == nil { + data = map[string][]byte{} + } + if cur, set := data[k]; !set || len(cur) == 0 { + data[k] = old + } + continue + } + } + out[k] = v + } + return data, out +} + +// preserveLastApplied 列表/详情不再返回 last-applied-configuration,更新时若请求未携带则保留集群中的原注解 +func preserveLastApplied(requested, existing map[string]string) map[string]string { + old, ok := existing[k8sutils.LastAppliedConfigAnnotation] + if !ok { + return requested + } + if _, provided := requested[k8sutils.LastAppliedConfigAnnotation]; provided { + return requested + } + out := make(map[string]string, len(requested)+1) + for k, v := range requested { + out[k] = v + } + out[k8sutils.LastAppliedConfigAnnotation] = old + return out +} + +// RevealSecret 返回 Secret 明文。每次调用都写审计,审计只含操作者、集群、命名空间、名称与键名,绝不含值。 +// 成功读取后若审计写入失败,则不返回明文(fail closed)。 +func (s *secretService) RevealSecret(ctx context.Context, req *model.RevealSecretReq, actor model.SecretRevealActor) (*model.K8sSecretReveal, error) { + if req == nil { + return nil, fmt.Errorf("请求参数不能为空") + } + start := time.Now() + + secret, err := s.secretManager.GetSecret(ctx, req.ClusterID, req.Namespace, req.Name) + if err != nil { + var userErr error + if errors.IsNotFound(err) { + userErr = fmt.Errorf("Secret不存在: %s/%s", req.Namespace, req.Name) + } else { + s.logger.Error("获取Secret失败", zap.Error(err), + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name)) + userErr = fmt.Errorf("获取Secret失败: %w", err) + } + _ = s.writeRevealAudit(ctx, req, actor, normalizeKeys(req.Keys), start, k8sErrorStatusCode(err), userErr) + return nil, userErr + } + + result := &model.K8sSecretReveal{ + ClusterID: req.ClusterID, + Namespace: secret.Namespace, + Name: secret.Name, + Type: model.K8sSecretType(secret.Type), + } + + wanted := normalizeKeys(req.Keys) + if len(wanted) == 0 { + result.Data = model.BinaryDataMap(secret.Data) + result.StringData = secret.StringData + } else { + result.Data = model.BinaryDataMap{} + for _, k := range wanted { + found := false + if v, ok := secret.Data[k]; ok { + result.Data[k] = v + found = true + } + // data 与 stringData 存在同名键时两者都返回,与不指定 keys 时的输出保持一致 + if v, ok := secret.StringData[k]; ok { + if result.StringData == nil { + result.StringData = map[string]string{} + } + result.StringData[k] = v + found = true + } + if found { + continue + } + keyErr := fmt.Errorf("Secret %s/%s 中不存在键 %s", req.Namespace, req.Name, k) + _ = s.writeRevealAudit(ctx, req, actor, wanted, start, http.StatusBadRequest, keyErr) + return nil, keyErr + } + } + + keySet := make(map[string]struct{}, len(result.Data)+len(result.StringData)) + for k := range result.Data { + keySet[k] = struct{}{} + } + for k := range result.StringData { + keySet[k] = struct{}{} + } + result.Keys = make([]string, 0, len(keySet)) + for k := range keySet { + result.Keys = append(result.Keys, k) + } + sort.Strings(result.Keys) + + if err := s.writeRevealAudit(ctx, req, actor, result.Keys, start, http.StatusOK, nil); err != nil { + s.logger.Error("写入Secret明文查看审计失败,拒绝返回明文", zap.Error(err), + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name)) + return nil, fmt.Errorf("%w,已拒绝返回Secret明文", ErrAuditWriteFailed) + } + + s.logger.Info("Secret明文已查看", + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name), + zap.Int("user_id", actor.UserID), + zap.Strings("keys", result.Keys)) + + return result, nil +} + +// normalizeKeys 去重、去空并排序请求中的键,同时支持逗号分隔 +func normalizeKeys(keys []string) []string { + seen := make(map[string]struct{}, len(keys)) + out := make([]string, 0, len(keys)) + for _, raw := range keys { + for _, k := range strings.Split(raw, ",") { + k = strings.TrimSpace(k) + if k == "" { + continue + } + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, k) + } + } + sort.Strings(out) + return out +} + +// ErrAuditWriteFailed 敏感读取接口(Secret reveal 等)同步审计写入失败。 +// handler 据此返回 HTTP 500,且不返回任何明文。 +var ErrAuditWriteFailed = stderrors.New("写入审计失败") + +// k8sErrorStatusCode 将读取 K8s 资源的错误映射为审计记录中的 HTTP 状态码: +// 优先使用 K8s API 返回的状态码(404/403 等),无法识别时记为 500。 +func k8sErrorStatusCode(err error) int { + if err == nil { + return http.StatusOK + } + var status errors.APIStatus + if stderrors.As(err, &status) { + if code := int(status.Status().Code); code >= 400 && code <= 599 { + return code + } + } + if errors.IsNotFound(err) { + return http.StatusNotFound + } + return http.StatusInternalServerError +} + +// writeRevealAudit 同步写入 reveal 审计;内容只含元数据与键名。statusCode 为本次操作实际的结果码(成功 200,失败为 4xx/5xx) +func (s *secretService) writeRevealAudit(ctx context.Context, req *model.RevealSecretReq, actor model.SecretRevealActor, keys []string, start time.Time, statusCode int, opErr error) error { + if s.auditService == nil { + return fmt.Errorf("审计服务未初始化") + } + if keys == nil { + keys = []string{} + } + body, err := json.Marshal(model.SecretRevealAuditRecord{ + Action: model.SecretRevealAuditAction, + UserID: actor.UserID, + Username: actor.Username, + ClusterID: req.ClusterID, + Namespace: req.Namespace, + Name: req.Name, + Keys: keys, + Success: opErr == nil, + RevealedAt: start.UTC().Format(time.RFC3339), + }) + if err != nil { + return err + } + errMsg := "" + if opErr != nil { + errMsg = opErr.Error() + } + return s.auditService.CreateAuditLog(ctx, &model.CreateAuditLogRequest{ + UserID: actor.UserID, + TraceID: actor.TraceID, + IPAddress: actor.IP, + UserAgent: actor.UserAgent, + HttpMethod: "GET", + Endpoint: actor.Endpoint, + OperationType: model.SecretRevealAuditOperation, + TargetType: model.SecretRevealAuditTargetType, + TargetID: fmt.Sprintf("%d/%s/%s", req.ClusterID, req.Namespace, req.Name), + StatusCode: statusCode, + RequestBody: body, + Duration: time.Since(start).Microseconds(), + ErrorMsg: errMsg, + }) +} diff --git a/internal/k8s/service/secret_service_test.go b/internal/k8s/service/secret_service_test.go new file mode 100644 index 00000000..3dfabe3d --- /dev/null +++ b/internal/k8s/service/secret_service_test.go @@ -0,0 +1,584 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" + k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" +) + +// 测试用的明文(非真实凭据),用于断言任何脱敏输出中都不出现 +const ( + fixtureAlpha = "plain-alpha-fixture-001" + fixtureBeta = "plain-beta-fixture-0002" + fixtureApplied = "plain-applied-fixture-03" +) + +type secretManagerFixture struct { + manager.SecretManager + secret *corev1.Secret + getErr error + updated *corev1.Secret + created *corev1.Secret +} + +func (m *secretManagerFixture) GetSecret(_ context.Context, _ int, _, _ string) (*corev1.Secret, error) { + if m.getErr != nil { + return nil, m.getErr + } + return m.secret.DeepCopy(), nil +} + +func (m *secretManagerFixture) ListSecrets(_ context.Context, _ int, _ string) (*corev1.SecretList, error) { + return &corev1.SecretList{Items: []corev1.Secret{*m.secret.DeepCopy()}}, nil +} + +func (m *secretManagerFixture) UpdateSecret(_ context.Context, _ int, secret *corev1.Secret) (*corev1.Secret, error) { + m.updated = secret.DeepCopy() + return secret, nil +} + +func (m *secretManagerFixture) CreateSecret(_ context.Context, _ int, secret *corev1.Secret) (*corev1.Secret, error) { + m.created = secret.DeepCopy() + return secret, nil +} + +type auditFixture struct { + systemService.AuditService + logs []*model.CreateAuditLogRequest + err error +} + +func (a *auditFixture) CreateAuditLog(_ context.Context, req *model.CreateAuditLogRequest) error { + if a.err != nil { + return a.err + } + cp := *req + a.logs = append(a.logs, &cp) + return nil +} + +func newSecretFixture() *corev1.Secret { + applied, _ := json.Marshal(map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{"name": "app", "namespace": "ops"}, + "data": map[string]string{"alpha": base64.StdEncoding.EncodeToString([]byte(fixtureApplied))}, + "stringData": map[string]string{"beta": fixtureApplied}, + }) + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app", + Namespace: "ops", + UID: "uid-1", + ResourceVersion: "42", + CreationTimestamp: metav1.NewTime(time.Unix(100, 0)), + Labels: map[string]string{"app": "demo"}, + Annotations: map[string]string{ + k8sutils.LastAppliedConfigAnnotation: string(applied), + "owner": "team-a", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{"alpha": []byte(fixtureAlpha), "beta": []byte(fixtureBeta)}, + StringData: map[string]string{"gamma": fixtureBeta}, + } +} + +// assertNoPlaintext 断言输出中不含任何明文及其 base64 形式 +func assertNoPlaintext(t *testing.T, where, out string) { + t.Helper() + for _, v := range []string{fixtureAlpha, fixtureBeta, fixtureApplied} { + if strings.Contains(out, v) || strings.Contains(out, base64.StdEncoding.EncodeToString([]byte(v))) { + t.Fatalf("%s 泄露了明文值", where) + } + } + if strings.Contains(out, k8sutils.LastAppliedConfigAnnotation) { + t.Fatalf("%s 仍包含 last-applied-configuration 注解", where) + } +} + +func newTestSecretService(m *secretManagerFixture, a *auditFixture) SecretService { + return NewSecretService(m, a, zap.NewNop()) +} + +func TestSecretListAndDetailAreRedacted(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + + list, err := svc.GetSecretList(context.Background(), &model.GetSecretListReq{ClusterID: 1, Namespace: "ops"}) + if err != nil || len(list.Items) != 1 { + t.Fatalf("list err=%v items=%d", err, len(list.Items)) + } + detail, err := svc.GetSecret(context.Background(), &model.GetSecretDetailsReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + + for where, item := range map[string]*model.K8sSecret{"list": list.Items[0], "detail": detail} { + raw, err := json.Marshal(item) + if err != nil { + t.Fatal(err) + } + assertNoPlaintext(t, where, string(raw)) + + if !item.Redacted { + t.Fatalf("%s 未标记 redacted", where) + } + if len(item.Data) != 2 || len(item.Data["alpha"]) != 0 || len(item.Data["beta"]) != 0 { + t.Fatalf("%s data 应只保留键且值为空: %v", where, item.Data) + } + if v, ok := item.StringData["gamma"]; !ok || v != "" { + t.Fatalf("%s string_data 应只保留键", where) + } + wantKeys := []model.SecretKeyInfo{{Key: "alpha", Size: len(fixtureAlpha)}, {Key: "beta", Size: len(fixtureBeta)}} + if len(item.DataKeys) != 2 || item.DataKeys[0] != wantKeys[0] || item.DataKeys[1] != wantKeys[1] { + t.Fatalf("%s data_keys 不符: %+v", where, item.DataKeys) + } + if len(item.StringDataKeys) != 1 || item.StringDataKeys[0] != (model.SecretKeyInfo{Key: "gamma", Size: len(fixtureBeta)}) { + t.Fatalf("%s string_data_keys 不符: %+v", where, item.StringDataKeys) + } + if item.Annotations["owner"] != "team-a" { + t.Fatalf("%s 普通注解应保留", where) + } + if item.DataCount != 3 { + t.Fatalf("%s data_count=%d", where, item.DataCount) + } + } + + // 原始对象不被修改 + if string(m.secret.Data["alpha"]) != fixtureAlpha { + t.Fatal("脱敏不应修改原始对象") + } +} + +func TestSecretYAMLIsRedacted(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + out, err := svc.GetSecretYAML(context.Background(), &model.GetSecretYamlReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + assertNoPlaintext(t, "yaml", out.YAML) + + var obj struct { + Data map[string]string `json:"data"` + StringData map[string]string `json:"stringData"` + Metadata struct { + Annotations map[string]string `json:"annotations"` + } `json:"metadata"` + } + if err := yaml.Unmarshal([]byte(out.YAML), &obj); err != nil { + t.Fatal(err) + } + if obj.Data["alpha"] != k8sutils.SecretRedactedPlaceholder || obj.Data["beta"] != k8sutils.SecretRedactedPlaceholder || obj.StringData["gamma"] != k8sutils.SecretRedactedPlaceholder { + t.Fatalf("data/stringData 应替换为占位符: %+v", obj) + } + if obj.Metadata.Annotations["owner"] != "team-a" { + t.Fatal("普通注解应保留") + } +} + +func TestRevealSecretReturnsValuesAndAuditsWithoutValues(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + a := &auditFixture{} + svc := newTestSecretService(m, a) + actor := model.SecretRevealActor{UserID: 7, Username: "alice", IP: "10.0.0.1", UserAgent: "ua", Endpoint: "/api/k8s/secret/1/ops/app/reveal"} + + out, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"}, actor) + if err != nil { + t.Fatal(err) + } + if string(out.Data["alpha"]) != fixtureAlpha || string(out.Data["beta"]) != fixtureBeta || out.StringData["gamma"] != fixtureBeta { + t.Fatal("reveal 未返回明文") + } + raw, _ := json.Marshal(out) + if !strings.Contains(string(raw), base64.StdEncoding.EncodeToString([]byte(fixtureAlpha))) { + t.Fatal("reveal data 应保持 base64 编码") + } + if strings.Join(out.Keys, ",") != "alpha,beta,gamma" { + t.Fatalf("keys=%v", out.Keys) + } + + if len(a.logs) != 1 { + t.Fatalf("应写 1 条审计,实际 %d", len(a.logs)) + } + log := a.logs[0] + auditRaw, _ := json.Marshal(log) + assertNoPlaintext(t, "audit", string(auditRaw)) + if log.ResponseBody != nil { + t.Fatal("审计不应记录响应体") + } + if log.UserID != 7 || log.OperationType != "REVEAL" || log.TargetType != "k8s_secret" || log.TargetID != "1/ops/app" || log.ErrorMsg != "" || log.Endpoint != actor.Endpoint { + t.Fatalf("审计元数据不符: %+v", log) + } + var rec model.SecretRevealAuditRecord + if err := json.Unmarshal(log.RequestBody, &rec); err != nil { + t.Fatal(err) + } + if rec.Username != "alice" || rec.ClusterID != 1 || rec.Namespace != "ops" || rec.Name != "app" || strings.Join(rec.Keys, ",") != "alpha,beta,gamma" || !rec.Success || rec.RevealedAt == "" { + t.Fatalf("审计内容不符: %+v", rec) + } +} + +func TestRevealSecretSelectedKeys(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + a := &auditFixture{} + svc := newTestSecretService(m, a) + + out, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app", Keys: []string{"beta", "beta, alpha"}}, model.SecretRevealActor{UserID: 1}) + if err != nil { + t.Fatal(err) + } + if len(out.Data) != 2 || out.StringData != nil || strings.Join(out.Keys, ",") != "alpha,beta" { + t.Fatalf("按键过滤失败: %+v", out.Keys) + } + + if _, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app", Keys: []string{"missing"}}, model.SecretRevealActor{UserID: 1}); err == nil { + t.Fatal("不存在的键应报错") + } + if len(a.logs) != 2 || a.logs[1].ErrorMsg == "" { + t.Fatal("失败的 reveal 也应写审计") + } +} + +func TestRevealSecretFailsClosedWhenAuditFails(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{err: errors.New("db down")}) + out, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"}, model.SecretRevealActor{UserID: 1}) + if err == nil || out != nil { + t.Fatal("审计写入失败时不应返回明文") + } + if strings.Contains(err.Error(), fixtureAlpha) { + t.Fatal("错误信息泄露明文") + } +} + +func TestRevealSecretAuditsGetFailure(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture(), getErr: errors.New("forbidden")} + a := &auditFixture{} + svc := newTestSecretService(m, a) + if _, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"}, model.SecretRevealActor{UserID: 1}); err == nil { + t.Fatal("应返回错误") + } + if len(a.logs) != 1 || a.logs[0].ErrorMsg == "" { + t.Fatal("读取失败也应写审计") + } +} + +func TestUpdateSecretKeepsValuesForRedactedRoundTrip(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + // 模拟前端把脱敏详情原样回填,只改了 beta 与标签 + err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", + Data: model.BinaryDataMap{"alpha": []byte{}, "beta": []byte("new-beta"), "delta": []byte{}}, + Labels: map[string]string{"app": "demo2"}, + Annotations: map[string]string{"owner": "team-a"}, + }) + if err != nil { + t.Fatal(err) + } + if string(m.updated.Data["alpha"]) != fixtureAlpha || string(m.updated.Data["beta"]) != "new-beta" { + t.Fatal("空值回填应保留原值,非空值应覆盖") + } + if v, ok := m.updated.Data["delta"]; !ok || len(v) != 0 { + t.Fatal("新键空值应原样写入") + } + if m.updated.Annotations[k8sutils.LastAppliedConfigAnnotation] == "" { + t.Fatal("未携带的 last-applied 注解应保留") + } +} + +func TestUpdateSecretByYamlRestoresPlaceholders(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + y, err := svc.GetSecretYAML(context.Background(), &model.GetSecretYamlReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + // 只改 beta + edited := strings.Replace(y.YAML, "beta: "+k8sutils.SecretRedactedPlaceholder, "beta: "+base64.StdEncoding.EncodeToString([]byte("new-beta")), 1) + if edited == y.YAML { + t.Fatal("测试前提失败:未找到 beta 占位符") + } + if err := svc.UpdateSecretByYaml(context.Background(), &model.UpdateSecretByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YAML: edited}); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["alpha"]) != fixtureAlpha || string(m.updated.Data["beta"]) != "new-beta" { + t.Fatalf("占位符应恢复原值: alpha=%d beta=%q", len(m.updated.Data["alpha"]), m.updated.Data["beta"]) + } + if m.updated.StringData["gamma"] != fixtureBeta { + t.Fatal("stringData 占位符应恢复原值") + } + + // 占位符对应的键在现有 Secret 中不存在 → 报错,不会写入占位符 + m.updated = nil + withUnknown := strings.Replace(edited, "data:\n", "data:\n omega: "+k8sutils.SecretRedactedPlaceholder+"\n", 1) + if err := svc.UpdateSecretByYaml(context.Background(), &model.UpdateSecretByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YAML: withUnknown}); err == nil || m.updated != nil { + t.Fatal("未知键的占位符应拒绝更新") + } +} + +func TestCreateSecretByYamlRejectsPlaceholder(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + y := "apiVersion: v1\nkind: Secret\nmetadata:\n name: x\n namespace: ops\ndata:\n alpha: " + k8sutils.SecretRedactedPlaceholder + "\n" + if err := svc.CreateSecretByYaml(context.Background(), &model.CreateSecretByYamlReq{ClusterID: 1, YAML: y}); err == nil || m.created != nil { + t.Fatal("含占位符的 YAML 不应被创建") + } +} + +// JSON 更新:stringData 中值为空的键与 data 一样保留原值,不能借 K8s 的 stringData→data 合并把真实值清空 +func TestUpdateSecretKeepsValuesForEmptyStringData(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", + // alpha 只在 stringData 中以空值出现(请求 data 不含);beta 在 data 中给新值、stringData 中为空;gamma 原本只在 stringData 中 + Data: model.BinaryDataMap{"beta": []byte("new-beta")}, + StringData: map[string]string{"alpha": "", "beta": "", "gamma": "", "delta": "", "eps": "new-eps"}, + }) + if err != nil { + t.Fatal(err) + } + u := m.updated + // SEC-25 P3-1:alpha/gamma 不在请求 data 中,视为已删除,不因 stringData 空串被恢复 + for _, k := range []string{"alpha", "gamma"} { + if _, ok := u.Data[k]; ok { + t.Fatalf("请求 data 不含 %s 时不应恢复原值", k) + } + } + if string(u.Data["beta"]) != "new-beta" { + t.Fatal("data 中的新值不应被原值覆盖") + } + for _, k := range []string{"alpha", "beta", "gamma"} { + if _, ok := u.StringData[k]; ok { + t.Fatalf("保留原值的键 %s 应从 stringData 中移除,否则写入时会覆盖 data", k) + } + } + if v, ok := u.StringData["delta"]; !ok || v != "" { + t.Fatal("集群中不存在的新键空值应原样写入") + } + if u.StringData["eps"] != "new-eps" { + t.Fatal("非空 stringData 应原样写入") + } +} + +func TestUpdateSecretEmptyStringDataWithNilData(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", + StringData: map[string]string{"alpha": ""}, + }); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["alpha"]) != fixtureAlpha || len(m.updated.StringData) != 0 { + t.Fatal("data 为空时 stringData 空值也应保留原值") + } +} + +// 审计记录中的 StatusCode 为实际结果:成功 200,失败为 4xx/5xx +func TestRevealSecretAuditStatusCodes(t *testing.T) { + ctx := context.Background() + req := &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"} + for _, tt := range []struct { + name string + getErr error + keys []string + want int + }{ + {"success", nil, nil, 200}, + {"missing key", nil, []string{"missing"}, 400}, + {"not found", apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, "app"), nil, 404}, + {"k8s forbidden", apierrors.NewForbidden(schema.GroupResource{Resource: "secrets"}, "app", errors.New("rbac")), nil, 403}, + {"unknown error", errors.New("connection refused"), nil, 500}, + } { + t.Run(tt.name, func(t *testing.T) { + a := &auditFixture{} + svc := newTestSecretService(&secretManagerFixture{secret: newSecretFixture(), getErr: tt.getErr}, a) + r := *req + r.Keys = tt.keys + _, _ = svc.RevealSecret(ctx, &r, model.SecretRevealActor{UserID: 1}) + if len(a.logs) != 1 || a.logs[0].StatusCode != tt.want { + t.Fatalf("审计 StatusCode 不符: %+v", a.logs) + } + }) + } +} + +func TestRevealSecretAuditFailureIsTyped(t *testing.T) { + svc := newTestSecretService(&secretManagerFixture{secret: newSecretFixture()}, &auditFixture{err: errors.New("db down")}) + _, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"}, model.SecretRevealActor{UserID: 1}) + if !errors.Is(err, ErrAuditWriteFailed) { + t.Fatalf("审计失败应返回 ErrAuditWriteFailed, got %v", err) + } +} + +// data 与 stringData 存在同名键:脱敏、回写与 reveal 都要正确 +func TestSecretSameKeyInDataAndStringData(t *testing.T) { + newSame := func() *corev1.Secret { + s := newSecretFixture() + s.Data = map[string][]byte{"shared": []byte(fixtureAlpha)} + s.StringData = map[string]string{"shared": fixtureBeta} + return s + } + ctx := context.Background() + + t.Run("detail 脱敏", func(t *testing.T) { + svc := newTestSecretService(&secretManagerFixture{secret: newSame()}, &auditFixture{}) + d, err := svc.GetSecret(ctx, &model.GetSecretDetailsReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(d) + assertNoPlaintext(t, "detail", string(raw)) + if v, ok := d.Data["shared"]; !ok || len(v) != 0 { + t.Fatal("data 同名键应保留且值为空") + } + if v, ok := d.StringData["shared"]; !ok || v != "" { + t.Fatal("stringData 同名键应保留且值为空") + } + if len(d.DataKeys) != 1 || d.DataKeys[0].Size != len(fixtureAlpha) || len(d.StringDataKeys) != 1 || d.StringDataKeys[0].Size != len(fixtureBeta) { + t.Fatal("同名键的 size 应各自统计") + } + y, err := svc.GetSecretYAML(ctx, &model.GetSecretYamlReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + assertNoPlaintext(t, "yaml", y.YAML) + }) + + t.Run("JSON 原样回填保留原值", func(t *testing.T) { + m := &secretManagerFixture{secret: newSame()} + svc := newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(ctx, &model.UpdateSecretReq{ClusterID: 1, Namespace: "ops", Name: "app", + Data: model.BinaryDataMap{"shared": []byte{}}, StringData: map[string]string{"shared": ""}}); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["shared"]) != fixtureAlpha { + t.Fatal("同名键原样回填后 data 应保留原值") + } + if _, ok := m.updated.StringData["shared"]; ok { + t.Fatal("stringData 空值不应提交,否则会覆盖 data") + } + }) + + t.Run("YAML 原样回写保留原值", func(t *testing.T) { + m := &secretManagerFixture{secret: newSame()} + svc := newTestSecretService(m, &auditFixture{}) + y, _ := svc.GetSecretYAML(ctx, &model.GetSecretYamlReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err := svc.UpdateSecretByYaml(ctx, &model.UpdateSecretByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YAML: y.YAML}); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["shared"]) != fixtureAlpha || m.updated.StringData["shared"] != fixtureBeta { + t.Fatal("同名键 YAML 回写后应各自恢复原值") + } + }) + + t.Run("reveal 全量与按键返回两处的值", func(t *testing.T) { + a := &auditFixture{} + svc := newTestSecretService(&secretManagerFixture{secret: newSame()}, a) + for _, keys := range [][]string{nil, {"shared"}} { + out, err := svc.RevealSecret(ctx, &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app", Keys: keys}, model.SecretRevealActor{UserID: 1}) + if err != nil { + t.Fatal(err) + } + if string(out.Data["shared"]) != fixtureAlpha || out.StringData["shared"] != fixtureBeta { + t.Fatalf("keys=%v 时应同时返回 data 与 stringData 的值", keys) + } + if strings.Join(out.Keys, ",") != "shared" { + t.Fatalf("同名键在 keys 中只应出现一次: %v", out.Keys) + } + } + raw, _ := json.Marshal(a.logs) + assertNoPlaintext(t, "audit", string(raw)) + }) +} + +// SEC-25 P3-1:stringData 空串只在请求 data 同样包含该键时恢复原值;data 不含该键时该键被删除 +func TestUpdateSecretEmptyStringDataRestoresOnlyKeysInRequestData(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", + // alpha 在 data 中(脱敏回填的空值)→ 恢复;beta 不在 data 中 → 删除 + Data: model.BinaryDataMap{"alpha": []byte{}}, + StringData: map[string]string{"alpha": "", "beta": ""}, + }); err != nil { + t.Fatal(err) + } + u := m.updated + if string(u.Data["alpha"]) != fixtureAlpha { + t.Fatal("请求 data 含该键时 stringData 空串应恢复原值") + } + if _, ok := u.Data["beta"]; ok { + t.Fatal("请求 data 不含该键时应删除,不应恢复原值") + } + if len(u.StringData) != 0 { + t.Fatalf("恢复或删除的键都不应留在 stringData 中(否则写入时会覆盖/写空 data): %v", u.StringData) + } +} + +// SEC-25 P3-2:请求未携带 data(nil)时保留集群现有 data;携带空对象 {} 时删除全部 data 键 +func TestUpdateSecretNilDataKeepsExistingEmptyDataClears(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Labels: map[string]string{"app": "demo2"}, + }); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["alpha"]) != fixtureAlpha || string(m.updated.Data["beta"]) != fixtureBeta || len(m.updated.Data) != 2 { + t.Fatalf("data 为 nil 时应保留集群现有 data: %v", keysOf(m.updated.Data)) + } + if m.updated.Labels["app"] != "demo2" { + t.Fatal("其他字段应正常更新") + } + + // JSON 层面:缺省 data 与 "data": null 均为 nil,"data": {} 为非 nil 空 map + for _, body := range []string{`{}`, `{"data":null}`} { + var req model.UpdateSecretReq + if err := json.Unmarshal([]byte(body), &req); err != nil || req.Data != nil { + t.Fatalf("%s 应解析为 nil data: %v %v", body, req.Data, err) + } + } + var empty model.UpdateSecretReq + if err := json.Unmarshal([]byte(`{"data":{}}`), &empty); err != nil || empty.Data == nil || len(empty.Data) != 0 { + t.Fatalf(`{"data":{}} 应解析为非 nil 空 map: %v %v`, empty.Data, err) + } + + m = &secretManagerFixture{secret: newSecretFixture()} + svc = newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Data: empty.Data, + }); err != nil { + t.Fatal(err) + } + if m.updated.Data == nil || len(m.updated.Data) != 0 { + t.Fatalf("data 为 {} 时应删除全部 data 键: %v", keysOf(m.updated.Data)) + } +} + +func keysOf(m map[string][]byte) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/k8s/service/serviceaccount_service.go b/internal/k8s/service/serviceaccount_service.go index 501f4b58..8fac2148 100644 --- a/internal/k8s/service/serviceaccount_service.go +++ b/internal/k8s/service/serviceaccount_service.go @@ -27,14 +27,22 @@ package service import ( "context" + "encoding/json" "fmt" + "net/http" + "slices" "time" + "github.com/GoSimplicity/AI-CloudOps/internal/constants" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/base" "go.uber.org/zap" authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -49,18 +57,22 @@ type ServiceAccountService interface { GetServiceAccountYaml(ctx context.Context, req *model.GetServiceAccountYamlReq) (*model.K8sYaml, error) UpdateServiceAccountYaml(ctx context.Context, req *model.UpdateServiceAccountByYamlReq) error - GetServiceAccountToken(ctx context.Context, req *model.GetServiceAccountTokenReq) (*model.ServiceAccountTokenInfo, error) - CreateServiceAccountToken(ctx context.Context, req *model.CreateServiceAccountTokenReq) (*model.ServiceAccountTokenInfo, error) + // GetServiceAccountToken 返回属于该 SA 的 token Secret 中的 token;每次调用同步写审计(不含 token),审计失败不返回 token + GetServiceAccountToken(ctx context.Context, req *model.GetServiceAccountTokenReq, actor model.SecretRevealActor) (*model.ServiceAccountTokenInfo, error) + // CreateServiceAccountToken 通过 TokenRequest 签发 token;审计规则同 GetServiceAccountToken + CreateServiceAccountToken(ctx context.Context, req *model.CreateServiceAccountTokenReq, actor model.SecretRevealActor) (*model.ServiceAccountTokenInfo, error) } type serviceAccountService struct { serviceAccountManager manager.ServiceAccountManager + auditService systemService.AuditService logger *zap.Logger } -func NewServiceAccountService(serviceAccountManager manager.ServiceAccountManager, logger *zap.Logger) ServiceAccountService { +func NewServiceAccountService(serviceAccountManager manager.ServiceAccountManager, auditService systemService.AuditService, logger *zap.Logger) ServiceAccountService { return &serviceAccountService{ serviceAccountManager: serviceAccountManager, + auditService: auditService, logger: logger, } } @@ -129,6 +141,8 @@ func (s *serviceAccountService) GetServiceAccountDetails(ctx context.Context, re func (s *serviceAccountService) CreateServiceAccount(ctx context.Context, req *model.CreateServiceAccountReq) error { sa := k8sutils.ConvertToK8sServiceAccount(req) + // SEC-29:secrets 引用由集群(token controller)维护,用户提交的引用一律忽略,防止借挂其他 SA 的 token Secret + s.dropUserSecretRefs(sa, req.ClusterID, "create") return s.serviceAccountManager.CreateServiceAccount(ctx, req.ClusterID, req.Namespace, sa) } @@ -141,10 +155,59 @@ func (s *serviceAccountService) UpdateServiceAccount(ctx context.Context, req *m Annotations: req.Annotations, AutomountServiceAccountToken: req.AutomountServiceAccountToken, ImagePullSecrets: req.ImagePullSecrets, - Secrets: req.Secrets, } - sa := k8sutils.ConvertToK8sServiceAccount(createReq) - return s.serviceAccountManager.UpdateServiceAccount(ctx, req.ClusterID, req.Namespace, sa) + desired := k8sutils.ConvertToK8sServiceAccount(createReq) + return s.updateWithRetry(ctx, req.ClusterID, req.Namespace, req.Name, desired, req.Secrets, "update") +} + +// updateWithRetry 用 RetryOnConflict 更新 SA:每次重试先读取最新版本,只把允许修改的字段 +// (labels/annotations/automountServiceAccountToken/imagePullSecrets)应用上去, +// SEC-29:请求中的 secrets 一律忽略,保留集群现有引用;name/namespace 以 URL 为准。 +func (s *serviceAccountService) updateWithRetry(ctx context.Context, clusterID int, namespace, name string, desired *corev1.ServiceAccount, requestedSecrets []string, op string) error { + err := s.serviceAccountManager.UpdateServiceAccountWithRetry(ctx, clusterID, namespace, name, func(latest *corev1.ServiceAccount) error { + s.warnIgnoredSecretRefs(latest, clusterID, requestedSecrets, op) + k8sutils.ApplyServiceAccountEditableFields(latest, desired) + return nil + }) + if err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("ServiceAccount不存在: %s/%s", namespace, name) + } + return fmt.Errorf("更新ServiceAccount失败: %w", err) + } + return nil +} + +// dropUserSecretRefs 创建时丢弃用户提交的 secrets 引用并记录告警日志(只记录名称) +func (s *serviceAccountService) dropUserSecretRefs(sa *corev1.ServiceAccount, clusterID int, op string) { + if len(sa.Secrets) == 0 { + return + } + s.logger.Warn("忽略请求中的ServiceAccount secrets引用(SEC-29:该字段由集群维护)", + zap.String("operation", op), + zap.Int("cluster_id", clusterID), + zap.String("namespace", sa.Namespace), + zap.String("name", sa.Name), + zap.Strings("ignored_secrets", k8sutils.ServiceAccountSecretRefNames(sa.Secrets))) + sa.Secrets = nil +} + +// warnIgnoredSecretRefs 请求中的 secrets 与集群现有引用不同时记录告警日志(只记录名称);引用本身不会被修改 +func (s *serviceAccountService) warnIgnoredSecretRefs(latest *corev1.ServiceAccount, clusterID int, requested []string, op string) { + if requested == nil { + return + } + existingNames := k8sutils.ServiceAccountSecretRefNames(latest.Secrets) + if slices.Equal(requested, existingNames) { + return + } + s.logger.Warn("忽略请求中的ServiceAccount secrets引用(SEC-29:该字段由集群维护),保留集群现有引用", + zap.String("operation", op), + zap.Int("cluster_id", clusterID), + zap.String("namespace", latest.Namespace), + zap.String("name", latest.Name), + zap.Strings("ignored_secrets", requested), + zap.Strings("kept_secrets", existingNames)) } func (s *serviceAccountService) DeleteServiceAccount(ctx context.Context, req *model.DeleteServiceAccountReq) error { @@ -175,7 +238,39 @@ func (s *serviceAccountService) UpdateServiceAccountYaml(ctx context.Context, re return err } - return s.serviceAccountManager.UpdateServiceAccount(ctx, req.ClusterID, req.Namespace, serviceAccount) + // name/namespace 以 URL 为准(YAML 中的 name/namespace 不生效); + // SEC-29:YAML 中的 secrets 同样忽略,保留集群现有引用(从详情 YAML 原样回写时不产生变化) + var requested []string + if serviceAccount.Secrets != nil { + requested = k8sutils.ServiceAccountSecretRefNames(serviceAccount.Secrets) + } + if rv := serviceAccount.ResourceVersion; rv != "" { + // YAML 带了 metadata.resourceVersion:严格乐观锁,只 Update 一次,版本过期返回 409,不重试 + return s.updateWithResourceVersion(ctx, req.ClusterID, req.Namespace, req.Name, rv, serviceAccount, requested, "update_yaml") + } + // 未带 resourceVersion:RetryOnConflict,基于最新版本应用可编辑字段 + return s.updateWithRetry(ctx, req.ClusterID, req.Namespace, req.Name, serviceAccount, requested, "update_yaml") +} + +// updateWithResourceVersion 以调用方提交的 resourceVersion 做一次乐观锁更新;可编辑字段与 secrets 规则同 updateWithRetry。 +// 版本冲突返回 BusinessError{constants.ErrK8sResourceConflict},handler 映射为 HTTP 409。 +func (s *serviceAccountService) updateWithResourceVersion(ctx context.Context, clusterID int, namespace, name, resourceVersion string, desired *corev1.ServiceAccount, requestedSecrets []string, op string) error { + err := s.serviceAccountManager.UpdateServiceAccountWithResourceVersion(ctx, clusterID, namespace, name, resourceVersion, func(latest *corev1.ServiceAccount) error { + s.warnIgnoredSecretRefs(latest, clusterID, requestedSecrets, op) + k8sutils.ApplyServiceAccountEditableFields(latest, desired) + return nil + }) + if err != nil { + switch { + case errors.IsNotFound(err): + return fmt.Errorf("ServiceAccount不存在: %s/%s", namespace, name) + case errors.IsConflict(err): + return base.NewBusinessError(constants.ErrK8sResourceConflict, fmt.Sprintf( + "ServiceAccount %s/%s 已被修改(提交的 resourceVersion=%s 已过期),请重新获取 YAML 后再提交", namespace, name, resourceVersion)) + } + return fmt.Errorf("更新ServiceAccount失败: %w", err) + } + return nil } func (s *serviceAccountService) CreateServiceAccountByYaml(ctx context.Context, req *model.CreateServiceAccountByYamlReq) error { @@ -192,42 +287,191 @@ func (s *serviceAccountService) CreateServiceAccountByYaml(ctx context.Context, zap.String("name", serviceAccount.Name)) } + // SEC-29:YAML 中的 secrets 引用同样忽略 + s.dropUserSecretRefs(serviceAccount, req.ClusterID, "create_yaml") + return s.serviceAccountManager.CreateServiceAccount(ctx, req.ClusterID, serviceAccount.Namespace, serviceAccount) } -func (s *serviceAccountService) GetServiceAccountToken(ctx context.Context, req *model.GetServiceAccountTokenReq) (*model.ServiceAccountTokenInfo, error) { - // Get existing tokens - this is a simplified implementation - tokens, err := s.serviceAccountManager.GetServiceAccountTokens(ctx, req.ClusterID, req.Namespace, req.Name) +// GetServiceAccountToken 只返回注解 kubernetes.io/service-account.name(及存在时的 .uid)指向该 SA 的 token Secret。 +// 每次调用同步写审计(只含元数据,绝不含 token);成功读取后审计写入失败则不返回 token(fail closed)。 +func (s *serviceAccountService) GetServiceAccountToken(ctx context.Context, req *model.GetServiceAccountTokenReq, actor model.SecretRevealActor) (*model.ServiceAccountTokenInfo, error) { + if req == nil { + return nil, fmt.Errorf("请求参数不能为空") + } + start := time.Now() + record := model.ServiceAccountTokenAuditRecord{ + Action: model.ServiceAccountTokenRevealAuditAction, + ClusterID: req.ClusterID, + Namespace: req.Namespace, + ServiceAccount: req.Name, + } + fail := func(status int, err error) error { + _ = s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenRevealAuditOperation, "GET", start, status, err) + return err + } + + sa, err := s.serviceAccountManager.GetServiceAccount(ctx, req.ClusterID, req.Namespace, req.Name) if err != nil { - return nil, err + return nil, fail(k8sErrorStatusCode(err), serviceAccountGetError(req.Namespace, req.Name, err)) } + record.ServiceAccountUID = string(sa.UID) - if len(tokens) == 0 { - return nil, fmt.Errorf("no tokens found for service account %s", req.Name) + // 复用上面读到的 SA(归属校验用的也是同一个对象),不再重复 Get + secrets, err := s.serviceAccountManager.GetServiceAccountSecrets(ctx, req.ClusterID, sa) + if err != nil { + return nil, fail(k8sErrorStatusCode(err), fmt.Errorf("获取ServiceAccount关联的Secret失败: %w", err)) + } + + selected, rejected := k8sutils.SelectServiceAccountTokenSecret(sa, secrets) + if len(rejected) > 0 { + s.logger.Warn("忽略不属于该ServiceAccount的token Secret(注解 service-account.name/uid 不匹配)", + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name), + zap.Strings("rejected_secrets", rejected)) + } + if selected == nil { + return nil, fail(http.StatusNotFound, fmt.Errorf("ServiceAccount %s/%s 没有属于自己的token Secret", req.Namespace, req.Name)) } + record.SecretName = selected.Name + + if err := s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenRevealAuditOperation, "GET", start, http.StatusOK, nil); err != nil { + s.logger.Error("写入ServiceAccount token查看审计失败,拒绝返回token", zap.Error(err), + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name)) + return nil, fmt.Errorf("%w,已拒绝返回ServiceAccount token", ErrAuditWriteFailed) + } + + s.logger.Info("ServiceAccount token已查看", + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name), + zap.String("secret_name", selected.Name), + zap.Int("user_id", actor.UserID)) - // Return the first token (simplified) return &model.ServiceAccountTokenInfo{ - Token: tokens[0], + Token: string(selected.Data[corev1.ServiceAccountTokenKey]), + CreatedAt: selected.CreationTimestamp.UTC().Format(time.RFC3339), }, nil } -func (s *serviceAccountService) CreateServiceAccountToken(ctx context.Context, req *model.CreateServiceAccountTokenReq) (*model.ServiceAccountTokenInfo, error) { - // Create token request +// CreateServiceAccountToken 通过 TokenRequest 为 SA 签发 token。审计与 fail closed 规则同 GetServiceAccountToken: +// 签发成功但审计写入失败时不返回 token(已签发的 token 无法撤销,只能等待过期,见发布文档)。 +func (s *serviceAccountService) CreateServiceAccountToken(ctx context.Context, req *model.CreateServiceAccountTokenReq, actor model.SecretRevealActor) (*model.ServiceAccountTokenInfo, error) { + if req == nil { + return nil, fmt.Errorf("请求参数不能为空") + } + start := time.Now() + record := model.ServiceAccountTokenAuditRecord{ + Action: model.ServiceAccountTokenCreateAuditAction, + ClusterID: req.ClusterID, + Namespace: req.Namespace, + ServiceAccount: req.ServiceAccountName, + ExpirationSeconds: req.ExpirationSeconds, + } + + // 有效期校验必须在调用 K8s 之前完成:越界直接拒绝(400),不签发 token,但仍写审计 + expiration, err := normalizeTokenExpiration(req.ExpirationSeconds) + if err != nil { + _ = s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenCreateAuditOperation, "POST", start, http.StatusBadRequest, err) + return nil, err + } + record.ExpirationSeconds = &expiration + tokenRequest := &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ - ExpirationSeconds: req.ExpirationSeconds, + ExpirationSeconds: &expiration, }, } tokenResp, err := s.serviceAccountManager.CreateServiceAccountToken(ctx, req.ClusterID, req.Namespace, req.ServiceAccountName, tokenRequest) if err != nil { - return nil, err + userErr := fmt.Errorf("创建ServiceAccount token失败: %w", err) + _ = s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenCreateAuditOperation, "POST", start, k8sErrorStatusCode(err), userErr) + return nil, userErr + } + + if err := s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenCreateAuditOperation, "POST", start, http.StatusOK, nil); err != nil { + s.logger.Error("写入ServiceAccount token签发审计失败,拒绝返回token", zap.Error(err), + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.ServiceAccountName)) + return nil, fmt.Errorf("%w,已拒绝返回ServiceAccount token", ErrAuditWriteFailed) } + s.logger.Info("ServiceAccount token已签发", + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.ServiceAccountName), + zap.Int("user_id", actor.UserID)) + + if tokenResp.Spec.ExpirationSeconds != nil { + expiration = *tokenResp.Spec.ExpirationSeconds + } + if tokenResp.Status.ExpirationTimestamp.IsZero() { + return &model.ServiceAccountTokenInfo{Token: tokenResp.Status.Token, ExpirationSeconds: &expiration}, nil + } return &model.ServiceAccountTokenInfo{ Token: tokenResp.Status.Token, - ExpirationSeconds: req.ExpirationSeconds, - ExpirationTime: tokenResp.Status.ExpirationTimestamp.Time.Format("2006-01-02T15:04:05Z"), + ExpirationSeconds: &expiration, + ExpirationTime: tokenResp.Status.ExpirationTimestamp.Time.UTC().Format("2006-01-02T15:04:05Z"), }, nil } + +// normalizeTokenExpiration 校验 TokenRequest 有效期:nil 或 0 取默认值 3600; +// 小于 600 或大于 3600(含负数)返回参数错误(BusinessError{ErrInvalidParam},handler 映射为 HTTP 400)。 +func normalizeTokenExpiration(requested *int64) (int64, error) { + if requested == nil || *requested == 0 { + return model.ServiceAccountTokenDefaultExpirationSeconds, nil + } + v := *requested + if v < model.ServiceAccountTokenMinExpirationSeconds || v > model.ServiceAccountTokenMaxExpirationSeconds { + return 0, base.NewBusinessError(constants.ErrInvalidParam, fmt.Sprintf("expiration_seconds 取值范围为 %d~%d 秒(不传或传 0 默认 %d),当前为 %d", + model.ServiceAccountTokenMinExpirationSeconds, model.ServiceAccountTokenMaxExpirationSeconds, + model.ServiceAccountTokenDefaultExpirationSeconds, v)) + } + return v, nil +} + +func serviceAccountGetError(namespace, name string, err error) error { + if errors.IsNotFound(err) { + return fmt.Errorf("ServiceAccount不存在: %s/%s", namespace, name) + } + return fmt.Errorf("获取ServiceAccount失败: %w", err) +} + +// writeTokenAudit 同步写入 token 接口审计;RequestBody 只含元数据,绝不含 token。 +func (s *serviceAccountService) writeTokenAudit(ctx context.Context, actor model.SecretRevealActor, record model.ServiceAccountTokenAuditRecord, operation, method string, start time.Time, statusCode int, opErr error) error { + if s.auditService == nil { + return fmt.Errorf("审计服务未初始化") + } + record.UserID = actor.UserID + record.Username = actor.Username + record.Success = opErr == nil + record.IssuedAt = start.UTC().Format(time.RFC3339) + body, err := json.Marshal(record) + if err != nil { + return err + } + errMsg := "" + if opErr != nil { + errMsg = opErr.Error() + } + return s.auditService.CreateAuditLog(ctx, &model.CreateAuditLogRequest{ + UserID: actor.UserID, + TraceID: actor.TraceID, + IPAddress: actor.IP, + UserAgent: actor.UserAgent, + HttpMethod: method, + Endpoint: actor.Endpoint, + OperationType: operation, + TargetType: model.ServiceAccountTokenAuditTargetType, + TargetID: fmt.Sprintf("%d/%s/%s", record.ClusterID, record.Namespace, record.ServiceAccount), + StatusCode: statusCode, + RequestBody: body, + Duration: time.Since(start).Microseconds(), + ErrorMsg: errMsg, + }) +} diff --git a/internal/k8s/service/serviceaccount_service_test.go b/internal/k8s/service/serviceaccount_service_test.go new file mode 100644 index 00000000..d37b94e5 --- /dev/null +++ b/internal/k8s/service/serviceaccount_service_test.go @@ -0,0 +1,681 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "testing" + "time" + + "github.com/GoSimplicity/AI-CloudOps/internal/constants" + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" + k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/pkg/base" + "go.uber.org/zap" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + "k8s.io/client-go/util/retry" +) + +// 测试用的 token(非真实凭据),用于断言审计与错误中都不出现 +const ( + fixtureOwnToken = "fake-sa-token-own-0001" + fixtureVictimToken = "fake-sa-token-victim-0002" + fixtureIssuedToken = "fake-sa-token-issued-0003" +) + +type saManagerFixture struct { + manager.ServiceAccountManager + sa *corev1.ServiceAccount + secrets []corev1.Secret + getErr error + created *corev1.ServiceAccount + updated *corev1.ServiceAccount + tokenReq *authv1.TokenRequest + tokenErr error + tokenTarget string + // kube 为 UpdateServiceAccountWithRetry 使用的 fake clientset,首次使用时以 sa 为初始数据创建; + // 测试可预先设置并挂 reactor 模拟并发冲突 + kube *fake.Clientset + // getCalls 记录 GetServiceAccount 调用次数;secretsSA 记录传给 GetServiceAccountSecrets 的 SA + getCalls int + secretsSA *corev1.ServiceAccount + // tokenExpiry 非零时作为 TokenRequest 返回的过期时间(默认 metav1.Now()) + tokenExpiry time.Time +} + +func (m *saManagerFixture) GetServiceAccount(_ context.Context, _ int, _, _ string) (*corev1.ServiceAccount, error) { + m.getCalls++ + if m.getErr != nil { + return nil, m.getErr + } + return m.sa.DeepCopy(), nil +} + +func (m *saManagerFixture) GetServiceAccountSecrets(_ context.Context, _ int, sa *corev1.ServiceAccount) ([]corev1.Secret, error) { + m.secretsSA = sa + out := make([]corev1.Secret, 0, len(m.secrets)) + for i := range m.secrets { + out = append(out, *m.secrets[i].DeepCopy()) + } + return out, nil +} + +func (m *saManagerFixture) CreateServiceAccount(_ context.Context, _ int, _ string, sa *corev1.ServiceAccount) error { + m.created = sa.DeepCopy() + return nil +} + +func (m *saManagerFixture) UpdateServiceAccount(_ context.Context, _ int, _ string, sa *corev1.ServiceAccount) error { + m.updated = sa.DeepCopy() + return nil +} + +func (m *saManagerFixture) ensureKube() { + if m.kube == nil { + var objs []runtime.Object + if m.sa != nil { + objs = append(objs, m.sa.DeepCopy()) + } + m.kube = fake.NewSimpleClientset(objs...) + } +} + +func (m *saManagerFixture) UpdateServiceAccountWithResourceVersion(ctx context.Context, _ int, namespace, name, resourceVersion string, mutate k8sutils.ServiceAccountMutateFunc) error { + m.ensureKube() + updated, err := k8sutils.UpdateServiceAccountWithResourceVersion(ctx, m.kube.CoreV1().ServiceAccounts(namespace), name, resourceVersion, mutate) + if err != nil { + return err + } + m.updated = updated.DeepCopy() + return nil +} + +func (m *saManagerFixture) UpdateServiceAccountWithRetry(ctx context.Context, _ int, namespace, name string, mutate k8sutils.ServiceAccountMutateFunc) error { + m.ensureKube() + updated, err := k8sutils.UpdateServiceAccountWithRetry(ctx, m.kube.CoreV1().ServiceAccounts(namespace), name, mutate) + if err != nil { + return err + } + m.updated = updated.DeepCopy() + return nil +} + +func (m *saManagerFixture) CreateServiceAccountToken(_ context.Context, _ int, _, name string, tr *authv1.TokenRequest) (*authv1.TokenRequest, error) { + m.tokenTarget = name + if m.tokenErr != nil { + return nil, m.tokenErr + } + m.tokenReq = tr.DeepCopy() + out := tr.DeepCopy() + out.Status.Token = fixtureIssuedToken + out.Status.ExpirationTimestamp = metav1.Now() + if !m.tokenExpiry.IsZero() { + out.Status.ExpirationTimestamp = metav1.NewTime(m.tokenExpiry) + } + return out, nil +} + +func newSAFixture() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops", UID: types.UID("uid-app")}, + Secrets: []corev1.ObjectReference{{Name: "app-token"}}, + } +} + +func tokenSecret(name, saName, saUID, token string) corev1.Secret { + ann := map[string]string{corev1.ServiceAccountNameKey: saName} + if saUID != "" { + ann[corev1.ServiceAccountUIDKey] = saUID + } + return corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "ops", Annotations: ann}, + Type: corev1.SecretTypeServiceAccountToken, + Data: map[string][]byte{corev1.ServiceAccountTokenKey: []byte(token)}, + } +} + +var saActor = model.SecretRevealActor{UserID: 7, Username: "operator", IP: "192.0.2.10", Endpoint: "/api/k8s/serviceaccount/1/ops/app/token"} + +func assertNoToken(t *testing.T, v interface{}, tokens ...string) { + t.Helper() + raw, _ := json.Marshal(v) + for _, tok := range tokens { + if strings.Contains(string(raw), tok) { + t.Fatalf("出现了 token: %s", raw) + } + } +} + +func TestGetServiceAccountTokenRejectsBorrowedSecret(t *testing.T) { + for _, tt := range []struct { + name string + secrets []corev1.Secret + }{ + {"借挂其他 SA 的 token Secret", []corev1.Secret{tokenSecret("admin-token", "admin", "uid-admin", fixtureVictimToken)}}, + {"名称相同但 UID 不同(同名 SA 被重建)", []corev1.Secret{tokenSecret("app-token-old", "app", "uid-old", fixtureVictimToken)}}, + {"UID 注解为空串", []corev1.Secret{tokenSecret("app-token-empty", "app", "", fixtureVictimToken)}}, + {"缺少 name 注解", []corev1.Secret{{ + ObjectMeta: metav1.ObjectMeta{Name: "no-ann", Namespace: "ops"}, + Type: corev1.SecretTypeServiceAccountToken, + Data: map[string][]byte{corev1.ServiceAccountTokenKey: []byte(fixtureVictimToken)}, + }}}, + {"非 service-account-token 类型", []corev1.Secret{func() corev1.Secret { + s := tokenSecret("opaque", "app", "uid-app", fixtureVictimToken) + s.Type = corev1.SecretTypeOpaque + return s + }()}}, + {"其他命名空间", []corev1.Secret{func() corev1.Secret { + s := tokenSecret("other-ns", "app", "uid-app", fixtureVictimToken) + s.Namespace = "kube-system" + return s + }()}}, + } { + t.Run(tt.name, func(t *testing.T) { + if tt.name == "UID 注解为空串" { + tt.secrets[0].Annotations[corev1.ServiceAccountUIDKey] = "" + } + audit := &auditFixture{} + svc := NewServiceAccountService(&saManagerFixture{sa: newSAFixture(), secrets: tt.secrets}, audit, zap.NewNop()) + got, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor) + if err == nil || got != nil { + t.Fatalf("借挂的 token 不应返回: %+v", got) + } + assertNoToken(t, err.Error(), fixtureVictimToken) + if len(audit.logs) != 1 || audit.logs[0].StatusCode != 404 || audit.logs[0].OperationType != model.ServiceAccountTokenRevealAuditOperation { + t.Fatalf("拒绝也要写审计: %+v", audit.logs) + } + assertNoToken(t, audit.logs, fixtureVictimToken) + }) + } +} + +func TestGetServiceAccountTokenReturnsOwnSecretOnly(t *testing.T) { + audit := &auditFixture{} + fx := &saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{ + tokenSecret("admin-token", "admin", "uid-admin", fixtureVictimToken), + tokenSecret("app-token", "app", "uid-app", fixtureOwnToken), + }} + svc := NewServiceAccountService(fx, audit, zap.NewNop()) + got, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor) + if err != nil || got == nil || got.Token != fixtureOwnToken { + t.Fatalf("应返回 SA 自己的 token: %+v %v", got, err) + } + + // 没有 uid 注解时只校验名称 + fx.secrets = []corev1.Secret{tokenSecret("app-token", "app", "", fixtureOwnToken)} + got, err = svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor) + if err != nil || got.Token != fixtureOwnToken { + t.Fatalf("无 uid 注解时名称匹配应放行: %+v %v", got, err) + } +} + +func TestGetServiceAccountTokenAuditHasMetadataOnly(t *testing.T) { + audit := &auditFixture{} + svc := NewServiceAccountService(&saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{tokenSecret("app-token", "app", "uid-app", fixtureOwnToken)}}, audit, zap.NewNop()) + if _, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor); err != nil { + t.Fatal(err) + } + if len(audit.logs) != 1 { + t.Fatalf("应同步写 1 条审计,实际 %d", len(audit.logs)) + } + l := audit.logs[0] + if l.OperationType != "TOKEN_REVEAL" || l.TargetType != model.ServiceAccountTokenAuditTargetType || l.TargetID != "1/ops/app" || + l.StatusCode != 200 || l.HttpMethod != "GET" || l.UserID != 7 || l.IPAddress != "192.0.2.10" || len(l.ResponseBody) != 0 { + t.Fatalf("审计元数据不符: %+v", l) + } + var rec model.ServiceAccountTokenAuditRecord + if err := json.Unmarshal(l.RequestBody, &rec); err != nil { + t.Fatal(err) + } + if rec.SecretName != "app-token" || rec.ServiceAccountUID != "uid-app" || !rec.Success || rec.Username != "operator" { + t.Fatalf("审计内容不符: %s", l.RequestBody) + } + assertNoToken(t, audit.logs, fixtureOwnToken) +} + +func TestGetServiceAccountTokenAuditFailureReturnsNoToken(t *testing.T) { + svc := NewServiceAccountService(&saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{tokenSecret("app-token", "app", "uid-app", fixtureOwnToken)}}, + &auditFixture{err: errors.New("audit db unavailable")}, zap.NewNop()) + got, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor) + if got != nil || !errors.Is(err, ErrAuditWriteFailed) { + t.Fatalf("审计失败不应返回 token: %+v %v", got, err) + } + assertNoToken(t, err.Error(), fixtureOwnToken) + + // 审计服务缺失同样 fail closed + svc = NewServiceAccountService(&saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{tokenSecret("app-token", "app", "uid-app", fixtureOwnToken)}}, nil, zap.NewNop()) + if got, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor); got != nil || !errors.Is(err, ErrAuditWriteFailed) { + t.Fatalf("未注入审计服务时不应返回 token: %+v %v", got, err) + } +} + +func TestGetServiceAccountTokenSANotFoundAudited(t *testing.T) { + audit := &auditFixture{} + svc := NewServiceAccountService(&saManagerFixture{getErr: apierrors.NewNotFound(schema.GroupResource{Resource: "serviceaccounts"}, "app")}, audit, zap.NewNop()) + if _, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor); err == nil { + t.Fatal("SA 不存在应报错") + } + if len(audit.logs) != 1 || audit.logs[0].StatusCode != 404 { + t.Fatalf("审计状态码应为 404: %+v", audit.logs) + } +} + +func TestCreateServiceAccountTokenAudited(t *testing.T) { + exp := int64(600) + req := &model.CreateServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", ServiceAccountName: "app", ExpirationSeconds: &exp} + + audit := &auditFixture{} + fx := &saManagerFixture{} + svc := NewServiceAccountService(fx, audit, zap.NewNop()) + got, err := svc.CreateServiceAccountToken(context.Background(), req, saActor) + if err != nil || got.Token != fixtureIssuedToken || fx.tokenTarget != "app" || *fx.tokenReq.Spec.ExpirationSeconds != 600 { + t.Fatalf("TokenRequest 结果不符: %+v %v", got, err) + } + if len(audit.logs) != 1 || audit.logs[0].OperationType != "TOKEN_CREATE" || audit.logs[0].HttpMethod != "POST" || audit.logs[0].StatusCode != 200 { + t.Fatalf("TokenRequest 审计不符: %+v", audit.logs) + } + var rec model.ServiceAccountTokenAuditRecord + if err := json.Unmarshal(audit.logs[0].RequestBody, &rec); err != nil || rec.ExpirationSeconds == nil || *rec.ExpirationSeconds != 600 { + t.Fatalf("审计内容不符: %s", audit.logs[0].RequestBody) + } + assertNoToken(t, audit.logs, fixtureIssuedToken) + + // 审计失败:已签发也不返回 + svc = NewServiceAccountService(&saManagerFixture{}, &auditFixture{err: errors.New("audit db unavailable")}, zap.NewNop()) + got, err = svc.CreateServiceAccountToken(context.Background(), req, saActor) + if got != nil || !errors.Is(err, ErrAuditWriteFailed) { + t.Fatalf("审计失败不应返回签发的 token: %+v %v", got, err) + } + assertNoToken(t, err.Error(), fixtureIssuedToken) + + // K8s 拒绝:审计记录真实状态码 + audit = &auditFixture{} + svc = NewServiceAccountService(&saManagerFixture{tokenErr: apierrors.NewForbidden(schema.GroupResource{Resource: "serviceaccounts/token"}, "app", errors.New("denied"))}, audit, zap.NewNop()) + if _, err := svc.CreateServiceAccountToken(context.Background(), req, saActor); err == nil { + t.Fatal("应返回错误") + } + if len(audit.logs) != 1 || audit.logs[0].StatusCode != 403 { + t.Fatalf("审计状态码应为 403: %+v", audit.logs) + } +} + +func TestCreateServiceAccountIgnoresSecretRefs(t *testing.T) { + fx := &saManagerFixture{} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + err := svc.CreateServiceAccount(context.Background(), &model.CreateServiceAccountReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Secrets: []string{"admin-token"}, ImagePullSecrets: []string{"registry"}, + }) + if err != nil || fx.created == nil || len(fx.created.Secrets) != 0 || len(fx.created.ImagePullSecrets) != 1 { + t.Fatalf("创建时应忽略 secrets、保留 imagePullSecrets: %+v %v", fx.created, err) + } + + fx = &saManagerFixture{} + svc = NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + yamlContent := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n namespace: ops\nsecrets:\n- name: admin-token\n" + if err := svc.CreateServiceAccountByYaml(context.Background(), &model.CreateServiceAccountByYamlReq{ClusterID: 1, YamlContent: yamlContent}); err != nil { + t.Fatal(err) + } + if fx.created == nil || len(fx.created.Secrets) != 0 { + t.Fatalf("YAML 创建时应忽略 secrets: %+v", fx.created) + } +} + +func TestUpdateServiceAccountKeepsExistingSecretRefs(t *testing.T) { + fx := &saManagerFixture{sa: newSAFixture()} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + err := svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Secrets: []string{"admin-token"}, Labels: map[string]string{"team": "ops"}, + }) + if err != nil || len(fx.updated.Secrets) != 1 || fx.updated.Secrets[0].Name != "app-token" || fx.updated.Labels["team"] != "ops" { + t.Fatalf("更新时应忽略请求 secrets、保留集群现有引用: %+v %v", fx.updated, err) + } + + // 不传 secrets 也不能清空集群维护的引用 + fx.updated = nil + if err := svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ClusterID: 1, Namespace: "ops", Name: "app"}); err != nil { + t.Fatal(err) + } + if len(fx.updated.Secrets) != 1 || fx.updated.Secrets[0].Name != "app-token" { + t.Fatalf("未传 secrets 时应保留现有引用: %+v", fx.updated.Secrets) + } + + // YAML 更新:secrets 被忽略,name/namespace 以 URL 为准 + fx.updated = nil + yamlContent := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: other\n namespace: kube-system\nsecrets:\n- name: admin-token\n" + if err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: yamlContent}); err != nil { + t.Fatal(err) + } + if fx.updated.Name != "app" || fx.updated.Namespace != "ops" || len(fx.updated.Secrets) != 1 || fx.updated.Secrets[0].Name != "app-token" { + t.Fatalf("YAML 更新应忽略 secrets 并以 URL 为准: %+v", fx.updated) + } + + // SA 不存在时不更新 + fx = &saManagerFixture{getErr: apierrors.NewNotFound(schema.GroupResource{Resource: "serviceaccounts"}, "app")} + svc = NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + if err := svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ClusterID: 1, Namespace: "ops", Name: "app"}); err == nil || fx.updated != nil { + t.Fatalf("SA 不存在应报错且不更新: %v", err) + } +} + +func TestCreateServiceAccountTokenExpirationBounds(t *testing.T) { + i64 := func(v int64) *int64 { return &v } + for _, tt := range []struct { + name string + requested *int64 + want int64 // 期望传给 K8s 的值;0 表示应被拒绝 + }{ + {"不传默认3600", nil, 3600}, + {"传0默认3600", i64(0), 3600}, + {"599低于下限", i64(599), 0}, + {"600下限", i64(600), 600}, + {"3600上限", i64(3600), 3600}, + {"3601高于上限", i64(3601), 0}, + {"负数", i64(-1), 0}, + {"负的600", i64(-600), 0}, + } { + t.Run(tt.name, func(t *testing.T) { + audit := &auditFixture{} + fx := &saManagerFixture{} + svc := NewServiceAccountService(fx, audit, zap.NewNop()) + got, err := svc.CreateServiceAccountToken(context.Background(), &model.CreateServiceAccountTokenReq{ + ClusterID: 1, Namespace: "ops", ServiceAccountName: "app", ExpirationSeconds: tt.requested, + }, saActor) + if len(audit.logs) != 1 { + t.Fatalf("应写且只写一条审计: %+v", audit.logs) + } + var rec model.ServiceAccountTokenAuditRecord + if err := json.Unmarshal(audit.logs[0].RequestBody, &rec); err != nil { + t.Fatal(err) + } + if tt.want == 0 { + var bizErr *base.BusinessError + if got != nil || !errors.As(err, &bizErr) || !errors.Is(bizErr.Code, constants.ErrInvalidParam) { + t.Fatalf("越界应返回参数错误且不返回 token: %+v %v", got, err) + } + if fx.tokenTarget != "" || fx.tokenReq != nil { + t.Fatalf("越界时不应调用 K8s TokenRequest: target=%q", fx.tokenTarget) + } + if audit.logs[0].StatusCode != 400 || rec.Success || rec.ExpirationSeconds == nil || *rec.ExpirationSeconds != *tt.requested { + t.Fatalf("越界审计应为 400、success=false、记录请求值: %+v %s", audit.logs[0], audit.logs[0].RequestBody) + } + assertNoToken(t, audit.logs, fixtureIssuedToken) + return + } + if err != nil || got == nil || got.Token != fixtureIssuedToken { + t.Fatalf("应签发成功: %+v %v", got, err) + } + if fx.tokenReq == nil || fx.tokenReq.Spec.ExpirationSeconds == nil || *fx.tokenReq.Spec.ExpirationSeconds != tt.want { + t.Fatalf("传给 K8s 的有效期应为 %d: %+v", tt.want, fx.tokenReq) + } + if got.ExpirationSeconds == nil || *got.ExpirationSeconds != tt.want { + t.Fatalf("响应有效期应为 %d: %+v", tt.want, got.ExpirationSeconds) + } + if audit.logs[0].StatusCode != 200 || rec.ExpirationSeconds == nil || *rec.ExpirationSeconds != tt.want { + t.Fatalf("审计应记录生效有效期 %d: %s", tt.want, audit.logs[0].RequestBody) + } + }) + } +} + +func countSAUpdates(c *fake.Clientset) int { + n := 0 + for _, a := range c.Actions() { + if a.GetVerb() == "update" && a.GetResource().Resource == "serviceaccounts" { + n++ + } + } + return n +} + +// 第一次 Update 前集群中的 SA 被并发修改并返回 Conflict(YAML 不带 resourceVersion,走 RetryOnConflict),第二次成功:JSON 与 YAML 两条路径都应基于最新版本重试、保留最新 secrets +func TestUpdateServiceAccountRetriesOnConflict(t *testing.T) { + yamlContent := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: other\n namespace: kube-system\n labels:\n team: ops\nsecrets:\n- name: admin-token\n" + for _, tt := range []struct { + name string + update func(svc ServiceAccountService) error + }{ + {"json", func(svc ServiceAccountService) error { + return svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Secrets: []string{"admin-token"}, Labels: map[string]string{"team": "ops"}, + }) + }}, + {"yaml", func(svc ServiceAccountService) error { + return svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: yamlContent}) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + seed := newSAFixture() + seed.ResourceVersion = "1" + kube := fake.NewSimpleClientset(seed) + calls := 0 + kube.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + calls++ + if calls > 1 { + return false, nil, nil + } + concurrent := newSAFixture() + concurrent.ResourceVersion = "2" + concurrent.Secrets = append(concurrent.Secrets, corev1.ObjectReference{Name: "app-token-rotated"}) + if err := kube.Tracker().Update(corev1.SchemeGroupVersion.WithResource("serviceaccounts"), concurrent, "ops"); err != nil { + t.Errorf("模拟并发修改失败: %v", err) + } + return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", errors.New("object has been modified")) + }) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + if err := tt.update(NewServiceAccountService(fx, &auditFixture{}, zap.NewNop())); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("应冲突一次后重试成功, update 调用 %d 次", calls) + } + var last *corev1.ServiceAccount + for _, a := range kube.Actions() { + if ua, ok := a.(k8stesting.UpdateAction); ok && a.GetResource().Resource == "serviceaccounts" { + last = ua.GetObject().(*corev1.ServiceAccount) + } + } + if last == nil || last.ResourceVersion != "2" { + t.Fatalf("最后一次 Update 应基于最新版本 resourceVersion=2: %+v", last) + } + if last.Name != "app" || last.Namespace != "ops" || last.Labels["team"] != "ops" { + t.Fatalf("name/namespace 应以 URL 为准、字段应被应用: %+v", last.ObjectMeta) + } + if got := k8sutils.ServiceAccountSecretRefNames(last.Secrets); len(got) != 2 || got[0] != "app-token" || got[1] != "app-token-rotated" { + t.Fatalf("应保留集群最新的 secrets 引用、忽略请求中的 admin-token: %v", got) + } + }) + } +} + +func TestUpdateServiceAccountConflictExhaustedReturnsError(t *testing.T) { + kube := fake.NewSimpleClientset(newSAFixture()) + kube.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", errors.New("object has been modified")) + }) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + err := svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err == nil || !apierrors.IsConflict(err) || fx.updated != nil { + t.Fatalf("连续冲突超过重试次数应返回 Conflict 错误: %v", err) + } + if n := countSAUpdates(kube); n != retry.DefaultRetry.Steps { + t.Fatalf("应尝试 %d 次, got %d", retry.DefaultRetry.Steps, n) + } + if err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ + ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n", + }); !apierrors.IsConflict(err) { + t.Fatalf("YAML 路径连续冲突同样应返回错误: %v", err) + } +} + +func TestUpdateServiceAccountNotFoundSkipsUpdate(t *testing.T) { + for _, op := range []string{"json", "yaml"} { + t.Run(op, func(t *testing.T) { + fx := &saManagerFixture{} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + var err error + if op == "json" { + err = svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + } else { + err = svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ + ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n", + }) + } + if err == nil || !strings.Contains(err.Error(), "ServiceAccount不存在") { + t.Fatalf("SA 不存在应报错: %v", err) + } + if n := countSAUpdates(fx.kube); n != 0 || fx.updated != nil { + t.Fatalf("SA 不存在时不应调用 Update, got %d", n) + } + }) + } +} + +// optimisticLockReactor 模拟 API Server 的乐观锁:Update 提交的 resourceVersion 与当前存储不一致时返回 Conflict, +// 一致时把版本号加 1 后交给默认 tracker(fake clientset 本身不校验 resourceVersion) +func optimisticLockReactor(t *testing.T, kube *fake.Clientset) { + gvr := corev1.SchemeGroupVersion.WithResource("serviceaccounts") + kube.PrependReactor("update", "serviceaccounts", func(a k8stesting.Action) (bool, runtime.Object, error) { + obj := a.(k8stesting.UpdateAction).GetObject().(*corev1.ServiceAccount) + cur, err := kube.Tracker().Get(gvr, obj.Namespace, obj.Name) + if err != nil { + return true, nil, err + } + curRV := cur.(*corev1.ServiceAccount).ResourceVersion + if obj.ResourceVersion != curRV { + return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, obj.Name, errors.New("the object has been modified")) + } + next := obj.DeepCopy() + n, _ := strconv.Atoi(curRV) + next.ResourceVersion = strconv.Itoa(n + 1) + if err := kube.Tracker().Update(gvr, next, obj.Namespace); err != nil { + t.Errorf("tracker 更新失败: %v", err) + } + return true, next, nil + }) +} + +func saYamlWithRV(rv string) string { + y := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n namespace: ops\n" + if rv != "" { + y += " resourceVersion: \"" + rv + "\"\n" + } + return y + " labels:\n team: yaml\nsecrets:\n- name: admin-token\n" +} + +// SEC-29(A):YAML 带 resourceVersion 时严格乐观锁——版本过期只 Update 一次并返回 409 业务错误,不重试 +func TestUpdateServiceAccountYamlStaleResourceVersionReturnsConflict(t *testing.T) { + seed := newSAFixture() + seed.ResourceVersion = "5" + kube := fake.NewSimpleClientset(seed) + optimisticLockReactor(t, kube) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + + err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: saYamlWithRV("4")}) + var bizErr *base.BusinessError + if !errors.As(err, &bizErr) || !errors.Is(bizErr.Code, constants.ErrK8sResourceConflict) { + t.Fatalf("过期 resourceVersion 应返回 ErrK8sResourceConflict 业务错误: %v", err) + } + if n := countSAUpdates(kube); n != 1 { + t.Fatalf("严格乐观锁只应 Update 一次(不重试), got %d", n) + } + if fx.updated != nil { + t.Fatalf("冲突时不应视为更新成功: %+v", fx.updated) + } + cur, _ := kube.CoreV1().ServiceAccounts("ops").Get(context.Background(), "app", metav1.GetOptions{}) + if cur.ResourceVersion != "5" || cur.Labels["team"] != "" { + t.Fatalf("冲突时集群对象不应被修改: %+v", cur.ObjectMeta) + } +} + +func TestUpdateServiceAccountYamlCurrentResourceVersionSucceeds(t *testing.T) { + seed := newSAFixture() + seed.ResourceVersion = "5" + kube := fake.NewSimpleClientset(seed) + optimisticLockReactor(t, kube) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + + if err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: saYamlWithRV("5")}); err != nil { + t.Fatal(err) + } + if n := countSAUpdates(kube); n != 1 { + t.Fatalf("应只 Update 一次, got %d", n) + } + cur, _ := kube.CoreV1().ServiceAccounts("ops").Get(context.Background(), "app", metav1.GetOptions{}) + if cur.ResourceVersion != "6" || cur.Labels["team"] != "yaml" { + t.Fatalf("应以提交的版本更新成功并应用字段: %+v", cur.ObjectMeta) + } + if got := k8sutils.ServiceAccountSecretRefNames(cur.Secrets); len(got) != 1 || got[0] != "app-token" { + t.Fatalf("乐观锁路径同样应忽略 YAML 中的 secrets、保留集群引用: %v", got) + } +} + +// 不带 resourceVersion 时保持 RetryOnConflict:即使存储中的版本与 YAML 无关,也基于最新版本重试成功 +func TestUpdateServiceAccountYamlWithoutResourceVersionUsesRetry(t *testing.T) { + seed := newSAFixture() + seed.ResourceVersion = "5" + kube := fake.NewSimpleClientset(seed) + optimisticLockReactor(t, kube) + conflicts := 0 + kube.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + if conflicts == 0 { + conflicts++ + return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", errors.New("the object has been modified")) + } + return false, nil, nil + }) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + if err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: saYamlWithRV("")}); err != nil { + t.Fatal(err) + } + if n := countSAUpdates(kube); n != 2 { + t.Fatalf("未带 resourceVersion 应走重试路径(冲突 1 次后成功), update %d 次", n) + } + cur, _ := kube.CoreV1().ServiceAccounts("ops").Get(context.Background(), "app", metav1.GetOptions{}) + if cur.Labels["team"] != "yaml" || cur.ResourceVersion != "6" { + t.Fatalf("重试后应基于最新版本更新成功: %+v", cur.ObjectMeta) + } +} + +// SEC-29(B):K8s 返回非 UTC 时区的过期时间时,响应中的 expiration_time 仍是正确的 UTC 字符串 +func TestCreateServiceAccountTokenExpirationTimeIsUTC(t *testing.T) { + cst := time.FixedZone("CST", 8*3600) + fx := &saManagerFixture{tokenExpiry: time.Date(2026, 10, 24, 9, 30, 15, 0, cst)} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + got, err := svc.CreateServiceAccountToken(context.Background(), &model.CreateServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", ServiceAccountName: "app"}, saActor) + if err != nil { + t.Fatal(err) + } + if got.ExpirationTime != "2026-10-24T01:30:15Z" { + t.Fatalf("expiration_time 应转换为 UTC: %q", got.ExpirationTime) + } +} + +// SEC-29 P3-3:GetServiceAccountToken 只读取一次 SA,并把同一个对象传给 GetServiceAccountSecrets +func TestGetServiceAccountTokenReadsServiceAccountOnce(t *testing.T) { + fx := &saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{tokenSecret("app-token", "app", "uid-app", fixtureOwnToken)}} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + if _, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor); err != nil { + t.Fatal(err) + } + if fx.getCalls != 1 { + t.Fatalf("GetServiceAccount 应只调用一次, got %d", fx.getCalls) + } + if fx.secretsSA == nil || fx.secretsSA.Name != "app" || fx.secretsSA.UID != "uid-app" { + t.Fatalf("应把已读取的 SA 传给 GetServiceAccountSecrets: %+v", fx.secretsSA) + } +} diff --git a/internal/k8s/utils/secret.go b/internal/k8s/utils/secret.go index cee9848b..4d34ef07 100644 --- a/internal/k8s/utils/secret.go +++ b/internal/k8s/utils/secret.go @@ -104,19 +104,24 @@ func CleanSecretForYAML(secret *corev1.Secret) *corev1.Secret { cleaned.ObjectMeta.ManagedFields = nil if cleaned.Annotations != nil { - delete(cleaned.Annotations, "kubectl.kubernetes.io/last-applied-configuration") + delete(cleaned.Annotations, LastAppliedConfigAnnotation) } return cleaned } -// SecretToYAML 将 Secret 转换为 YAML 字符串 +// SecretToYAML 将 Secret 转换为脱敏后的 YAML 字符串: +// data/stringData 的值替换为 SecretRedactedPlaceholder,last-applied-configuration 注解被移除。 +// 明文只能通过 reveal 接口获取。 func SecretToYAML(sec *corev1.Secret) (string, error) { if sec == nil { return "", fmt.Errorf("Secret 不能为空") } - clean := CleanSecretForYAML(sec) - b, err := yaml.Marshal(clean) + obj, err := redactedSecretYAMLObject(sec) + if err != nil { + return "", fmt.Errorf("转换为YAML失败: %w", err) + } + b, err := yaml.Marshal(obj) if err != nil { return "", fmt.Errorf("转换为YAML失败: %w", err) } diff --git a/internal/k8s/utils/secret_redact.go b/internal/k8s/utils/secret_redact.go new file mode 100644 index 00000000..ceb98954 --- /dev/null +++ b/internal/k8s/utils/secret_redact.go @@ -0,0 +1,217 @@ +/* + * MIT License + * + * Copyright (c) 2024 Bamboo + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +package utils + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "sort" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +const ( + // SecretRedactedPlaceholder YAML 中替换 data/stringData 值的占位符。 + // 它不是合法的 base64,因此不可能被当作真实值静默写回集群。 + SecretRedactedPlaceholder = "" + // LastAppliedConfigAnnotation kubectl apply 写入的注解,内容为完整对象(含 Secret 明文) + LastAppliedConfigAnnotation = "kubectl.kubernetes.io/last-applied-configuration" +) + +// SecretDataKeyInfos 返回 data 的键与值字节数(按 key 升序),不包含值 +func SecretDataKeyInfos(data map[string][]byte) []model.SecretKeyInfo { + infos := make([]model.SecretKeyInfo, 0, len(data)) + for k, v := range data { + infos = append(infos, model.SecretKeyInfo{Key: k, Size: len(v)}) + } + sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key }) + return infos +} + +// SecretStringDataKeyInfos 返回 stringData 的键与值字节数(按 key 升序),不包含值 +func SecretStringDataKeyInfos(data map[string]string) []model.SecretKeyInfo { + infos := make([]model.SecretKeyInfo, 0, len(data)) + for k, v := range data { + infos = append(infos, model.SecretKeyInfo{Key: k, Size: len(v)}) + } + sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key }) + return infos +} + +// RedactSecretData 仅保留键,值置为空 +func RedactSecretData(data map[string][]byte) model.BinaryDataMap { + if data == nil { + return nil + } + out := make(model.BinaryDataMap, len(data)) + for k := range data { + out[k] = []byte{} + } + return out +} + +// RedactSecretStringData 仅保留键,值置为空 +func RedactSecretStringData(data map[string]string) map[string]string { + if data == nil { + return nil + } + out := make(map[string]string, len(data)) + for k := range data { + out[k] = "" + } + return out +} + +// RedactSecretAnnotations 复制注解并去掉可能包含明文的 last-applied-configuration +func RedactSecretAnnotations(annotations map[string]string) map[string]string { + if annotations == nil { + return nil + } + out := make(map[string]string, len(annotations)) + for k, v := range annotations { + if k == LastAppliedConfigAnnotation { + continue + } + out[k] = v + } + return out +} + +// redactedSecretYAMLObject 将清理后的 Secret 转为通用对象,并把 data/stringData 的值替换为占位符 +func redactedSecretYAMLObject(sec *corev1.Secret) (map[string]interface{}, error) { + clean := CleanSecretForYAML(sec) + raw, err := json.Marshal(clean) + if err != nil { + return nil, err + } + var obj map[string]interface{} + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, err + } + for _, field := range []string{"data", "stringData"} { + values, ok := obj[field].(map[string]interface{}) + if !ok { + continue + } + for k := range values { + values[k] = SecretRedactedPlaceholder + } + } + return obj, nil +} + +// secretYAMLValues 解析 YAML 中的 data 与 stringData(值统一视为字符串) +func secretYAMLValues(obj map[string]interface{}, field string) map[string]interface{} { + values, _ := obj[field].(map[string]interface{}) + return values +} + +// SecretYAMLHasRedactedPlaceholder 判断 YAML 的 data/stringData 中是否仍含脱敏占位符 +func SecretYAMLHasRedactedPlaceholder(y string) (bool, error) { + var obj map[string]interface{} + if err := yaml.Unmarshal([]byte(y), &obj); err != nil { + return false, fmt.Errorf("解析YAML失败: %w", err) + } + for _, field := range []string{"data", "stringData"} { + for _, v := range secretYAMLValues(obj, field) { + if s, ok := v.(string); ok && s == SecretRedactedPlaceholder { + return true, nil + } + } + } + return false, nil +} + +// RestoreRedactedSecretYAML 将 YAML 中仍为占位符的键恢复为集群中的现有值。 +// 前端拿到脱敏 YAML 后只改了部分字段再提交时,未改动的键保持原值而不是被占位符覆盖。 +// 占位符对应的键在现有 Secret 中不存在时返回错误。 +func RestoreRedactedSecretYAML(y string, existing *corev1.Secret) (string, error) { + var obj map[string]interface{} + if err := yaml.Unmarshal([]byte(y), &obj); err != nil { + return "", fmt.Errorf("解析YAML失败: %w", err) + } + if obj == nil { + return y, nil + } + var existingData map[string][]byte + var existingStringData map[string]string + if existing != nil { + existingData = existing.Data + existingStringData = existing.StringData + } + changed := false + + data := secretYAMLValues(obj, "data") + for k, v := range data { + if s, ok := v.(string); ok && s == SecretRedactedPlaceholder { + if old, exists := existingData[k]; exists { + data[k] = base64.StdEncoding.EncodeToString(old) + } else if old, exists := existingStringData[k]; exists { + data[k] = base64.StdEncoding.EncodeToString([]byte(old)) + } else { + return "", fmt.Errorf("键 %s 仍为脱敏占位符且在现有Secret中不存在,请填写真实值", k) + } + changed = true + } + } + + stringData := secretYAMLValues(obj, "stringData") + for k, v := range stringData { + if s, ok := v.(string); ok && s == SecretRedactedPlaceholder { + if old, exists := existingStringData[k]; exists { + stringData[k] = old + changed = true + continue + } + old, exists := existingData[k] + if !exists { + return "", fmt.Errorf("键 %s 仍为脱敏占位符且在现有Secret中不存在,请填写真实值", k) + } + // stringData 写入后会合并到 data,这里直接以 data 形式恢复原值 + delete(stringData, k) + if data == nil { + data = map[string]interface{}{} + obj["data"] = data + } + if _, set := data[k]; !set { + data[k] = base64.StdEncoding.EncodeToString(old) + } + changed = true + } + } + + if !changed { + return y, nil + } + out, err := yaml.Marshal(obj) + if err != nil { + return "", fmt.Errorf("生成YAML失败: %w", err) + } + return string(out), nil +} diff --git a/internal/k8s/utils/secret_redact_test.go b/internal/k8s/utils/secret_redact_test.go new file mode 100644 index 00000000..9492439c --- /dev/null +++ b/internal/k8s/utils/secret_redact_test.go @@ -0,0 +1,121 @@ +package utils + +import ( + "encoding/base64" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// 测试用的明文(非真实凭据) +const ( + redactFixtureData = "plain-redact-data-fixture-01" + redactFixtureString = "plain-redact-string-fixture-02" + redactFixtureNew = "plain-redact-new-fixture-03" +) + +func parseSecretYAML(t *testing.T, y string) *corev1.Secret { + t.Helper() + var sec corev1.Secret + if err := yaml.Unmarshal([]byte(y), &sec); err != nil { + t.Fatalf("解析恢复后的 YAML 失败: %v", err) + } + return &sec +} + +// stringData 中的占位符在现有 Secret 的 stringData 中不存在、只在 data 中存在时, +// 应从 stringData 移除并以 data 形式恢复原值(secret_redact.go 中 stringData→data 分支)。 +func TestRestoreRedactedSecretYAMLStringDataPlaceholderFallsBackToData(t *testing.T) { + existing := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops"}, + Data: map[string][]byte{"token": []byte(redactFixtureData)}, + } + + t.Run("data 中未出现该键时补入 data", func(t *testing.T) { + in := "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: ops\nstringData:\n token: " + SecretRedactedPlaceholder + "\n" + out, err := RestoreRedactedSecretYAML(in, existing) + if err != nil { + t.Fatal(err) + } + sec := parseSecretYAML(t, out) + if _, ok := sec.StringData["token"]; ok { + t.Fatal("占位符键应从 stringData 中移除") + } + if string(sec.Data["token"]) != redactFixtureData { + t.Fatalf("应以 data 形式恢复原值, got %d bytes", len(sec.Data["token"])) + } + if strings.Contains(out, SecretRedactedPlaceholder) { + t.Fatal("恢复后的 YAML 不应再含占位符") + } + }) + + t.Run("data 中已有同名新值时以 data 为准", func(t *testing.T) { + in := "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: ops\ndata:\n token: " + + base64.StdEncoding.EncodeToString([]byte(redactFixtureNew)) + "\nstringData:\n token: " + SecretRedactedPlaceholder + "\n" + out, err := RestoreRedactedSecretYAML(in, existing) + if err != nil { + t.Fatal(err) + } + sec := parseSecretYAML(t, out) + if _, ok := sec.StringData["token"]; ok { + t.Fatal("占位符键应从 stringData 中移除,否则 K8s 合并时会覆盖 data") + } + if string(sec.Data["token"]) != redactFixtureNew { + t.Fatal("data 中用户填写的新值不应被原值覆盖") + } + }) + + t.Run("data 与 stringData 均不存在时拒绝", func(t *testing.T) { + in := "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: ops\nstringData:\n missing: " + SecretRedactedPlaceholder + "\n" + if _, err := RestoreRedactedSecretYAML(in, existing); err == nil { + t.Fatal("未知键的占位符应报错") + } + }) +} + +// data 与 stringData 存在同名键时:脱敏 YAML 两处都替换为占位符;原样回写后两处各自恢复原值。 +func TestRedactAndRestoreSameKeyInDataAndStringData(t *testing.T) { + existing := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"}, + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops"}, + Data: map[string][]byte{"shared": []byte(redactFixtureData)}, + StringData: map[string]string{"shared": redactFixtureString}, + } + y, err := SecretToYAML(existing) + if err != nil { + t.Fatal(err) + } + for _, v := range []string{redactFixtureData, redactFixtureString, base64.StdEncoding.EncodeToString([]byte(redactFixtureData))} { + if strings.Contains(y, v) { + t.Fatal("脱敏 YAML 泄露了明文") + } + } + if strings.Count(y, "shared: "+SecretRedactedPlaceholder) != 2 { + t.Fatalf("data 与 stringData 的同名键都应为占位符:\n%s", y) + } + + out, err := RestoreRedactedSecretYAML(y, existing) + if err != nil { + t.Fatal(err) + } + sec := parseSecretYAML(t, out) + if string(sec.Data["shared"]) != redactFixtureData || sec.StringData["shared"] != redactFixtureString { + t.Fatal("同名键原样回写后 data 与 stringData 应各自恢复原值") + } + + if got := RedactSecretData(existing.Data); len(got["shared"]) != 0 { + t.Fatal("data 值应被清空") + } + if got := RedactSecretStringData(existing.StringData); got["shared"] != "" { + t.Fatal("stringData 值应被清空") + } + if k := SecretDataKeyInfos(existing.Data); len(k) != 1 || k[0].Size != len(redactFixtureData) { + t.Fatalf("data_keys 不符: %+v", k) + } + if k := SecretStringDataKeyInfos(existing.StringData); len(k) != 1 || k[0].Size != len(redactFixtureString) { + t.Fatalf("string_data_keys 不符: %+v", k) + } +} diff --git a/internal/k8s/utils/serviceaccount.go b/internal/k8s/utils/serviceaccount.go index b6420234..6f723885 100644 --- a/internal/k8s/utils/serviceaccount.go +++ b/internal/k8s/utils/serviceaccount.go @@ -245,3 +245,53 @@ func CreateServiceAccountToken(ctx context.Context, kubeClient *kubernetes.Clien return resp, nil } + +// IsServiceAccountTokenSecretFor 判断 secret 是否是 sa 自己的 token Secret(SEC-29): +// 类型必须是 kubernetes.io/service-account-token,注解 kubernetes.io/service-account.name 必须等于 SA 名称, +// 若存在注解 kubernetes.io/service-account.uid 则必须等于 SA 的 UID,且命名空间一致、token 非空。 +// sa.secrets 只是引用列表,任何能更新 SA 的人都能把别的 SA 的 token Secret 挂进来,因此不能仅凭引用信任。 +func IsServiceAccountTokenSecretFor(secret *corev1.Secret, sa *corev1.ServiceAccount) bool { + if secret == nil || sa == nil || sa.Name == "" { + return false + } + if secret.Type != corev1.SecretTypeServiceAccountToken { + return false + } + if secret.Namespace != "" && sa.Namespace != "" && secret.Namespace != sa.Namespace { + return false + } + if secret.Annotations[corev1.ServiceAccountNameKey] != sa.Name { + return false + } + if uid, ok := secret.Annotations[corev1.ServiceAccountUIDKey]; ok && uid != string(sa.UID) { + return false + } + return len(secret.Data[corev1.ServiceAccountTokenKey]) > 0 +} + +// SelectServiceAccountTokenSecret 从 SA 引用的 Secrets 中挑出第一个属于该 SA 的 token Secret; +// rejected 返回被拒绝的 service-account-token 类型 Secret 名称(只含名称,便于记录日志)。 +func SelectServiceAccountTokenSecret(sa *corev1.ServiceAccount, secrets []corev1.Secret) (selected *corev1.Secret, rejected []string) { + for i := range secrets { + secret := &secrets[i] + if IsServiceAccountTokenSecretFor(secret, sa) { + if selected == nil { + selected = secret + } + continue + } + if secret.Type == corev1.SecretTypeServiceAccountToken { + rejected = append(rejected, secret.Name) + } + } + return selected, rejected +} + +// ServiceAccountSecretRefNames 返回 SA secrets 引用中的名称列表 +func ServiceAccountSecretRefNames(refs []corev1.ObjectReference) []string { + names := make([]string, 0, len(refs)) + for _, ref := range refs { + names = append(names, ref.Name) + } + return names +} diff --git a/internal/k8s/utils/serviceaccount_token_test.go b/internal/k8s/utils/serviceaccount_token_test.go new file mode 100644 index 00000000..c0e5a257 --- /dev/null +++ b/internal/k8s/utils/serviceaccount_token_test.go @@ -0,0 +1,76 @@ +package utils + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func saTokenSecret(name string, ann map[string]string) corev1.Secret { + return corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "ops", Annotations: ann}, + Type: corev1.SecretTypeServiceAccountToken, + Data: map[string][]byte{corev1.ServiceAccountTokenKey: []byte("fake-utils-token")}, + } +} + +func TestIsServiceAccountTokenSecretFor(t *testing.T) { + sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops", UID: types.UID("uid-app")}} + own := map[string]string{corev1.ServiceAccountNameKey: "app", corev1.ServiceAccountUIDKey: "uid-app"} + for _, tt := range []struct { + name string + mutate func(*corev1.Secret) + want bool + }{ + {"own secret", func(*corev1.Secret) {}, true}, + {"no uid annotation", func(s *corev1.Secret) { delete(s.Annotations, corev1.ServiceAccountUIDKey) }, true}, + {"name mismatch", func(s *corev1.Secret) { s.Annotations[corev1.ServiceAccountNameKey] = "admin" }, false}, + {"uid mismatch", func(s *corev1.Secret) { s.Annotations[corev1.ServiceAccountUIDKey] = "uid-old" }, false}, + {"empty uid annotation", func(s *corev1.Secret) { s.Annotations[corev1.ServiceAccountUIDKey] = "" }, false}, + {"no annotations", func(s *corev1.Secret) { s.Annotations = nil }, false}, + {"opaque type", func(s *corev1.Secret) { s.Type = corev1.SecretTypeOpaque }, false}, + {"other namespace", func(s *corev1.Secret) { s.Namespace = "kube-system" }, false}, + {"empty token", func(s *corev1.Secret) { s.Data = map[string][]byte{corev1.ServiceAccountTokenKey: nil} }, false}, + } { + t.Run(tt.name, func(t *testing.T) { + ann := map[string]string{} + for k, v := range own { + ann[k] = v + } + s := saTokenSecret("s", ann) + tt.mutate(&s) + if got := IsServiceAccountTokenSecretFor(&s, sa); got != tt.want { + t.Fatalf("got %v want %v", got, tt.want) + } + }) + } + if IsServiceAccountTokenSecretFor(nil, sa) || IsServiceAccountTokenSecretFor(&corev1.Secret{}, nil) { + t.Fatal("nil 输入应返回 false") + } +} + +func TestSelectServiceAccountTokenSecret(t *testing.T) { + sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops", UID: types.UID("uid-app")}} + opaque := saTokenSecret("cfg", nil) + opaque.Type = corev1.SecretTypeOpaque + secrets := []corev1.Secret{ + saTokenSecret("admin-token", map[string]string{corev1.ServiceAccountNameKey: "admin"}), + opaque, + saTokenSecret("app-token", map[string]string{corev1.ServiceAccountNameKey: "app", corev1.ServiceAccountUIDKey: "uid-app"}), + } + selected, rejected := SelectServiceAccountTokenSecret(sa, secrets) + if selected == nil || selected.Name != "app-token" { + t.Fatalf("应选中 SA 自己的 token Secret: %+v", selected) + } + if len(rejected) != 1 || rejected[0] != "admin-token" { + t.Fatalf("rejected 应只含借挂的 token Secret: %v", rejected) + } + if s, _ := SelectServiceAccountTokenSecret(sa, secrets[:2]); s != nil { + t.Fatal("没有属于自己的 token Secret 时应返回 nil") + } + if names := ServiceAccountSecretRefNames([]corev1.ObjectReference{{Name: "a"}, {Name: ""}, {Name: "b"}}); len(names) < 2 { + t.Fatalf("names=%v", names) + } +} diff --git a/internal/k8s/utils/serviceaccount_update.go b/internal/k8s/utils/serviceaccount_update.go new file mode 100644 index 00000000..a9a8c8d2 --- /dev/null +++ b/internal/k8s/utils/serviceaccount_update.go @@ -0,0 +1,103 @@ +/* + * MIT License + * + * Copyright (c) 2024 Bamboo + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +package utils + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/util/retry" +) + +// ServiceAccountMutateFunc 把请求中允许修改的字段应用到从集群读取的最新 SA 上。 +// 每次冲突重试都会以新读取的对象重新调用,实现必须是幂等的。 +type ServiceAccountMutateFunc func(latest *corev1.ServiceAccount) error + +// UpdateServiceAccountWithRetry 以 retry.RetryOnConflict(retry.DefaultRetry) 更新 SA: +// 每次尝试先 Get 最新版本(携带最新 resourceVersion),调用 mutate 应用允许修改的字段,再 Update。 +// 无论 mutate 做了什么,name 与 secrets 引用都以集群中最新版本为准(SEC-29:secrets 由集群维护)。 +// Get 失败(如 NotFound)直接返回且不调用 Update;连续冲突超过重试次数时返回最后一次的 Conflict 错误。 +func UpdateServiceAccountWithRetry(ctx context.Context, saClient corev1client.ServiceAccountInterface, name string, mutate ServiceAccountMutateFunc) (*corev1.ServiceAccount, error) { + var updated *corev1.ServiceAccount + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest, err := saClient.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + keptName, keptNamespace := latest.Name, latest.Namespace + keptSecrets := append([]corev1.ObjectReference(nil), latest.Secrets...) + if mutate != nil { + if err := mutate(latest); err != nil { + return err + } + } + latest.Name, latest.Namespace = keptName, keptNamespace + latest.Secrets = keptSecrets + updated, err = saClient.Update(ctx, latest, metav1.UpdateOptions{}) + return err + }) + if err != nil { + return nil, err + } + return updated, nil +} + +// UpdateServiceAccountWithResourceVersion 严格乐观锁更新:调用方提交了 resourceVersion(如 YAML 中的 metadata.resourceVersion)时使用。 +// 先 Get 当前对象作为基础(保留不可编辑字段与集群维护的 secrets 引用),调用 mutate 应用允许修改的字段, +// 再以调用方提交的 resourceVersion 只做一次 Update:版本过期时由 API Server 返回 409 Conflict,原样返回,不重试。 +// Get 失败(如 NotFound)直接返回且不调用 Update。 +func UpdateServiceAccountWithResourceVersion(ctx context.Context, saClient corev1client.ServiceAccountInterface, name, resourceVersion string, mutate ServiceAccountMutateFunc) (*corev1.ServiceAccount, error) { + latest, err := saClient.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + keptName, keptNamespace := latest.Name, latest.Namespace + keptSecrets := append([]corev1.ObjectReference(nil), latest.Secrets...) + if mutate != nil { + if err := mutate(latest); err != nil { + return nil, err + } + } + latest.Name, latest.Namespace = keptName, keptNamespace + latest.Secrets = keptSecrets + latest.ResourceVersion = resourceVersion + return saClient.Update(ctx, latest, metav1.UpdateOptions{}) +} + +// ApplyServiceAccountEditableFields 把 desired 中允许通过更新接口修改的字段整体覆盖到 latest: +// labels、annotations、automountServiceAccountToken、imagePullSecrets。 +// 其余字段(name/namespace、secrets、resourceVersion、uid、ownerReferences、finalizers 等)保持 latest 的值。 +func ApplyServiceAccountEditableFields(latest, desired *corev1.ServiceAccount) { + if latest == nil || desired == nil { + return + } + latest.Labels = desired.Labels + latest.Annotations = desired.Annotations + latest.AutomountServiceAccountToken = desired.AutomountServiceAccountToken + latest.ImagePullSecrets = desired.ImagePullSecrets +} diff --git a/internal/k8s/utils/serviceaccount_update_test.go b/internal/k8s/utils/serviceaccount_update_test.go new file mode 100644 index 00000000..5bdb387f --- /dev/null +++ b/internal/k8s/utils/serviceaccount_update_test.go @@ -0,0 +1,141 @@ +package utils + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + "k8s.io/client-go/util/retry" +) + +var saGVR = corev1.SchemeGroupVersion.WithResource("serviceaccounts") + +func newRetrySA() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops", ResourceVersion: "1", Labels: map[string]string{"v": "1"}}, + Secrets: []corev1.ObjectReference{{Name: "app-token"}}, + } +} + +func saUpdateActions(c *fake.Clientset) []k8stesting.UpdateAction { + var out []k8stesting.UpdateAction + for _, a := range c.Actions() { + if a.GetVerb() == "update" && a.GetResource().Resource == "serviceaccounts" { + out = append(out, a.(k8stesting.UpdateAction)) + } + } + return out +} + +func conflictErr() error { + return apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", nil) +} + +// 第一次 Update 前集群里的 SA 被并发修改(resourceVersion 变为 2、多了一个 secrets 引用),Update 返回 Conflict; +// 重试时应重新 Get 最新版本,在其上应用字段后再 Update,且保留最新的 secrets 引用。 +func TestUpdateServiceAccountWithRetryConflictThenSuccess(t *testing.T) { + client := fake.NewSimpleClientset(newRetrySA()) + calls := 0 + client.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + calls++ + if calls > 1 { + return false, nil, nil // 交给默认 tracker 处理 + } + concurrent := newRetrySA() + concurrent.ResourceVersion = "2" + concurrent.Labels = map[string]string{"v": "2"} + concurrent.Secrets = append(concurrent.Secrets, corev1.ObjectReference{Name: "app-token-rotated"}) + if err := client.Tracker().Update(saGVR, concurrent, "ops"); err != nil { + t.Errorf("模拟并发修改失败: %v", err) + } + return true, nil, conflictErr() + }) + + mutations := 0 + updated, err := UpdateServiceAccountWithRetry(context.Background(), client.CoreV1().ServiceAccounts("ops"), "app", func(latest *corev1.ServiceAccount) error { + mutations++ + latest.Labels = map[string]string{"team": "ops"} + latest.Secrets = []corev1.ObjectReference{{Name: "admin-token"}} // 企图篡改 secrets,必须被还原 + latest.Name = "admin" // 企图改名,必须被还原 + return nil + }) + if err != nil { + t.Fatal(err) + } + actions := saUpdateActions(client) + if calls != 2 || len(actions) != 2 || mutations != 2 { + t.Fatalf("应冲突一次后重试成功: updateCalls=%d actions=%d mutations=%d", calls, len(actions), mutations) + } + last := actions[1].GetObject().(*corev1.ServiceAccount) + if last.ResourceVersion != "2" { + t.Fatalf("重试应基于最新版本(resourceVersion=2),got %q", last.ResourceVersion) + } + if last.Name != "app" || last.Labels["team"] != "ops" { + t.Fatalf("name 应保持 URL 值、字段应被应用: %+v", last.ObjectMeta) + } + if len(last.Secrets) != 2 || last.Secrets[0].Name != "app-token" || last.Secrets[1].Name != "app-token-rotated" { + t.Fatalf("应保留集群最新的 secrets 引用: %+v", last.Secrets) + } + if updated == nil || len(updated.Secrets) != 2 { + t.Fatalf("返回值应为更新后的对象: %+v", updated) + } +} + +func TestUpdateServiceAccountWithRetryExhausted(t *testing.T) { + client := fake.NewSimpleClientset(newRetrySA()) + client.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, conflictErr() + }) + _, err := UpdateServiceAccountWithRetry(context.Background(), client.CoreV1().ServiceAccounts("ops"), "app", func(*corev1.ServiceAccount) error { return nil }) + if !apierrors.IsConflict(err) { + t.Fatalf("连续冲突超过重试次数应返回 Conflict 错误, got %v", err) + } + if n := len(saUpdateActions(client)); n != retry.DefaultRetry.Steps { + t.Fatalf("应尝试 %d 次 Update, got %d", retry.DefaultRetry.Steps, n) + } +} + +func TestUpdateServiceAccountWithRetryNotFound(t *testing.T) { + client := fake.NewSimpleClientset() + called := false + _, err := UpdateServiceAccountWithRetry(context.Background(), client.CoreV1().ServiceAccounts("ops"), "app", func(*corev1.ServiceAccount) error { + called = true + return nil + }) + if !apierrors.IsNotFound(err) || called { + t.Fatalf("SA 不存在应返回 NotFound 且不调用 mutate: %v called=%v", err, called) + } + if n := len(saUpdateActions(client)); n != 0 { + t.Fatalf("SA 不存在时不应调用 Update, got %d", n) + } +} + +// 严格乐观锁:以调用方提交的 resourceVersion 只 Update 一次;Conflict 原样返回、不重试;保留集群 secrets 与 name/namespace +func TestUpdateServiceAccountWithResourceVersionSingleAttempt(t *testing.T) { + client := fake.NewSimpleClientset(newRetrySA()) + client.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, conflictErr() + }) + _, err := UpdateServiceAccountWithResourceVersion(context.Background(), client.CoreV1().ServiceAccounts("ops"), "app", "7", func(sa *corev1.ServiceAccount) error { + sa.Name, sa.Namespace = "evil", "kube-system" + sa.Secrets = nil + return nil + }) + if !apierrors.IsConflict(err) { + t.Fatalf("应原样返回 Conflict: %v", err) + } + actions := saUpdateActions(client) + if len(actions) != 1 { + t.Fatalf("只应 Update 一次, got %d", len(actions)) + } + sent := actions[0].GetObject().(*corev1.ServiceAccount) + if sent.ResourceVersion != "7" || sent.Name != "app" || sent.Namespace != "ops" || len(sent.Secrets) != len(newRetrySA().Secrets) { + t.Fatalf("提交对象应携带调用方 resourceVersion 并保留不可编辑字段: %+v", sent) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 1b8ab33a..d57a18f1 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -28,6 +28,7 @@ package middleware import ( "strings" + "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "github.com/GoSimplicity/AI-CloudOps/pkg/base" "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" @@ -45,6 +46,13 @@ var skipAuthPaths = map[string]bool{ "/favicon.ico": true, } +// explicitGrantRoutes 敏感路由只接受按路由显式授予的权限:授权路径必须与路由模板(gin FullPath)逐字相等且方法一致。 +// 通配符(/api/k8s/*、/*)、参数化路径(如 /api/k8s/secret/:a/:b/:c/:action)以及具体请求路径都不覆盖。 +var explicitGrantRoutes = map[string]bool{ + model.K8sSecretRevealRoute: true, + model.K8sServiceAccountTokenRoute: true, // GET 读取 token Secret、POST TokenRequest 签发,均按方法精确授权 +} + // HTTP方法映射 var methodMapping = map[string]int8{ "GET": 1, @@ -188,6 +196,14 @@ func (am *AuthMiddleware) CheckAuth() gin.HandlerFunc { // 检查API权限 for _, api := range role.Apis { + if explicitGrantRoutes[c.FullPath()] { + // 敏感路由只认精确匹配,不走 matchWildcardPath 的通配/参数段匹配 + if api.Path == c.FullPath() && api.Method == methodCode { + c.Next() + return + } + continue + } if (api.Path == c.FullPath() && api.Method == methodCode) || matchWildcardPath(api.Path, path, methodCode, api.Method) { c.Next() return diff --git a/internal/middleware/auth_sa_token_test.go b/internal/middleware/auth_sa_token_test.go new file mode 100644 index 00000000..4d4edf06 --- /dev/null +++ b/internal/middleware/auth_sa_token_test.go @@ -0,0 +1,72 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" +) + +// SEC-29:GET(token Secret)与 POST(TokenRequest)/token 只接受按路由模板 + 方法的显式授权 +func TestServiceAccountTokenRequiresExplicitGrant(t *testing.T) { + const ( + detailRoute = "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/detail" + tokenPath = "/api/k8s/serviceaccount/1/ops/app/token" + detailPath = "/api/k8s/serviceaccount/1/ops/app/detail" + ) + for _, tt := range []struct { + name string + grants []*model.Api + method string + path string + want int + }{ + {"explicit GET grant", []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 1}}, "GET", tokenPath, 204}, + {"explicit POST grant", []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 2}}, "POST", tokenPath, 204}, + {"GET grant does not cover POST TokenRequest", []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 1}}, "POST", tokenPath, 403}, + {"POST grant does not cover GET", []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 2}}, "GET", tokenPath, 403}, + {"module wildcard does not cover GET token", []*model.Api{{Path: "/api/k8s/*", Method: 1}}, "GET", tokenPath, 403}, + {"module wildcard does not cover POST token", []*model.Api{{Path: "/api/k8s/*", Method: 2}}, "POST", tokenPath, 403}, + {"global wildcard does not cover GET token", []*model.Api{{Path: "/*", Method: 1}}, "GET", tokenPath, 403}, + {"global wildcard does not cover POST token", []*model.Api{{Path: "/*", Method: 2}}, "POST", tokenPath, 403}, + {"parameterized action path does not cover token", []*model.Api{{Path: "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/:action", Method: 1}}, "GET", tokenPath, 403}, + {"renamed params do not cover token", []*model.Api{{Path: "/api/k8s/serviceaccount/:a/:b/:c/token", Method: 2}}, "POST", tokenPath, 403}, + {"fully parameterized path does not cover token", []*model.Api{{Path: "/:a/:b/:c/:d/:e/:f/:g", Method: 1}}, "GET", tokenPath, 403}, + {"concrete request path does not cover token", []*model.Api{{Path: tokenPath, Method: 1}}, "GET", tokenPath, 403}, + {"middle wildcard does not cover token", []*model.Api{{Path: "/api/k8s/*/token", Method: 1}}, "GET", tokenPath, 403}, + {"detail grant does not cover token", []*model.Api{{Path: detailRoute, Method: 1}}, "GET", tokenPath, 403}, + {"exact grant among wildcard grants", []*model.Api{{Path: "/api/k8s/*", Method: 1}, {Path: model.K8sServiceAccountTokenRoute, Method: 1}}, "GET", tokenPath, 204}, + {"module wildcard still covers detail", []*model.Api{{Path: "/api/k8s/*", Method: 1}}, "GET", detailPath, 204}, + } { + t.Run(tt.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 7, Username: "operator"}); c.Next() }) + engine.Use(NewAuthMiddleware(aiProxyRoles{apis: tt.grants}).CheckAuth()) + ok := func(c *gin.Context) { c.Status(204) } + engine.GET(model.K8sServiceAccountTokenRoute, ok) + engine.POST(model.K8sServiceAccountTokenRoute, ok) + engine.GET(detailRoute, ok) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + if rec.Code != tt.want { + t.Fatalf("status=%d want=%d", rec.Code, tt.want) + } + }) + } +} + +func TestServiceAccountTokenAdminBypass(t *testing.T) { + for _, method := range []string{"GET", "POST"} { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 1, Username: "admin"}); c.Next() }) + engine.Use(NewAuthMiddleware(aiProxyRoles{}).CheckAuth()) + engine.Handle(method, model.K8sServiceAccountTokenRoute, func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(method, "/api/k8s/serviceaccount/1/ops/app/token", nil)) + if rec.Code != 204 { + t.Fatalf("%s admin 应直接放行, status=%d", method, rec.Code) + } + } +} diff --git a/internal/middleware/auth_secret_reveal_test.go b/internal/middleware/auth_secret_reveal_test.go new file mode 100644 index 00000000..1de3f29a --- /dev/null +++ b/internal/middleware/auth_secret_reveal_test.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" +) + +func TestSecretRevealRequiresExplicitGrant(t *testing.T) { + const detailRoute = "/api/k8s/secret/:cluster_id/:namespace/:name/detail" + for _, tt := range []struct { + name string + grants []*model.Api + path string + want int + }{ + {"explicit reveal grant", []*model.Api{{Path: model.K8sSecretRevealRoute, Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 204}, + {"module wildcard does not cover reveal", []*model.Api{{Path: "/api/k8s/*", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"global wildcard does not cover reveal", []*model.Api{{Path: "/*", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"detail grant does not cover reveal", []*model.Api{{Path: detailRoute, Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"module wildcard still covers detail", []*model.Api{{Path: "/api/k8s/*", Method: 1}}, "/api/k8s/secret/1/ops/app/detail", 204}, + // 参数化路径(不含 *)在 matchWildcardPath 中按段匹配,但敏感路由只认与路由模板逐字相等的授权 + {"parameterized action path does not cover reveal", []*model.Api{{Path: "/api/k8s/secret/:cluster_id/:namespace/:name/:action", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"renamed params do not cover reveal", []*model.Api{{Path: "/api/k8s/secret/:a/:b/:c/reveal", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"fully parameterized path does not cover reveal", []*model.Api{{Path: "/:a/:b/:c/:d/:e/:f/:g", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"concrete request path does not cover reveal", []*model.Api{{Path: "/api/k8s/secret/1/ops/app/reveal", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"middle wildcard does not cover reveal", []*model.Api{{Path: "/api/k8s/*/reveal", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"reveal route with wrong method does not cover reveal", []*model.Api{{Path: model.K8sSecretRevealRoute, Method: 2}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"exact grant among wildcard grants", []*model.Api{{Path: "/*", Method: 1}, {Path: model.K8sSecretRevealRoute, Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 204}, + {"parameterized grant still covers detail", []*model.Api{{Path: "/api/k8s/secret/:a/:b/:c/detail", Method: 1}}, "/api/k8s/secret/1/ops/app/detail", 204}, + } { + t.Run(tt.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 7, Username: "operator"}); c.Next() }) + engine.Use(NewAuthMiddleware(aiProxyRoles{apis: tt.grants}).CheckAuth()) + engine.GET(model.K8sSecretRevealRoute, func(c *gin.Context) { c.Status(204) }) + engine.GET(detailRoute, func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", tt.path, nil)) + if rec.Code != tt.want { + t.Fatalf("status=%d want=%d", rec.Code, tt.want) + } + }) + } +} + +func TestSecretRevealAdminBypass(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 1, Username: "admin"}); c.Next() }) + engine.Use(NewAuthMiddleware(aiProxyRoles{}).CheckAuth()) + engine.GET(model.K8sSecretRevealRoute, func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/k8s/secret/1/ops/app/reveal", nil)) + if rec.Code != 204 { + t.Fatalf("admin 应直接放行, status=%d", rec.Code) + } +} diff --git a/internal/model/k8s_secret.go b/internal/model/k8s_secret.go index 7c281837..472d2e87 100644 --- a/internal/model/k8s_secret.go +++ b/internal/model/k8s_secret.go @@ -47,21 +47,85 @@ const ( // K8sSecret Kubernetes Secret type K8sSecret struct { - Name string `json:"name" binding:"required,min=1,max=200"` // Secret名称 - Namespace string `json:"namespace" binding:"required,min=1,max=200"` // 所属命名空间 - ClusterID int `json:"cluster_id" gorm:"index;not null"` // 所属集群ID - UID string `json:"uid" gorm:"size:100"` // Secret UID - Type K8sSecretType `json:"type"` // Secret类型 - Data BinaryDataMap `json:"data"` // 加密数据 - StringData map[string]string `json:"string_data"` // 明文数据 - Labels map[string]string `json:"labels"` // 标签 - Annotations map[string]string `json:"annotations"` // 注解 - Immutable bool `json:"immutable"` // 是否不可变 - DataCount int `json:"data_count"` // 数据条目数量 - Size string `json:"size"` // 数据大小 - Age string `json:"age"` // 存在时间 - CreatedAt time.Time `json:"created_at"` // 创建时间 - RawSecret *corev1.Secret `json:"-"` // 原始 Secret 对象,不序列化到 JSON + Name string `json:"name" binding:"required,min=1,max=200"` // Secret名称 + Namespace string `json:"namespace" binding:"required,min=1,max=200"` // 所属命名空间 + ClusterID int `json:"cluster_id" gorm:"index;not null"` // 所属集群ID + UID string `json:"uid" gorm:"size:100"` // Secret UID + Type K8sSecretType `json:"type"` // Secret类型 + Data BinaryDataMap `json:"data"` // 默认脱敏:仅保留键,值恒为空字符串;明文需调用 reveal 接口 + StringData map[string]string `json:"string_data"` // 默认脱敏:仅保留键,值恒为空字符串 + // DataKeys / StringDataKeys 脱敏后的键与原始值字节数(按 key 升序) + DataKeys []SecretKeyInfo `json:"data_keys"` + StringDataKeys []SecretKeyInfo `json:"string_data_keys"` + Redacted bool `json:"redacted"` // 为 true 表示 data/string_data 的值已被移除 + Labels map[string]string `json:"labels"` // 标签 + Annotations map[string]string `json:"annotations"` // 注解 + Immutable bool `json:"immutable"` // 是否不可变 + DataCount int `json:"data_count"` // 数据条目数量 + Size string `json:"size"` // 数据大小 + Age string `json:"age"` // 存在时间 + CreatedAt time.Time `json:"created_at"` // 创建时间 + RawSecret *corev1.Secret `json:"-"` // 原始 Secret 对象,不序列化到 JSON +} + +// SecretKeyInfo Secret 中单个键的脱敏描述,只包含键名与值的字节数,不包含值 +type SecretKeyInfo struct { + Key string `json:"key"` + Size int `json:"size"` // 值的字节数(data 为解码后的原始字节数) +} + +// RevealSecretReq 获取Secret明文请求(每次调用都会写审计) +type RevealSecretReq struct { + ClusterID int `json:"cluster_id" form:"cluster_id" binding:"required" comment:"集群ID"` // 集群ID + Namespace string `json:"namespace" form:"namespace" binding:"required" comment:"命名空间"` // 命名空间 + Name string `json:"name" form:"name" binding:"required" comment:"Secret名称"` // Secret名称 + Keys []string `json:"keys" form:"keys" comment:"仅返回指定的键,为空时返回全部"` // 仅返回指定的键 +} + +// K8sSecretReveal Secret 明文响应 +type K8sSecretReveal struct { + ClusterID int `json:"cluster_id"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Type K8sSecretType `json:"type"` + Data BinaryDataMap `json:"data"` // base64 编码的明文,与改造前 detail 的 data 编码一致 + StringData map[string]string `json:"string_data"` // 明文(通常为空,K8s 会合并进 data) + Keys []string `json:"keys"` // 本次返回的键(同时写入审计) +} + +// SecretRevealActor reveal 操作者信息(由 handler 从登录态中提取) +type SecretRevealActor struct { + UserID int + Username string + IP string + UserAgent string + TraceID string + Endpoint string +} + +// K8sSecretRevealRoute Secret 明文接口的完整路由(权限码),仅允许按该路由显式授权,通配符授权不生效 +const K8sSecretRevealRoute = "/api/k8s/secret/:cluster_id/:namespace/:name/reveal" + +// K8sSecretRevealPermissionName Secret 明文接口在接口目录中的权限名 +const K8sSecretRevealPermissionName = "k8s:secret:reveal" + +const ( + SecretRevealAuditAction = "k8s_secret_reveal" + SecretRevealAuditOperation = "REVEAL" + SecretRevealAuditTargetType = "k8s_secret" +) + +// SecretRevealAuditRecord reveal 审计内容,写入 AuditLog.RequestBody。只含元数据与键名,绝不含值。 +type SecretRevealAuditRecord struct { + Action string `json:"action"` + UserID int `json:"user_id"` + Username string `json:"username"` + ClusterID int `json:"cluster_id"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Keys []string `json:"keys"` + Success bool `json:"success"` + RevealedAt string `json:"revealed_at"` // RFC3339 UTC } // GetSecretListReq 获取Secret列表请求 diff --git a/internal/model/k8s_serviceaccount.go b/internal/model/k8s_serviceaccount.go index 2ba949ab..5e0fa967 100644 --- a/internal/model/k8s_serviceaccount.go +++ b/internal/model/k8s_serviceaccount.go @@ -107,7 +107,7 @@ type CreateServiceAccountTokenReq struct { ClusterID int `json:"cluster_id" binding:"required" comment:"集群ID"` Namespace string `json:"namespace" binding:"required" comment:"命名空间"` ServiceAccountName string `json:"service_account_name" binding:"required" comment:"ServiceAccount名称"` - ExpirationSeconds *int64 `json:"expiration_seconds" comment:"令牌过期时间(秒)"` + ExpirationSeconds *int64 `json:"expiration_seconds" comment:"令牌过期时间(秒),不传或0为3600,允许600~3600"` } // K8sServiceAccount ServiceAccount主model @@ -134,3 +134,47 @@ type ServiceAccountTokenInfo struct { CreatedAt string `json:"created_at"` ExpirationTime string `json:"expiration_time"` } + +// POST /token(TokenRequest)的有效期约束(秒)。不传或传 0 使用默认值; +// 下限为 K8s TokenRequest 允许的最小值 600,上限 3600,越界返回参数错误且不调用 K8s。 +const ( + ServiceAccountTokenDefaultExpirationSeconds int64 = 3600 + ServiceAccountTokenMinExpirationSeconds int64 = 600 + ServiceAccountTokenMaxExpirationSeconds int64 = 3600 +) + +// K8sServiceAccountTokenRoute ServiceAccount token 接口的完整路由(权限码对应的 path)。 +// GET 读取 SA 名下的 token Secret,POST 通过 TokenRequest 签发新 token;两者都只允许按该路由 + 方法显式授权, +// /api/k8s/*、/* 及带 :param 的通配授权都不生效。 +const K8sServiceAccountTokenRoute = "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token" + +const ( + // K8sServiceAccountTokenPermissionName GET token 在接口目录(cl_system_apis.name)中的权限名 + K8sServiceAccountTokenPermissionName = "k8s:serviceaccount:token" + // K8sServiceAccountTokenCreatePermissionName POST token(TokenRequest)在接口目录中的权限名; + // cl_system_apis.name 有唯一索引,GET/POST 不能共用同一个名字 + K8sServiceAccountTokenCreatePermissionName = "k8s:serviceaccount:token:create" +) + +const ( + ServiceAccountTokenRevealAuditAction = "k8s_serviceaccount_token_reveal" + ServiceAccountTokenCreateAuditAction = "k8s_serviceaccount_token_create" + ServiceAccountTokenRevealAuditOperation = "TOKEN_REVEAL" + ServiceAccountTokenCreateAuditOperation = "TOKEN_CREATE" + ServiceAccountTokenAuditTargetType = "k8s_serviceaccount_token" +) + +// ServiceAccountTokenAuditRecord token 接口审计内容,写入 AuditLog.RequestBody。只含元数据,绝不含 token。 +type ServiceAccountTokenAuditRecord struct { + Action string `json:"action"` + UserID int `json:"user_id"` + Username string `json:"username"` + ClusterID int `json:"cluster_id"` + Namespace string `json:"namespace"` + ServiceAccount string `json:"service_account"` + ServiceAccountUID string `json:"service_account_uid,omitempty"` + SecretName string `json:"secret_name,omitempty"` // GET:返回的 token 所在 Secret 名称 + ExpirationSeconds *int64 `json:"expiration_seconds,omitempty"` // POST:生效的有效期(越界被拒时为请求值) + Success bool `json:"success"` + IssuedAt string `json:"issued_at"` // RFC3339 UTC +} diff --git a/internal/system/service/audit_service.go b/internal/system/service/audit_service.go index 319a7bc3..d923f091 100644 --- a/internal/system/service/audit_service.go +++ b/internal/system/service/audit_service.go @@ -207,6 +207,7 @@ func (s *auditService) GetAuditTypes(ctx context.Context) ([]model.AuditTypeInfo {Type: "UPDATE", Description: "更新操作", Category: "数据操作"}, {Type: "DELETE", Description: "删除操作", Category: "数据操作"}, {Type: "VIEW", Description: "查看操作", Category: "数据访问"}, + {Type: "REVEAL", Description: "查看敏感明文(如 Secret reveal)", Category: "数据访问"}, {Type: "LOGIN", Description: "登录操作", Category: "身份认证"}, {Type: "LOGOUT", Description: "登出操作", Category: "身份认证"}, {Type: "EXPORT", Description: "导出操作", Category: "数据导出"}, diff --git a/pkg/base/apiresponse.go b/pkg/base/apiresponse.go index f74586f8..25833e42 100644 --- a/pkg/base/apiresponse.go +++ b/pkg/base/apiresponse.go @@ -98,6 +98,20 @@ func BadRequest(c *gin.Context, code int, data interface{}, message string) { }) } +// Conflict 资源版本冲突的返回,使用HTTP 409状态码 +func Conflict(c *gin.Context, code int, data interface{}, message string) { + c.JSON(http.StatusConflict, ApiResponse{ + Code: code, + Data: data, + Message: message, + }) +} + +// ConflictError 资源版本冲突(乐观锁失败)的返回:HTTP 409 + code=1 +func ConflictError(c *gin.Context, message string) { + Conflict(c, StatusError, map[string]interface{}{}, message) +} + // Forbidden 无权限的返回,使用HTTP 403状态码 func Forbidden(c *gin.Context, data interface{}, message string) { c.JSON(http.StatusForbidden, ApiResponse{ diff --git a/pkg/di/wire_gen.go b/pkg/di/wire_gen.go index 3e14b45d..e3c91c37 100644 --- a/pkg/di/wire_gen.go +++ b/pkg/di/wire_gen.go @@ -125,7 +125,7 @@ func ProvideApp(cfg *config.Config) (*app.App, error) { statefulSetService := service3.NewStatefulSetService(statefulSetManager, logger) k8sStatefulSetHandler := api3.NewK8sStatefulSetHandler(statefulSetService) serviceAccountManager := manager.NewServiceAccountManager(logger, k8sClient) - serviceAccountService := service3.NewServiceAccountService(serviceAccountManager, logger) + serviceAccountService := service3.NewServiceAccountService(serviceAccountManager, auditService, logger) k8sServiceAccountHandler := api3.NewK8sServiceAccountHandler(serviceAccountService) roleManager := manager.NewRoleManager(k8sClient, logger) serviceRoleService := service3.NewRoleService(roleManager, logger) @@ -143,7 +143,7 @@ func ProvideApp(cfg *config.Config) (*app.App, error) { configMapService := service3.NewConfigMapService(k8sClient, configMapManager, logger) k8sConfigMapHandler := api3.NewK8sConfigMapHandler(configMapService) secretManager := manager.NewSecretManager(k8sClient, logger) - secretService := service3.NewSecretService(secretManager, logger) + secretService := service3.NewSecretService(secretManager, auditService, logger) k8sSecretHandler := api3.NewK8sSecretHandler(secretService) alertManagerEventDAO := alert.NewAlertManagerEventDAO(db, logger, userDAO) scrapePoolDAO := scrape.NewScrapePoolDAO(db, logger, userDAO)