feat(ql3): add opt-in security administration job

This commit is contained in:
whyour
2026-08-25 03:15:37 +08:00
parent fbedae0116
commit 931f9eee38
31 changed files with 1606 additions and 14 deletions
+1
View File
@@ -61,6 +61,7 @@ jobs:
pnpm audit:edge-imports:ql3 pnpm audit:edge-imports:ql3
pnpm audit:cluster-dependencies:ql3 pnpm audit:cluster-dependencies:ql3
pnpm audit:cluster-deployment:ql3 pnpm audit:cluster-deployment:ql3
pnpm audit:security-administration-kubernetes:ql3
- name: Smoke benchmark edge executor - name: Smoke benchmark edge executor
run: pnpm benchmark:edge -- --json run: pnpm benchmark:edge -- --json
- name: Prove disabled AI Profile has zero storage or credential reachability - name: Prove disabled AI Profile has zero storage or credential reachability
+12
View File
@@ -76,6 +76,18 @@ command schemas and operator procedure are documented in
response-loss behavior and remaining gates are frozen in response-loss behavior and remaining gates are frozen in
[`ADR-0500`](../../../docs/adr/ADR-0500-short-lived-cluster-security-administration-command.md). [`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 Workstation operators may pass an explicit owner-private context to reuse only
stable client paths: stable client paths:
@@ -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
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- service-account.yaml
- job.yaml
- 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
@@ -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
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../cloudnative-pg
components:
- ../credential-delivery/component
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+3 -1
View File
@@ -11,6 +11,8 @@
最新增量证据(2026-08-25): 最新增量证据(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 componenttoken 只进入唯一 `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,拒绝其他管理面的 assertioncommand、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/418-package clean build/test 退出 0backend 为 `1575 total / 1573 pass / 2 conditional skip / 0 fail`。真实 arm64 Admin 镜像在 non-root、read-only、无网络/能力、128 MiB/0.25 CPU 下通过 12-command live contractPostgreSQL 18.6 arm64 physical HA timeline 1→2 的 147 gates 全部通过,报告 SHA-256 为 `8fbb606773080dae15de5e31db5726abb8700862b51e4616b5f3f50e0b8374f3`。package、Cluster dependency、122-module Edge import、部署、镜像与 service bridge 审计全部 compatibleEdge/Standalone package、依赖和常驻资源不变。本切片关闭 ADR-0050“只有 application service、没有受审产品入口”的缺口,但不冒充远程 HTTP/API/UI、双人复核/break-glass、pepper rotation、audit retention/export/alert 或完整 Kubernetes Job ceremony。 - 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,拒绝其他管理面的 assertioncommand、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/418-package clean build/test 退出 0backend 为 `1575 total / 1573 pass / 2 conditional skip / 0 fail`。真实 arm64 Admin 镜像在 non-root、read-only、无网络/能力、128 MiB/0.25 CPU 下通过 12-command live contractPostgreSQL 18.6 arm64 physical HA timeline 1→2 的 147 gates 全部通过,报告 SHA-256 为 `8fbb606773080dae15de5e31db5726abb8700862b51e4616b5f3f50e0b8374f3`。package、Cluster dependency、122-module Edge import、部署、镜像与 service bridge 审计全部 compatibleEdge/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 退出 0package、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 与断电恢复门。 - 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 退出 0package、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。 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` 产品 CLIcredential 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` 产品 CLIcredential 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 ChildProcesssystem crond 的 `scheduled_system`、once/boot/grpc 与这些来源的 Primary owner 切换仍须独立门禁。 未进入当前孵化切片的代码在通过对应 Gate 前必须保持不可达:不得仅因 schema、service 或 Primary 编排器已存在,就让旧 Controller、Scheduler、gRPC callback 或 Shell 脚本直接写入新状态表或调用新 Executor。已接入的 Shadow 观察只能通过默认关闭的 Feature Flag 和 origin owner 决策到达,不得调用 Executor、再次 spawn 或改变 Legacy 返回结果。manual `runSingle` 只增加 owner selection seam;默认没有 router。HTTP bootstrap 每次启动只读取一次 manifest,缺失、禁用、拒绝或非 primary 时保持 Legacy 且不加载重组件;显式 accepted manual primary 会在恢复门禁通过后安装唯一 owner,选中后禁止回退双跑。ADR-0445 已让 `ScheduleService.runTask` 的 subscription/system/script 在显式 origin flag 下只观察同一个 Legacy ChildProcesssystem crond 的 `scheduled_system`、once/boot/grpc 与这些来源的 Primary owner 切换仍须独立门禁。
@@ -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 egressCloudNativePG 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 MiBlimit 为 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 failbackend 为 1579 total / 1577 pass / 2 conditional skip / 0 fail。Edge import 仍为 122 modulesCluster 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。
+1
View File
@@ -504,6 +504,7 @@
| [ADR-0498](./ADR-0498-cluster-legacy-env-application-ha-replay.md) | Cluster Legacy Env Application 的 HA Promotion 后精确重放 | Accepted | | [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-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-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 |
## 规则 ## 规则
@@ -125,6 +125,32 @@ ql3-cluster-admin security \
成功签发或轮换时,stdout 只包含 delivery 文件名和 SHA-256。token 只存在于新建的 `0600` delivery 文件。目标已存在时命令失败且绝不覆盖。精确重放返回 `status=existing` 且不重新发布 token;如果首次响应丢失,先检查原 delivery 文件,确实丢失时使用新的 mutationId 执行 rotate,不能尝试恢复旧 token。 成功签发或轮换时,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/rotatePVC 中的 `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)
+1
View File
@@ -92,6 +92,7 @@
"audit:vault-kv-worker-secret-deployment:ql3": "node scripts/ql3-vault-kv-worker-secret-deployment-audit.cjs", "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", "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: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:ql3": "node scripts/ql3-cluster-copilot-console-audit.cjs",
"audit:cluster-copilot-console-distribution:ql3": "node scripts/ql3-cluster-copilot-console-distribution-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", "evidence:cluster-admin-release-workstation:ql3": "node scripts/ql3-cluster-admin-release-workstation-ceremony.cjs",
@@ -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<ClusterAdministrationKubernetesInputStageResult> {
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;
}
}
@@ -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<string, string>();
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<Record<string, unknown>> {
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));
}
@@ -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',
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ const IMAGE_NAMES = Object.freeze({
const EXPECTED_SOURCE_SURFACES = Object.freeze({ const EXPECTED_SOURCE_SURFACES = Object.freeze({
control: 2, control: 2,
'control-ai': 1, 'control-ai': 1,
admin: 26, admin: 28,
worker: 2, worker: 2,
}); });
const ADMISSION_CONFIG_NAME = 'ql3-plugin-package-secret-action-admission'; const ADMISSION_CONFIG_NAME = 'ql3-plugin-package-secret-action-admission';
@@ -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 };
+2 -2
View File
@@ -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.clusterCopilotMcpHost, 'external-host-stdio');
assert.equal(report.promptOutputKeyRotation, 'caller-driven-staged-material'); assert.equal(report.promptOutputKeyRotation, 'caller-driven-staged-material');
assert.equal(report.clusterAdminImageReferences, 24); assert.equal(report.clusterAdminImageReferences, 26);
assert.deepEqual(report.workspacePackages, [ assert.deepEqual(report.workspacePackages, [
'@qinglong/runtime-core', '@qinglong/runtime-core',
'@qinglong/cluster-postgres', '@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.compatible, false);
assert.equal(report.clusterAdminImageReferences, 24); assert.equal(report.clusterAdminImageReferences, 26);
assert.equal( assert.equal(
report.findings.some( report.findings.some(
({ code }) => code === 'QL3_CLUSTER_ADMIN_IMAGE_COMMAND_IMPLICIT', ({ code }) => code === 'QL3_CLUSTER_ADMIN_IMAGE_COMMAND_IMPLICIT',
+2 -2
View File
@@ -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', () => { test('source-surface audit freezes every reviewed cluster and worker authority', () => {
assert.deepEqual(auditDeploymentImageSurfaces(root), { assert.deepEqual(auditDeploymentImageSurfaces(root), {
schemaVersion: 1, schemaVersion: 1,
deploymentYamlFiles: 227, deploymentYamlFiles: 241,
imageOccurrences: { imageOccurrences: {
control: 2, control: 2,
'control-ai': 1, 'control-ai': 1,
admin: 26, admin: 28,
worker: 2, worker: 2,
}, },
admissionAuthorityCount: 2, admissionAuthorityCount: 2,
+2 -2
View File
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
}, },
{ {
sourceFiles: 132, sourceFiles: 134,
rootSourceFiles: 1, rootSourceFiles: 1,
rootSourceLines: 61, rootSourceLines: 61,
nestedSourceFiles: 131, nestedSourceFiles: 133,
rootSourceFileRoles: { rootSourceFileRoles: {
'modelInvocationMigrationCli.ts': 'binary_entry', 'modelInvocationMigrationCli.ts': 'binary_entry',
}, },
@@ -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',
),
);
});
+5 -5
View File
@@ -102,9 +102,9 @@ test('audits one source-derived QingLong 3 release identity', () => {
legacyRootExcluded: true, legacyRootExcluded: true,
workspacePackageCount: 18, workspacePackageCount: 18,
containerRootCount: 4, containerRootCount: 4,
deploymentFileCount: 246, deploymentFileCount: 260,
deploymentImageReferences: 32, deploymentImageReferences: 34,
deploymentVersionOccurrences: 36, deploymentVersionOccurrences: 38,
compatible: true, compatible: true,
}); });
}); });
@@ -115,8 +115,8 @@ test('plans the exact governed version surface without touching legacy 2.x', ()
sourceVersion: SOURCE_VERSION, sourceVersion: SOURCE_VERSION,
targetVersion: TARGET_VERSION, targetVersion: TARGET_VERSION,
}); });
assert.equal(plan.fileCount, 65); assert.equal(plan.fileCount, 66);
assert.equal(plan.replacementCount, 83); assert.equal(plan.replacementCount, 85);
assert.equal(plan.legacyRootPackageVersion, LEGACY_VERSION); assert.equal(plan.legacyRootPackageVersion, LEGACY_VERSION);
assert.equal(plan.legacyRootExcluded, true); assert.equal(plan.legacyRootExcluded, true);
assert.equal( assert.equal(