diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index db1d5696..d6acc7fb 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -61,6 +61,7 @@ jobs: pnpm audit:edge-imports:ql3 pnpm audit:cluster-dependencies:ql3 pnpm audit:cluster-deployment:ql3 + pnpm audit:security-administration-kubernetes:ql3 - name: Smoke benchmark edge executor run: pnpm benchmark:edge -- --json - name: Prove disabled AI Profile has zero storage or credential reachability diff --git a/deploy/kubernetes/ql3-cluster/README.md b/deploy/kubernetes/ql3-cluster/README.md index 51764439..5e5a762c 100644 --- a/deploy/kubernetes/ql3-cluster/README.md +++ b/deploy/kubernetes/ql3-cluster/README.md @@ -76,6 +76,18 @@ command schemas and operator procedure are documented in response-loss behavior and remaining gates are frozen in [`ADR-0500`](../../../docs/adr/ADR-0500-short-lived-cluster-security-administration-command.md). +An opt-in one-shot Job is available under +[`operations/security-administration`](./operations/security-administration). +It is deliberately absent from the shared operations Kustomization. Select +`base` or `cloudnative-pg`; select a credential-delivery variant only for +issue/rotate after provisioning a private encrypted RWO PVC. A non-root init +container copies the kubelet symlink projection into a `0700` memory-backed +directory with `0600` files before the administrator starts. The Job has no +Kubernetes API token or RBAC, does not retry, and retains the one-connection +database ceiling. The exact serial dispatch and cleanup procedure is in the +operator guide above; production remains gated on the ADR-0501 live K3s, +PostgreSQL and PVC ceremony. + Workstation operators may pass an explicit owner-private context to reuse only stable client paths: diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml new file mode 100644 index 00000000..e48fc8b9 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml @@ -0,0 +1,134 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: ql3-security-administration + namespace: qinglong3-system + labels: + app.kubernetes.io/name: ql3-security-administration + app.kubernetes.io/component: security-administration + app.kubernetes.io/part-of: qinglong3 + qinglong.io/execution-model: caller-driven +spec: + backoffLimit: 0 + activeDeadlineSeconds: 300 + ttlSecondsAfterFinished: 600 + template: + metadata: + labels: + app.kubernetes.io/name: ql3-security-administration + app.kubernetes.io/component: security-administration + app.kubernetes.io/part-of: qinglong3 + qinglong.io/execution-model: caller-driven + spec: + serviceAccountName: ql3-security-administration + automountServiceAccountToken: false + enableServiceLinks: false + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + initContainers: + - name: stage-private-input + image: qinglong3-cluster-admin:3.0.0-alpha.0 + imagePullPolicy: IfNotPresent + command: + - node + - /opt/qinglong/node_modules/@qinglong/cluster-admin/dist/security-administration/clusterAdministrationKubernetesInputStageCli.js + args: + - --source=/var/run/secrets/qinglong3/security-administration-projected + - --target=/var/run/qinglong3/security-administration-private/input + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 25m + memory: 48Mi + limits: + cpu: 250m + memory: 128Mi + volumeMounts: + - name: projected-input + mountPath: /var/run/secrets/qinglong3/security-administration-projected + readOnly: true + - name: private-input + mountPath: /var/run/qinglong3/security-administration-private + containers: + - name: administrator + image: qinglong3-cluster-admin:3.0.0-alpha.0 + imagePullPolicy: IfNotPresent + command: + - node + - /opt/qinglong/node_modules/@qinglong/cluster-admin/dist/security-administration/clusterAdministrationCli.js + args: + - --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 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + env: + - name: QL3_POSTGRES_ADMIN_TLS_MODE + value: verify-full + - name: QL3_POSTGRES_ADMIN_TLS_CA_FILE + value: /var/run/secrets/qinglong3/postgres-security-administration/ca.crt + - name: QL3_POSTGRES_ADMIN_URL + valueFrom: + secretKeyRef: + name: ql3-security-administration-database + key: postgres-admin-url + - name: QL3_POSTGRES_ADMIN_TLS_SERVERNAME + valueFrom: + secretKeyRef: + name: ql3-security-administration-database + key: postgres-tls-servername + resources: + requests: + cpu: 25m + memory: 48Mi + limits: + cpu: 250m + memory: 128Mi + volumeMounts: + - name: private-input + mountPath: /var/run/qinglong3/security-administration-private + readOnly: true + - name: postgres-ca + mountPath: /var/run/secrets/qinglong3/postgres-security-administration + readOnly: true + volumes: + - name: projected-input + secret: + secretName: ql3-security-administration-input + defaultMode: 288 + items: + - key: command.json + path: command.json + - key: assertion.jwt + path: assertion.jwt + - key: keyset.json + path: keyset.json + - key: pepper + path: pepper + - name: private-input + emptyDir: + medium: Memory + sizeLimit: 1Mi + - name: postgres-ca + secret: + secretName: ql3-security-administration-database + defaultMode: 292 + items: + - key: postgres-ca.crt + path: ca.crt diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/base/kustomization.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/kustomization.yaml new file mode 100644 index 00000000..b3a1bcf1 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - service-account.yaml + - job.yaml + - network-policy.yaml diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/base/network-policy.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/network-policy.yaml new file mode 100644 index 00000000..80ad3798 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/network-policy.yaml @@ -0,0 +1,31 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: ql3-security-administration + namespace: qinglong3-system + labels: + app.kubernetes.io/name: ql3-security-administration + app.kubernetes.io/component: security-administration + app.kubernetes.io/part-of: qinglong3 +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: ql3-security-administration + app.kubernetes.io/component: security-administration + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/base/service-account.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/service-account.yaml new file mode 100644 index 00000000..665872d0 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/service-account.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ql3-security-administration + namespace: qinglong3-system + labels: + app.kubernetes.io/name: ql3-security-administration + app.kubernetes.io/component: security-administration + app.kubernetes.io/part-of: qinglong3 +automountServiceAccountToken: false diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg-credential-delivery/kustomization.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg-credential-delivery/kustomization.yaml new file mode 100644 index 00000000..9b10b4be --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg-credential-delivery/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../cloudnative-pg + +components: + - ../credential-delivery/component diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg/job-patch.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg/job-patch.yaml new file mode 100644 index 00000000..0de3acb0 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg/job-patch.yaml @@ -0,0 +1,31 @@ +- op: replace + path: /spec/template/spec/containers/0/env + value: + - name: QL3_POSTGRES_ADMIN_TLS_MODE + value: verify-full + - name: QL3_POSTGRES_ADMIN_TLS_CA_FILE + value: /var/run/secrets/qinglong3/postgres-security-administration/ca.crt + - name: QL3_POSTGRES_ADMIN_HOST + value: ql3-postgres-rw.qinglong3-system.svc + - name: QL3_POSTGRES_ADMIN_PORT + value: '5432' + - name: QL3_POSTGRES_ADMIN_DATABASE + value: qinglong + - name: QL3_POSTGRES_ADMIN_USER + valueFrom: + secretKeyRef: + name: ql3-postgres-admin-auth + key: username + - name: QL3_POSTGRES_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: ql3-postgres-admin-auth + key: password + - name: QL3_POSTGRES_ADMIN_TLS_SERVERNAME + value: ql3-postgres-rw.qinglong3-system.svc +- op: replace + path: /spec/template/spec/volumes/2/secret/secretName + value: ql3-postgres-ca +- op: replace + path: /spec/template/spec/volumes/2/secret/items/0/key + value: ca.crt diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg/kustomization.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg/kustomization.yaml new file mode 100644 index 00000000..a0937914 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg/kustomization.yaml @@ -0,0 +1,24 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../base + +patches: + - path: job-patch.yaml + target: + group: batch + version: v1 + kind: Job + name: ql3-security-administration + - path: network-policy-patch.yaml + target: + group: networking.k8s.io + version: v1 + kind: NetworkPolicy + name: ql3-security-administration + +images: + - name: qinglong3-cluster-admin + newName: registry.example.com/qinglong/qinglong3-cluster-admin + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg/network-policy-patch.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg/network-policy-patch.yaml new file mode 100644 index 00000000..1c2db571 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg/network-policy-patch.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: ql3-security-administration + namespace: qinglong3-system +spec: + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + - to: + - podSelector: + matchLabels: + cnpg.io/cluster: ql3-postgres + ports: + - protocol: TCP + port: 5432 diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/component/job-patch.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/component/job-patch.yaml new file mode 100644 index 00000000..d28d956c --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/component/job-patch.yaml @@ -0,0 +1,22 @@ +- op: add + path: /spec/template/spec/initContainers/0/args/- + value: --delivery-directory=/var/lib/qinglong3/security-administration-delivery/private +- op: add + path: /spec/template/spec/initContainers/0/volumeMounts/- + value: + name: credential-delivery + mountPath: /var/lib/qinglong3/security-administration-delivery +- op: add + path: /spec/template/spec/containers/0/args/- + value: --delivery=/var/lib/qinglong3/security-administration-delivery/private/replace-with-unique-delivery.json +- op: add + path: /spec/template/spec/containers/0/volumeMounts/- + value: + name: credential-delivery + mountPath: /var/lib/qinglong3/security-administration-delivery +- op: add + path: /spec/template/spec/volumes/- + value: + name: credential-delivery + persistentVolumeClaim: + claimName: ql3-security-administration-delivery diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/component/kustomization.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/component/kustomization.yaml new file mode 100644 index 00000000..6131fe93 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/component/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component + +patches: + - path: job-patch.yaml + target: + group: batch + version: v1 + kind: Job + name: ql3-security-administration diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/kustomization.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/kustomization.yaml new file mode 100644 index 00000000..d0381a30 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../base + +components: + - component + +images: + - name: qinglong3-cluster-admin + newName: registry.example.com/qinglong/qinglong3-cluster-admin + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/database-secret.example.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/database-secret.example.yaml new file mode 100644 index 00000000..2a4aa8fd --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/database-secret.example.yaml @@ -0,0 +1,12 @@ +# Generic PostgreSQL example only. CloudNativePG users do not need this Secret. +apiVersion: v1 +kind: Secret +metadata: + name: ql3-security-administration-database + namespace: qinglong3-system +type: Opaque +immutable: true +stringData: + postgres-admin-url: postgresql://ql3_admin:REPLACE@postgres.example.test:5432/qinglong + postgres-tls-servername: postgres.example.test + postgres-ca.crt: REPLACE_WITH_POSTGRES_CA_CERTIFICATE diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/delivery-pvc.example.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/delivery-pvc.example.yaml new file mode 100644 index 00000000..0467a849 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/delivery-pvc.example.yaml @@ -0,0 +1,12 @@ +# Example only. Use an encrypted, access-controlled StorageClass and a fresh PVC. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ql3-security-administration-delivery + namespace: qinglong3-system +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 16Mi diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/input-secret.example.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/input-secret.example.yaml new file mode 100644 index 00000000..969b42cb --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/input-secret.example.yaml @@ -0,0 +1,14 @@ +# Example only. Create a private per-dispatch Secret; never commit real values. +apiVersion: v1 +kind: Secret +metadata: + name: ql3-security-administration-input + namespace: qinglong3-system +type: Opaque +immutable: true +stringData: + command.json: | + {"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 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index c6f2f310..cf114032 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,8 @@ 最新增量证据(2026-08-25): +- D-406/ADR-0501(进行中:静态部署契约已验收,live gate pending):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 文件。6 个 stager/CLI 测试、4 个静态审计及四种 `kubectl kustomize` 渲染均通过;18-package clean build/test 退出 0,当前 `cluster-admin` 为 `454 total / 451 pass / 3 conditional skip / 0 fail`,backend 为 `1579 total / 1577 pass / 2 conditional skip / 0 fail`,122-module Edge import、Cluster dependency、package boundary、deployment、deployment-lock 与 release-version 审计全部 compatible。本地 Admin 镜像 `sha256:5464f0bbf5aa1302c080b13c9f18aaa89ba418ab5d09a917f5b5b4937c0ede2f` 在 non-root/read-only、无网络/能力、128 MiB/0.25 CPU 下包含并运行固定 stager 入口。真实 K3s + PostgreSQL register/query/issue/replay/revoke、PVC custody、response-loss 与清理证据尚未执行,所以 D-406 不冒充完整生产 ceremony,也不关闭双人复核/break-glass、pepper rotation、audit retention/export/alert 或远程 UI/API 门禁。 + - D-405/ADR-0500(已验收):Cluster Identity/API Credential/Security Audit 现在有首个受审产品入口。`ql3-security-admin` 由既有 `ql3-cluster-admin security` facade 到达同一安装内的固定 target,不新建单文件 package,也不进入常驻 `cluster-control`;每次只执行一个 exact-shape 的 identity register/enable/disable、credential issue/rotate/revoke 或最多 200 条的 keyset audit query,随后关闭单连接 admin PostgreSQL authority。命令使用独立 `ql3-security-administration+jwt` type、`security-administration` purpose、audience 与 generation/revocation ledger,拒绝其他管理面的 assertion;command、assertion、keyset 与 pepper 均来自显式有界文件,不发现 home、ambient Kubernetes context 或默认 credential。issue/rotate token 只经私有目录内 `0600`、`fsync`、hard-link no-replace 文件交付,stdout 仅含 basename 与 SHA-256;精确重放的 `token=null` 不重新生成或发布 secret。默认 PostgreSQL `verify-full`,Pool 最大一个连接,无 listener、daemon、timer、watcher 或后台 retention。`cluster-admin` 完整回归为 `448 total / 445 pass / 3 conditional skip / 0 fail`,新增聚焦测试 19/19、产品 facade 4/4、backend product contract 4/4;18-package clean build/test 退出 0,backend 为 `1575 total / 1573 pass / 2 conditional skip / 0 fail`。真实 arm64 Admin 镜像在 non-root、read-only、无网络/能力、128 MiB/0.25 CPU 下通过 12-command live contract;PostgreSQL 18.6 arm64 physical HA timeline 1→2 的 147 gates 全部通过,报告 SHA-256 为 `8fbb606773080dae15de5e31db5726abb8700862b51e4616b5f3f50e0b8374f3`。package、Cluster dependency、122-module Edge import、部署、镜像与 service bridge 审计全部 compatible,Edge/Standalone package、依赖和常驻资源不变。本切片关闭 ADR-0050“只有 application service、没有受审产品入口”的缺口,但不冒充远程 HTTP/API/UI、双人复核/break-glass、pepper rotation、audit retention/export/alert 或完整 Kubernetes Job ceremony。 - D-404/ADR-0499(已验收):Cluster Worker 现在有可选的直接外部 Secret custody adapter,而不再只能依赖 Kubernetes Secret value projection。`vault-kv-v2` 位于既有 `@qinglong/cluster-control` Remote Execution 子域,只有显式选择 provider 的 Cluster 进程才动态加载;基础部署继续使用 `mounted-files`,Edge/Standalone 不新增 package、依赖、daemon、timer、watcher、连接池或常驻内存。adapter 只在 durable Run/Attempt/Lease/Worker Session/execution digest/SecretRef authority 通过后,用 `SHA-256(canonical SecretRef)` 路径读取 KV v2;只接受显式私有 CA 的 TLS 1.3、每次重新打开的短期 orphan/non-renewable service token 和唯一精确 policy,不跟随 redirect、不使用系统 CA 回退、不缓存值或 token。Kubernetes overlay 删除 value Secret projection,只挂载 CA 与 token;普通 Secret、opaque environment bundle 和总响应仍受原 16/96/256 KiB 边界约束,空 Secret 保持合法,异常 envelope、metadata、digest、token、TLS 或 Vault availability 均失败关闭且不回退。真实 arm64 Vault 1.21.4 gate 已完成 3-share/2-threshold init、两个普通 Secret 与一个 bundle、value/token 原子轮换、旧 accessor revoke、缺失 material、不可信 CA、seal/unseal 与同持久存储容器替换;capability-free `0600` content-free 报告 SHA-256 为 `281fe542e1bf6078132216b9701a28e76f36dcbccc95367997d8457773a9c210`,audit 为 `compatible=true/findings=[]`。live fixture 以非 root、read-only rootfs、`cap-drop=ALL`、零新增 capability、`no-new-privileges` 与 `memory-swappiness=0` 运行,并为原生 hosted runner 的 capability/lock 差异显式关闭 fixture mlock;生产 Vault host 的 swap/mlock 硬化不由该门冒充。Cluster Control 为 `279 total / 277 pass / 2 conditional skip / 0 fail`,backend 为 `1574 total / 1572 pass / 2 conditional skip / 0 fail`,18-package clean build/test 退出 0;package、Cluster dependency、122-module Edge import、部署和 14 档 Local artifact 审计全部 compatible,基础 Edge/Standalone 仍为 `2,669,390 / 2,669,468 bytes`、325 files、58 modules。共享 CI 新增原生 x64/arm64 live matrix。该 fixture 关闭 QingLong 直接 custody adapter/data-boundary 门,不冒充生产 Vault HA、KMS/HSM seal、审计设备或灾备证明;ADR-0491 现在只剩固定低性能物理 Edge 的真实空间、RSS/I/O、写放大、ENOSPC 与断电恢复门。 @@ -10076,7 +10078,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 继续收敛: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 文件交付。当前 Gate 仍要求远程 API/UI 或完整 Kubernetes Job ceremony、管理入口独立 rate limit、双人复核或 break-glass、pepper rotation、audit retention/export/alert;不得把短生命周期 CLI、application service 或 process-local HTTP shield 的存在解释为 cluster-control 已获得管理 authority 或全局 quota。 +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/PVC live ceremony 仍是当前 Gate。远程 API/UI、管理入口独立 rate limit、双人复核或 break-glass、pepper rotation、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 切换仍须独立门禁。 diff --git a/docs/adr/ADR-0501-opt-in-kubernetes-security-administration-job.md b/docs/adr/ADR-0501-opt-in-kubernetes-security-administration-job.md new file mode 100644 index 00000000..a8a38dbe --- /dev/null +++ b/docs/adr/ADR-0501-opt-in-kubernetes-security-administration-job.md @@ -0,0 +1,69 @@ +# ADR-0501:可选的一次性 Kubernetes Security Administration Job + +- 状态:Accepted +- 日期:2026-08-25 +- 决策:D-406 +- 关联:ADR-0050、ADR-0129、ADR-0276、ADR-0301、ADR-0500 + +## 背景 + +ADR-0500 已提供无 listener、单命令、单数据库连接的 `ql3-security-admin` 产品入口,但 Cluster 部署者仍需自行编写 Job、Secret 投影、网络策略和 credential delivery。自行组合容易把 admin credential 放入常驻 `cluster-control`、为 Job 挂载 Kubernetes API token、直接让宽权限 Secret 文件成为命令输入,或把新签发 token 留在日志和易失卷中。 + +QingLong 同时服务低性能路由设备、单机和多节点集群。Cluster 运维能力不能增加 Edge/Standalone package、依赖、启动路径或常驻资源;仅共享同一 Cluster Admin 镜像与故障生命周期的输入适配器也不应拆成单文件 workspace package。 + +## 决策 + +### 1. Job 必须显式选择且一次只执行一个操作 + +在 `deploy/kubernetes/ql3-cluster/operations/security-administration/` 提供通用 PostgreSQL、CloudNativePG、credential delivery 和二者组合的 Kustomize 入口。它们不进入共享 `operations/kustomization.yaml`,操作者必须显式 `create`;Job 设置 `backoffLimit=0`、300 秒 deadline、600 秒完成后 TTL,不运行 listener、timer、watcher 或 sidecar。 + +ServiceAccount 和 Pod 都关闭自动 token 挂载,不创建 Role、RoleBinding 或 ClusterRole。Job 只有 DNS 与受审 PostgreSQL egress;CloudNativePG overlay 精确限制到 `cnpg.io/cluster=ql3-postgres` 的 TCP 5432。通用 base 默认没有任意 PostgreSQL egress,部署者必须在私有 overlay 中为实际数据库增加精确目的地。 + +### 2. Kubernetes Secret 不是命令的直接私有文件边界 + +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`。任何输入失败都清理已发布目标,主容器不会启动。 + +### 3. 数据库和进程权限保持最小化 + +两个容器固定 UID/GID/fsGroup 10001、RuntimeDefault seccomp、只读 rootfs、drop all capabilities、禁止 privilege escalation;每个容器 request 为 25m/48 MiB,limit 为 250m/128 MiB。主容器直接执行同镜像的固定 Security Administration CLI,不经 shell,也不读取 ambient kubeconfig、home 或默认 credential。 + +通用 base 从独立 Secret 读取 `QL3_POSTGRES_ADMIN_URL`、TLS server name 和 CA。CloudNativePG overlay 使用 `ql3-postgres-admin-auth`、`ql3-postgres-rw` 和 `ql3-postgres-ca`;runtime、migration 和其他管理角色不能代替 `ql3_admin`。应用层仍强制 `verify-full`、显式 DNS server name、一个 Pool connection 和短连接生命周期。 + +### 4. Credential 交付是独立 opt-in capability + +Identity 变更、revoke 和 audit query 使用无 delivery 的 base。只有 `credential.issue` / `credential.rotate` 选择 credential-delivery component;它要求调用方预置受加密和访问控制的 RWO PVC。init stager 在 PVC 内创建或复验 `0700` 私有目录,主容器只向操作者指定的唯一新文件执行 ADR-0500 的 `0600` no-replace 发布。token 不进入 stdout、日志、Secret patch、API response 或易失 `emptyDir`。 + +固定示例文件只含占位符且不被任何 Kustomization 引入。每次 dispatch 必须使用新的 mutation ID、短期 assertion 和唯一 delivery 文件名;固定资源名使当前基线只支持受控的串行 ceremony,Job 与输入 Secret 完成后必须显式清理。并发 dispatch、自动命名和 delivery acknowledgement 属于后续产品化门禁。 + +## 被拒绝的替代方案 + +### 默认安装 Admin Deployment 或 CronJob + +拒绝。它会把高权限数据库凭据、pepper 和资源成本变成常驻面,并影响不使用该能力的集群与低配设备。 + +### 直接把 projected Secret 交给主命令 + +拒绝。kubelet 的 symlink 和 group-readable 投影与 ADR-0500 的 POSIX 私有文件契约不同,放宽主命令会同时削弱工作站路径。 + +### 给 Job Kubernetes Secret 读写权限并写回 token + +拒绝。API token/RBAC 会扩大 blast radius,更新 Secret 还引入资源版本竞争、日志/审计暴露和难以证明的响应丢失语义。 + +### 新建 Kubernetes Stager workspace package + +拒绝。它只由同一 admin 镜像、同一 Security Administration ceremony 使用,没有独立发布、依赖、权限或故障生命周期;拆包会重新制造单文件 package。 + +## 验证 + +- stager 聚焦测试覆盖真实 kubelet symlink 布局、`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` 为 454 total / 451 pass / 3 conditional skip / 0 fail,backend 为 1579 total / 1577 pass / 2 conditional skip / 0 fail。Edge import 仍为 122 modules,Cluster dependency、package boundary、deployment、deployment-lock source surface 与 release-version 审计均 compatible。 +- 本地构建的 Cluster Admin 镜像 digest 为 `sha256:5464f0bbf5aa1302c080b13c9f18aaa89ba418ab5d09a917f5b5b4937c0ede2f`;新 stager 在 non-root、read-only rootfs、`network=none`、drop-all、no-new-privileges、32 PID、128 MiB 与 0.25 CPU 下完成独立 `--help` smoke,证明发布镜像包含该固定入口。 +- 真实 K3s Pod、PostgreSQL admin operation、PVC token custody、response loss 与清理演练尚未执行,因此不得把本 ADR 解释为 live ceremony 已验收。 + +## 影响与剩余门禁 + +D-406 关闭“每个部署者都要从零编写一次性 Admin Job”的静态部署缺口,且 Edge/Standalone 和默认 Cluster 常驻资源保持不变。下一门是以临时 K3s + PostgreSQL 执行 register/query/issue/replay/revoke、证明 token 仅存在于 PVC no-replace 文件、Pod 无 API authority、失败清理和证据 content-free;之后仍有双人复核/break-glass、pepper rotation、audit retention/export/alert、并发 dispatch 和远程管理 UI/API。 diff --git a/docs/adr/README.md b/docs/adr/README.md index c49bed13..09297cec 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -504,6 +504,7 @@ | [ADR-0498](./ADR-0498-cluster-legacy-env-application-ha-replay.md) | Cluster Legacy Env Application 的 HA Promotion 后精确重放 | Accepted | | [ADR-0499](./ADR-0499-direct-vault-kv-worker-secret-custody.md) | 直接 Vault KV Worker Secret 外部托管 | Accepted | | [ADR-0500](./ADR-0500-short-lived-cluster-security-administration-command.md) | 短生命周期 Cluster Security Administration 产品命令 | Accepted | +| [ADR-0501](./ADR-0501-opt-in-kubernetes-security-administration-job.md) | 可选的一次性 Kubernetes Security Administration Job | Accepted | ## 规则 diff --git a/docs/operations/ql3-cluster-security-administration.md b/docs/operations/ql3-cluster-security-administration.md index b132fac3..063d07ca 100644 --- a/docs/operations/ql3-cluster-security-administration.md +++ b/docs/operations/ql3-cluster-security-administration.md @@ -125,6 +125,32 @@ ql3-cluster-admin security \ 成功签发或轮换时,stdout 只包含 delivery 文件名和 SHA-256。token 只存在于新建的 `0600` delivery 文件。目标已存在时命令失败且绝不覆盖。精确重放返回 `status=existing` 且不重新发布 token;如果首次响应丢失,先检查原 delivery 文件,确实丢失时使用新的 mutationId 执行 rotate,不能尝试恢复旧 token。 +## Kubernetes 一次性 Job + +仓库提供显式 opt-in 的部署模板,但不会随共享 Cluster operations 安装: + +- `base`:外部 PostgreSQL;默认 NetworkPolicy 只有 DNS,必须用私有 overlay 增加数据库的精确 IP/Pod egress; +- `cloudnative-pg`:使用 `ql3-postgres-admin-auth`、`ql3-postgres-rw`、`ql3-postgres-ca`; +- `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` 改为本次唯一文件名。 + +以 CloudNativePG 的无 delivery audit query 为例: + +```sh +kubectl create -f /secure/qinglong3/security-administration-input.yaml +kubectl kustomize \ + deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg \ + | kubectl create -f - +kubectl wait --for=condition=complete --timeout=300s \ + job/ql3-security-administration -n qinglong3-system +kubectl logs job/ql3-security-administration -n qinglong3-system \ + -c administrator +``` + +当前固定资源名只允许串行执行。收集 content-free 结果和(仅 issue/rotate)PVC 中的 `0600` delivery 文件后,删除 Job 与本次 immutable input Secret;不得重用 assertion、把 token 复制到终端输出,或以 `kubectl apply` 修改旧 Job。真实 K3s + PostgreSQL live ceremony 尚未验收,生产启用前仍需完成 ADR-0501 的 live gate。 + ## 当前边界 -本入口没有远程 API/UI、双人复核或 break-glass、pepper rotation、audit retention/export/alert,也没有默认安装的 Kubernetes Job。生产部署应把它放在受控工作站或自行审查的一次性 Job 中,并确保 admin database credential 不进入常驻 Cluster Control。完整安全决策见 [ADR-0500](../adr/ADR-0500-short-lived-cluster-security-administration-command.md)。 +本入口没有远程 API/UI、双人复核或 break-glass、pepper rotation、audit retention/export/alert。可选 Job 已有受审静态部署契约,但不默认安装,真实 K3s + PostgreSQL/PVC ceremony 仍待验收;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)。 diff --git a/package.json b/package.json index 0a2430fb..6686b651 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "audit:vault-kv-worker-secret-deployment:ql3": "node scripts/ql3-vault-kv-worker-secret-deployment-audit.cjs", "test:postgres-backup-prompt-output-recovery-live:ql3": "pnpm --filter @qinglong/ai build && pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-postgres-prompt-output-recovery-live-contract.cjs", "audit:cluster-deployment:ql3": "node scripts/ql3-cluster-deployment-audit.cjs", + "audit:security-administration-kubernetes:ql3": "node scripts/ql3-security-administration-kubernetes-audit.cjs", "audit:cluster-copilot-console:ql3": "node scripts/ql3-cluster-copilot-console-audit.cjs", "audit:cluster-copilot-console-distribution:ql3": "node scripts/ql3-cluster-copilot-console-distribution-audit.cjs", "evidence:cluster-admin-release-workstation:ql3": "node scripts/ql3-cluster-admin-release-workstation-ceremony.cjs", diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStage.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStage.ts new file mode 100644 index 00000000..f2a9a764 --- /dev/null +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStage.ts @@ -0,0 +1,431 @@ +import { + closeSync, + constants, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readSync, + realpathSync, + rmdirSync, + unlinkSync, + writeSync, +} from 'node:fs'; +import { + dirname, + isAbsolute, + join, + normalize, + parse, + relative, + sep, +} from 'node:path'; + +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 }), +]); + +export interface ClusterAdministrationKubernetesInputStagePaths { + readonly sourceDirectory: string; + readonly targetDirectory: string; + readonly deliveryDirectory?: string; +} + +export interface ClusterAdministrationKubernetesInputStageResult { + readonly schemaVersion: 1; + readonly component: 'qinglong3-security-administration-kubernetes-input-stage'; + readonly stagedFileCount: 4; + readonly deliveryDirectoryPrepared: boolean; +} + +export class ClusterAdministrationKubernetesInputStageError extends TypeError { + readonly code = 'QL3_CLUSTER_ADMINISTRATION_KUBERNETES_INPUT_STAGE_INVALID'; + + constructor(message: string, readonly cause?: unknown) { + super( + `Cluster administration Kubernetes input stage is invalid: ${message}`, + ); + this.name = 'ClusterAdministrationKubernetesInputStageError'; + } +} + +function exactObject( + value: unknown, +): asserts value is ClusterAdministrationKubernetesInputStagePaths { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ClusterAdministrationKubernetesInputStageError( + 'paths must be an object', + ); + } + const actual = Object.keys(value).sort(); + const expected = [ + 'sourceDirectory', + 'targetDirectory', + ...('deliveryDirectory' in value ? ['deliveryDirectory'] : []), + ].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new ClusterAdministrationKubernetesInputStageError( + 'paths shape is invalid', + ); + } +} + +function directoryPath(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + !isAbsolute(value) || + normalize(value) !== value || + parse(value).root === value || + value.includes('\0') || + Buffer.byteLength(value, 'utf8') > 4_096 + ) { + throw new ClusterAdministrationKubernetesInputStageError( + `${label} must be a normalized absolute non-root path`, + ); + } + return value; +} + +function sameFileState( + left: Readonly<{ + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; + }>, + right: Readonly<{ + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; + }>, +): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ); +} + +function verifySourceDirectory(sourceDirectory: string): string { + const status = lstatSync(sourceDirectory, { throwIfNoEntry: false }); + if ( + status === undefined || + !status.isDirectory() || + status.isSymbolicLink() || + (status.mode & 0o002) !== 0 + ) { + throw new ClusterAdministrationKubernetesInputStageError( + 'projected source directory authority is invalid', + ); + } + try { + return realpathSync(sourceDirectory); + } catch (error) { + throw new ClusterAdministrationKubernetesInputStageError( + 'projected source directory cannot be resolved', + error, + ); + } +} + +function confinedSourceFile( + sourceDirectory: string, + sourceRealDirectory: string, + name: string, +): string { + const candidate = join(sourceDirectory, name); + let resolved: string; + try { + resolved = realpathSync(candidate); + } catch (error) { + throw new ClusterAdministrationKubernetesInputStageError( + 'projected input cannot be resolved', + error, + ); + } + const pathFromSource = relative(sourceRealDirectory, resolved); + if ( + pathFromSource === '' || + pathFromSource === '..' || + pathFromSource.startsWith(`..${sep}`) || + isAbsolute(pathFromSource) + ) { + throw new ClusterAdministrationKubernetesInputStageError( + 'projected input escapes its source directory', + ); + } + return resolved; +} + +function readStableSourceFile(filePath: string, maximumBytes: number): Buffer { + let descriptor: number | undefined; + let bytes: Buffer | undefined; + try { + descriptor = openSync( + filePath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + const before = fstatSync(descriptor); + if ( + !before.isFile() || + before.size < 1 || + before.size > maximumBytes || + (before.mode & 0o027) !== 0 + ) { + throw new ClusterAdministrationKubernetesInputStageError( + 'projected input file authority is invalid', + ); + } + bytes = Buffer.alloc(before.size + 1); + let offset = 0; + while (offset < bytes.length) { + const count = readSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset, + ); + if (count === 0) break; + offset += count; + } + const after = fstatSync(descriptor); + if (offset !== before.size || !sameFileState(before, after)) { + throw new ClusterAdministrationKubernetesInputStageError( + 'projected input changed while being read', + ); + } + return bytes.subarray(0, offset); + } catch (error) { + bytes?.fill(0); + if (error instanceof ClusterAdministrationKubernetesInputStageError) { + throw error; + } + throw new ClusterAdministrationKubernetesInputStageError( + 'projected input cannot be read', + error, + ); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function verifyPrivateDirectory(directory: string, label: string): void { + const status = lstatSync(directory, { throwIfNoEntry: false }); + const effectiveUser = process.geteuid?.(); + if ( + status === undefined || + !status.isDirectory() || + status.isSymbolicLink() || + (status.mode & 0o777) !== 0o700 || + (effectiveUser !== undefined && status.uid !== effectiveUser) + ) { + throw new ClusterAdministrationKubernetesInputStageError( + `${label} authority is invalid`, + ); + } +} + +function verifyWritableParent(parent: string, label: string): void { + const status = lstatSync(parent, { throwIfNoEntry: false }); + if ( + status === undefined || + !status.isDirectory() || + status.isSymbolicLink() || + (status.mode & 0o002) !== 0 + ) { + throw new ClusterAdministrationKubernetesInputStageError( + `${label} parent authority is invalid`, + ); + } +} + +function syncDirectory(directory: string): void { + const descriptor = openSync( + directory, + constants.O_RDONLY | (constants.O_DIRECTORY ?? 0), + ); + try { + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } +} + +function createPrivateDirectory(directory: string, label: string): void { + const parent = dirname(directory); + verifyWritableParent(parent, label); + try { + mkdirSync(directory, { mode: 0o700 }); + } catch (error) { + throw new ClusterAdministrationKubernetesInputStageError( + `${label} cannot be created`, + error, + ); + } + try { + verifyPrivateDirectory(directory, label); + syncDirectory(parent); + } catch (error) { + try { + rmdirSync(directory); + } catch { + // Preserve the original authority failure. + } + throw error; + } +} + +function prepareDeliveryDirectory(directory: string): void { + const existing = lstatSync(directory, { throwIfNoEntry: false }); + if (existing === undefined) { + createPrivateDirectory(directory, 'delivery directory'); + return; + } + verifyPrivateDirectory(directory, 'delivery directory'); +} + +function publishPrivateFile(filePath: string, bytes: Buffer): void { + const temporary = `${filePath}.stage`; + let descriptor: number | undefined; + try { + descriptor = openSync( + temporary, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + (constants.O_NOFOLLOW ?? 0), + 0o600, + ); + let offset = 0; + while (offset < bytes.length) { + offset += writeSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset, + ); + } + fsyncSync(descriptor); + const status = fstatSync(descriptor); + if ( + !status.isFile() || + status.size !== bytes.length || + (status.mode & 0o077) !== 0 + ) { + throw new ClusterAdministrationKubernetesInputStageError( + 'private staged input file authority is invalid', + ); + } + closeSync(descriptor); + descriptor = undefined; + linkSync(temporary, filePath); + unlinkSync(temporary); + } catch (error) { + if (descriptor !== undefined) closeSync(descriptor); + try { + unlinkSync(temporary); + } catch { + // Preserve the original publication failure. + } + if (error instanceof ClusterAdministrationKubernetesInputStageError) { + throw error; + } + throw new ClusterAdministrationKubernetesInputStageError( + 'private staged input cannot be published', + error, + ); + } +} + +export function stageClusterAdministrationKubernetesInputs( + pathsValue: ClusterAdministrationKubernetesInputStagePaths, +): Readonly { + exactObject(pathsValue); + const sourceDirectory = directoryPath( + pathsValue.sourceDirectory, + 'sourceDirectory', + ); + const targetDirectory = directoryPath( + pathsValue.targetDirectory, + 'targetDirectory', + ); + const deliveryDirectory = + pathsValue.deliveryDirectory === undefined + ? undefined + : directoryPath(pathsValue.deliveryDirectory, 'deliveryDirectory'); + if ( + sourceDirectory === targetDirectory || + sourceDirectory === deliveryDirectory || + targetDirectory === deliveryDirectory + ) { + throw new ClusterAdministrationKubernetesInputStageError( + 'source, target and delivery directories must be distinct', + ); + } + + const sourceRealDirectory = verifySourceDirectory(sourceDirectory); + if (lstatSync(targetDirectory, { throwIfNoEntry: false }) !== undefined) { + throw new ClusterAdministrationKubernetesInputStageError( + 'target directory must not already exist', + ); + } + createPrivateDirectory(targetDirectory, 'target directory'); + const published: string[] = []; + try { + for (const input of INPUTS) { + const sourceFile = confinedSourceFile( + sourceDirectory, + sourceRealDirectory, + input.name, + ); + const bytes = readStableSourceFile(sourceFile, input.maximumBytes); + const targetFile = join(targetDirectory, input.name); + try { + publishPrivateFile(targetFile, bytes); + published.push(targetFile); + } finally { + bytes.fill(0); + } + } + syncDirectory(targetDirectory); + if (deliveryDirectory !== undefined) { + prepareDeliveryDirectory(deliveryDirectory); + } + return Object.freeze({ + schemaVersion: 1, + component: 'qinglong3-security-administration-kubernetes-input-stage', + stagedFileCount: 4, + deliveryDirectoryPrepared: deliveryDirectory !== undefined, + }); + } catch (error) { + for (const targetFile of published.reverse()) { + try { + unlinkSync(targetFile); + } catch { + // The failed stage remains fail-closed and the Pod never starts main. + } + } + try { + syncDirectory(targetDirectory); + rmdirSync(targetDirectory); + } catch { + // Preserve the original staging failure. + } + throw error; + } +} diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStageCli.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStageCli.ts new file mode 100644 index 00000000..8b2241fd --- /dev/null +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStageCli.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { + ClusterAdministrationKubernetesInputStageError, + stageClusterAdministrationKubernetesInputs, +} from './clusterAdministrationKubernetesInputStage'; + +const USAGE = + 'Usage: ql3-security-admin-kubernetes-stage --source=/absolute/projected-input --target=/absolute/private-input [--delivery-directory=/absolute/private-delivery]'; + +function argumentsFrom(argv: readonly string[]) { + if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { + return Object.freeze({ kind: 'help' as const }); + } + const values = new Map(); + for (const argument of argv) { + const match = /^--(source|target|delivery-directory)=(\/.+)$/.exec( + argument, + ); + if (!match || values.has(match[1]!)) { + throw new ClusterAdministrationKubernetesInputStageError( + 'CLI arguments are invalid', + ); + } + values.set(match[1]!, match[2]!); + } + if (!values.has('source') || !values.has('target')) { + throw new ClusterAdministrationKubernetesInputStageError( + 'CLI arguments are invalid', + ); + } + return Object.freeze({ + kind: 'run' as const, + paths: Object.freeze({ + sourceDirectory: values.get('source')!, + targetDirectory: values.get('target')!, + ...(values.has('delivery-directory') + ? { deliveryDirectory: values.get('delivery-directory')! } + : {}), + }), + }); +} + +function failure(error: unknown): Readonly> { + const candidate = error as { + readonly name?: unknown; + readonly code?: unknown; + }; + return Object.freeze({ + schemaVersion: 1, + component: 'qinglong3-security-administration-kubernetes-input-stage', + event: 'stage_failed', + name: + typeof candidate?.name === 'string' && candidate.name.length <= 128 + ? candidate.name + : 'Error', + ...(typeof candidate?.code === 'string' && candidate.code.length <= 128 + ? { code: candidate.code } + : {}), + }); +} + +function main(argv: readonly string[]): void { + try { + const parsed = argumentsFrom(argv); + if (parsed.kind === 'help') { + process.stdout.write(`${USAGE}\n`); + return; + } + process.stdout.write( + `${JSON.stringify( + stageClusterAdministrationKubernetesInputs(parsed.paths), + )}\n`, + ); + } catch (error) { + process.stderr.write(`${JSON.stringify(failure(error))}\n`); + process.exitCode = + error instanceof ClusterAdministrationKubernetesInputStageError ? 64 : 1; + } +} + +if (require.main === module) { + main(process.argv.slice(2)); +} diff --git a/packages/ql3-cluster-admin/test/clusterAdministrationKubernetesInputStage.test.cjs b/packages/ql3-cluster-admin/test/clusterAdministrationKubernetesInputStage.test.cjs new file mode 100644 index 00000000..6eb7db7c --- /dev/null +++ b/packages/ql3-cluster-admin/test/clusterAdministrationKubernetesInputStage.test.cjs @@ -0,0 +1,187 @@ +const assert = require('node:assert/strict'); +const { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join, resolve } = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { afterEach, test } = require('node:test'); + +const { + ClusterAdministrationKubernetesInputStageError, + stageClusterAdministrationKubernetesInputs, +} = require('../dist/security-administration/clusterAdministrationKubernetesInputStage.js'); + +const roots = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +function projectedInput() { + const root = mkdtempSync(join(tmpdir(), 'ql3-security-admin-stage-')); + roots.push(root); + const sourceDirectory = join(root, 'projected'); + const versionDirectory = join(sourceDirectory, '..2026_08_25_00_00_00'); + mkdirSync(versionDirectory, { mode: 0o700, recursive: true }); + const inputs = { + 'command.json': '{"schemaVersion":1,"operation":"audit.list"}\n', + 'assertion.jwt': 'signed.assertion.value', + 'keyset.json': '{"keys":[]}', + pepper: 'A'.repeat(43), + }; + for (const [name, value] of Object.entries(inputs)) { + const versionFile = join(versionDirectory, name); + writeFileSync(versionFile, value, { mode: 0o440 }); + symlinkSync(join('..data', name), join(sourceDirectory, name)); + } + symlinkSync('..2026_08_25_00_00_00', join(sourceDirectory, '..data')); + return { + root, + sourceDirectory, + targetDirectory: join(root, 'private-input'), + deliveryDirectory: join(root, 'private-delivery'), + inputs, + }; +} + +function mode(filePath) { + return lstatSync(filePath).mode & 0o777; +} + +test('copies a Kubernetes projected Secret into a private immutable input boundary', () => { + const fixture = projectedInput(); + + const result = stageClusterAdministrationKubernetesInputs({ + sourceDirectory: fixture.sourceDirectory, + targetDirectory: fixture.targetDirectory, + }); + + assert.deepEqual(result, { + schemaVersion: 1, + component: 'qinglong3-security-administration-kubernetes-input-stage', + stagedFileCount: 4, + deliveryDirectoryPrepared: false, + }); + assert.equal(mode(fixture.targetDirectory), 0o700); + for (const [name, value] of Object.entries(fixture.inputs)) { + const target = join(fixture.targetDirectory, name); + assert.equal(mode(target), 0o600); + assert.equal(readFileSync(target, 'utf8'), value); + assert.equal(lstatSync(target).isSymbolicLink(), false); + } + assert.equal( + JSON.stringify(result).includes('signed.assertion.value'), + false, + ); + assert.equal(JSON.stringify(result).includes('A'.repeat(43)), false); +}); + +test('prepares a private persistent delivery directory without weakening it', () => { + const fixture = projectedInput(); + + const first = stageClusterAdministrationKubernetesInputs({ + sourceDirectory: fixture.sourceDirectory, + targetDirectory: fixture.targetDirectory, + deliveryDirectory: fixture.deliveryDirectory, + }); + + assert.equal(first.deliveryDirectoryPrepared, true); + assert.equal(mode(fixture.deliveryDirectory), 0o700); + + const secondTarget = join(fixture.root, 'second-private-input'); + const second = stageClusterAdministrationKubernetesInputs({ + sourceDirectory: fixture.sourceDirectory, + targetDirectory: secondTarget, + deliveryDirectory: fixture.deliveryDirectory, + }); + assert.equal(second.deliveryDirectoryPrepared, true); + assert.equal(mode(fixture.deliveryDirectory), 0o700); +}); + +test('rejects a projected input symlink that escapes the Secret authority', () => { + const fixture = projectedInput(); + const external = join(fixture.root, 'external-command.json'); + writeFileSync(external, 'outside', { mode: 0o400 }); + unlinkSync(join(fixture.sourceDirectory, 'command.json')); + symlinkSync(external, join(fixture.sourceDirectory, 'command.json')); + + assert.throws( + () => + stageClusterAdministrationKubernetesInputs({ + sourceDirectory: fixture.sourceDirectory, + targetDirectory: fixture.targetDirectory, + }), + (error) => + error instanceof ClusterAdministrationKubernetesInputStageError && + /escapes/.test(error.message), + ); + assert.throws(() => lstatSync(fixture.targetDirectory)); +}); + +test('rejects source material readable by every local process', () => { + const fixture = projectedInput(); + chmodSync(resolve(fixture.sourceDirectory, '..data', 'assertion.jwt'), 0o444); + + assert.throws( + () => + stageClusterAdministrationKubernetesInputs({ + sourceDirectory: fixture.sourceDirectory, + targetDirectory: fixture.targetDirectory, + }), + /file authority is invalid/, + ); + assert.throws(() => lstatSync(fixture.targetDirectory)); +}); + +test('never replaces an existing private input directory', () => { + const fixture = projectedInput(); + mkdirSync(fixture.targetDirectory, { mode: 0o700 }); + const sentinel = join(fixture.targetDirectory, 'sentinel'); + writeFileSync(sentinel, 'preserve', { mode: 0o600 }); + + assert.throws( + () => + stageClusterAdministrationKubernetesInputs({ + sourceDirectory: fixture.sourceDirectory, + targetDirectory: fixture.targetDirectory, + }), + /must not already exist/, + ); + assert.equal(readFileSync(sentinel, 'utf8'), 'preserve'); +}); + +test('CLI emits only bounded content-free failures', () => { + const cli = join( + __dirname, + '../dist/security-administration/clusterAdministrationKubernetesInputStageCli.js', + ); + const sensitive = 'ql3c_private-token-material'; + + const result = spawnSync( + process.execPath, + [cli, `--source=/${sensitive}`, '--target=relative'], + { encoding: 'utf8' }, + ); + + assert.equal(result.status, 64); + assert.equal(result.stdout, ''); + assert.equal(result.stderr.includes(sensitive), false); + assert.deepEqual(JSON.parse(result.stderr), { + schemaVersion: 1, + component: 'qinglong3-security-administration-kubernetes-input-stage', + event: 'stage_failed', + name: 'ClusterAdministrationKubernetesInputStageError', + code: 'QL3_CLUSTER_ADMINISTRATION_KUBERNETES_INPUT_STAGE_INVALID', + }); +}); diff --git a/scripts/ql3-deployment-lock-contract.cjs b/scripts/ql3-deployment-lock-contract.cjs index 66bbeb23..fe1e7840 100644 --- a/scripts/ql3-deployment-lock-contract.cjs +++ b/scripts/ql3-deployment-lock-contract.cjs @@ -30,7 +30,7 @@ const IMAGE_NAMES = Object.freeze({ const EXPECTED_SOURCE_SURFACES = Object.freeze({ control: 2, 'control-ai': 1, - admin: 26, + admin: 28, worker: 2, }); const ADMISSION_CONFIG_NAME = 'ql3-plugin-package-secret-action-admission'; diff --git a/scripts/ql3-security-administration-kubernetes-audit.cjs b/scripts/ql3-security-administration-kubernetes-audit.cjs new file mode 100644 index 00000000..696c8a64 --- /dev/null +++ b/scripts/ql3-security-administration-kubernetes-audit.cjs @@ -0,0 +1,341 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + +const NAME = 'ql3-security-administration'; +const PRIVATE_ROOT = '/var/run/qinglong3/security-administration-private'; +const DELIVERY_ROOT = + '/var/lib/qinglong3/security-administration-delivery/private'; + +function finding(code, detail) { + return Object.freeze({ code, detail }); +} + +function parse(readFile, filePath) { + return yaml.load(readFile(filePath, 'utf8')); +} + +function named(entries, name) { + return Array.isArray(entries) + ? entries.find((entry) => entry?.name === name) + : undefined; +} + +function environment(container) { + return new Map((container?.env ?? []).map((entry) => [entry.name, entry])); +} + +function hasExactResources(container) { + return ( + JSON.stringify(container?.resources) === + JSON.stringify({ + requests: { cpu: '25m', memory: '48Mi' }, + limits: { cpu: '250m', memory: '128Mi' }, + }) + ); +} + +function lockedContainer(container) { + return ( + container?.securityContext?.allowPrivilegeEscalation === false && + container?.securityContext?.readOnlyRootFilesystem === true && + JSON.stringify(container?.securityContext?.capabilities?.drop) === + JSON.stringify(['ALL']) && + hasExactResources(container) + ); +} + +function auditSecurityAdministrationKubernetes(options = {}) { + const root = options.root ?? path.resolve(__dirname, '..'); + const readFile = options.readFile ?? fs.readFileSync; + const operation = path.join( + root, + 'deploy/kubernetes/ql3-cluster/operations/security-administration', + ); + const findings = []; + try { + const base = path.join(operation, 'base'); + const baseKustomization = parse( + readFile, + path.join(base, 'kustomization.yaml'), + ); + const serviceAccount = parse( + readFile, + path.join(base, 'service-account.yaml'), + ); + const job = parse(readFile, path.join(base, 'job.yaml')); + const networkPolicy = parse( + readFile, + path.join(base, 'network-policy.yaml'), + ); + const pod = job?.spec?.template?.spec; + const administrator = named(pod?.containers, 'administrator'); + const stager = named(pod?.initContainers, 'stage-private-input'); + const input = named(pod?.volumes, 'projected-input'); + const privateInput = named(pod?.volumes, 'private-input'); + const postgresCa = named(pod?.volumes, 'postgres-ca'); + const adminEnv = environment(administrator); + + if ( + JSON.stringify(baseKustomization?.resources) !== + JSON.stringify([ + 'service-account.yaml', + 'job.yaml', + 'network-policy.yaml', + ]) || + JSON.stringify(baseKustomization).includes('rbac.authorization.k8s.io') + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_BASE_CLOSURE_INVALID', + 'base closure must contain exactly ServiceAccount, Job and NetworkPolicy without Kubernetes API RBAC', + ), + ); + } + + if ( + serviceAccount?.metadata?.name !== NAME || + serviceAccount?.automountServiceAccountToken !== false || + pod?.serviceAccountName !== NAME || + pod?.automountServiceAccountToken !== false || + pod?.enableServiceLinks !== false || + JSON.stringify(pod).includes('serviceAccountToken') + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_API_AUTHORITY_INVALID', + 'the one-shot Job must have no Kubernetes API token or RBAC authority', + ), + ); + } + + if ( + job?.metadata?.name !== NAME || + job?.metadata?.labels?.['qinglong.io/execution-model'] !== + 'caller-driven' || + job?.spec?.backoffLimit !== 0 || + job?.spec?.activeDeadlineSeconds !== 300 || + job?.spec?.ttlSecondsAfterFinished !== 600 || + pod?.restartPolicy !== 'Never' || + pod?.securityContext?.runAsNonRoot !== true || + pod?.securityContext?.runAsUser !== 10001 || + pod?.securityContext?.runAsGroup !== 10001 || + pod?.securityContext?.fsGroup !== 10001 || + pod?.securityContext?.seccompProfile?.type !== 'RuntimeDefault' + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_JOB_BOUNDARY_INVALID', + 'Job must remain caller-driven, non-root, non-retrying and deadline/TTL bounded', + ), + ); + } + + const expectedStagerCommand = [ + 'node', + '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/security-administration/clusterAdministrationKubernetesInputStageCli.js', + ]; + const expectedStagerArgs = [ + '--source=/var/run/secrets/qinglong3/security-administration-projected', + `--target=${PRIVATE_ROOT}/input`, + ]; + const expectedAdminCommand = [ + 'node', + '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/security-administration/clusterAdministrationCli.js', + ]; + const expectedAdminArgs = [ + `--command=${PRIVATE_ROOT}/input/command.json`, + `--assertion=${PRIVATE_ROOT}/input/assertion.jwt`, + `--keyset=${PRIVATE_ROOT}/input/keyset.json`, + `--pepper=${PRIVATE_ROOT}/input/pepper`, + ]; + if ( + JSON.stringify(stager?.command) !== + JSON.stringify(expectedStagerCommand) || + JSON.stringify(stager?.args) !== JSON.stringify(expectedStagerArgs) || + JSON.stringify(administrator?.command) !== + JSON.stringify(expectedAdminCommand) || + JSON.stringify(administrator?.args) !== + JSON.stringify(expectedAdminArgs) || + !lockedContainer(stager) || + !lockedContainer(administrator) || + JSON.stringify(pod).includes('/bin/sh') || + JSON.stringify(pod).includes('--delivery=') + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_PROCESS_INVALID', + 'base Job must directly execute the reviewed stager and non-delivery administrator with compact resources', + ), + ); + } + + if ( + input?.secret?.secretName !== 'ql3-security-administration-input' || + input?.secret?.defaultMode !== 0o440 || + JSON.stringify(input?.secret?.items?.map((entry) => entry.key)) !== + JSON.stringify([ + 'command.json', + 'assertion.jwt', + 'keyset.json', + 'pepper', + ]) || + privateInput?.emptyDir?.medium !== 'Memory' || + privateInput?.emptyDir?.sizeLimit !== '1Mi' || + postgresCa?.secret?.secretName !== 'ql3-security-administration-database' + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_INPUT_INVALID', + 'projected immutable inputs must be copied to a 1Mi memory-backed private boundary before use', + ), + ); + } + + if ( + adminEnv.get('QL3_POSTGRES_ADMIN_TLS_MODE')?.value !== 'verify-full' || + adminEnv.get('QL3_POSTGRES_ADMIN_TLS_CA_FILE')?.value !== + '/var/run/secrets/qinglong3/postgres-security-administration/ca.crt' || + adminEnv.get('QL3_POSTGRES_ADMIN_URL')?.valueFrom?.secretKeyRef?.name !== + 'ql3-security-administration-database' || + adminEnv.get('QL3_POSTGRES_ADMIN_TLS_SERVERNAME')?.valueFrom?.secretKeyRef + ?.name !== 'ql3-security-administration-database' + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_POSTGRES_INVALID', + 'generic base must use isolated Secret-backed admin credentials and verify-full TLS', + ), + ); + } + + if ( + networkPolicy?.spec?.ingress?.length !== 0 || + networkPolicy?.spec?.egress?.length !== 1 || + JSON.stringify(networkPolicy).includes('ipBlock') + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_NETWORK_INVALID', + 'generic base must deny ingress and expose only DNS until a reviewed database overlay is selected', + ), + ); + } + + const inputExample = parse( + readFile, + path.join(operation, 'input-secret.example.yaml'), + ); + const aggregate = parse( + readFile, + path.join( + root, + 'deploy/kubernetes/ql3-cluster/operations/kustomization.yaml', + ), + ); + if ( + inputExample?.immutable !== true || + inputExample?.metadata?.name !== 'ql3-security-administration-input' || + JSON.stringify(Object.keys(inputExample?.stringData ?? {}).sort()) !== + JSON.stringify( + ['command.json', 'assertion.jwt', 'keyset.json', 'pepper'].sort(), + ) || + JSON.stringify(aggregate).includes('security-administration') + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_OPT_IN_INVALID', + 'input authority must be immutable and the operation must remain absent from the shared deployment aggregate', + ), + ); + } + + const cnpgPatch = parse( + readFile, + path.join(operation, 'cloudnative-pg/job-patch.yaml'), + ); + const cnpgNetwork = parse( + readFile, + path.join(operation, 'cloudnative-pg/network-policy-patch.yaml'), + ); + const cnpgEnv = new Map( + ( + cnpgPatch?.find((entry) => entry.path.endsWith('/env'))?.value ?? [] + ).map((entry) => [entry.name, entry]), + ); + if ( + cnpgEnv.get('QL3_POSTGRES_ADMIN_HOST')?.value !== + 'ql3-postgres-rw.qinglong3-system.svc' || + cnpgEnv.get('QL3_POSTGRES_ADMIN_USER')?.valueFrom?.secretKeyRef?.name !== + 'ql3-postgres-admin-auth' || + cnpgEnv.get('QL3_POSTGRES_ADMIN_PASSWORD')?.valueFrom?.secretKeyRef + ?.name !== 'ql3-postgres-admin-auth' || + cnpgEnv.get('QL3_POSTGRES_ADMIN_TLS_MODE')?.value !== 'verify-full' || + cnpgNetwork?.spec?.egress?.length !== 2 || + !JSON.stringify(cnpgNetwork).includes('cnpg.io/cluster') || + !JSON.stringify(cnpgNetwork).includes('5432') + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_CNPG_INVALID', + 'CloudNativePG overlay must bind the dedicated ql3_admin Secret, RW service, verify-full TLS and only PostgreSQL egress', + ), + ); + } + + const deliveryPatch = parse( + readFile, + path.join(operation, 'credential-delivery/component/job-patch.yaml'), + ); + const deliveryText = JSON.stringify(deliveryPatch); + const combined = parse( + readFile, + path.join( + operation, + 'cloudnative-pg-credential-delivery/kustomization.yaml', + ), + ); + if ( + !deliveryText.includes(`--delivery-directory=${DELIVERY_ROOT}`) || + !deliveryText.includes( + `--delivery=${DELIVERY_ROOT}/replace-with-unique-delivery.json`, + ) || + !deliveryText.includes('ql3-security-administration-delivery') || + !deliveryText.includes('persistentVolumeClaim') || + JSON.stringify(combined?.components) !== + JSON.stringify(['../credential-delivery/component']) + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_DELIVERY_INVALID', + 'credential issue/rotate must opt into one reusable persistent no-replace delivery component', + ), + ); + } + } catch (error) { + findings.push( + finding( + 'QL3_SECURITY_ADMIN_KUBERNETES_AUDIT_UNAVAILABLE', + error instanceof Error ? error.message : String(error), + ), + ); + } + return Object.freeze({ + schemaVersion: 1, + executionModel: 'opt-in-caller-driven-one-shot', + residentResourceOverhead: 'zero', + databaseConnectionsPerExecution: 1, + findings: Object.freeze(findings), + compatible: findings.length === 0, + }); +} + +if (require.main === module) { + const report = auditSecurityAdministrationKubernetes(); + process.stdout.write(`${JSON.stringify(report)}\n`); + if (!report.compatible) process.exitCode = 1; +} + +module.exports = { auditSecurityAdministrationKubernetes }; diff --git a/test/back/ql3ClusterDeploymentAudit.test.cjs b/test/back/ql3ClusterDeploymentAudit.test.cjs index 69b3bfc5..1f8bd182 100644 --- a/test/back/ql3ClusterDeploymentAudit.test.cjs +++ b/test/back/ql3ClusterDeploymentAudit.test.cjs @@ -34,7 +34,7 @@ test('accepts the exact locked non-root multi-replica cluster deployment', () => ); assert.equal(report.clusterCopilotMcpHost, 'external-host-stdio'); assert.equal(report.promptOutputKeyRotation, 'caller-driven-staged-material'); - assert.equal(report.clusterAdminImageReferences, 24); + assert.equal(report.clusterAdminImageReferences, 26); assert.deepEqual(report.workspacePackages, [ '@qinglong/runtime-core', '@qinglong/cluster-postgres', @@ -92,7 +92,7 @@ test('requires every Cluster Admin Kubernetes workload to override the image com ), }); assert.equal(report.compatible, false); - assert.equal(report.clusterAdminImageReferences, 24); + assert.equal(report.clusterAdminImageReferences, 26); assert.equal( report.findings.some( ({ code }) => code === 'QL3_CLUSTER_ADMIN_IMAGE_COMMAND_IMPLICIT', diff --git a/test/back/ql3DeploymentLockContract.test.cjs b/test/back/ql3DeploymentLockContract.test.cjs index c19a1622..2e33d303 100644 --- a/test/back/ql3DeploymentLockContract.test.cjs +++ b/test/back/ql3DeploymentLockContract.test.cjs @@ -919,11 +919,11 @@ test('CLI rejects symlinks, open arguments, output aliasing and policy ambiguity test('source-surface audit freezes every reviewed cluster and worker authority', () => { assert.deepEqual(auditDeploymentImageSurfaces(root), { schemaVersion: 1, - deploymentYamlFiles: 227, + deploymentYamlFiles: 241, imageOccurrences: { control: 2, 'control-ai': 1, - admin: 26, + admin: 28, worker: 2, }, admissionAuthorityCount: 2, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index a135820a..75691e70 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, }, { - sourceFiles: 132, + sourceFiles: 134, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 131, + nestedSourceFiles: 133, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', }, diff --git a/test/back/ql3SecurityAdministrationKubernetesAudit.test.cjs b/test/back/ql3SecurityAdministrationKubernetesAudit.test.cjs new file mode 100644 index 00000000..7f28d1e0 --- /dev/null +++ b/test/back/ql3SecurityAdministrationKubernetesAudit.test.cjs @@ -0,0 +1,83 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + auditSecurityAdministrationKubernetes, +} = require('../../scripts/ql3-security-administration-kubernetes-audit.cjs'); + +const ROOT = path.resolve(__dirname, '../..'); + +function intercept(relativePath, transform) { + const target = path.join(ROOT, relativePath); + return (filePath, encoding) => { + const value = fs.readFileSync(filePath, encoding); + return path.resolve(filePath) === target ? transform(value) : value; + }; +} + +test('accepts the opt-in one-shot security administration deployment', () => { + const report = auditSecurityAdministrationKubernetes({ root: ROOT }); + + assert.equal(report.compatible, true, JSON.stringify(report.findings)); + assert.equal(report.executionModel, 'opt-in-caller-driven-one-shot'); + assert.equal(report.residentResourceOverhead, 'zero'); + assert.equal(report.databaseConnectionsPerExecution, 1); +}); + +test('rejects Kubernetes API token authority in the administration Job', () => { + const report = auditSecurityAdministrationKubernetes({ + root: ROOT, + readFile: intercept( + 'deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml', + (value) => + value.replace( + 'automountServiceAccountToken: false', + 'automountServiceAccountToken: true', + ), + ), + }); + + assert.equal(report.compatible, false); + assert.ok( + report.findings.some( + ({ code }) => + code === 'QL3_SECURITY_ADMIN_KUBERNETES_API_AUTHORITY_INVALID', + ), + ); +}); + +test('rejects a non-persistent credential delivery boundary', () => { + const report = auditSecurityAdministrationKubernetes({ + root: ROOT, + readFile: intercept( + 'deploy/kubernetes/ql3-cluster/operations/security-administration/credential-delivery/component/job-patch.yaml', + (value) => value.replace('persistentVolumeClaim:', 'emptyDir:'), + ), + }); + + assert.equal(report.compatible, false); + assert.ok( + report.findings.some( + ({ code }) => code === 'QL3_SECURITY_ADMIN_KUBERNETES_DELIVERY_INVALID', + ), + ); +}); + +test('rejects accidental installation in the shared cluster aggregate', () => { + const report = auditSecurityAdministrationKubernetes({ + root: ROOT, + readFile: intercept( + 'deploy/kubernetes/ql3-cluster/operations/kustomization.yaml', + (value) => `${value} - security-administration/base\n`, + ), + }); + + assert.equal(report.compatible, false); + assert.ok( + report.findings.some( + ({ code }) => code === 'QL3_SECURITY_ADMIN_KUBERNETES_OPT_IN_INVALID', + ), + ); +}); diff --git a/test/back/ql3VersionTransition.test.cjs b/test/back/ql3VersionTransition.test.cjs index 1b2ef9c9..153c1d26 100644 --- a/test/back/ql3VersionTransition.test.cjs +++ b/test/back/ql3VersionTransition.test.cjs @@ -102,9 +102,9 @@ test('audits one source-derived QingLong 3 release identity', () => { legacyRootExcluded: true, workspacePackageCount: 18, containerRootCount: 4, - deploymentFileCount: 246, - deploymentImageReferences: 32, - deploymentVersionOccurrences: 36, + deploymentFileCount: 260, + deploymentImageReferences: 34, + deploymentVersionOccurrences: 38, compatible: true, }); }); @@ -115,8 +115,8 @@ test('plans the exact governed version surface without touching legacy 2.x', () sourceVersion: SOURCE_VERSION, targetVersion: TARGET_VERSION, }); - assert.equal(plan.fileCount, 65); - assert.equal(plan.replacementCount, 83); + assert.equal(plan.fileCount, 66); + assert.equal(plan.replacementCount, 85); assert.equal(plan.legacyRootPackageVersion, LEGACY_VERSION); assert.equal(plan.legacyRootExcluded, true); assert.equal(