feat(ql3): validate cluster credential key rotation

This commit is contained in:
whyour
2026-08-26 03:31:13 +08:00
parent b45a5e04b7
commit ac0992081e
23 changed files with 1119 additions and 167 deletions
+12 -9
View File
@@ -958,22 +958,25 @@ stringData:
-----BEGIN CERTIFICATE-----
REPLACE_WITH_OPERATOR_CA_BUNDLE
-----END CERTIFICATE-----
api-credential-pepper: REPLACE_WITH_CANONICAL_32_BYTE_BASE64URL
api-credential-pepper-keyring.json: |
{"schemaVersion":1,"activePepperKeyId":"legacy-v1","keys":[{"pepperKeyId":"legacy-v1","pepper":"REPLACE_WITH_CANONICAL_32_BYTE_BASE64URL"}]}
```
The URL must not contain `ssl*` query parameters. TLS is configured separately
and is fixed to `verify-full` in the committed deployment. The servername is
mandatory, must be an explicit DNS name rather than an IP literal, and must
match the endpoint certificate SAN. Only
`postgres-ca.crt` is projected from this Secret into the runtime trust mount;
the URL, servername and pepper remain environment-only values. The CA loader
requires an absolute path to a regular file that is not group/world writable,
1256 KiB, and contains 116 unique PEM X.509 CA certificates with no trailing
data.
`postgres-ca.crt` and `api-credential-pepper-keyring.json` are projected from
this Secret into the runtime private mount; only the URL and servername remain
environment values. The keyring is a canonical, bounded 12 generation file;
the singleton above is the bootstrap form. The CA loader requires an absolute
path to a regular file that is not group/world writable, 1256 KiB, and
contains 116 unique PEM X.509 CA certificates with no trailing data.
The trust bundle is loaded once for each new application activation. Rotate the
Secret and perform a controlled Deployment rollout; an active Pool never
silently changes trust roots in place. Use the
The trust bundle and API credential keyring are loaded once for each new
application activation. Rotate the Secret and perform a controlled Deployment
rollout; an active process never silently changes either authority in place.
Use the
[old → overlap → new runbook](operations/postgres-ca-rotation.md) and its
`audit:postgres-ca-overlap:ql3` preflight rather than replacing a trust root in
one step.
@@ -113,11 +113,8 @@ spec:
secretKeyRef:
name: ql3-cluster-control-runtime
key: postgres-tls-servername
- name: QL3_API_CREDENTIAL_PEPPER
valueFrom:
secretKeyRef:
name: ql3-cluster-control-runtime
key: api-credential-pepper
- name: QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE
value: /var/run/secrets/qinglong3/postgres-runtime/api-credential-pepper-keyring.json
- name: QL3_POSTGRES_WORKER_INGRESS_URL
valueFrom:
secretKeyRef:
@@ -250,6 +247,8 @@ spec:
items:
- key: postgres-ca.crt
path: ca.crt
- key: api-credential-pepper-keyring.json
path: api-credential-pepper-keyring.json
- name: postgres-worker-ingress-ca
secret:
secretName: ql3-cluster-worker-ingress
@@ -72,7 +72,7 @@ spec:
- --command=/var/run/qinglong3/security-administration-private/input/command.json
- --assertion=/var/run/qinglong3/security-administration-private/input/assertion.jwt
- --keyset=/var/run/qinglong3/security-administration-private/input/keyset.json
- --pepper=/var/run/qinglong3/security-administration-private/input/pepper
- --pepper-keyring=/var/run/qinglong3/security-administration-private/input/pepper-keyring.json
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -120,8 +120,8 @@ spec:
path: assertion.jwt
- key: keyset.json
path: keyset.json
- key: pepper
path: pepper
- key: pepper-keyring.json
path: pepper-keyring.json
- name: private-input
emptyDir:
medium: Memory
@@ -11,4 +11,5 @@ stringData:
{"schemaVersion":1,"operation":"audit.list","request":{"limit":25,"filter":{"outcome":"allowed"}}}
assertion.jwt: REPLACE_WITH_SHORT_LIVED_MULTI_FACTOR_ASSERTION
keyset.json: REPLACE_WITH_PINNED_SECURITY_ADMINISTRATION_KEYSET
pepper: REPLACE_WITH_CANONICAL_32_BYTE_BASE64URL_API_CREDENTIAL_PEPPER
pepper-keyring.json: |
{"schemaVersion":1,"activePepperKeyId":"REPLACE_WITH_ACTIVE_KEY_ID","keys":[{"pepperKeyId":"REPLACE_WITH_ACTIVE_KEY_ID","pepper":"REPLACE_WITH_CANONICAL_32_BYTE_BASE64URL_API_CREDENTIAL_PEPPER"}]}
@@ -175,7 +175,8 @@ metadata:
namespace: qinglong3-system
type: Opaque
stringData:
api-credential-pepper: REPLACE_WITH_CANONICAL_32_BYTE_BASE64URL
api-credential-pepper-keyring.json: |
{"schemaVersion":1,"activePepperKeyId":"REPLACE_WITH_ACTIVE_KEY_ID","keys":[{"pepperKeyId":"REPLACE_WITH_ACTIVE_KEY_ID","pepper":"REPLACE_WITH_CANONICAL_32_BYTE_BASE64URL"}]}
---
apiVersion: v1
kind: Secret
+3 -3
View File
@@ -11,9 +11,9 @@
最新增量证据(2026-08-26):
- Alpha 阶段产物门(已实现、首次远端产物待本提交 CI):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。Local amd64/arm64 Docker archive 必须先通过 OS vulnerability policy、SBOM/实际库存复核、128 MiB entrypoint、Edge/Standalone fresh SQLite lifecycle 和真实 Local API cancellationCluster control/control-ai/admin/worker 的双架构 archive 必须通过对应 vulnerability、non-root、SBOM inventory 门,Admin 额外通过产品 facade smoke。每个 30 天 artifact 绑定完整 source SHA、3.0 版本、架构、image ID、archive SHA-256、CycloneDX SBOM、已通过 gate 和试运行/回退说明。Local 可称为用户隔离试运行候选;Cluster 是 integration candidate,因为离线 tag 尚未满足 public GHCR digest、catalog、deployment-lock、生产 HA/DR/CSI/IdP 门。只有实际远端 run 成功并记录 artifact 名/digest 后,才把本阶段标记为“已有可下载产物”;当前仍不能宣称公开 3.0 release。
- Alpha 阶段产物门(已有本地可加载候选,远端归档待显式授权):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。source `b45a5e04b7f49ffdadd5117b6b5253c6f1c05430` 已产出 Local arm64 Docker archiveSHA-256 `58bbc250833c9e86321718aea70ac0a637699b84c18531fa7a82b35e90b7fa83`)、CycloneDX SBOM、manifest、verification evidence 与 checksum;重新 `docker load` 后的 image ID/architecture/user、read-only/no-network smoke、128 MiB Edge/Standalone lifecycle、SQLite integrity 与 HIGH/CRITICAL=0 已复验。对应远端 CI 40/40 与原生 arm64 Local image job 已通过,但因尚未授权向 GitHub 上传源码派生镜像/SBOM,当前是 owner-private 的本地阶段产物,不是公开可下载 release。Local 可称为用户隔离试运行候选;Cluster 仍只是 integration candidate,因为离线 tag 尚未满足 public GHCR digest、catalog、deployment-lock、生产 HA/DR/CSI/IdP 门。
- D-407/ADR-0502(代码门已通过,Kubernetes 双代 live ceremony 待验收):Cluster API credential pepper 从“数据库保存 key ID、运行时却只有一个固定 material”收敛为最多 old/new 两代的显式 keyring。Security Administration 只用 active key 签发并持久化 exact IDCluster Control 按 credential record 精确选一把 key,未知 ID/material 一律 unavailable,绝不 fallback 或遍历,因此认证热路径仍为一次摘要。旧 raw pepper 只通过 `legacy-v1` singleton bridge 保持兼容。新增 `pepper.references` 以数据库时间返回最多 64 个当前 latest active/unexpired credential ID 和 `hasMore`,只作为退休前检查,不执行删除。keyring 文件有 2 KiB、canonical/no-symlink/private/stable-read 边界,无 watcher/timer/新连接池;Edge/Standalone package、依赖与常驻资源零变化。`cluster-admin` 完整回归`458 total / 455 pass / 3 conditional skip / 0 fail``cluster-control``281 total / 279 pass / 2 conditional skip / 0 fail`D-407 聚焦构建与 33 项测试通过;`3.0.0-alpha.1` 身份下 18-package clean build/test 退出 0backend 为 `1592 total / 1590 pass / 2 conditional skip / 0 fail`当前 D-406 Kubernetes stager 仍只支持单 peppermaterial GC、持久 active catalog、索引/大规模查询计划、远程 UI/API、双人复核和真实 K3s/CNPG overlap→activate→contract 仍是后续门禁。
- D-407/ADR-0502(代码与静态门已通过,Kubernetes 双代 live ceremony 待验收):Cluster API credential pepper 从“数据库保存 key ID、运行时却只有一个固定 material”收敛为最多 old/new 两代的显式 keyring。Security Administration 只用 active key 签发并持久化 exact IDCluster Control 按 credential record 精确选一把 key,未知 ID/material 一律 unavailable,绝不 fallback 或遍历,因此认证热路径仍为一次摘要。旧 raw pepper 只通过 `legacy-v1` singleton bridge 保持通用 CLI/进程兼容;Kubernetes Job 和常驻 Cluster Control manifest 已统一为 keyring-only,不再维护第二套单值 Secret 注入模式。新增 `pepper.references` 以数据库时间返回最多 64 个当前 latest active/unexpired credential ID 和 `hasMore`,只作为退休前检查,不执行删除。keyring 文件有 2 KiB、canonical/no-symlink/private/stable-read 边界,无 watcher/timer/新连接池;Edge/Standalone package、依赖与常驻资源零变化。三节点 live 合同现已编码 overlap-old-active、activate-new、旧代引用 1→0、contract-new,并在每个阶段滚动真实双副本 Cluster Control:有效但无 Project role 的 old/new credential 必须到达生产 HTTP 认证面后返回 403,收缩后的旧 credential 必须返回 401。content-free schema 固定 2→1 代收缩、数据库跨两个 credential 的 1 个旧代/3 个新代版本事实、四次授权拒绝与一次认证拒绝;本轮 Kubernetes/report/stager 聚焦测试 23/23Security Administration、Cluster Deployment、CloudNativePG 三个静态审计及 54 项相关共享 live fixture 测试通过。18-package clean build/test 已完成;Worker Runtime 的 3 个 loopback 用例在沙箱内因 `listen EPERM` 失败后,于允许 loopback 的同一工作区重跑为 `135/135`。完整 `cluster-admin``459 total / 456 pass / 3 conditional skip / 0 fail``cluster-control``281 total / 279 pass / 2 conditional skip / 0 fail`最新 backend 为 `1594 total / 1592 pass / 2 conditional skip / 0 fail`。material GC、持久 active catalog、索引/大规模查询计划、远程 UI/API、双人复核和真实 K3s/CNPG live 验收仍是后续门禁。
- D-406/ADR-0501(已验收):Cluster Security Administration 现在有可选的一次性 Kubernetes Job,而不是要求每个部署者自行拼装。通用 PostgreSQL、CloudNativePG、credential delivery 与组合入口均显式 opt-in,不进入共享 operations;因此 Edge/Standalone 以及默认 Cluster 的 package、依赖、启动路径和常驻资源仍为零增量。Job 固定 non-root/read-only/drop-all、无 ServiceAccount token/RBAC、`backoffLimit=0`、300 秒 deadline、600 秒 TTL、每容器 25m/48 MiB request 和 250m/128 MiB limit;主命令仍只打开一个 admin PostgreSQL connection。stager 保留 kubelet Secret symlink 兼容性,同时以 realpath confinement、`O_NOFOLLOW`、读前/读后 inode 复验和独立大小上限把四个输入复制成 memory-backed `0700/0600` 私有边界,失败时主容器不可启动;issue/rotate 才选择 PVC delivery componenttoken 只进入唯一 `0600` no-replace 文件。真实 arm64 live gate 已在 1 control-plane + 2 worker 的 K3s `v1.34.3+k3s1`、Flannel、CloudNativePG 1.30.0、3 个 PostgreSQL 18.4 实例、migration 71/control-core capability 70 上完成 register、audit query、issue、response-loss exact replay、rotate、revoke;证明 kubelet `0440` immutable Secret 投影可被私有化、`0444` 扩权会在主容器启动前失败、RWO PVC 跨 Job 保留 `0700/0600` no-replace 交付、Kubernetes API/公网 egress 与 Secret/Job RBAC 均被拒绝。全部管理 Job 继续以 UID/GID 10001 运行;仅 live local-path fixture 用一个无网络、无 API token、drop-all 的 root Job 把实现特有的 `02777 root:10001` PVC 根收紧为 `02770`,不能据此声称生产 CSI 加密或 custody 已验收。content-free `0600` 报告独立审计为 `compatible=true/findings=[]`SHA-256 为 `e5c24af77034e1a2efee062107176e218c11a9f9f0d6c8c7308fdc280b0a82cf`18-package clean build/test 退出 0,当前 `cluster-admin``456 total / 453 pass / 3 conditional skip / 0 fail`backend 为 `1590 total / 1588 pass / 2 conditional skip / 0 fail`。D-406 关闭单主机 K3s/CNPG/PVC 的产品命令与权限边界门,但不冒充生产 Kubernetes control-plane HA、跨主机 STONITH/DR、加密 CSI、外部 IdP、双人复核/break-glass、pepper rotation、audit retention/export/alert、并发 dispatch 或远程 UI/API。
@@ -10082,7 +10082,7 @@ PR-8 的本机最新增量由 ADR-0075/0076/0077/0078/0079/0080/0081/0082/0083/0
ADR-0087 Owner package 更新同样适用于上段 PR-8 累计描述:现行产品 CLI 只能经 console facade 到达其内部 bootstrap/credential-recovery,三个历史 ceremony package 名都只表示旧切片;`ql3-owner-gc` 由 maintenance 直接提供,不再拥有独立 importer。
PR-8 的 cluster ADR-0049 未完成项由 ADR-0050/0051/0500/0501 继续收敛:Identity register/enable/disable、credential issue/rotate/revoke、mutation ledger、强 actor、同事务 audit、有界 audit query,以及常驻 `/api/v3` 的认证前 overload shield 已孵化完成;D-405 新增默认无 listener、一次只执行一个命令并关闭单连接 admin authority 的 `ql3-security-admin` 产品 CLIcredential token 只向私有 no-replace 文件交付。D-406 又补齐默认不安装、无 Kubernetes API authority 的一次性 Job、私密输入 stager、CloudNativePG 与 PVC delivery 契约,并已由真实三节点 K3s、三实例 PostgreSQL 18.4、response-loss replay、PVC custody、网络/RBAC 拒绝与失败清理 live ceremony 验收。该单 Docker host fixture 仍不证明生产 control-plane HA、跨主机 STONITH/DR 或加密 CSI;远程 API/UI、管理入口独立 rate limit、双人复核或 break-glass、pepper rotation、audit retention/export/alert 也仍未完成。不得把短生命周期 CLI、一次性 Job、application service 或 process-local HTTP shield 的存在解释为 cluster-control 已获得管理 authority 或全局 quota。
PR-8 的 cluster ADR-0049 未完成项由 ADR-0050/0051/0500/0501/0502 继续收敛:Identity register/enable/disable、credential issue/rotate/revoke、mutation ledger、强 actor、同事务 audit、有界 audit query,以及常驻 `/api/v3` 的认证前 overload shield 已孵化完成;D-405 新增默认无 listener、一次只执行一个命令并关闭单连接 admin authority 的 `ql3-security-admin` 产品 CLIcredential token 只向私有 no-replace 文件交付。D-406 又补齐默认不安装、无 Kubernetes API authority 的一次性 Job、私密输入 stager、CloudNativePG 与 PVC delivery 契约,并已由真实三节点 K3s、三实例 PostgreSQL 18.4、response-loss replay、PVC custody、网络/RBAC 拒绝与失败清理 live ceremony 验收。D-407 进一步把常驻 Cluster Control 与 Admin Job 的 Kubernetes 配置收敛为最多 old/new 两代的 keyring-only 文件投影,保留一次精确摘要认证和显式滚动重启;overlap→activate→contract live 合同已经编码,但受审三节点门仍待执行。该单 Docker host fixture 仍不证明生产 control-plane HA、跨主机 STONITH/DR 或加密 CSI;远程 API/UI、管理入口独立 rate limit、双人复核或 break-glass、自动 rotation/material GC、audit retention/export/alert 也仍未完成。不得把短生命周期 CLI、一次性 Job、application service 或 process-local HTTP shield 的存在解释为 cluster-control 已获得管理 authority 或全局 quota。
未进入当前孵化切片的代码在通过对应 Gate 前必须保持不可达:不得仅因 schema、service 或 Primary 编排器已存在,就让旧 Controller、Scheduler、gRPC callback 或 Shell 脚本直接写入新状态表或调用新 Executor。已接入的 Shadow 观察只能通过默认关闭的 Feature Flag 和 origin owner 决策到达,不得调用 Executor、再次 spawn 或改变 Legacy 返回结果。manual `runSingle` 只增加 owner selection seam;默认没有 router。HTTP bootstrap 每次启动只读取一次 manifest,缺失、禁用、拒绝或非 primary 时保持 Legacy 且不加载重组件;显式 accepted manual primary 会在恢复门禁通过后安装唯一 owner,选中后禁止回退双跑。ADR-0445 已让 `ScheduleService.runTask` 的 subscription/system/script 在显式 origin flag 下只观察同一个 Legacy ChildProcesssystem crond 的 `scheduled_system`、once/boot/grpc 与这些来源的 Primary owner 切换仍须独立门禁。
@@ -23,7 +23,7 @@ ServiceAccount 和 Pod 都关闭自动 token 挂载,不创建 Role、RoleBindi
Secret volume 必须兼容 kubelet 的版本目录与 symlink 投影,默认 `0440` 以便固定的非 root group 读取;但 ADR-0500 的命令拒绝 symlink 和 group/world 权限。因此在既有 `@qinglong/cluster-admin/security-administration` 内增加专用 init stager,而不新建 package。
stager 只接受固定的 `command.json``assertion.jwt``keyset.json``pepper`,分别有 64 KiB、16 KiB、256 KiB 与 256 B 上限。解析 kubelet symlink 后仍要求 realpath 留在投影 authority 内,以 `O_NOFOLLOW` 打开最终文件,复验类型、权限、大小和读前/读后 inode 状态,再清零源 Buffer。目标目录必须不存在,由 stager 创建为 `0700`;文件以 `0600``fsync` 和 hard-link no-replace 发布到 1 MiB memory-backed `emptyDir`。任何输入失败都清理已发布目标,主容器不会启动。
stager 只接受固定的 `command.json``assertion.jwt``keyset.json``pepper-keyring.json`,分别有 64 KiB、16 KiB、256 KiB 与 2 KiB 上限。Kubernetes 路径以 D-407 keyring 为唯一 canonical 输入;旧单 pepper 只保留在通用 CLI 兼容桥,不形成第二套 Kubernetes 配置模式。stager 解析 kubelet symlink 后仍要求 realpath 留在投影 authority 内,以 `O_NOFOLLOW` 打开最终文件,复验类型、权限、大小和读前/读后 inode 状态,再清零源 Buffer。目标目录必须不存在,由 stager 创建为 `0700`;文件以 `0600``fsync` 和 hard-link no-replace 发布到 1 MiB memory-backed `emptyDir`。任何输入失败都清理已发布目标,主容器不会启动。
### 3. 数据库和进程权限保持最小化
@@ -57,7 +57,7 @@ Identity 变更、revoke 和 audit query 使用无 delivery 的 base。只有 `c
## 验证
- stager 聚焦测试覆盖真实 kubelet symlink 布局、`0700/0600` 收紧、持久 delivery 目录复验、realpath 逃逸、world-readable material、目标不可覆盖和 CLI 无敏感回显。
- stager 聚焦测试覆盖真实 kubelet symlink 布局、2 KiB keyring 边界、`0700/0600` 收紧、持久 delivery 目录复验、realpath 逃逸、world-readable material、目标不可覆盖和 CLI 无敏感回显。
- 部署审计冻结无 API token/RBAC、caller-driven/零重试/deadline/TTL、non-root/read-only/drop-all、资源上限、固定 CLI、内存私有输入、独立 admin credential、CloudNativePG egress、PVC delivery 和默认聚合不可达;失败注入覆盖权限扩大、非持久 delivery 与误入共享 aggregate。
- `kubectl kustomize` 已分别渲染 base、CloudNativePG、credential-delivery 和 CloudNativePG + delivery 四个入口。
- 18-package clean build/test 退出 0;当前 `cluster-admin` 为 456 total / 453 pass / 3 conditional skip / 0 failbackend 为 1590 total / 1588 pass / 2 conditional skip / 0 fail。
@@ -17,7 +17,7 @@ QingLong 3.0 同时服务低配路由设备与集群节点。pepper rotation 只
`runtime-core` 提供 schema v1 keyring:一个 `activePepperKeyId` 和 12 个唯一 `{pepperKeyId,pepper}`。每个 ID 与 32-byte canonical base64url material 都使用既有 credential contract 校验,active ID 必须存在于 keys。原单 pepper 配置只通过显式 `legacy-v1` singleton bridge 保持兼容,不允许自动发现、环境合并或第三代历史 key。
Cluster Control 可从 `QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE` 读取不超过 2 KiB 的 canonical 私有 JSON;它与旧 `QL3_API_CREDENTIAL_PEPPER` 必须二选一。文件必须是 canonical absolute regular file、不可为 symlink、不可向 group/world 开放,并在稳定 inode/mtime/size 下读取。运行时不安装 watcher;切换 keyring 后由部署系统执行受控滚动重启。
Cluster Control 可从 `QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE` 读取不超过 2 KiB 的 canonical 私有 JSON;它与旧 `QL3_API_CREDENTIAL_PEPPER` 必须二选一。文件必须是 canonical absolute regular file、不可为 symlink、不可向 group/world 写入,并在稳定 inode/mtime/size 下读取。Kubernetes 以只读 Secret volume 和 Pod 专属 fsGroup 提供该文件。运行时不安装 watcher;切换 keyring 后由部署系统执行受控滚动重启。
### 2. 认证严格按 durable key ID 选择
@@ -70,10 +70,12 @@ CLI 在 `--pepper` 与 `--pepper-keyring` 中必须精确选择一个。keyring
- Cluster Control 覆盖 old/new overlap、stored key exact selection、unknown stored key fail-closed、旧环境变量 bridge、私有 keyring 文件与双来源拒绝;
- Security Administration 覆盖新 credential 绑定 active key、keyring CLI、引用查询 exact command/result 与旧单 pepper bridge
- PostgreSQL repository 覆盖数据库时间、latest active/unexpired 过滤、limit+1、空引用与畸形行 fail-closed
- 当前 `cluster-admin` 完整回归为 458 total / 455 pass / 3 conditional skip / 0 fail`cluster-control` 为 281 total / 279 pass / 2 conditional skip / 0 failD-407 聚焦构建与 33 项测试通过,`3.0.0-alpha.1` 身份下 18-package clean build/test 退出 0backend 为 1592 total / 1590 pass / 2 conditional skip / 0 fail。
- Kubernetes Security Administration 只投影 `pepper-keyring.json`stager 以 2 KiB 边界私有化到 memory-backed `0700/0600` 目录;常驻 Cluster Control 同样只挂载 keyring 文件,不再从 Secret 注入旧单值环境变量;
- overlap-old-active、activate-new、旧代引用 1→0 与 contract-new 的三节点 K3s/CloudNativePG live 合同已经编码;它在三次 rollout 中保持两个反亲和 Cluster Control 副本,并以真实 `/api/v3` 请求证明 old/new 在 overlap 期间认证成功但因无 Project role 返回 403、old 在 contract 后返回 401、new 仍返回 403。content-free schema 只记录状态码、引用计数、1 个旧代/3 个新代 durable version 与审计计数;远程 live 门通过前不得宣称该 ceremony 已验收;
- 当前 `cluster-admin` 完整回归为 459 total / 456 pass / 3 conditional skip / 0 fail`cluster-control` 为 281 total / 279 pass / 2 conditional skip / 0 fail;本轮 Kubernetes/report/stager 聚焦测试 23 项、相关共享 live fixture 静态测试 54 项以及三个部署审计通过,`3.0.0-alpha.1` 身份下 18-package clean build/test 完成(Worker Runtime 3 个 loopback 用例在沙箱外复验为 135/135),backend 为 1594 total / 1592 pass / 2 conditional skip / 0 fail。
## 影响与剩余门禁
D-407 关闭了“数据库记录 key ID、运行时却只能使用一个固定 pepper”的结构性缺口。Edge/Standalone package、依赖和常驻路径零变化;Cluster 每个认证请求仍只解析 record 并计算一个摘要,管理引用检查只在显式短命令中打开一个 admin connection。
D-406 Kubernetes stager/Job 仍只接受单 `pepper` 文件,不能用它执行双代 ceremony;在增加 keyring Secret 投影、失败注入和真实 K3s/CNPG overlap→activate→contract 验收前,Kubernetes 部署继续停留在 singleton bridge。远程管理 API/UI、双人复核/break-glass、material GC、audit retention/export/alert 和大规模引用查询计划也仍未完成。
D-406 Kubernetes stager/Job 与常驻 Cluster Control manifest 已收敛为 keyring-only;旧单 pepper 只存在于通用 CLI/进程配置兼容桥。真实 K3s/CNPG overlap→activate→contract live 合同已实现但仍需在受审远程门中验收,因此当前不能把实现完成解释为 live ceremony 已关闭。远程管理 API/UI、双人复核/break-glass、material GC、audit retention/export/alert 和大规模引用查询计划也仍未完成。
+12
View File
@@ -12,6 +12,18 @@
只有第一等级可以称为本阶段“用户可试运行产物”。Cluster archive 是工程集成产物,因为离线 per-architecture tag 不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。
## 当前阶段实物(2026-08-26
当前已经存在一份 owner-private、可重新加载的 Local arm64 候选,而不是只有源码或 Dockerfile:
- source revision`b45a5e04b7f49ffdadd5117b6b5253c6f1c05430`
- image`qinglong3-local-application:ci-arm64`image ID `sha256:59e39cd0c71e5a5c2bc99c599d5aa240c59f215008f8d12fde4243c984274426`
- archive SHA-256`58bbc250833c9e86321718aea70ac0a637699b84c18531fa7a82b35e90b7fa83`
- 同目录包含 `manifest.json``verification-evidence.json`、CycloneDX 1.5 SBOM、`README.md``SHA256SUMS`,全套 checksum 和 `docker load` 后身份/smoke 已复验;
- HIGH/CRITICAL OS vulnerability 为 0128 MiB、0.5 CPU、read-only、no-network、drop-all 下的 Edge/Standalone fresh lifecycle 与 SQLite integrity 已通过;原生 Linux arm64 CI 另行覆盖 macOS bind-mount 无法等价证明的 Local API cancellation。
该实物保存在工作区忽略目录,不进入 Git,也尚未上传 GitHub。远端 40/40 CI 与原生 arm64 image job 已通过;公开下载仍需维护者明确授权上传。它足以作为单架构内部试运行材料,但在 amd64 同级 archive 和远端 artifact identity 未齐全前,不得把它升级为完整双架构阶段里程碑或公开 release。
## 生成
在 GitHub Actions 手动运行 `QingLong 3.0 CI`,选择目标 `next` 提交并设置 `produce_alpha_artifacts=true`。普通 push/PR 不上传大镜像,避免每次开发提交都制造伪里程碑和额外存储成本。
@@ -170,7 +170,7 @@ Cluster Control 使用 `QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE`;它与旧 `QL3
- `credential-delivery`:在 base 上增加调用方提供的 RWO PVC;
- `cloudnative-pg-credential-delivery`CloudNativePG 与 PVC 交付的组合。
`input-secret.example.yaml` 复制到仓库外的私有目录,替换四个占位值,并保持 `immutable: true`。示例不属于任何 Kustomization。非签发操作不要选择 delivery overlay`credential.issue` / `credential.rotate` 必须先按 `delivery-pvc.example.yaml` 创建受加密、受访问控制的 PVC,并把 manifest 中的 `replace-with-unique-delivery.json` 改为本次唯一文件名。
`input-secret.example.yaml` 复制到仓库外的私有目录,替换四个占位值,并保持 `immutable: true`第四项必须是最多 old/new 两代、2 KiB 内的 canonical `pepper-keyring.json`Kubernetes 不接受旧单 pepper 文件。示例不属于任何 Kustomization。非签发操作不要选择 delivery overlay`credential.issue` / `credential.rotate` 必须先按 `delivery-pvc.example.yaml` 创建受加密、受访问控制的 PVC,并把 manifest 中的 `replace-with-unique-delivery.json` 改为本次唯一文件名。
以 CloudNativePG 的无 delivery audit query 为例:
@@ -189,4 +189,4 @@ kubectl logs job/ql3-security-administration -n qinglong3-system \
## 当前边界
本入口没有远程 API/UI、双人复核或 break-glass、自动 pepper rotation/material GC、audit retention/export/alert。D-407 已提供 old/new 双代 keyring、active issuance、exact-key authentication 和退休前引用检查,但 active 切换仍由显式配置更新加滚动重启完成。现有 Kubernetes Job stager 只接受单 pepper,尚未完成 keyring Secret 投影和真实 overlap→activate→contract live gate,因此不能用 D-406 模板宣称 Kubernetes pepper rotation 已完成。可选 Job 的静态契约与单主机 K3s + PostgreSQL/PVC ceremony 已验收,但仍不默认安装,也不证明生产基础设施 HA/DR 或存储加密;admin database credential 始终不得进入常驻 Cluster Control。命令决策见 [ADR-0500](../adr/ADR-0500-short-lived-cluster-security-administration-command.md),部署决策见 [ADR-0501](../adr/ADR-0501-opt-in-kubernetes-security-administration-job.md),双代 keyring 见 [ADR-0502](../adr/ADR-0502-bounded-cluster-api-credential-pepper-keyring.md)。
本入口没有远程管理 API/UI、双人复核或 break-glass、自动 pepper rotation/material GC、audit retention/export/alert。D-407 已提供 old/new 双代 keyring、active issuance、exact-key authentication 和退休前引用检查active 切换仍由显式 Secret 更新加受控滚动重启完成。Kubernetes Job stager 与常驻 Cluster Control manifest 已收敛为 keyring-onlyoverlap→activate→contract live 合同会在三次 rollout 中保持两个反亲和 Control 副本,并用真实 `/api/v3` 请求区分“认证成功但未授权”的 403 与旧 credential 收缩后被拒绝的 401。该合同已编码并通过聚焦/静态门,但远程三节点 K3s/CNPG live 验收仍待执行,因此不能宣称 Kubernetes pepper rotation 已完成;它的内部 HTTP probe 也不替代外部 ingress TLS 验收。可选 Job 仍不默认安装,也不证明生产基础设施 HA/DR 或存储加密;admin database credential 始终不得进入常驻 Cluster Control。命令决策见 [ADR-0500](../adr/ADR-0500-short-lived-cluster-security-administration-command.md),部署决策见 [ADR-0501](../adr/ADR-0501-opt-in-kubernetes-security-administration-job.md),双代 keyring 见 [ADR-0502](../adr/ADR-0502-bounded-cluster-api-credential-pepper-keyring.md)。
@@ -27,7 +27,7 @@ const INPUTS = Object.freeze([
Object.freeze({ name: 'command.json', maximumBytes: 64 * 1024 }),
Object.freeze({ name: 'assertion.jwt', maximumBytes: 16 * 1024 }),
Object.freeze({ name: 'keyset.json', maximumBytes: 256 * 1024 }),
Object.freeze({ name: 'pepper', maximumBytes: 256 }),
Object.freeze({ name: 'pepper-keyring.json', maximumBytes: 2 * 1024 }),
]);
export interface ClusterAdministrationKubernetesInputStagePaths {
@@ -39,7 +39,11 @@ function projectedInput() {
'command.json': '{"schemaVersion":1,"operation":"audit.list"}\n',
'assertion.jwt': 'signed.assertion.value',
'keyset.json': '{"keys":[]}',
pepper: 'A'.repeat(43),
'pepper-keyring.json': JSON.stringify({
schemaVersion: 1,
activePepperKeyId: 'legacy-v1',
keys: [{ pepperKeyId: 'legacy-v1', pepper: 'A'.repeat(43) }],
}),
};
for (const [name, value] of Object.entries(inputs)) {
const versionFile = join(versionDirectory, name);
@@ -88,6 +92,31 @@ test('copies a Kubernetes projected Secret into a private immutable input bounda
assert.equal(JSON.stringify(result).includes('A'.repeat(43)), false);
});
test('rejects a projected pepper keyring larger than the runtime boundary', () => {
const fixture = projectedInput();
const keyringFile = resolve(
fixture.sourceDirectory,
'..data',
'pepper-keyring.json',
);
chmodSync(keyringFile, 0o640);
writeFileSync(
keyringFile,
'A'.repeat(2 * 1024 + 1),
);
chmodSync(keyringFile, 0o440);
assert.throws(
() =>
stageClusterAdministrationKubernetesInputs({
sourceDirectory: fixture.sourceDirectory,
targetDirectory: fixture.targetDirectory,
}),
/file authority is invalid/,
);
assert.throws(() => lstatSync(fixture.targetDirectory));
});
test('accepts only the exact root-owned sticky Kubernetes mount authority', () => {
assert.equal(
isClusterAdministrationProjectedSourceDirectoryAuthority({
@@ -676,7 +676,14 @@ function postgresRestoreApplicationProbeResources({
kind: 'List',
items: Object.freeze([
kubernetesSecret('ql3-dr', secretName, {
'api-credential-pepper': apiCredentialPepper,
'api-credential-pepper-keyring.json': `${JSON.stringify({
schemaVersion: 1,
activePepperKeyId: 'legacy-v1',
keys: [{
pepperKeyId: 'legacy-v1',
pepper: apiCredentialPepper,
}],
})}\n`,
}),
{
apiVersion: 'apps/v1',
@@ -753,11 +760,10 @@ function postgresRestoreApplicationProbeResources({
value: `qinglong3-dr-${suffix}`,
},
{ name: 'QL3_POSTGRES_MAX_CONNECTIONS', value: '2' },
secretValue(
'QL3_API_CREDENTIAL_PEPPER',
secretName,
'api-credential-pepper',
),
{
name: 'QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE',
value: '/var/run/secrets/qinglong3/api-credential/keyring.json',
},
],
ports: [{ name: 'http', containerPort: 5800 }],
startupProbe: {
@@ -794,6 +800,11 @@ function postgresRestoreApplicationProbeResources({
mountPath: '/var/run/secrets/qinglong3/postgres',
readOnly: true,
},
{
name: 'api-credential-keyring',
mountPath: '/var/run/secrets/qinglong3/api-credential',
readOnly: true,
},
],
},
],
@@ -810,6 +821,17 @@ function postgresRestoreApplicationProbeResources({
items: [{ key: 'ca.crt', path: 'ca.crt' }],
},
},
{
name: 'api-credential-keyring',
secret: {
secretName,
defaultMode: 288,
items: [{
key: 'api-credential-pepper-keyring.json',
path: 'keyring.json',
}],
},
},
],
},
},
@@ -354,13 +354,13 @@ function assertSecretBoundary(readFile, root, findings) {
if (name === 'ql3-cluster-control-runtime') {
if (
secret?.type !== 'Opaque' ||
secret?.stringData?.['api-credential-pepper'] !==
'REPLACE_WITH_CANONICAL_32_BYTE_BASE64URL'
secret?.stringData?.['api-credential-pepper-keyring.json'] !==
'{"schemaVersion":1,"activePepperKeyId":"REPLACE_WITH_ACTIVE_KEY_ID","keys":[{"pepperKeyId":"REPLACE_WITH_ACTIVE_KEY_ID","pepper":"REPLACE_WITH_CANONICAL_32_BYTE_BASE64URL"}]}\n'
) {
findings.push(
finding(
'QL3_CNPG_RUNTIME_SECRET_EXAMPLE',
'runtime Secret example may contain only the placeholder credential pepper',
'runtime Secret example must contain only the bounded placeholder credential pepper keyring',
),
);
}
@@ -503,6 +503,7 @@ function assertMigrationBinding(readFile, root, findings) {
env.has('QL3_POSTGRES_MIGRATION_URL') ||
env.has('QL3_POSTGRES_RUNTIME_URL') ||
env.has('QL3_API_CREDENTIAL_PEPPER') ||
env.has('QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE') ||
env.get('QL3_POSTGRES_MIGRATION_HOST')?.value !== PRIMARY_DNS ||
env.get('QL3_POSTGRES_MIGRATION_PORT')?.value !== '5432' ||
env.get('QL3_POSTGRES_MIGRATION_DATABASE')?.value !== 'qinglong' ||
+5 -1
View File
@@ -760,7 +760,11 @@ async function main() {
);
}
applySecret('ql3-cluster-control-runtime', 'Opaque', {
'api-credential-pepper': randomSecret(),
'api-credential-pepper-keyring.json': `${JSON.stringify({
schemaVersion: 1,
activePepperKeyId: 'legacy-v1',
keys: [{ pepperKeyId: 'legacy-v1', pepper: randomSecret() }],
})}\n`,
});
const workerIngressTls = createWorkerIngressTls(tempDirectory);
applySecret('ql3-cluster-worker-ingress', 'Opaque', {
+12 -2
View File
@@ -796,6 +796,10 @@ function assertKubernetes(readFile, root, findings) {
'QL3_POSTGRES_TLS_CA_FILE',
'/var/run/secrets/qinglong3/postgres-runtime/ca.crt',
],
[
'QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE',
'/var/run/secrets/qinglong3/postgres-runtime/api-credential-pepper-keyring.json',
],
['QL3_WORKER_INGRESS_ENABLED', 'true'],
['QL3_WORKER_INGRESS_HOST', '0.0.0.0'],
['QL3_WORKER_INGRESS_PORT', '5801'],
@@ -854,7 +858,14 @@ function assertKubernetes(readFile, root, findings) {
runtimeCaVolume?.secret?.secretName !== 'ql3-cluster-control-runtime' ||
runtimeCaVolume?.secret?.defaultMode !== 0o444 ||
JSON.stringify(runtimeCaVolume?.secret?.items) !==
JSON.stringify([{ key: 'postgres-ca.crt', path: 'ca.crt' }])
JSON.stringify([
{ key: 'postgres-ca.crt', path: 'ca.crt' },
{
key: 'api-credential-pepper-keyring.json',
path: 'api-credential-pepper-keyring.json',
},
]) ||
env.has('QL3_API_CREDENTIAL_PEPPER')
) {
findings.push(
finding(
@@ -866,7 +877,6 @@ function assertKubernetes(readFile, root, findings) {
for (const [name, key] of [
['QL3_POSTGRES_RUNTIME_URL', 'postgres-runtime-url'],
['QL3_POSTGRES_TLS_SERVERNAME', 'postgres-tls-servername'],
['QL3_API_CREDENTIAL_PEPPER', 'api-credential-pepper'],
]) {
const secret = env.get(name)?.valueFrom?.secretKeyRef;
if (secret?.name !== 'ql3-cluster-control-runtime' || secret?.key !== key) {
@@ -1483,7 +1483,14 @@ function applyRuntimeAfterRecovery(recoveryJob, migrationJobValue, secrets) {
type: 'Opaque',
stringData: {
'postgres-runtime-url': `postgresql://ql3_runtime:${secrets.runtime}@${POSTGRES_NAME}:5432/qinglong`,
'api-credential-pepper': randomBytes(32).toString('base64url'),
'api-credential-pepper-keyring.json': `${JSON.stringify({
schemaVersion: 1,
activePepperKeyId: 'legacy-v1',
keys: [{
pepperKeyId: 'legacy-v1',
pepper: randomBytes(32).toString('base64url'),
}],
})}\n`,
},
},
'create runtime-only credential after recovery success',
@@ -1535,21 +1542,34 @@ function applyRuntimeAfterRecovery(recoveryJob, migrationJobValue, secrets) {
},
},
{
name: 'QL3_API_CREDENTIAL_PEPPER',
valueFrom: {
secretKeyRef: {
name: 'ql3-cluster-control-runtime',
key: 'api-credential-pepper',
name: 'QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE',
value: '/var/run/secrets/qinglong3/api-credential/keyring.json',
},
},
];
container.volumeMounts = [
{ name: 'tmp', mountPath: '/tmp' },
{
name: 'api-credential-keyring',
mountPath: '/var/run/secrets/qinglong3/api-credential',
readOnly: true,
},
];
container.volumeMounts = [{ name: 'tmp', mountPath: '/tmp' }];
resource.spec.template.spec.volumes = [
{
name: 'tmp',
emptyDir: { medium: 'Memory', sizeLimit: '16Mi' },
},
{
name: 'api-credential-keyring',
secret: {
secretName: 'ql3-cluster-control-runtime',
defaultMode: 288,
items: [{
key: 'api-credential-pepper-keyring.json',
path: 'keyring.json',
}],
},
},
];
}
for (const resource of resources) {
@@ -150,7 +150,7 @@ function auditSecurityAdministrationKubernetes(options = {}) {
`--command=${PRIVATE_ROOT}/input/command.json`,
`--assertion=${PRIVATE_ROOT}/input/assertion.jwt`,
`--keyset=${PRIVATE_ROOT}/input/keyset.json`,
`--pepper=${PRIVATE_ROOT}/input/pepper`,
`--pepper-keyring=${PRIVATE_ROOT}/input/pepper-keyring.json`,
];
if (
JSON.stringify(stager?.command) !==
@@ -181,7 +181,7 @@ function auditSecurityAdministrationKubernetes(options = {}) {
'command.json',
'assertion.jwt',
'keyset.json',
'pepper',
'pepper-keyring.json',
]) ||
privateInput?.emptyDir?.medium !== 'Memory' ||
privateInput?.emptyDir?.sizeLimit !== '1Mi' ||
@@ -241,7 +241,12 @@ function auditSecurityAdministrationKubernetes(options = {}) {
inputExample?.metadata?.name !== 'ql3-security-administration-input' ||
JSON.stringify(Object.keys(inputExample?.stringData ?? {}).sort()) !==
JSON.stringify(
['command.json', 'assertion.jwt', 'keyset.json', 'pepper'].sort(),
[
'command.json',
'assertion.jwt',
'keyset.json',
'pepper-keyring.json',
].sort(),
) ||
JSON.stringify(aggregate).includes('security-administration')
) {
@@ -13,6 +13,7 @@ const LIMITATIONS = Object.freeze([
'CloudNativePG inside one Docker host is not infrastructure STONITH or disaster-recovery evidence',
'the local-path ReadWriteOnce volume is not encrypted production CSI custody evidence',
'a dedicated root storage-fixture Job constrains the local-path volume root before every non-root administration Job',
'the credential probe reaches the production HTTP surface inside the isolated cluster and is not external ingress TLS evidence',
]);
const BANNED_KEYS = new Set([
'assertion',
@@ -143,6 +144,7 @@ function validateSecurityAdministrationKubernetesLiveReport(report) {
'architecture',
'kubernetesImageId',
'administrationImageId',
'controlImageId',
'cniName',
'cniDistributionBinding',
'controlPlaneNodes',
@@ -154,6 +156,7 @@ function validateSecurityAdministrationKubernetesLiveReport(report) {
!['amd64', 'arm64'].includes(platform?.architecture) ||
!isSha256(platform?.kubernetesImageId) ||
!isSha256(platform?.administrationImageId) ||
!isSha256(platform?.controlImageId) ||
platform?.cniName !== 'flannel' ||
platform?.cniDistributionBinding !== 'rancher/k3s:v1.34.3-k3s1' ||
platform?.controlPlaneNodes !== 1 ||
@@ -213,6 +216,10 @@ function validateSecurityAdministrationKubernetesLiveReport(report) {
'operations',
'completedJobs',
'failedJobs',
'authenticationProbeJobs',
'controlReplicas',
'controlRollouts',
'controlReplicaAntiAffinity',
'callerDriven',
'backoffLimit',
'activeDeadlineSeconds',
@@ -221,19 +228,39 @@ function validateSecurityAdministrationKubernetesLiveReport(report) {
'serviceAccountTokenMounted',
'rbacGranted',
'responseLossReplayObserved',
'overlapGenerationCount',
'contractedGenerationCount',
'activeGenerationChanged',
'oldReferencesBeforeActivation',
'oldReferencesAfterActivation',
'oldReferencesAfterConvergence',
'oldAuthenticationBeforeActivation',
'oldAuthenticationDuringOverlap',
'newAuthenticationDuringOverlap',
'oldAuthenticationRejectedAfterConvergence',
'newAuthenticationAfterContraction',
'contractedToActiveGeneration',
'sensitiveMaterialReported',
]) ||
JSON.stringify(ceremony?.operations) !==
JSON.stringify([
'identity.register',
'audit.list',
'credential.issue',
'credential.issue.replay',
'credential.rotate',
'credential.revoke',
'credential.issue.old',
'credential.issue.old.replay',
'credential.key-references.before-activate',
'credential.issue.new',
'credential.rotate.new',
'credential.key-references.after-activate',
'credential.revoke.old',
'credential.key-references.after-converge',
]) ||
ceremony?.completedJobs !== 6 ||
ceremony?.completedJobs !== 10 ||
ceremony?.failedJobs !== 1 ||
ceremony?.authenticationProbeJobs !== 5 ||
ceremony?.controlReplicas !== 2 ||
ceremony?.controlRollouts !== 3 ||
ceremony?.controlReplicaAntiAffinity !== true ||
ceremony?.callerDriven !== true ||
ceremony?.backoffLimit !== 0 ||
ceremony?.activeDeadlineSeconds !== 300 ||
@@ -242,12 +269,24 @@ function validateSecurityAdministrationKubernetesLiveReport(report) {
ceremony?.serviceAccountTokenMounted !== false ||
ceremony?.rbacGranted !== false ||
ceremony?.responseLossReplayObserved !== true ||
ceremony?.overlapGenerationCount !== 2 ||
ceremony?.contractedGenerationCount !== 1 ||
ceremony?.activeGenerationChanged !== true ||
ceremony?.oldReferencesBeforeActivation !== 1 ||
ceremony?.oldReferencesAfterActivation !== 1 ||
ceremony?.oldReferencesAfterConvergence !== 0 ||
ceremony?.oldAuthenticationBeforeActivation !== true ||
ceremony?.oldAuthenticationDuringOverlap !== true ||
ceremony?.newAuthenticationDuringOverlap !== true ||
ceremony?.oldAuthenticationRejectedAfterConvergence !== true ||
ceremony?.newAuthenticationAfterContraction !== true ||
ceremony?.contractedToActiveGeneration !== true ||
ceremony?.sensitiveMaterialReported !== false
) {
findings.push(
finding(
'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_CEREMONY',
'six serial caller-created commands plus one failed input stage must use the exact tokenless Job contract',
'ten serial caller-created commands, five content-free authentication probes and one failed input stage must prove the exact two-replica overlap, activation, convergence and contraction contract',
),
);
}
@@ -299,7 +338,7 @@ function validateSecurityAdministrationKubernetesLiveReport(report) {
delivery?.fixtureProvisionerRanAsRoot !== true ||
delivery?.privateDirectoryMode !== '0700' ||
delivery?.fileMode !== '0600' ||
delivery?.fileCount !== 2 ||
delivery?.fileCount !== 3 ||
!isSha256(delivery?.issueDigest) ||
!isSha256(delivery?.rotationDigest) ||
delivery?.issueDigest === delivery?.rotationDigest ||
@@ -342,28 +381,42 @@ function validateSecurityAdministrationKubernetesLiveReport(report) {
!exactKeys(durability, [
'identityVersion',
'identityStatus',
'credentialVersion',
'credentialState',
'oldCredentialVersion',
'oldCredentialState',
'newCredentialVersion',
'newCredentialState',
'identityMutationCount',
'credentialMutationCount',
'issueMutationCount',
'credentialVersionCount',
'oldGenerationVersionCount',
'newGenerationVersionCount',
'latestGenerationsAreNew',
'allowedAuditCount',
'authenticationDeniedAuditCount',
'authenticationRejectedAuditCount',
]) ||
durability?.identityVersion !== 1 ||
durability?.identityStatus !== 'active' ||
durability?.credentialVersion !== 3 ||
durability?.credentialState !== 'revoked' ||
durability?.oldCredentialVersion !== 2 ||
durability?.oldCredentialState !== 'revoked' ||
durability?.newCredentialVersion !== 2 ||
durability?.newCredentialState !== 'active' ||
durability?.identityMutationCount !== 1 ||
durability?.credentialMutationCount !== 3 ||
durability?.credentialMutationCount !== 4 ||
durability?.issueMutationCount !== 1 ||
durability?.credentialVersionCount !== 3 ||
durability?.allowedAuditCount !== 4
durability?.credentialVersionCount !== 4 ||
durability?.oldGenerationVersionCount !== 1 ||
durability?.newGenerationVersionCount !== 3 ||
durability?.latestGenerationsAreNew !== true ||
durability?.allowedAuditCount !== 5 ||
durability?.authenticationDeniedAuditCount !== 4 ||
durability?.authenticationRejectedAuditCount !== 1
) {
findings.push(
finding(
'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_DURABILITY',
'the database must retain one identity mutation and exactly three credential generations without replay duplication',
'the database must retain one old-key version followed by three new-key versions across two credentials, exact replay and the expected authentication audits',
),
);
}
@@ -375,6 +428,9 @@ function validateSecurityAdministrationKubernetesLiveReport(report) {
'evidenceJobsDeleted',
'storageProvisionJobDeleted',
'deliveryVolumeClaimDeleted',
'controlDeploymentDeleted',
'controlServiceDeleted',
'controlRuntimeSecretDeleted',
])
) {
findings.push(
@@ -392,6 +448,7 @@ function validateSecurityAdministrationKubernetesLiveReport(report) {
'realKubeletSecretProjection',
'realAdministrationProductCommands',
'realPersistentCredentialCustody',
'realClusterControlAuthenticationRotation',
'responseLossReplay',
'failedInputStageClosed',
'leastPrivilege',
File diff suppressed because it is too large Load Diff
@@ -1617,6 +1617,25 @@ async function main(argv = process.argv.slice(2)) {
'tls.crt': pkiMaterial.oldClientCertificate,
},
});
apply({
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: 'ql3-worker-control-live-runtime',
namespace: NAMESPACE,
},
type: 'Opaque',
stringData: {
'api-credential-pepper-keyring.json': `${JSON.stringify({
schemaVersion: 1,
activePepperKeyId: 'legacy-v1',
keys: [{
pepperKeyId: 'legacy-v1',
pepper: Buffer.alloc(32, 29).toString('base64url'),
}],
})}\n`,
},
});
const workerArchitecture = remoteWorkerArchitectureForNodeRuntime(
process.arch, process.config.variables.arm_version,
);
@@ -1700,8 +1719,8 @@ async function main(argv = process.argv.slice(2)) {
{ name: 'QL3_POSTGRES_ALLOW_INSECURE', value: 'true' },
{ name: 'QL3_POSTGRES_MAX_CONNECTIONS', value: '2' },
{
name: 'QL3_API_CREDENTIAL_PEPPER',
value: Buffer.alloc(32, 29).toString('base64url'),
name: 'QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE',
value: '/var/run/secrets/qinglong3/api-credential/keyring.json',
},
{ name: 'QL3_WORKER_INGRESS_ENABLED', value: 'true' },
{ name: 'QL3_WORKER_INGRESS_HOST', value: '0.0.0.0' },
@@ -1738,14 +1757,37 @@ async function main(argv = process.argv.slice(2)) {
timeoutSeconds: 1,
failureThreshold: 20,
},
volumeMounts: [{ name: 'tls', mountPath: '/tls', readOnly: true }],
volumeMounts: [
{ name: 'tls', mountPath: '/tls', readOnly: true },
{
name: 'api-credential-keyring',
mountPath: '/var/run/secrets/qinglong3/api-credential',
readOnly: true,
},
],
}],
volumes: [{
volumes: [
{
name: 'tls',
secret: { secretName: 'ql3-worker-ingress-tls-live', defaultMode: 288 },
secret: {
secretName: 'ql3-worker-ingress-tls-live',
defaultMode: 288,
},
},
{
name: 'api-credential-keyring',
secret: {
secretName: 'ql3-worker-control-live-runtime',
defaultMode: 288,
items: [{
key: 'api-credential-pepper-keyring.json',
path: 'keyring.json',
}],
},
},
],
},
},
},
});
kubectl([
@@ -20,6 +20,7 @@ function report() {
architecture: 'amd64',
kubernetesImageId: digest('1'),
administrationImageId: digest('2'),
controlImageId: digest('4'),
cniName: 'flannel',
cniDistributionBinding: 'rancher/k3s:v1.34.3-k3s1',
controlPlaneNodes: 1,
@@ -45,13 +46,21 @@ function report() {
operations: [
'identity.register',
'audit.list',
'credential.issue',
'credential.issue.replay',
'credential.rotate',
'credential.revoke',
'credential.issue.old',
'credential.issue.old.replay',
'credential.key-references.before-activate',
'credential.issue.new',
'credential.rotate.new',
'credential.key-references.after-activate',
'credential.revoke.old',
'credential.key-references.after-converge',
],
completedJobs: 6,
completedJobs: 10,
failedJobs: 1,
authenticationProbeJobs: 5,
controlReplicas: 2,
controlRollouts: 3,
controlReplicaAntiAffinity: true,
callerDriven: true,
backoffLimit: 0,
activeDeadlineSeconds: 300,
@@ -60,6 +69,18 @@ function report() {
serviceAccountTokenMounted: false,
rbacGranted: false,
responseLossReplayObserved: true,
overlapGenerationCount: 2,
contractedGenerationCount: 1,
activeGenerationChanged: true,
oldReferencesBeforeActivation: 1,
oldReferencesAfterActivation: 1,
oldReferencesAfterConvergence: 0,
oldAuthenticationBeforeActivation: true,
oldAuthenticationDuringOverlap: true,
newAuthenticationDuringOverlap: true,
oldAuthenticationRejectedAfterConvergence: true,
newAuthenticationAfterContraction: true,
contractedToActiveGeneration: true,
sensitiveMaterialReported: false,
},
inputBoundary: {
@@ -80,7 +101,7 @@ function report() {
fixtureProvisionerRanAsRoot: true,
privateDirectoryMode: '0700',
fileMode: '0600',
fileCount: 2,
fileCount: 3,
issueDigest: digest('4'),
rotationDigest: digest('5'),
distinctRotationMaterial: true,
@@ -100,13 +121,20 @@ function report() {
durability: {
identityVersion: 1,
identityStatus: 'active',
credentialVersion: 3,
credentialState: 'revoked',
oldCredentialVersion: 2,
oldCredentialState: 'revoked',
newCredentialVersion: 2,
newCredentialState: 'active',
identityMutationCount: 1,
credentialMutationCount: 3,
credentialMutationCount: 4,
issueMutationCount: 1,
credentialVersionCount: 3,
allowedAuditCount: 4,
credentialVersionCount: 4,
oldGenerationVersionCount: 1,
newGenerationVersionCount: 3,
latestGenerationsAreNew: true,
allowedAuditCount: 5,
authenticationDeniedAuditCount: 4,
authenticationRejectedAuditCount: 1,
},
cleanup: {
jobsDeleted: true,
@@ -114,6 +142,9 @@ function report() {
evidenceJobsDeleted: true,
storageProvisionJobDeleted: true,
deliveryVolumeClaimDeleted: true,
controlDeploymentDeleted: true,
controlServiceDeleted: true,
controlRuntimeSecretDeleted: true,
},
gates: {
realThreeNodeKubernetes: true,
@@ -121,6 +152,7 @@ function report() {
realKubeletSecretProjection: true,
realAdministrationProductCommands: true,
realPersistentCredentialCustody: true,
realClusterControlAuthenticationRotation: true,
responseLossReplay: true,
failedInputStageClosed: true,
leastPrivilege: true,
@@ -142,6 +174,7 @@ test('rejects widened authority, false custody and replay duplication', () => {
candidate.ceremony.serviceAccountTokenMounted = true;
candidate.deliveryCustody.noReplaceReplayPreserved = false;
candidate.durability.issueMutationCount = 2;
candidate.ceremony.oldReferencesAfterConvergence = 1;
candidate.gates.passed = false;
const codes = validateSecurityAdministrationKubernetesLiveReport(
candidate,
@@ -6,6 +6,8 @@ const { test } = require('node:test');
const {
auditListCommand,
clusterControlResources,
credentialAuthenticationProbeSource,
credentialIssueCommand,
credentialRevokeCommand,
credentialRotateCommand,
@@ -188,6 +190,43 @@ test('waits for per-Pod network policy before mounting private material', () =>
assert.doesNotMatch(source, /readFile|process\.env|console\.log/);
});
test('runs the credential ceremony against two real anti-affine control replicas', () => {
const [service, deployment] = clusterControlResources(
'qinglong3-cluster-control:test',
);
assert.equal(service.kind, 'Service');
assert.equal(service.spec.ports[0].port, 5800);
assert.equal(deployment.kind, 'Deployment');
assert.equal(deployment.spec.replicas, 2);
assert.equal(deployment.spec.strategy.rollingUpdate.maxUnavailable, 0);
assert.equal(
deployment.spec.template.spec.affinity.podAntiAffinity
.requiredDuringSchedulingIgnoredDuringExecution[0].topologyKey,
'kubernetes.io/hostname',
);
const environment = deployment.spec.template.spec.containers[0].env;
assert.ok(
environment.some(
(entry) =>
entry.name === 'QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE' &&
entry.value.endsWith('/keyring.json'),
),
);
assert.equal(
environment.some((entry) => entry.name === 'QL3_API_CREDENTIAL_PEPPER'),
false,
);
});
test('keeps the real authentication probe content-free', () => {
const source = credentialAuthenticationProbeSource();
assert.match(source, /ql3-security-live-control/);
assert.match(source, /\/api\/v3\/projects\/prj_default\/runs\?limit=1/);
assert.match(source, /observedStatus:observed/);
assert.match(source, /bytes\?\.fill\(0\)/);
assert.doesNotMatch(source, /console\.log|process\.env/);
});
test('live runner remains opt-in, reviewed, cleanup-bound and log-free', () => {
const source = fs.readFileSync(
path.resolve(
@@ -206,7 +245,15 @@ test('live runner remains opt-in, reviewed, cleanup-bound and log-free', () => {
assert.match(source, /net\.bridge\.bridge-nf-call-iptables=1/);
assert.match(source, /wait-network-policy/);
assert.match(source, /projectedMode: 0o444/);
assert.match(source, /credential\.issue\.replay/);
assert.match(source, /credential\.issue\.old\.replay/);
assert.match(source, /ql3-security-live-auth-old-before-activate/);
assert.match(source, /ql3-security-live-auth-old-overlap/);
assert.match(source, /ql3-security-live-auth-new-overlap/);
assert.match(source, /ql3-security-live-auth-old-contracted/);
assert.match(source, /ql3-security-live-auth-new-contracted/);
assert.match(source, /expectedStatus: 401/);
assert.match(source, /expectedStatus: 403/);
assert.match(source, /controlRollouts: 3/);
assert.match(source, /FallbackToLogsOnError/);
assert.match(source, /failureMessage: 'rejected'/);
assert.match(