mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add opt-in security administration job
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- service-account.yaml
|
||||
- job.yaml
|
||||
- network-policy.yaml
|
||||
+31
@@ -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
|
||||
+10
@@ -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
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../cloudnative-pg
|
||||
|
||||
components:
|
||||
- ../credential-delivery/component
|
||||
+31
@@ -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
|
||||
+24
@@ -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
|
||||
+26
@@ -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
|
||||
+22
@@ -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
|
||||
+10
@@ -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
|
||||
+13
@@ -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
|
||||
+12
@@ -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
|
||||
+12
@@ -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
|
||||
+14
@@ -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
|
||||
@@ -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 切换仍须独立门禁。
|
||||
|
||||
|
||||
@@ -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。
|
||||
@@ -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 |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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",
|
||||
|
||||
+431
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user