mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): validate cluster credential key rotation
This commit is contained in:
@@ -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,
|
||||
1–256 KiB, and contains 1–16 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 1–2 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, 1–256 KiB, and
|
||||
contains 1–16 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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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 cancellation;Cluster 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 archive(SHA-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 ID;Cluster 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 退出 0,backend 为 `1592 total / 1590 pass / 2 conditional skip / 0 fail`。当前 D-406 Kubernetes stager 仍只支持单 pepper,material 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 ID;Cluster 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/23,Security 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 component,token 只进入唯一 `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` 产品 CLI,credential 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` 产品 CLI,credential 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 ChildProcess;system 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 fail,backend 为 1590 total / 1588 pass / 2 conditional skip / 0 fail。
|
||||
|
||||
@@ -17,7 +17,7 @@ QingLong 3.0 同时服务低配路由设备与集群节点。pepper rotation 只
|
||||
|
||||
`runtime-core` 提供 schema v1 keyring:一个 `activePepperKeyId` 和 1–2 个唯一 `{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 fail;D-407 聚焦构建与 33 项测试通过,`3.0.0-alpha.1` 身份下 18-package clean build/test 退出 0,backend 为 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,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 为 0;128 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-only;overlap→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)。
|
||||
|
||||
+1
-1
@@ -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' ||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,12 +1757,35 @@ async function main(argv = process.argv.slice(2)) {
|
||||
timeoutSeconds: 1,
|
||||
failureThreshold: 20,
|
||||
},
|
||||
volumeMounts: [{ name: 'tls', mountPath: '/tls', readOnly: true }],
|
||||
}],
|
||||
volumes: [{
|
||||
name: 'tls',
|
||||
secret: { secretName: 'ql3-worker-ingress-tls-live', defaultMode: 288 },
|
||||
volumeMounts: [
|
||||
{ name: 'tls', mountPath: '/tls', readOnly: true },
|
||||
{
|
||||
name: 'api-credential-keyring',
|
||||
mountPath: '/var/run/secrets/qinglong3/api-credential',
|
||||
readOnly: true,
|
||||
},
|
||||
],
|
||||
}],
|
||||
volumes: [
|
||||
{
|
||||
name: 'tls',
|
||||
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',
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user