diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index d6acc7fb..2efc372d 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -8,6 +8,12 @@ on: branches: - next workflow_dispatch: + inputs: + produce_alpha_artifacts: + description: Package tested native images as commit-bound Alpha artifacts + required: true + default: false + type: boolean permissions: contents: read @@ -471,6 +477,65 @@ jobs: --report="${{ runner.temp }}/ql3-local-api-cancellation/edge.json" node scripts/ql3-local-api-cancellation-live-audit.cjs \ --report="${{ runner.temp }}/ql3-local-api-cancellation/standalone.json" + - name: Package the tested native Local Alpha image + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts + env: + IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} + IMAGE_ARCH: ${{ matrix.image_arch }} + BUNDLE_ROOT: ${{ runner.temp }}/ql3-alpha-local-${{ matrix.image_arch }} + run: | + set -euo pipefail + umask 077 + mkdir "${BUNDLE_ROOT}" + archive="${BUNDLE_ROOT}/qinglong3-local-application-${IMAGE_ARCH}.docker.tar" + docker image save --output "${archive}" "${IMAGE}" + cp "${RUNNER_TEMP}/ql3-local-application.cdx.json" \ + "${BUNDLE_ROOT}/qinglong3-local-application.cdx.json" + cp docs/operations/ql3-alpha-candidate.md "${BUNDLE_ROOT}/README.md" + archive_sha256="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)" + ARCHIVE="${archive}" ARCHIVE_SHA256="${archive_sha256}" \ + MANIFEST="${BUNDLE_ROOT}/manifest.json" \ + node <<'NODE' + const fs = require('node:fs'); + const childProcess = require('node:child_process'); + const release = require('./ql3-release.json'); + const imageId = childProcess + .execFileSync('docker', ['image', 'inspect', '--format', '{{.Id}}', process.env.IMAGE], { encoding: 'utf8' }) + .trim(); + const manifest = { + schemaVersion: 1, + schema: 'qinglong/alpha-native-image@v1', + maturity: 'alpha_candidate_not_public_release', + product: 'local', + version: release.version, + sourceRevision: process.env.GITHUB_SHA, + architecture: process.env.IMAGE_ARCH, + image: process.env.IMAGE, + imageId, + archive: process.env.ARCHIVE.split('/').pop(), + archiveSha256: process.env.ARCHIVE_SHA256, + verification: { + osVulnerabilityPolicy: 'passed', + sbomInventoryReconciliation: 'passed', + router128MiBEntrypoint: 'passed', + edgeFreshLifecycle: 'passed', + standaloneFreshLifecycle: 'passed', + localApiCancellation: 'passed', + }, + }; + fs.writeFileSync(process.env.MANIFEST, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' }); + NODE + - name: Upload the tested native Local Alpha image + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-alpha-${{ github.sha }}-local-${{ matrix.image_arch }} + path: ${{ runner.temp }}/ql3-alpha-local-${{ matrix.image_arch }} + if-no-files-found: error + retention-days: 30 + compression-level: 0 + overwrite: false + include-hidden-files: false cluster-image: name: Cluster ${{ matrix.image }} image (${{ matrix.image_arch }}) @@ -663,6 +728,66 @@ jobs: scripts/ql3-cluster-image-sbom.cjs --image=${{ matrix.image }} --inventory-root=/opt/qinglong/node_modules + - name: Package the tested native Cluster Alpha image + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts + env: + IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }} + IMAGE_ARCH: ${{ matrix.image_arch }} + IMAGE_ROLE: ${{ matrix.image }} + IMAGE_REPOSITORY: ${{ matrix.repository }} + BUNDLE_ROOT: ${{ runner.temp }}/ql3-alpha-${{ matrix.image }}-${{ matrix.image_arch }} + run: | + set -euo pipefail + umask 077 + mkdir "${BUNDLE_ROOT}" + archive="${BUNDLE_ROOT}/${IMAGE_REPOSITORY}-${IMAGE_ARCH}.docker.tar" + docker image save --output "${archive}" "${IMAGE}" + cp "${RUNNER_TEMP}/ql3-cluster-${IMAGE_ROLE}.cdx.json" \ + "${BUNDLE_ROOT}/${IMAGE_REPOSITORY}.cdx.json" + cp docs/operations/ql3-alpha-candidate.md "${BUNDLE_ROOT}/README.md" + archive_sha256="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)" + ARCHIVE="${archive}" ARCHIVE_SHA256="${archive_sha256}" \ + MANIFEST="${BUNDLE_ROOT}/manifest.json" \ + node <<'NODE' + const fs = require('node:fs'); + const childProcess = require('node:child_process'); + const release = require('./ql3-release.json'); + const imageId = childProcess + .execFileSync('docker', ['image', 'inspect', '--format', '{{.Id}}', process.env.IMAGE], { encoding: 'utf8' }) + .trim(); + const manifest = { + schemaVersion: 1, + schema: 'qinglong/alpha-native-image@v1', + maturity: 'alpha_candidate_not_public_release', + product: 'cluster', + role: process.env.IMAGE_ROLE, + version: release.version, + sourceRevision: process.env.GITHUB_SHA, + architecture: process.env.IMAGE_ARCH, + image: process.env.IMAGE, + imageId, + archive: process.env.ARCHIVE.split('/').pop(), + archiveSha256: process.env.ARCHIVE_SHA256, + verification: { + osVulnerabilityPolicy: 'passed', + sbomInventoryReconciliation: 'passed', + nonRootRuntimeIdentity: 'passed', + clusterAdminProductFacade: process.env.IMAGE_ROLE === 'admin' ? 'passed' : 'not_applicable', + }, + }; + fs.writeFileSync(process.env.MANIFEST, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' }); + NODE + - name: Upload the tested native Cluster Alpha image + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-alpha-${{ github.sha }}-${{ matrix.image }}-${{ matrix.image_arch }} + path: ${{ runner.temp }}/ql3-alpha-${{ matrix.image }}-${{ matrix.image_arch }} + if-no-files-found: error + retention-days: 30 + compression-level: 0 + overwrite: false + include-hidden-files: false cluster-console-capacity-release-evidence: name: Cross-architecture Cluster Copilot Console capacity evidence diff --git a/deploy/console/ql3-cluster-copilot/README.md b/deploy/console/ql3-cluster-copilot/README.md index edcdb329..765600a9 100644 --- a/deploy/console/ql3-cluster-copilot/README.md +++ b/deploy/console/ql3-cluster-copilot/README.md @@ -42,7 +42,7 @@ deploy/console/ql3-cluster-copilot/verify-release.sh \ ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:REPLACE_64_HEX \ replace-owner/qinglong \ REPLACE_40_HEX_SOURCE_REVISION \ - refs/tags/v3.0.0-alpha.0 + refs/tags/v3.0.0-alpha.1 ``` For a release acceptance ceremony, prefer the source-tag workstation runner. @@ -57,7 +57,7 @@ node scripts/ql3-cluster-admin-release-workstation-ceremony.cjs \ --image=ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:REPLACE_64_HEX \ --repository=replace-owner/qinglong \ --source-revision=REPLACE_40_HEX_SOURCE_REVISION \ - --source-ref=refs/tags/v3.0.0-alpha.0 \ + --source-ref=refs/tags/v3.0.0-alpha.1 \ --cosign=/canonical/absolute/cosign \ --gh=/canonical/absolute/gh \ --docker=/canonical/absolute/docker \ @@ -81,7 +81,7 @@ node scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs \ --image=ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:REPLACE_64_HEX \ --repository=replace-owner/qinglong \ --source-revision=REPLACE_40_HEX_SOURCE_REVISION \ - --source-ref=refs/tags/v3.0.0-alpha.0 + --source-ref=refs/tags/v3.0.0-alpha.1 ``` The offline audit verifies canonical encoding, exact structure, tool and diff --git a/deploy/containers/ql3-cluster-admin/Dockerfile b/deploy/containers/ql3-cluster-admin/Dockerfile index c5a3c535..e66215a8 100644 --- a/deploy/containers/ql3-cluster-admin/Dockerfile +++ b/deploy/containers/ql3-cluster-admin/Dockerfile @@ -103,7 +103,7 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Admin" \ org.opencontainers.image.source="https://github.com/whyour/qinglong" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.version="3.0.0-alpha.0" + org.opencontainers.image.version="3.0.0-alpha.1" ENV NODE_ENV=production diff --git a/deploy/containers/ql3-cluster-admin/package-lock.json b/deploy/containers/ql3-cluster-admin/package-lock.json index 7bd5a651..47931fa2 100644 --- a/deploy/containers/ql3-cluster-admin/package-lock.json +++ b/deploy/containers/ql3-cluster-admin/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qinglong/cluster-admin-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qinglong/cluster-admin-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "license": "Apache-2.0", "dependencies": { "@kubernetes/client-node": "1.4.0", diff --git a/deploy/containers/ql3-cluster-admin/package.json b/deploy/containers/ql3-cluster-admin/package.json index 584793f0..7220ef79 100644 --- a/deploy/containers/ql3-cluster-admin/package.json +++ b/deploy/containers/ql3-cluster-admin/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/cluster-admin-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "Locked external dependencies for QingLong 3.0 cluster operations and bounded Copilot surfaces", "license": "Apache-2.0", diff --git a/deploy/containers/ql3-cluster-admin/runtime-dependencies/package-lock.json b/deploy/containers/ql3-cluster-admin/runtime-dependencies/package-lock.json index d6e83820..71d93f29 100644 --- a/deploy/containers/ql3-cluster-admin/runtime-dependencies/package-lock.json +++ b/deploy/containers/ql3-cluster-admin/runtime-dependencies/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qinglong/cluster-admin-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qinglong/cluster-admin-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "license": "Apache-2.0", "dependencies": { "@kubernetes/client-node": "1.4.0", diff --git a/deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json b/deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json index 3919a282..131790a7 100644 --- a/deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json +++ b/deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/cluster-admin-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "Production-only external dependency root for QingLong 3.0 cluster operations and bounded Copilot surfaces", "license": "Apache-2.0", diff --git a/deploy/containers/ql3-cluster-control/Dockerfile b/deploy/containers/ql3-cluster-control/Dockerfile index 028beb96..c68d4ad9 100644 --- a/deploy/containers/ql3-cluster-control/Dockerfile +++ b/deploy/containers/ql3-cluster-control/Dockerfile @@ -98,7 +98,7 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Control" \ org.opencontainers.image.source="https://github.com/whyour/qinglong" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.version="3.0.0-alpha.0" + org.opencontainers.image.version="3.0.0-alpha.1" ENV NODE_ENV=production diff --git a/deploy/containers/ql3-cluster-control/package-lock.json b/deploy/containers/ql3-cluster-control/package-lock.json index 0e84df12..49a48d23 100644 --- a/deploy/containers/ql3-cluster-control/package-lock.json +++ b/deploy/containers/ql3-cluster-control/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qinglong/cluster-control-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qinglong/cluster-control-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-s3": "3.1093.0", diff --git a/deploy/containers/ql3-cluster-control/package.json b/deploy/containers/ql3-cluster-control/package.json index 67567a59..2012b23d 100644 --- a/deploy/containers/ql3-cluster-control/package.json +++ b/deploy/containers/ql3-cluster-control/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/cluster-control-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "Locked external runtime dependencies for the QingLong 3.0 cluster-control image", "license": "Apache-2.0", diff --git a/deploy/containers/ql3-cluster-control/runtime-dependencies/package-lock.json b/deploy/containers/ql3-cluster-control/runtime-dependencies/package-lock.json index 2cf8dabf..08f39bca 100644 --- a/deploy/containers/ql3-cluster-control/runtime-dependencies/package-lock.json +++ b/deploy/containers/ql3-cluster-control/runtime-dependencies/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qinglong/cluster-control-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qinglong/cluster-control-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-s3": "3.1093.0", diff --git a/deploy/containers/ql3-cluster-control/runtime-dependencies/package.json b/deploy/containers/ql3-cluster-control/runtime-dependencies/package.json index 7e01665d..266c8153 100644 --- a/deploy/containers/ql3-cluster-control/runtime-dependencies/package.json +++ b/deploy/containers/ql3-cluster-control/runtime-dependencies/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/cluster-control-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "Production-only external dependency root for the QingLong 3.0 cluster-control image", "license": "Apache-2.0", diff --git a/deploy/containers/ql3-local-application/Dockerfile b/deploy/containers/ql3-local-application/Dockerfile index adc4941d..0d738d6a 100644 --- a/deploy/containers/ql3-local-application/Dockerfile +++ b/deploy/containers/ql3-local-application/Dockerfile @@ -139,7 +139,7 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Local Application" \ org.opencontainers.image.source="https://github.com/whyour/qinglong" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.version="3.0.0-alpha.0" \ + org.opencontainers.image.version="3.0.0-alpha.1" \ io.qinglong.profile="edge,standalone" \ io.qinglong.ai="excluded" \ io.qinglong.local.application-config="2,3,4" \ diff --git a/deploy/containers/ql3-local-application/package-lock.json b/deploy/containers/ql3-local-application/package-lock.json index 39807db3..4a835222 100644 --- a/deploy/containers/ql3-local-application/package-lock.json +++ b/deploy/containers/ql3-local-application/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qinglong/local-application-image", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qinglong/local-application-image", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "license": "Apache-2.0", "dependencies": { "croner": "7.0.8", diff --git a/deploy/containers/ql3-local-application/package.json b/deploy/containers/ql3-local-application/package.json index f2172ba1..428d0e85 100644 --- a/deploy/containers/ql3-local-application/package.json +++ b/deploy/containers/ql3-local-application/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-application-image", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "Locked build dependencies for the QingLong 3.0 AI-excluded local application image", "license": "Apache-2.0", diff --git a/deploy/containers/ql3-local-application/runtime-dependencies/package-lock.json b/deploy/containers/ql3-local-application/runtime-dependencies/package-lock.json index 09f3f748..c66ea739 100644 --- a/deploy/containers/ql3-local-application/runtime-dependencies/package-lock.json +++ b/deploy/containers/ql3-local-application/runtime-dependencies/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qinglong/local-application-image", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qinglong/local-application-image", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "license": "Apache-2.0", "dependencies": { "croner": "7.0.8", diff --git a/deploy/containers/ql3-local-application/runtime-dependencies/package.json b/deploy/containers/ql3-local-application/runtime-dependencies/package.json index 8e55956b..67949e7a 100644 --- a/deploy/containers/ql3-local-application/runtime-dependencies/package.json +++ b/deploy/containers/ql3-local-application/runtime-dependencies/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-application-image", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "Production-only external dependency root for the QingLong 3.0 AI-excluded local application image", "license": "Apache-2.0", diff --git a/deploy/containers/ql3-worker/Dockerfile b/deploy/containers/ql3-worker/Dockerfile index f4d20338..49a4bdf1 100644 --- a/deploy/containers/ql3-worker/Dockerfile +++ b/deploy/containers/ql3-worker/Dockerfile @@ -81,7 +81,7 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Worker" \ org.opencontainers.image.source="https://github.com/whyour/qinglong" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.version="3.0.0-alpha.0" \ + org.opencontainers.image.version="3.0.0-alpha.1" \ io.qinglong.profile="worker" \ io.qinglong.worker.capacity-profiles="edge,node" diff --git a/deploy/containers/ql3-worker/package-lock.json b/deploy/containers/ql3-worker/package-lock.json index 54d59e8c..216d828d 100644 --- a/deploy/containers/ql3-worker/package-lock.json +++ b/deploy/containers/ql3-worker/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qinglong/worker-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qinglong/worker-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "license": "Apache-2.0", "dependencies": { "@peculiar/x509": "2.0.0", diff --git a/deploy/containers/ql3-worker/package.json b/deploy/containers/ql3-worker/package.json index e2c8d096..9f5e66e1 100644 --- a/deploy/containers/ql3-worker/package.json +++ b/deploy/containers/ql3-worker/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/worker-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "Locked external build and runtime dependencies for the QingLong 3.0 Worker image", "license": "Apache-2.0", diff --git a/deploy/containers/ql3-worker/runtime-dependencies/package-lock.json b/deploy/containers/ql3-worker/runtime-dependencies/package-lock.json index cf82b2b4..af6e68d7 100644 --- a/deploy/containers/ql3-worker/runtime-dependencies/package-lock.json +++ b/deploy/containers/ql3-worker/runtime-dependencies/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qinglong/worker-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qinglong/worker-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "license": "Apache-2.0", "dependencies": { "@peculiar/x509": "2.0.0", diff --git a/deploy/containers/ql3-worker/runtime-dependencies/package.json b/deploy/containers/ql3-worker/runtime-dependencies/package.json index 023bd32c..0a1b7abe 100644 --- a/deploy/containers/ql3-worker/runtime-dependencies/package.json +++ b/deploy/containers/ql3-worker/runtime-dependencies/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/worker-image-dependencies", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "Production-only external dependency root for the QingLong 3.0 Worker image", "license": "Apache-2.0", diff --git a/deploy/kubernetes/ql3-cluster/README.md b/deploy/kubernetes/ql3-cluster/README.md index 5e5a762c..9acfb2e1 100644 --- a/deploy/kubernetes/ql3-cluster/README.md +++ b/deploy/kubernetes/ql3-cluster/README.md @@ -17,7 +17,7 @@ Build from the repository root: ```bash docker build \ --file deploy/containers/ql3-cluster-control/Dockerfile \ - --tag qinglong3-cluster-control:3.0.0-alpha.0 \ + --tag qinglong3-cluster-control:3.0.0-alpha.1 \ --build-arg SOURCE_REVISION="$(git rev-parse HEAD)" \ . ``` @@ -28,7 +28,7 @@ control-plane image: ```bash docker build \ --file deploy/containers/ql3-cluster-admin/Dockerfile \ - --tag qinglong3-cluster-admin:3.0.0-alpha.0 \ + --tag qinglong3-cluster-admin:3.0.0-alpha.1 \ --build-arg SOURCE_REVISION="$(git rev-parse HEAD)" \ . ``` diff --git a/deploy/kubernetes/ql3-cluster/base/deployment.yaml b/deploy/kubernetes/ql3-cluster/base/deployment.yaml index 4d6f71b1..0f3f6e07 100644 --- a/deploy/kubernetes/ql3-cluster/base/deployment.yaml +++ b/deploy/kubernetes/ql3-cluster/base/deployment.yaml @@ -47,7 +47,7 @@ spec: app.kubernetes.io/component: control-plane containers: - name: cluster-control - image: qinglong3-cluster-control:3.0.0-alpha.0 + image: qinglong3-cluster-control:3.0.0-alpha.1 imagePullPolicy: IfNotPresent securityContext: allowPrivilegeEscalation: false diff --git a/deploy/kubernetes/ql3-cluster/components/cluster-ai/deployment-patch.yaml b/deploy/kubernetes/ql3-cluster/components/cluster-ai/deployment-patch.yaml index 0f168c6a..b1034e35 100644 --- a/deploy/kubernetes/ql3-cluster/components/cluster-ai/deployment-patch.yaml +++ b/deploy/kubernetes/ql3-cluster/components/cluster-ai/deployment-patch.yaml @@ -7,7 +7,7 @@ spec: spec: containers: - name: cluster-control - image: qinglong3-cluster-control-ai:3.0.0-alpha.0 + image: qinglong3-cluster-control-ai:3.0.0-alpha.1 env: - name: QL3_CLUSTER_AI_ENABLED value: "true" diff --git a/deploy/kubernetes/ql3-cluster/operations/approval-management-client/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/approval-management-client/base/job.yaml index 2a620842..6ce2b88d 100644 --- a/deploy/kubernetes/ql3-cluster/operations/approval-management-client/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/approval-management-client/base/job.yaml @@ -34,7 +34,7 @@ spec: type: RuntimeDefault initContainers: - name: wait-for-manager - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node @@ -109,7 +109,7 @@ spec: readOnly: true containers: - name: client - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - /bin/sh diff --git a/deploy/kubernetes/ql3-cluster/operations/approval-management/base/deployment.yaml b/deploy/kubernetes/ql3-cluster/operations/approval-management/base/deployment.yaml index 2b5b4a3e..f99d13de 100644 --- a/deploy/kubernetes/ql3-cluster/operations/approval-management/base/deployment.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/approval-management/base/deployment.yaml @@ -51,7 +51,7 @@ spec: app.kubernetes.io/component: approval-management containers: - name: management - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/automation-management-client/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/automation-management-client/base/job.yaml index 88c69738..8557a21f 100644 --- a/deploy/kubernetes/ql3-cluster/operations/automation-management-client/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/automation-management-client/base/job.yaml @@ -34,7 +34,7 @@ spec: type: RuntimeDefault initContainers: - name: wait-for-manager - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node @@ -109,7 +109,7 @@ spec: readOnly: true containers: - name: client - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - /bin/sh diff --git a/deploy/kubernetes/ql3-cluster/operations/automation-management/base/deployment.yaml b/deploy/kubernetes/ql3-cluster/operations/automation-management/base/deployment.yaml index af12d7fe..93d4db6e 100644 --- a/deploy/kubernetes/ql3-cluster/operations/automation-management/base/deployment.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/automation-management/base/deployment.yaml @@ -53,7 +53,7 @@ spec: app.kubernetes.io/component: automation-management containers: - name: management - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/base/migrate-job.yaml b/deploy/kubernetes/ql3-cluster/operations/base/migrate-job.yaml index 1eb67e8e..100e7a71 100644 --- a/deploy/kubernetes/ql3-cluster/operations/base/migrate-job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/base/migrate-job.yaml @@ -30,7 +30,7 @@ spec: type: RuntimeDefault containers: - name: migration - image: qinglong3-cluster-control:3.0.0-alpha.0 + image: qinglong3-cluster-control:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml index 926786c6..f044e4d5 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml @@ -37,7 +37,7 @@ spec: type: RuntimeDefault containers: - name: executor - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-management/base/deployment.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-management/base/deployment.yaml index f7beab68..669754d2 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-management/base/deployment.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-management/base/deployment.yaml @@ -48,7 +48,7 @@ spec: app.kubernetes.io/component: plugin-package-management containers: - name: management - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-recovery/base/recover-job.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-recovery/base/recover-job.yaml index bfb7d2e4..e4d02375 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-recovery/base/recover-job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-recovery/base/recover-job.yaml @@ -30,7 +30,7 @@ spec: type: RuntimeDefault containers: - name: recovery - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/prompt-output-external-recovery/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/prompt-output-external-recovery/base/job.yaml index d57af43c..cfa0b095 100644 --- a/deploy/kubernetes/ql3-cluster/operations/prompt-output-external-recovery/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/prompt-output-external-recovery/base/job.yaml @@ -33,7 +33,7 @@ spec: type: RuntimeDefault containers: - name: verifier - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/prompt-output-gc/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/prompt-output-gc/base/job.yaml index 5542b8a5..50f8493b 100644 --- a/deploy/kubernetes/ql3-cluster/operations/prompt-output-gc/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/prompt-output-gc/base/job.yaml @@ -33,7 +33,7 @@ spec: type: RuntimeDefault containers: - name: collector - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/prompt-output-key-retirement/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/prompt-output-key-retirement/base/job.yaml index 04ad1053..08b9babd 100644 --- a/deploy/kubernetes/ql3-cluster/operations/prompt-output-key-retirement/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/prompt-output-key-retirement/base/job.yaml @@ -33,7 +33,7 @@ spec: type: RuntimeDefault initContainers: - name: network-policy-ready - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node @@ -110,7 +110,7 @@ spec: memory: 64Mi containers: - name: retirement - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/prompt-output-key-rotation/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/prompt-output-key-rotation/base/job.yaml index 69b057d2..fabd8fc7 100644 --- a/deploy/kubernetes/ql3-cluster/operations/prompt-output-key-rotation/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/prompt-output-key-rotation/base/job.yaml @@ -33,7 +33,7 @@ spec: type: RuntimeDefault initContainers: - name: network-policy-ready - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node @@ -110,7 +110,7 @@ spec: memory: 64Mi containers: - name: rotation - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/provider-credential-management-client/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/provider-credential-management-client/base/job.yaml index d13d9c50..07083c77 100644 --- a/deploy/kubernetes/ql3-cluster/operations/provider-credential-management-client/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/provider-credential-management-client/base/job.yaml @@ -34,7 +34,7 @@ spec: type: RuntimeDefault initContainers: - name: wait-for-manager - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node @@ -109,7 +109,7 @@ spec: readOnly: true containers: - name: client - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - /bin/sh diff --git a/deploy/kubernetes/ql3-cluster/operations/provider-credential-management/base/deployment.yaml b/deploy/kubernetes/ql3-cluster/operations/provider-credential-management/base/deployment.yaml index 9ad5dd24..a70ea9a3 100644 --- a/deploy/kubernetes/ql3-cluster/operations/provider-credential-management/base/deployment.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/provider-credential-management/base/deployment.yaml @@ -52,7 +52,7 @@ spec: app.kubernetes.io/component: provider-credential-management containers: - name: management - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/provider-credential-test-executor/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/provider-credential-test-executor/base/job.yaml index c62a8a5d..2442dee3 100644 --- a/deploy/kubernetes/ql3-cluster/operations/provider-credential-test-executor/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/provider-credential-test-executor/base/job.yaml @@ -33,7 +33,7 @@ spec: type: RuntimeDefault containers: - name: executor - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/run-management/base/deployment.yaml b/deploy/kubernetes/ql3-cluster/operations/run-management/base/deployment.yaml index 4926472a..a1e1f154 100644 --- a/deploy/kubernetes/ql3-cluster/operations/run-management/base/deployment.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/run-management/base/deployment.yaml @@ -51,7 +51,7 @@ spec: app.kubernetes.io/component: run-management containers: - name: management - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml index 0f3db1c5..291e431e 100644 --- a/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml @@ -34,7 +34,7 @@ spec: type: RuntimeDefault initContainers: - name: stage-private-input - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node @@ -63,7 +63,7 @@ spec: mountPath: /var/run/qinglong3/security-administration-private containers: - name: administrator - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/worker-credential-executor/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/worker-credential-executor/base/job.yaml index 13c33773..76d952f9 100644 --- a/deploy/kubernetes/ql3-cluster/operations/worker-credential-executor/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/worker-credential-executor/base/job.yaml @@ -32,7 +32,7 @@ spec: type: RuntimeDefault containers: - name: executor - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/operations/worker-credential-management-client/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/worker-credential-management-client/base/job.yaml index e659e69d..670123f8 100644 --- a/deploy/kubernetes/ql3-cluster/operations/worker-credential-management-client/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/worker-credential-management-client/base/job.yaml @@ -34,7 +34,7 @@ spec: type: RuntimeDefault initContainers: - name: wait-for-manager - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node @@ -109,7 +109,7 @@ spec: readOnly: true containers: - name: client - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - /bin/sh diff --git a/deploy/kubernetes/ql3-cluster/operations/worker-credential-management/base/deployment.yaml b/deploy/kubernetes/ql3-cluster/operations/worker-credential-management/base/deployment.yaml index 8bdfe443..f7f5ed77 100644 --- a/deploy/kubernetes/ql3-cluster/operations/worker-credential-management/base/deployment.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/worker-credential-management/base/deployment.yaml @@ -55,7 +55,7 @@ spec: app.kubernetes.io/component: worker-credential-management containers: - name: management - image: qinglong3-cluster-admin:3.0.0-alpha.0 + image: qinglong3-cluster-admin:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - node diff --git a/deploy/kubernetes/ql3-cluster/overlays/example/kustomization.yaml b/deploy/kubernetes/ql3-cluster/overlays/example/kustomization.yaml index 4dc89249..e5415357 100644 --- a/deploy/kubernetes/ql3-cluster/overlays/example/kustomization.yaml +++ b/deploy/kubernetes/ql3-cluster/overlays/example/kustomization.yaml @@ -7,4 +7,4 @@ resources: images: - name: qinglong3-cluster-control newName: registry.example.com/qinglong/qinglong3-cluster-control - newTag: 3.0.0-alpha.0 + newTag: 3.0.0-alpha.1 diff --git a/deploy/kubernetes/ql3-worker/README.md b/deploy/kubernetes/ql3-worker/README.md index 8c4cceb8..2e761be9 100644 --- a/deploy/kubernetes/ql3-worker/README.md +++ b/deploy/kubernetes/ql3-worker/README.md @@ -26,7 +26,7 @@ Build the image from the repository root: node scripts/ql3-worker-image-lock.cjs docker build \ --file deploy/containers/ql3-worker/Dockerfile \ - --tag qinglong3-worker:3.0.0-alpha.0 \ + --tag qinglong3-worker:3.0.0-alpha.1 \ --build-arg SOURCE_REVISION="$(git rev-parse HEAD)" \ . ``` diff --git a/deploy/kubernetes/ql3-worker/base/deployment.yaml b/deploy/kubernetes/ql3-worker/base/deployment.yaml index 4cc4559a..c0211b2c 100644 --- a/deploy/kubernetes/ql3-worker/base/deployment.yaml +++ b/deploy/kubernetes/ql3-worker/base/deployment.yaml @@ -41,7 +41,7 @@ spec: type: RuntimeDefault initContainers: - name: materialize-worker-authority - image: qinglong3-worker:3.0.0-alpha.0 + image: qinglong3-worker:3.0.0-alpha.1 imagePullPolicy: IfNotPresent command: - /bin/sh @@ -81,7 +81,7 @@ spec: mountPath: /state containers: - name: worker - image: qinglong3-worker:3.0.0-alpha.0 + image: qinglong3-worker:3.0.0-alpha.1 imagePullPolicy: IfNotPresent securityContext: allowPrivilegeEscalation: false diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 5e7b8ef9..5eedb6f6 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -6,10 +6,14 @@ - 目标版本:QingLong 3.x - 作者:QingLong Maintainers - 创建日期:2026-07-17 -- 最后更新:2026-08-25 +- 最后更新:2026-08-26 - 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 -最新增量证据(2026-08-25): +最新增量证据(2026-08-26): + +- Alpha 阶段产物门(已实现、首次远端产物待本提交 CI):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。Local amd64/arm64 Docker archive 必须先通过 OS vulnerability policy、SBOM/实际库存复核、128 MiB entrypoint、Edge/Standalone fresh SQLite lifecycle 和真实 Local API cancellation;Cluster control/control-ai/admin/worker 的双架构 archive 必须通过对应 vulnerability、non-root、SBOM inventory 门,Admin 额外通过产品 facade smoke。每个 30 天 artifact 绑定完整 source SHA、3.0 版本、架构、image ID、archive SHA-256、CycloneDX SBOM、已通过 gate 和试运行/回退说明。Local 可称为用户隔离试运行候选;Cluster 仅是 integration candidate,因为离线 tag 尚未满足 public GHCR digest、catalog、deployment-lock、生产 HA/DR/CSI/IdP 门。只有实际远端 run 成功并记录 artifact 名/digest 后,才把本阶段标记为“已有可下载产物”;当前仍不能宣称公开 3.0 release。 + +- D-407/ADR-0502(代码门已通过,Kubernetes 双代 live ceremony 待验收):Cluster API credential pepper 从“数据库保存 key ID、运行时却只有一个固定 material”收敛为最多 old/new 两代的显式 keyring。Security Administration 只用 active key 签发并持久化 exact ID;Cluster Control 按 credential record 精确选一把 key,未知 ID/material 一律 unavailable,绝不 fallback 或遍历,因此认证热路径仍为一次摘要。旧 raw pepper 只通过 `legacy-v1` singleton bridge 保持兼容。新增 `pepper.references` 以数据库时间返回最多 64 个当前 latest active/unexpired credential ID 和 `hasMore`,只作为退休前检查,不执行删除。keyring 文件有 2 KiB、canonical/no-symlink/private/stable-read 边界,无 watcher/timer/新连接池;Edge/Standalone package、依赖与常驻资源零变化。`cluster-admin` 完整回归为 `458 total / 455 pass / 3 conditional skip / 0 fail`,`cluster-control` 为 `281 total / 279 pass / 2 conditional skip / 0 fail`,D-407 聚焦构建与 33 项测试通过;`3.0.0-alpha.1` 身份下 18-package clean build/test 退出 0,backend 为 `1592 total / 1590 pass / 2 conditional skip / 0 fail`。当前 D-406 Kubernetes stager 仍只支持单 pepper,material GC、持久 active catalog、索引/大规模查询计划、远程 UI/API、双人复核和真实 K3s/CNPG overlap→activate→contract 仍是后续门禁。 - D-406/ADR-0501(已验收):Cluster Security Administration 现在有可选的一次性 Kubernetes Job,而不是要求每个部署者自行拼装。通用 PostgreSQL、CloudNativePG、credential delivery 与组合入口均显式 opt-in,不进入共享 operations;因此 Edge/Standalone 以及默认 Cluster 的 package、依赖、启动路径和常驻资源仍为零增量。Job 固定 non-root/read-only/drop-all、无 ServiceAccount token/RBAC、`backoffLimit=0`、300 秒 deadline、600 秒 TTL、每容器 25m/48 MiB request 和 250m/128 MiB limit;主命令仍只打开一个 admin PostgreSQL connection。stager 保留 kubelet Secret symlink 兼容性,同时以 realpath confinement、`O_NOFOLLOW`、读前/读后 inode 复验和独立大小上限把四个输入复制成 memory-backed `0700/0600` 私有边界,失败时主容器不可启动;issue/rotate 才选择 PVC delivery component,token 只进入唯一 `0600` no-replace 文件。真实 arm64 live gate 已在 1 control-plane + 2 worker 的 K3s `v1.34.3+k3s1`、Flannel、CloudNativePG 1.30.0、3 个 PostgreSQL 18.4 实例、migration 71/control-core capability 70 上完成 register、audit query、issue、response-loss exact replay、rotate、revoke;证明 kubelet `0440` immutable Secret 投影可被私有化、`0444` 扩权会在主容器启动前失败、RWO PVC 跨 Job 保留 `0700/0600` no-replace 交付、Kubernetes API/公网 egress 与 Secret/Job RBAC 均被拒绝。全部管理 Job 继续以 UID/GID 10001 运行;仅 live local-path fixture 用一个无网络、无 API token、drop-all 的 root Job 把实现特有的 `02777 root:10001` PVC 根收紧为 `02770`,不能据此声称生产 CSI 加密或 custody 已验收。content-free `0600` 报告独立审计为 `compatible=true/findings=[]`,SHA-256 为 `e5c24af77034e1a2efee062107176e218c11a9f9f0d6c8c7308fdc280b0a82cf`;18-package clean build/test 退出 0,当前 `cluster-admin` 为 `456 total / 453 pass / 3 conditional skip / 0 fail`,backend 为 `1590 total / 1588 pass / 2 conditional skip / 0 fail`。D-406 关闭单主机 K3s/CNPG/PVC 的产品命令与权限边界门,但不冒充生产 Kubernetes control-plane HA、跨主机 STONITH/DR、加密 CSI、外部 IdP、双人复核/break-glass、pepper rotation、audit retention/export/alert、并发 dispatch 或远程 UI/API。 diff --git a/docs/adr/ADR-0502-bounded-cluster-api-credential-pepper-keyring.md b/docs/adr/ADR-0502-bounded-cluster-api-credential-pepper-keyring.md new file mode 100644 index 00000000..7c8b918e --- /dev/null +++ b/docs/adr/ADR-0502-bounded-cluster-api-credential-pepper-keyring.md @@ -0,0 +1,79 @@ +# ADR-0502:有界 Cluster API Credential Pepper Keyring + +- 状态:Accepted +- 日期:2026-08-26 +- 决策:D-407 +- 关联:ADR-0049、ADR-0050、ADR-0080、ADR-0083、ADR-0500、ADR-0501 + +## 背景 + +PostgreSQL API credential record 已保存 `pepperKeyId`,但 Cluster Control 运行时仍只装载一个 pepper,Security Administration 签发时也固定写入 `legacy-v1`。这意味着数据库虽然保留了密钥来源,却无法在不中断全部旧 credential 的情况下切换 pepper;如果认证端通过遍历所有历史 pepper 猜测摘要,又会扩大每个未认证请求的 CPU 成本和 timing surface。 + +QingLong 3.0 同时服务低配路由设备与集群节点。pepper rotation 只能进入 Cluster opt-in 路径,不能给 Edge/Standalone 增加 package、依赖、watcher、timer、连接池或常驻资源;Cluster 的认证热路径也必须保持一次精确摘要计算,而不是随历史 key 数线性增长。 + +## 决策 + +### 1. 运行时只接受最多两个显式 generation + +`runtime-core` 提供 schema v1 keyring:一个 `activePepperKeyId` 和 1–2 个唯一 `{pepperKeyId,pepper}`。每个 ID 与 32-byte canonical base64url material 都使用既有 credential contract 校验,active ID 必须存在于 keys。原单 pepper 配置只通过显式 `legacy-v1` singleton bridge 保持兼容,不允许自动发现、环境合并或第三代历史 key。 + +Cluster Control 可从 `QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE` 读取不超过 2 KiB 的 canonical 私有 JSON;它与旧 `QL3_API_CREDENTIAL_PEPPER` 必须二选一。文件必须是 canonical absolute regular file、不可为 symlink、不可向 group/world 开放,并在稳定 inode/mtime/size 下读取。运行时不安装 watcher;切换 keyring 后由部署系统执行受控滚动重启。 + +### 2. 认证严格按 durable key ID 选择 + +认证先读取 credential record,再用其 `pepperKeyId` 精确选择一把 pepper,并只计算一次摘要。未知 key ID、缺失 material、畸形 keyring 或存储不可用都返回统一 unavailable;不得回退 active key、legacy key或遍历其他 key。未知 credential 的 timing dummy work 只使用 active key,不产生多 key 探测。 + +因此 overlap 期间旧 credential 继续使用旧 key,新 credential 使用新 key,但每个请求成本仍为常数。keyring 不进入日志、响应、审计详情或错误字段。 + +### 3. 新签发只绑定 active generation + +Security Administration 使用 keyring 的 active key 计算新 token digest,并把 exact active ID 写入 credential record。`credential.issue`、`credential.rotate` 的 mutation/replay、私有 delivery 和同事务 audit 语义不变;旧 raw pepper 参数仍只映射成 `legacy-v1` singleton bridge。 + +CLI 在 `--pepper` 与 `--pepper-keyring` 中必须精确选择一个。keyring 文件属于短生命周期 admin authority,不进入 command JSON,也不复制到输出或数据库。 + +### 4. 退休前只做有界引用检查 + +新增只读 `pepper.references` 管理操作,输入仅为 exact `pepperKeyId` 和 1–64 的 limit。PostgreSQL 使用 `statement_timestamp()`,只返回该 key 当前最新、active、未过期 credential ID、观察时间与 `hasMore`;结果行、时间、ID、重复项或空集表示发生漂移时全部失败关闭。 + +该查询用于人工轮换 ceremony 的退休前判断,不删除 credential、keyring material 或历史记录,也不修改 active key。结果大小有界,但当前查询仍可能检查历史 credential versions;大规模生产数据上的索引/查询计划证据是独立门禁,不能把输出上限解释为数据库成本已经完全有界。 + +## 轮换顺序 + +1. overlap:部署 `{old,new}`,active 仍为 old,滚动重启并确认旧 credential 可认证; +2. activate:把 active 切到 new,再滚动重启,之后的新 issue/rotate 都绑定 new; +3. converge:轮换或撤销 old credential,分页执行 `pepper.references`,直到得到同一稳定部署下的空引用结果; +4. contract:从 keyring 删除 old,滚动重启并确认 old credential 失败、new credential 成功。 + +任何阶段都不得在仍有引用时删除 material。当前切片不实现 material destructive GC、持久 active-generation catalog、自动轮换调度或 Kubernetes Secret CAS;这些能力必须使用独立 ADR 和 live gate。 + +## 被拒绝的替代方案 + +### 认证时遍历所有 pepper + +拒绝。它让未认证请求的成本随历史线性增长,扩大 timing surface,也掩盖 record 的 durable provenance 漂移。 + +### 无界 keyring 或永久保留所有历史 key + +拒绝。Cluster 常驻 secret footprint、审计范围和错误配置 blast radius 会持续增长。3.0 首个轮换只允许 old/new 双代 overlap。 + +### 在进程内 watcher 自动热切换 + +拒绝。它增加后台 I/O、竞态和跨副本不可观察状态。初始产品基线使用显式配置更新和部署系统滚动重启。 + +### 同时给 Edge/Standalone 增加该 keyring + +拒绝。D-407 只关闭 Cluster API credential 的结构缺口;本机 Owner credential 已有独立 catalog/keyring/GC ceremony,不能混合 authority。 + +## 验证 + +- keyring contract 覆盖 exact shape、1–2 key 上限、active membership、duplicate、invalid ID/material 和 singleton bridge; +- Cluster Control 覆盖 old/new overlap、stored key exact selection、unknown stored key fail-closed、旧环境变量 bridge、私有 keyring 文件与双来源拒绝; +- Security Administration 覆盖新 credential 绑定 active key、keyring CLI、引用查询 exact command/result 与旧单 pepper bridge; +- PostgreSQL repository 覆盖数据库时间、latest active/unexpired 过滤、limit+1、空引用与畸形行 fail-closed; +- 当前 `cluster-admin` 完整回归为 458 total / 455 pass / 3 conditional skip / 0 fail,`cluster-control` 为 281 total / 279 pass / 2 conditional skip / 0 fail;D-407 聚焦构建与 33 项测试通过,`3.0.0-alpha.1` 身份下 18-package clean build/test 退出 0,backend 为 1592 total / 1590 pass / 2 conditional skip / 0 fail。 + +## 影响与剩余门禁 + +D-407 关闭了“数据库记录 key ID、运行时却只能使用一个固定 pepper”的结构性缺口。Edge/Standalone package、依赖和常驻路径零变化;Cluster 每个认证请求仍只解析 record 并计算一个摘要,管理引用检查只在显式短命令中打开一个 admin connection。 + +D-406 Kubernetes stager/Job 仍只接受单 `pepper` 文件,不能用它执行双代 ceremony;在增加 keyring Secret 投影、失败注入和真实 K3s/CNPG overlap→activate→contract 验收前,Kubernetes 部署继续停留在 singleton bridge。远程管理 API/UI、双人复核/break-glass、material GC、audit retention/export/alert 和大规模引用查询计划也仍未完成。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 09297cec..74ff1a1d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -505,6 +505,7 @@ | [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 | +| [ADR-0502](./ADR-0502-bounded-cluster-api-credential-pepper-keyring.md) | 有界 Cluster API Credential Pepper Keyring | Accepted | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md new file mode 100644 index 00000000..15ac9f9b --- /dev/null +++ b/docs/operations/ql3-alpha-candidate.md @@ -0,0 +1,63 @@ +# QingLong 3.0 阶段性 Alpha 候选产物 + +该产物回答“当前源码是否已经形成可下载、可验证、可试运行的阶段成果”。它不是公开 release、不可变 GHCR digest 或生产升级承诺,也不能替代正式 release-set、签名、catalog、部署锁和回退门。 + +## 产物等级 + +| 等级 | 面向对象 | 必须通过 | 当前用途 | +| --- | --- | --- | --- | +| Local Alpha Candidate | amd64/arm64 路由器、NAS、单机 | OS 漏洞策略、SBOM 与镜像库存复核、128 MiB entrypoint、Edge/Standalone fresh SQLite lifecycle、真实本机 API cancellation | 下载 Docker archive,核验后进行隔离试运行与设备兼容测试 | +| Cluster Integration Candidate | amd64/arm64 集群测试节点 | OS 漏洞策略、SBOM 与镜像库存复核、non-root identity;Admin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release | +| Public Release Set | 生产用户 | 受保护 tag、五镜像 multi-arch digest、签名/attestation、私有发布证据、catalog、Local/Cluster 部署与回退闭环 | 尚未实际发布;只能由受保护 release workflow 生成 | + +只有第一等级可以称为本阶段“用户可试运行产物”。Cluster archive 是工程集成产物,因为离线 per-architecture tag 不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。 + +## 生成 + +在 GitHub Actions 手动运行 `QingLong 3.0 CI`,选择目标 `next` 提交并设置 `produce_alpha_artifacts=true`。普通 push/PR 不上传大镜像,避免每次开发提交都制造伪里程碑和额外存储成本。 + +成功后同一次 run 生成、保留 30 天: + +- `ql3-alpha--local-amd64` 与 `ql3-alpha--local-arm64`; +- `ql3-alpha--control-`、`control-ai-`、`admin-`、`worker-`。 + +每个 artifact 含: + +- 通过对应测试的 native Docker archive; +- `manifest.json`,绑定版本、完整 source commit、架构、原始 image tag、image ID、archive SHA-256 与已通过 gate; +- 与实际只读镜像 inventory 对账过的 CycloneDX SBOM; +- 本说明。 + +任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。 + +## 下载后验证与最小 smoke + +在同架构 Linux Docker 主机上进入解压后的 artifact 目录: + +```sh +archive="$(find . -maxdepth 1 -name '*.docker.tar' -type f -print -quit)" +expected="$(node -p "require('./manifest.json').archiveSha256")" +actual="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)" +test "${actual}" = "${expected}" + +docker load --input "${archive}" +image="$(node -p "require('./manifest.json').image")" +expected_id="$(node -p "require('./manifest.json').imageId")" +test "$(docker image inspect --format '{{.Id}}' "${image}")" = "${expected_id}" +docker run --rm --read-only --network none --cap-drop ALL \ + --security-opt no-new-privileges "${image}" --help +``` + +下载页本身不是 source identity;还必须把 `manifest.json.sourceRevision` 与预期 `next` commit 对齐。不要在生产数据库、生产 Secret 或 2.x 唯一数据目录上直接试用。 + +## 试运行与回退边界 + +Local 正式部署仍应遵循 [Edge/Standalone 部署准备](./ql3-local-deployment.md),先做 fresh 私有目录/数据库/Owner authority,再执行受审配置、preflight 和 rollout。Alpha Docker archive 只替代“待测镜像来源”,不会替操作者生成 pepper、credential、数据库备份或 2.x cutover evidence。 + +阶段试运行必须使用独立目录和独立数据库;回退的最低保证是停止并删除 Alpha 容器、保留测试目录用于诊断,然后回到未被修改的 2.x 实例。凡是执行 2.x→3.0 数据迁移或 3.0 写入后切回,都必须走既有 reconciliation/cutover/rollback ceremony,不能只换镜像。 + +Cluster candidate 必须先导入隔离 registry 并重新绑定该 registry 的 immutable digest。当前 archive 不带 public catalog、签名或正式 deployment selection;生产 Kubernetes、CloudNativePG HA、跨主机 STONITH/DR、CSI custody 和外部 IdP 不在此阶段产物的声明范围内。 + +## 里程碑判定 + +一次阶段里程碑只有同时记录以下事实才成立:源码 commit、版本、两种 Tier-1 架构所需产物、完整 CI run、artifact 名与 digest、至少一个目标 Profile smoke、已知限制和回退路径。仅有源码、`dist/`、单元测试数字、Dockerfile 或“理论上可构建”都不算阶段性可用产物。 diff --git a/docs/operations/ql3-cluster-security-administration.md b/docs/operations/ql3-cluster-security-administration.md index cd735b75..bbb70e99 100644 --- a/docs/operations/ql3-cluster-security-administration.md +++ b/docs/operations/ql3-cluster-security-administration.md @@ -48,7 +48,20 @@ keyset 使用既有 generation/revocation 协议,例如: JWT header 必须使用 `typ=ql3-security-administration+jwt`;payload 的 issuer/audience 必须匹配 keyset,并包含 `ql3_purpose=security-administration`。只接受当前、未撤销的强认证 principal。其他管理面即使使用同一签名 key,也会因 type/purpose/audience 不同而被拒绝。 -pepper 是现有 API credential digest authority 要求的 32-byte canonical base64url 值。它必须与已存 credential 的 pepper authority 一致,不要为了单次命令临时生成新值,也不要写入 command JSON。 +单 pepper 是现有 API credential digest authority 要求的 32-byte canonical base64url 值。它必须与已存 credential 的 pepper authority 一致,不要为了单次命令临时生成新值,也不要写入 command JSON。D-407 后也可以提供最多两代的私有 keyring: + +```json +{ + "schemaVersion": 1, + "activePepperKeyId": "api-pepper-2026-08", + "keys": [ + { "pepperKeyId": "legacy-v1", "pepper": "REPLACE_WITH_32_BYTE_BASE64URL" }, + { "pepperKeyId": "api-pepper-2026-08", "pepper": "REPLACE_WITH_32_BYTE_BASE64URL" } + ] +} +``` + +keyring 不超过 2 KiB,文件必须为 canonical、non-symlink、私有 regular file。`--pepper` 与 `--pepper-keyring` 必须且只能选择一个;单 pepper 明确映射到 `legacy-v1`,不会自动猜测历史 key。 ## 精确命令 @@ -104,6 +117,21 @@ pepper 是现有 API credential digest authority 要求的 32-byte canonical bas limit 范围为 1–200。可选 filter 只有 projectId、subject 和 outcome;翻页使用上一页的 exact `{occurredAtMs,eventId}` 作为 `before`,不支持 offset、自由文本或无界导出。 +退休旧 pepper 前检查当前引用: + +```json +{ + "schemaVersion": 1, + "operation": "pepper.references", + "request": { + "pepperKeyId": "legacy-v1", + "limit": 64 + } +} +``` + +结果只含数据库观察时间、请求的 key ID、有界 credential ID 列表和 `hasMore`。它不返回 token/digest/material,也不删除任何对象。必须继续分页和轮换/撤销旧 credential,直到在稳定部署下得到空引用结果,才能进入 material contraction。 + ## 执行 生产默认要求 TLS hostname verification: @@ -121,6 +149,14 @@ ql3-cluster-admin security \ --delivery=/secure/qinglong3/delivery/new-api-credential.json ``` +双代轮换时把 `--pepper` 替换为: + +```sh + --pepper-keyring=/secure/qinglong3/api-credential-pepper-keyring.json +``` + +Cluster Control 使用 `QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE`;它与旧 `QL3_API_CREDENTIAL_PEPPER` 同样二选一。认证严格使用 credential record 保存的 key ID,不尝试 active/legacy fallback,也不遍历 keyring。 + 也可以直接调用同镜像内的 `ql3-security-admin`。测试环境只有同时设置 `QL3_POSTGRES_ADMIN_TLS_MODE=disable` 与 `QL3_POSTGRES_ADMIN_ALLOW_INSECURE=true` 才能关闭 TLS;生产禁止这样部署。 成功签发或轮换时,stdout 只包含 delivery 文件名和 SHA-256。token 只存在于新建的 `0600` delivery 文件。目标已存在时命令失败且绝不覆盖。精确重放返回 `status=existing` 且不重新发布 token;如果首次响应丢失,先检查原 delivery 文件,确实丢失时使用新的 mutationId 执行 rotate,不能尝试恢复旧 token。 @@ -153,4 +189,4 @@ kubectl logs job/ql3-security-administration -n qinglong3-system \ ## 当前边界 -本入口没有远程 API/UI、双人复核或 break-glass、pepper rotation、audit retention/export/alert。可选 Job 的静态契约与单主机 K3s + PostgreSQL/PVC ceremony 已验收,但仍不默认安装,也不证明生产基础设施 HA/DR 或存储加密;admin database credential 始终不得进入常驻 Cluster Control。命令决策见 [ADR-0500](../adr/ADR-0500-short-lived-cluster-security-administration-command.md),部署决策见 [ADR-0501](../adr/ADR-0501-opt-in-kubernetes-security-administration-job.md)。 +本入口没有远程 API/UI、双人复核或 break-glass、自动 pepper rotation/material GC、audit retention/export/alert。D-407 已提供 old/new 双代 keyring、active issuance、exact-key authentication 和退休前引用检查,但 active 切换仍由显式配置更新加滚动重启完成。现有 Kubernetes Job stager 只接受单 pepper,尚未完成 keyring Secret 投影和真实 overlap→activate→contract live gate,因此不能用 D-406 模板宣称 Kubernetes pepper rotation 已完成。可选 Job 的静态契约与单主机 K3s + PostgreSQL/PVC ceremony 已验收,但仍不默认安装,也不证明生产基础设施 HA/DR 或存储加密;admin database credential 始终不得进入常驻 Cluster Control。命令决策见 [ADR-0500](../adr/ADR-0500-short-lived-cluster-security-administration-command.md),部署决策见 [ADR-0501](../adr/ADR-0501-opt-in-kubernetes-security-administration-job.md),双代 keyring 见 [ADR-0502](../adr/ADR-0502-bounded-cluster-api-credential-pepper-keyring.md)。 diff --git a/packages/ql3-ai/package.json b/packages/ql3-ai/package.json index 50516739..05b5e9e4 100644 --- a/packages/ql3-ai/package.json +++ b/packages/ql3-ai/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/ai", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 optional bounded model gateway and remote providers", "license": "Apache-2.0", diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index d33b26f5..b9320297 100644 --- a/packages/ql3-cluster-admin/package.json +++ b/packages/ql3-cluster-admin/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/cluster-admin", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 cluster operations and bounded Copilot MCP/Console surfaces", "license": "Apache-2.0", diff --git a/packages/ql3-cluster-admin/src/application-runtime/clusterAdminRuntime.ts b/packages/ql3-cluster-admin/src/application-runtime/clusterAdminRuntime.ts index 175f3e20..f20ab8e3 100644 --- a/packages/ql3-cluster-admin/src/application-runtime/clusterAdminRuntime.ts +++ b/packages/ql3-cluster-admin/src/application-runtime/clusterAdminRuntime.ts @@ -3,6 +3,8 @@ import type { PostgresDatabaseResource, } from '@qinglong/runtime-core'; import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token'; +import { createSingletonApiCredentialPepperKeyring } from '@qinglong/runtime-core/api-credential-pepper-keyring'; +import { LEGACY_API_CREDENTIAL_PEPPER_KEY_ID } from '@qinglong/runtime-core/api-credential'; import { assertWorkerCredentialPepper } from '@qinglong/runtime-core/worker-credential-token'; import type { SecurityAuditQueryRepository } from '@qinglong/runtime-core/security-audit-query'; import { @@ -97,7 +99,10 @@ export async function bootstrapClusterAdmin( administration: createClusterAdministrationService( identities, credentials, - options.apiCredentialPepper, + createSingletonApiCredentialPepperKeyring( + options.apiCredentialPepper, + LEGACY_API_CREDENTIAL_PEPPER_KEY_ID, + ).keys[0]!, { ...(options.now ? { now: options.now } : {}), ...(options.randomBytes ? { randomBytes: options.randomBytes } : {}), diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministration.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministration.ts index 1f1a7c3d..d53977ba 100644 --- a/packages/ql3-cluster-admin/src/security-administration/clusterAdministration.ts +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministration.ts @@ -13,9 +13,11 @@ import { formatApiCredentialToken, } from '@qinglong/runtime-core/api-credential-token'; import { - LEGACY_API_CREDENTIAL_PEPPER_KEY_ID, + assertApiCredentialPepperKeyId, type ApiCredentialRecord, + LEGACY_API_CREDENTIAL_PEPPER_KEY_ID, } from '@qinglong/runtime-core/api-credential'; +import type { ApiCredentialPepperKey } from '@qinglong/runtime-core/api-credential-pepper-keyring'; import { type AppendIdentitySubjectResult, IdentityAdministrationMutationConflictError, @@ -283,7 +285,7 @@ function sameCredentialReplay( export function createClusterAdministrationService( identities: IdentityAdministrationRepository, credentials: ApiCredentialAdministrationRepository, - pepper: string, + activePepperKeyValue: Readonly | string, options: ClusterAdministrationOptions = {}, ): ClusterAdministrationService { if ( @@ -305,10 +307,28 @@ export function createClusterAdministrationService( 'credential repository is invalid', ); } + const activePepperKey = + typeof activePepperKeyValue === 'string' + ? Object.freeze({ + pepperKeyId: LEGACY_API_CREDENTIAL_PEPPER_KEY_ID, + pepper: activePepperKeyValue, + }) + : activePepperKeyValue; try { - assertApiCredentialPepper(pepper); + if ( + !activePepperKey || + typeof activePepperKey !== 'object' || + Array.isArray(activePepperKey) || + Object.keys(activePepperKey).sort().join(',') !== 'pepper,pepperKeyId' + ) { + throw new TypeError(); + } + assertApiCredentialPepper(activePepperKey.pepper); + assertApiCredentialPepperKeyId(activePepperKey.pepperKeyId); } catch { - throw new ClusterAdministrationConfigurationError('pepper is invalid'); + throw new ClusterAdministrationConfigurationError( + 'active pepper key is invalid', + ); } exactObject(options, 'options'); const optionKeys = Object.keys(options); @@ -442,7 +462,7 @@ export function createClusterAdministrationService( try { secretBase64Url = secret.toString('base64url'); secretDigest = apiCredentialSecretDigest( - pepper, + activePepperKey.pepper, request.credentialId, secretBase64Url, ); @@ -454,7 +474,7 @@ export function createClusterAdministrationService( const credential: ApiCredentialRecord = { credentialId: request.credentialId, version: request.expectedCurrentVersion + 1, - pepperKeyId: LEGACY_API_CREDENTIAL_PEPPER_KEY_ID, + pepperKeyId: activePepperKey.pepperKeyId, state: operation === 'revoke' ? 'revoked' : 'active', subject: request.subject, subjectStatus: identity.status, diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCli.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCli.ts index c0447005..540c5c01 100644 --- a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCli.ts +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCli.ts @@ -6,7 +6,7 @@ import { } from './clusterAdministrationCommand'; const USAGE = - 'Usage: ql3-security-admin --command=/absolute/command.json --assertion=/absolute/assertion.jwt --keyset=/absolute/keyset.json --pepper=/absolute/pepper [--delivery=/absolute/token.json]'; + 'Usage: ql3-security-admin --command=/absolute/command.json --assertion=/absolute/assertion.jwt --keyset=/absolute/keyset.json (--pepper=/absolute/pepper | --pepper-keyring=/absolute/keyring.json) [--delivery=/absolute/token.json]'; function argumentsFrom(argv: readonly string[]) { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -14,9 +14,10 @@ function argumentsFrom(argv: readonly string[]) { } const values = new Map(); for (const argument of argv) { - const match = /^--(command|assertion|keyset|pepper|delivery)=(\/.+)$/.exec( - argument, - ); + const match = + /^--(command|assertion|keyset|pepper|pepper-keyring|delivery)=(\/.+)$/.exec( + argument, + ); if (!match || values.has(match[1]!)) { throw new ClusterAdministrationCommandError('CLI arguments are invalid'); } @@ -26,7 +27,7 @@ function argumentsFrom(argv: readonly string[]) { !values.has('command') || !values.has('assertion') || !values.has('keyset') || - !values.has('pepper') + values.has('pepper') === values.has('pepper-keyring') ) { throw new ClusterAdministrationCommandError('CLI arguments are invalid'); } @@ -36,7 +37,9 @@ function argumentsFrom(argv: readonly string[]) { commandFile: values.get('command')!, assertionFile: values.get('assertion')!, keysetFile: values.get('keyset')!, - pepperFile: values.get('pepper')!, + ...(values.has('pepper') + ? { pepperFile: values.get('pepper')! } + : { pepperKeyringFile: values.get('pepper-keyring')! }), ...(values.has('delivery') ? { deliveryFile: values.get('delivery')! } : {}), diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommand.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommand.ts index 44056d7a..c04b3544 100644 --- a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommand.ts +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommand.ts @@ -1,7 +1,17 @@ import { createHash } from 'node:crypto'; import { basename } from 'node:path'; -import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token'; +import { + createSingletonApiCredentialPepperKeyring, + normalizeApiCredentialPepperKeyring, + type ApiCredentialPepperKeyring, +} from '@qinglong/runtime-core/api-credential-pepper-keyring'; +import { LEGACY_API_CREDENTIAL_PEPPER_KEY_ID } from '@qinglong/runtime-core/api-credential'; +import { + normalizeApiCredentialPepperReferenceKeyId, + normalizeApiCredentialPepperReferenceLimit, + type ApiCredentialPepperReferenceRepository, +} from '@qinglong/runtime-core/api-credential-pepper-reference'; import { normalizeIdentityAdministrationSubject } from '@qinglong/runtime-core/identity-administration'; import { normalizeSecurityAuditQuery, @@ -34,6 +44,7 @@ const MAX_VERSION = 2_147_483_646; const MAX_COMMAND_BYTES = 64 * 1024; const MAX_ASSERTION_BYTES = 16 * 1024; const MAX_PEPPER_BYTES = 256; +const MAX_PEPPER_KEYRING_BYTES = 2 * 1024; export type ClusterAdministrationCommandOperation = | 'identity.register' @@ -42,6 +53,7 @@ export type ClusterAdministrationCommandOperation = | 'credential.issue' | 'credential.rotate' | 'credential.revoke' + | 'pepper.references' | 'audit.list'; interface BaseMutationRequest { @@ -79,16 +91,27 @@ interface AuditCommand { readonly request: SecurityAuditQuery; } +interface PepperReferenceCommand { + readonly schemaVersion: 1; + readonly operation: 'pepper.references'; + readonly request: Readonly<{ + readonly pepperKeyId: string; + readonly limit: number; + }>; +} + export type ClusterAdministrationCommand = | IdentityCommand | CredentialCommand + | PepperReferenceCommand | AuditCommand; export interface ClusterAdministrationCommandPaths { readonly commandFile: string; readonly assertionFile: string; readonly keysetFile: string; - readonly pepperFile: string; + readonly pepperFile?: string; + readonly pepperKeyringFile?: string; readonly deliveryFile?: string; } @@ -115,18 +138,27 @@ export type ClusterAdministrationCommandResult = schemaVersion: 1; operation: 'audit.list'; page: Readonly; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'pepper.references'; + pepperKeyId: string; + observedAtMs: number; + credentialIds: readonly string[]; + hasMore: boolean; }>; export interface ClusterAdministrationCommandAuthority { readonly administration: ClusterAdministrationService; readonly audit: SecurityAuditQueryRepository; + readonly pepperReferences: ApiCredentialPepperReferenceRepository; close(): Promise; } export interface ClusterAdministrationCommandDependencies { readonly openAuthority: ( environment: Readonly>, - pepper: string, + pepperKeyring: Readonly, ) => Promise>; readonly authenticate: ( keysetFile: string, @@ -243,6 +275,7 @@ export function normalizeClusterAdministrationCommand( 'credential.issue', 'credential.rotate', 'credential.revoke', + 'pepper.references', 'audit.list', ]; if ( @@ -257,6 +290,28 @@ export function normalizeClusterAdministrationCommand( ); } const operation = value.operation as ClusterAdministrationCommandOperation; + if (operation === 'pepper.references') { + exactObject(value.request, ['limit', 'pepperKeyId'], 'pepper reference'); + try { + return Object.freeze({ + schemaVersion: 1 as const, + operation, + request: Object.freeze({ + pepperKeyId: normalizeApiCredentialPepperReferenceKeyId( + value.request.pepperKeyId as string, + ), + limit: normalizeApiCredentialPepperReferenceLimit( + value.request.limit as number, + ), + }), + }); + } catch (error) { + throw new ClusterAdministrationCommandError( + 'pepper reference query is invalid', + error, + ); + } + } if (operation === 'audit.list') { let request: Readonly; try { @@ -370,9 +425,11 @@ export function createClusterAdministrationCommandRunner( MAX_ASSERTION_BYTES, true, ); - const pepperBytes = dependencies.readFile( - paths.pepperFile, - MAX_PEPPER_BYTES, + const pepperKeyringBytes = dependencies.readFile( + paths.pepperKeyringFile ?? paths.pepperFile!, + paths.pepperKeyringFile === undefined + ? MAX_PEPPER_BYTES + : MAX_PEPPER_KEYRING_BYTES, true, ); let authority: @@ -380,13 +437,46 @@ export function createClusterAdministrationCommandRunner( | undefined; try { const assertion = strictUtf8(assertionBytes, 'assertion file').trim(); - const pepper = strictUtf8(pepperBytes, 'pepper file').trim(); - assertApiCredentialPepper(pepper); + let pepperKeyring: Readonly; + if (paths.pepperKeyringFile === undefined) { + pepperKeyring = createSingletonApiCredentialPepperKeyring( + strictUtf8(pepperKeyringBytes, 'pepper file').trim(), + LEGACY_API_CREDENTIAL_PEPPER_KEY_ID, + ); + } else { + try { + pepperKeyring = normalizeApiCredentialPepperKeyring( + JSON.parse( + strictUtf8(pepperKeyringBytes, 'pepper keyring file'), + ), + ); + } catch (error) { + throw new ClusterAdministrationCommandError( + 'pepper keyring file is invalid', + error, + ); + } + } const principal = await dependencies.authenticate( paths.keysetFile, assertion, ); - authority = await dependencies.openAuthority(environment, pepper); + authority = await dependencies.openAuthority( + environment, + pepperKeyring, + ); + if (command.operation === 'pepper.references') { + void principal; + const inspection = await authority.pepperReferences.inspect( + command.request.pepperKeyId, + command.request.limit, + ); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + ...inspection, + }); + } if (command.operation === 'audit.list') { // Successful verification is the short-lived admin admission. Audit // queries remain read-only and use the repository's bounded contract. @@ -460,7 +550,7 @@ export function createClusterAdministrationCommandRunner( }); } finally { assertionBytes.fill(0); - pepperBytes.fill(0); + pepperKeyringBytes.fill(0); await authority?.close(); } }, diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommandRuntime.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommandRuntime.ts index c7f080fd..76bb478f 100644 --- a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommandRuntime.ts +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationCommandRuntime.ts @@ -20,9 +20,13 @@ import { resolve, } from 'node:path'; -import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token'; +import { + activeApiCredentialPepperKey, + type ApiCredentialPepperKeyring, +} from '@qinglong/runtime-core/api-credential-pepper-keyring'; import { PostgresApiCredentialAdministrationRepository, + PostgresApiCredentialPepperReferenceRepository, PostgresIdentityAdministrationRepository, PostgresSecurityAuditQueryRepository, assertPostgresAdminSchemaReady, @@ -249,9 +253,9 @@ function defaultDatabaseOpener( async function openDefaultAuthority( environment: Readonly>, - pepper: string, + pepperKeyring: Readonly, ): Promise> { - assertApiCredentialPepper(pepper); + const activePepperKey = activeApiCredentialPepperKey(pepperKeyring); const database = await defaultDatabaseOpener(environment)(); let closePromise: Promise | undefined; const close = (): Promise => { @@ -264,9 +268,12 @@ async function openDefaultAuthority( administration: createClusterAdministrationService( new PostgresIdentityAdministrationRepository(database.pool), new PostgresApiCredentialAdministrationRepository(database.pool), - pepper, + activePepperKey, ), audit: new PostgresSecurityAuditQueryRepository(database.pool), + pepperReferences: new PostgresApiCredentialPepperReferenceRepository( + database.pool, + ), close, }); } catch (error) { @@ -388,11 +395,20 @@ export function normalizeClusterAdministrationCommandPaths( 'command paths must be an object', ); } + const pepperPathKeys = [ + value.pepperFile === undefined ? null : 'pepperFile', + value.pepperKeyringFile === undefined ? null : 'pepperKeyringFile', + ].filter((key): key is string => key !== null); + if (pepperPathKeys.length !== 1) { + throw new ClusterAdministrationCommandError( + 'exactly one pepper source is required', + ); + } const expected = [ 'assertionFile', 'commandFile', 'keysetFile', - 'pepperFile', + pepperPathKeys[0]!, ...(requiresDelivery ? ['deliveryFile'] : []), ].sort(); const actual = Object.keys(value).sort(); @@ -417,10 +433,19 @@ export function normalizeClusterAdministrationCommandPaths( value.keysetFile, 'identity keyset file', ), - pepperFile: boundedClusterAdministrationFile( - value.pepperFile, - 'pepper file', - ), + ...(value.pepperFile === undefined + ? { + pepperKeyringFile: boundedClusterAdministrationFile( + value.pepperKeyringFile, + 'pepper keyring file', + ), + } + : { + pepperFile: boundedClusterAdministrationFile( + value.pepperFile, + 'pepper file', + ), + }), ...(requiresDelivery ? { deliveryFile: boundedClusterAdministrationFile( @@ -441,11 +466,18 @@ export function clusterAdministrationCommandFileBeforeAdmission( ); } const candidate = value as Record; - const required = ['assertionFile', 'commandFile', 'keysetFile', 'pepperFile']; + const required = ['assertionFile', 'commandFile', 'keysetFile']; + const pepperSources = ['pepperFile', 'pepperKeyringFile'].filter((key) => + Object.hasOwn(candidate, key), + ); if ( required.some((key) => !Object.hasOwn(candidate, key)) || + pepperSources.length !== 1 || Object.keys(candidate).some( - (key) => !required.includes(key) && key !== 'deliveryFile', + (key) => + !required.includes(key) && + !pepperSources.includes(key) && + key !== 'deliveryFile', ) ) { throw new ClusterAdministrationCommandError( diff --git a/packages/ql3-cluster-admin/test/administration.test.cjs b/packages/ql3-cluster-admin/test/administration.test.cjs index 42390ecb..5359a789 100644 --- a/packages/ql3-cluster-admin/test/administration.test.cjs +++ b/packages/ql3-cluster-admin/test/administration.test.cjs @@ -122,6 +122,29 @@ test('issues one token, stores only its digest and clears mutable secret bytes', ); }); +test('binds newly issued credentials to the selected active pepper key', async () => { + const repos = repositories(); + const nextPepper = Buffer.alloc(32, 2).toString('base64url'); + const generated = Buffer.alloc(32, 9); + const service = createClusterAdministrationService( + repos.identities, + repos.credentials, + { pepperKeyId: 'rotation-2026-08', pepper: nextPepper }, + { now: () => NOW, randomBytes: () => generated }, + ); + + const result = await service.issueCredential(request()); + const secret = result.token.split('_').at(-1); + assert.equal( + repos.credentialCommands[0].credential.pepperKeyId, + 'rotation-2026-08', + ); + assert.equal( + repos.credentialCommands[0].credential.secretDigest, + apiCredentialSecretDigest(nextPepper, 'credential_primary', secret), + ); +}); + test('semantic mutation replay returns no token and does not generate a new secret', async () => { const repos = repositories(); let randomCalls = 0; diff --git a/packages/ql3-cluster-admin/test/clusterAdministrationCommand.test.cjs b/packages/ql3-cluster-admin/test/clusterAdministrationCommand.test.cjs index 48b0dee2..6770492f 100644 --- a/packages/ql3-cluster-admin/test/clusterAdministrationCommand.test.cjs +++ b/packages/ql3-cluster-admin/test/clusterAdministrationCommand.test.cjs @@ -114,6 +114,17 @@ function authority(overrides = {}) { return { records: [], nextCursor: null }; }, }, + pepperReferences: { + async inspect(pepperKeyId, limit) { + calls.push(['pepper.references', { pepperKeyId, limit }]); + return { + pepperKeyId, + observedAtMs: 1_000, + credentialIds: ['automation-primary'], + hasMore: false, + }; + }, + }, async close() { closes += 1; }, @@ -175,7 +186,11 @@ test('executes one strongly authenticated identity mutation and closes authority assertion: 'signed.assertion.value', }, ]); - assert.equal(execution.opens[0].pepper, 'A'.repeat(43)); + assert.deepEqual(execution.opens[0].pepper, { + schemaVersion: 1, + activePepperKeyId: 'legacy-v1', + keys: [{ pepperKeyId: 'legacy-v1', pepper: 'A'.repeat(43) }], + }); assert.equal(target.calls[0][1].principal, PRINCIPAL); assert.equal(target.closes(), 1); assert.equal( @@ -312,6 +327,73 @@ test('keeps audit query bounded and rejects widened command shapes before admiss ); }); +test('loads a dual-generation keyring and exposes bounded retirement references', async () => { + const target = authority(); + const nextPepper = Buffer.alloc(32, 2).toString('base64url'); + const command = { + schemaVersion: 1, + operation: 'pepper.references', + request: { pepperKeyId: 'legacy-v1', limit: 32 }, + }; + const paths = { + commandFile: PATHS.commandFile, + assertionFile: PATHS.assertionFile, + keysetFile: PATHS.keysetFile, + pepperKeyringFile: '/private/pepper-keyring.json', + }; + const buffers = []; + const opens = []; + const files = new Map([ + [paths.commandFile, JSON.stringify(command)], + [paths.assertionFile, 'signed.assertion.value'], + [ + paths.pepperKeyringFile, + JSON.stringify({ + schemaVersion: 1, + activePepperKeyId: 'rotation-2026-08', + keys: [ + { pepperKeyId: 'legacy-v1', pepper: 'A'.repeat(43) }, + { pepperKeyId: 'rotation-2026-08', pepper: nextPepper }, + ], + }), + ], + ]); + const instance = createClusterAdministrationCommandRunner({ + async openAuthority(environment, keyring) { + opens.push({ environment, keyring }); + return target.value; + }, + async authenticate() { + return PRINCIPAL; + }, + readFile(filePath) { + const buffer = Buffer.from(files.get(filePath)); + buffers.push(buffer); + return buffer; + }, + publishDelivery() { + throw new Error('unexpected delivery'); + }, + }); + + assert.deepEqual(await instance.run(paths, {}), { + schemaVersion: 1, + operation: 'pepper.references', + pepperKeyId: 'legacy-v1', + observedAtMs: 1_000, + credentialIds: ['automation-primary'], + hasMore: false, + }); + assert.equal(opens[0].keyring.activePepperKeyId, 'rotation-2026-08'); + assert.equal(opens[0].keyring.keys.length, 2); + assert.equal(target.calls[0][0], 'pepper.references'); + assert.equal(target.closes(), 1); + assert.equal( + buffers.every((buffer) => buffer.every((byte) => byte === 0)), + true, + ); +}); + test('rejects widened path authority before reading a command file', async () => { let reads = 0; const instance = createClusterAdministrationCommandRunner({ diff --git a/packages/ql3-cluster-control/package.json b/packages/ql3-cluster-control/package.json index 8aa8dd6c..cf4594f5 100644 --- a/packages/ql3-cluster-control/package.json +++ b/packages/ql3-cluster-control/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/cluster-control", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 cluster-control composition root", "license": "Apache-2.0", diff --git a/packages/ql3-cluster-control/src/application-runtime/application.ts b/packages/ql3-cluster-control/src/application-runtime/application.ts index 8cd10aa0..8df06eac 100644 --- a/packages/ql3-cluster-control/src/application-runtime/application.ts +++ b/packages/ql3-cluster-control/src/application-runtime/application.ts @@ -8,6 +8,12 @@ import type { DeploymentProfile, OpenPostgresDatabase, } from '@qinglong/runtime-core'; +import { + createSingletonApiCredentialPepperKeyring, + normalizeApiCredentialPepperKeyring, + type ApiCredentialPepperKeyring, +} from '@qinglong/runtime-core/api-credential-pepper-keyring'; +import { LEGACY_API_CREDENTIAL_PEPPER_KEY_ID } from '@qinglong/runtime-core/api-credential'; import { bootstrapClusterControlRuntime, type ClusterControlAssemblyInput, @@ -17,13 +23,13 @@ import { type ClusterSchedulerRuntimeOptions, type ClusterWorkerRuntimeDependencies, } from './clusterControlRuntime'; -import { assertClusterControlApiCredentialPepper } from '../authentication/apiCredentialAuthenticator'; import { startClusterControlHttpSurface, type ClusterControlAdmissionPipeline, type ClusterControlHttpAddress, type ClusterControlHttpSurfaceOptions, } from '../transport/httpSurface'; +import { ClusterControlApiCredentialConfigurationError } from '../authentication/apiCredentialAuthenticator'; import type { ClusterControlAvailabilitySource } from '../database/availability'; export interface ClusterControlApplicationStack { @@ -36,7 +42,9 @@ export interface ClusterControlApplicationStack { export interface ClusterControlApplicationOptions { readonly enabled?: boolean; readonly profile: DeploymentProfile; + /** Explicit singleton compatibility bridge; production config emits a keyring. */ readonly apiCredentialPepper?: string; + readonly apiCredentialPepperKeyring?: Readonly; readonly recovery?: ClusterControlRecoveryRuntimeOptions; readonly scheduler?: ClusterSchedulerRuntimeOptions; readonly cancellationConvergence?: ClusterRunCancellationConvergenceRuntimeOptions; @@ -83,6 +91,9 @@ function inactiveBootstrap( ...(options.apiCredentialPepper === undefined ? {} : { apiCredentialPepper: options.apiCredentialPepper }), + ...(options.apiCredentialPepperKeyring === undefined + ? {} + : { apiCredentialPepperKeyring: options.apiCredentialPepperKeyring }), ...(options.recovery === undefined ? {} : { recovery: options.recovery }), ...(options.scheduler === undefined ? {} @@ -121,8 +132,30 @@ export async function startClusterControlApplication( return inactive; } - assertClusterControlApiCredentialPepper(options.apiCredentialPepper ?? ''); - const apiCredentialPepper = options.apiCredentialPepper!; + if ( + (options.apiCredentialPepper === undefined) === + (options.apiCredentialPepperKeyring === undefined) + ) { + throw new TypeError( + 'Cluster-control API credential configuration is invalid', + ); + } + let apiCredentialPepperKeyring: Readonly; + try { + apiCredentialPepperKeyring = + options.apiCredentialPepperKeyring === undefined + ? createSingletonApiCredentialPepperKeyring( + options.apiCredentialPepper!, + LEGACY_API_CREDENTIAL_PEPPER_KEY_ID, + ) + : normalizeApiCredentialPepperKeyring( + options.apiCredentialPepperKeyring, + ); + } catch { + throw new ClusterControlApiCredentialConfigurationError( + 'pepper keyring is invalid', + ); + } if ( !options.availability || typeof options.availability.subscribe !== 'function' @@ -160,7 +193,7 @@ export async function startClusterControlApplication( activation = await bootstrapClusterControlRuntime({ enabled: true, profile: options.profile, - apiCredentialPepper, + apiCredentialPepperKeyring, ...(options.recovery === undefined ? {} : { recovery: options.recovery }), ...(options.scheduler === undefined ? {} diff --git a/packages/ql3-cluster-control/src/application-runtime/clusterControlRuntime.ts b/packages/ql3-cluster-control/src/application-runtime/clusterControlRuntime.ts index 8f72fd30..964926eb 100644 --- a/packages/ql3-cluster-control/src/application-runtime/clusterControlRuntime.ts +++ b/packages/ql3-cluster-control/src/application-runtime/clusterControlRuntime.ts @@ -30,6 +30,12 @@ import { type ClusterRunCancellationConvergenceCycleResult, } from '@qinglong/runtime-core'; import type { ClusterRunCancellationRepository } from '@qinglong/runtime-core/cluster-run-cancellation'; +import { + createSingletonApiCredentialPepperKeyring, + normalizeApiCredentialPepperKeyring, + type ApiCredentialPepperKeyring, +} from '@qinglong/runtime-core/api-credential-pepper-keyring'; +import { LEGACY_API_CREDENTIAL_PEPPER_KEY_ID } from '@qinglong/runtime-core/api-credential'; import { MAX_CLUSTER_RUN_ATTEMPT_LOG_RETENTION_CLAIMS, MAX_CLUSTER_RUN_ATTEMPT_LOG_RETENTION_LEASE_MS, @@ -100,7 +106,7 @@ import { PostgresAuthorizedPluginPackageWorkflowStepRunListRepository, } from '@qinglong/cluster-postgres/plugin-package-workflow-administration'; import { - assertClusterControlApiCredentialPepper, + ClusterControlApiCredentialConfigurationError, createClusterControlApiCredentialAuthenticator, } from '../authentication/apiCredentialAuthenticator'; import type { @@ -218,7 +224,9 @@ export interface ClusterRunAttemptLogRetentionRuntimeOptions { export interface ClusterControlBootstrapOptions { readonly enabled?: boolean; readonly profile: DeploymentProfile; + /** Explicit singleton compatibility bridge; production config emits a keyring. */ readonly apiCredentialPepper?: string; + readonly apiCredentialPepperKeyring?: Readonly; readonly recovery?: ClusterControlRecoveryRuntimeOptions; readonly scheduler?: ClusterSchedulerRuntimeOptions; readonly cancellationConvergence?: ClusterRunCancellationConvergenceRuntimeOptions; @@ -628,8 +636,33 @@ export async function bootstrapClusterControlRuntime( | undefined; let logRetentionRuntime: PreparedLogRetentionRuntime | undefined; let recoveryRegistry: ClusterControlRecoveryEvidenceRegistry | undefined; + let apiCredentialPepperKeyring: + | Readonly + | undefined; if ((options.enabled ?? false) && options.profile === 'cluster-control') { - assertClusterControlApiCredentialPepper(options.apiCredentialPepper ?? ''); + if ( + (options.apiCredentialPepper === undefined) === + (options.apiCredentialPepperKeyring === undefined) + ) { + throw new TypeError( + 'Cluster-control API credential configuration is invalid', + ); + } + try { + apiCredentialPepperKeyring = + options.apiCredentialPepperKeyring === undefined + ? createSingletonApiCredentialPepperKeyring( + options.apiCredentialPepper!, + LEGACY_API_CREDENTIAL_PEPPER_KEY_ID, + ) + : normalizeApiCredentialPepperKeyring( + options.apiCredentialPepperKeyring, + ); + } catch { + throw new ClusterControlApiCredentialConfigurationError( + 'pepper keyring is invalid', + ); + } recoveryRuntime = prepareRecoveryRuntime(options.recovery); schedulerRuntime = prepareSchedulerRuntime( options.scheduler, @@ -857,7 +890,7 @@ export async function bootstrapClusterControlRuntime( evidence, authenticator: createClusterControlApiCredentialAuthenticator( new PostgresApiCredentialRepository(database.pool), - options.apiCredentialPepper ?? '', + apiCredentialPepperKeyring!, ), policies: new PostgresProjectPolicyRepository(database.pool), runs, diff --git a/packages/ql3-cluster-control/src/application-runtime/productionApplication.ts b/packages/ql3-cluster-control/src/application-runtime/productionApplication.ts index 58188280..95ce4b6e 100644 --- a/packages/ql3-cluster-control/src/application-runtime/productionApplication.ts +++ b/packages/ql3-cluster-control/src/application-runtime/productionApplication.ts @@ -166,6 +166,7 @@ export interface ProductionClusterControlApplicationOptions | 'enabled' | 'profile' | 'apiCredentialPepper' + | 'apiCredentialPepperKeyring' | 'openDatabase' | 'availability' | 'http' @@ -436,7 +437,7 @@ export function startProductionClusterControlApplication( ...applicationOptions, enabled: true, profile: 'cluster-control', - apiCredentialPepper: config.security.apiCredentialPepper, + apiCredentialPepperKeyring: config.security.apiCredentialPepperKeyring, http: config.http, ...(workerIngress === undefined ? {} diff --git a/packages/ql3-cluster-control/src/authentication/apiCredentialAuthenticator.ts b/packages/ql3-cluster-control/src/authentication/apiCredentialAuthenticator.ts index 6a6cfac9..aaf2964f 100644 --- a/packages/ql3-cluster-control/src/authentication/apiCredentialAuthenticator.ts +++ b/packages/ql3-cluster-control/src/authentication/apiCredentialAuthenticator.ts @@ -7,6 +7,13 @@ import { normalizeApiCredentialRecord, type ApiCredentialRepository, } from '@qinglong/runtime-core/api-credential'; +import { + activeApiCredentialPepperKey, + createSingletonApiCredentialPepperKeyring, + normalizeApiCredentialPepperKeyring, + resolveApiCredentialPepperKey, + type ApiCredentialPepperKeyring, +} from '@qinglong/runtime-core/api-credential-pepper-keyring'; import { normalizeSecurityPrincipal, type SecurityPrincipal, @@ -137,7 +144,7 @@ function parseAuthorization( export function createClusterControlApiCredentialAuthenticator( repository: ApiCredentialRepository, - pepperBase64Url: string, + pepperKeyringValue: Readonly | string, options: ClusterControlApiCredentialAuthenticatorOptions = {}, ): ClusterControlRequestAuthenticator { if (!repository || typeof repository.resolve !== 'function') { @@ -164,16 +171,25 @@ export function createClusterControlApiCredentialAuthenticator( if (options.now !== undefined && typeof options.now !== 'function') { throw new ClusterControlApiCredentialConfigurationError('now is invalid'); } - const pepperKeyId = - options.pepperKeyId ?? LEGACY_API_CREDENTIAL_PEPPER_KEY_ID; + let pepperKeyring: Readonly; try { - assertApiCredentialPepperKeyId(pepperKeyId); + if (typeof pepperKeyringValue === 'string') { + const pepperKeyId = + options.pepperKeyId ?? LEGACY_API_CREDENTIAL_PEPPER_KEY_ID; + assertApiCredentialPepperKeyId(pepperKeyId); + pepperKeyring = createSingletonApiCredentialPepperKeyring( + pepperKeyringValue, + pepperKeyId, + ); + } else { + if (options.pepperKeyId !== undefined) throw new TypeError(); + pepperKeyring = normalizeApiCredentialPepperKeyring(pepperKeyringValue); + } } catch { throw new ClusterControlApiCredentialConfigurationError( - 'pepperKeyId is invalid', + 'pepper keyring is invalid', ); } - const pepper = decodeSecret('pepper', pepperBase64Url); const ttlMs = principalTtl(options.principalTtlMs); const now = options.now ?? Date.now; @@ -183,37 +199,42 @@ export function createClusterControlApiCredentialAuthenticator( ): Promise | null> { const parsed = parseAuthorization(metadata); if (!parsed) return null; - const presentedDigest = digest( - pepper, - parsed.credentialId, - parsed.secret, - ); - parsed.secret.fill(0); let candidate; try { candidate = await repository.resolve(parsed.credentialId); } catch (error) { - presentedDigest.fill(0); + parsed.secret.fill(0); if (error instanceof ApiCredentialUnavailableError) { throw new ClusterControlApiCredentialUnavailableError(); } throw new ClusterControlApiCredentialUnavailableError(); } if (metadata.signal.aborted) { - presentedDigest.fill(0); + parsed.secret.fill(0); throw new ClusterControlApiCredentialUnavailableError(); } let record; try { record = candidate ? normalizeApiCredentialRecord(candidate) : null; } catch { - presentedDigest.fill(0); + parsed.secret.fill(0); throw new ClusterControlApiCredentialUnavailableError(); } - if (record && record.pepperKeyId !== pepperKeyId) { - presentedDigest.fill(0); + const key = record + ? resolveApiCredentialPepperKey(pepperKeyring, record.pepperKeyId) + : activeApiCredentialPepperKey(pepperKeyring); + if (!key) { + parsed.secret.fill(0); throw new ClusterControlApiCredentialUnavailableError(); } + const pepper = decodeSecret('pepper', key.pepper); + const presentedDigest = digest( + pepper, + parsed.credentialId, + parsed.secret, + ); + pepper.fill(0); + parsed.secret.fill(0); const storedDigest = record ? Buffer.from(record.secretDigest, 'hex') : Buffer.alloc(32); diff --git a/packages/ql3-cluster-control/src/production-process/config.ts b/packages/ql3-cluster-control/src/production-process/config.ts index 36a3c59a..35b88f63 100644 --- a/packages/ql3-cluster-control/src/production-process/config.ts +++ b/packages/ql3-cluster-control/src/production-process/config.ts @@ -2,6 +2,20 @@ import type { DeploymentProfile, OpenPostgresDatabase, } from '@qinglong/runtime-core'; +import { + createSingletonApiCredentialPepperKeyring, + normalizeApiCredentialPepperKeyring, + type ApiCredentialPepperKeyring, +} from '@qinglong/runtime-core/api-credential-pepper-keyring'; +import { LEGACY_API_CREDENTIAL_PEPPER_KEY_ID } from '@qinglong/runtime-core/api-credential'; +import { + closeSync, + constants, + fstatSync, + openSync, + readFileSync, +} from 'node:fs'; +import { isAbsolute, normalize } from 'node:path'; import { createPostgresDatabaseOpener, isPostgresTlsDnsServername, @@ -41,7 +55,7 @@ export interface EnabledClusterControlConfig { pool: PostgresPoolOptions; }>; readonly security: Readonly<{ - apiCredentialPepper: string; + apiCredentialPepperKeyring: Readonly; }>; readonly logRetention: | Readonly<{ readonly enabled: false }> @@ -224,26 +238,88 @@ function runtimeConnection( }); } -function apiCredentialPepper(environment: ClusterControlEnvironment): string { - const value = boundedValue( +function apiCredentialPepperKeyring( + environment: ClusterControlEnvironment, +): Readonly { + const legacyPepper = boundedValue( environment, 'QL3_API_CREDENTIAL_PEPPER', 64, - true, - )!; - if (!/^[A-Za-z0-9_-]{43}$/.test(value)) { + ); + const keyringFile = boundedValue( + environment, + 'QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE', + 4_096, + ); + if ((legacyPepper === undefined) === (keyringFile === undefined)) { throw new ClusterControlConfigError( - 'QL3_API_CREDENTIAL_PEPPER must be canonical base64url for 32 bytes', + 'exactly one API credential pepper source is required', ); } - const decoded = Buffer.from(value, 'base64url'); - if (decoded.byteLength !== 32 || decoded.toString('base64url') !== value) { + if (legacyPepper !== undefined) { + try { + return createSingletonApiCredentialPepperKeyring( + legacyPepper, + LEGACY_API_CREDENTIAL_PEPPER_KEY_ID, + ); + } catch { + throw new ClusterControlConfigError( + 'QL3_API_CREDENTIAL_PEPPER must be canonical base64url for 32 bytes', + ); + } + } + if ( + !isAbsolute(keyringFile!) || + normalize(keyringFile!) !== keyringFile || + keyringFile!.includes('\0') + ) { throw new ClusterControlConfigError( - 'QL3_API_CREDENTIAL_PEPPER must be canonical base64url for 32 bytes', + 'QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE must be a normalized absolute path', ); } - decoded.fill(0); - return value; + let descriptor: number | undefined; + let bytes: Buffer | undefined; + try { + descriptor = openSync( + keyringFile!, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + const before = fstatSync(descriptor); + if ( + !before.isFile() || + before.size < 1 || + before.size > 2_048 || + (before.mode & 0o022) !== 0 + ) { + throw new ClusterControlConfigError( + 'API credential pepper keyring file authority is invalid', + ); + } + bytes = readFileSync(descriptor); + const after = fstatSync(descriptor); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + bytes.byteLength !== before.size + ) { + throw new ClusterControlConfigError( + 'API credential pepper keyring changed while being read', + ); + } + return normalizeApiCredentialPepperKeyring( + JSON.parse(bytes.toString('utf8')), + ); + } catch (error) { + if (error instanceof ClusterControlConfigError) throw error; + throw new ClusterControlConfigError( + 'QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE is invalid', + ); + } finally { + bytes?.fill(0); + if (descriptor !== undefined) closeSync(descriptor); + } } function logRetentionConfig( @@ -443,7 +519,7 @@ export function loadClusterControlConfig( }), }), security: Object.freeze({ - apiCredentialPepper: apiCredentialPepper(environment), + apiCredentialPepperKeyring: apiCredentialPepperKeyring(environment), }), logRetention: logRetentionConfig(environment), }; diff --git a/packages/ql3-cluster-control/test/apiCredentialAuthenticator.test.cjs b/packages/ql3-cluster-control/test/apiCredentialAuthenticator.test.cjs index 3fdd5bc0..140ffebf 100644 --- a/packages/ql3-cluster-control/test/apiCredentialAuthenticator.test.cjs +++ b/packages/ql3-cluster-control/test/apiCredentialAuthenticator.test.cjs @@ -13,6 +13,7 @@ const { const NOW = 10_000; const PEPPER = Buffer.alloc(32, 1).toString('base64url'); +const NEXT_PEPPER = Buffer.alloc(32, 3).toString('base64url'); const SECRET = Buffer.alloc(32, 2).toString('base64url'); const CREDENTIAL_ID = 'app_primary'; @@ -86,6 +87,53 @@ test('derives a domain-separated HMAC digest and user assurance', async () => { assert.equal(principal.assurance, 'single_factor'); }); +test('authenticates overlap generations by exact stored key id without fallback', async () => { + const keyring = { + schemaVersion: 1, + activePepperKeyId: 'rotation-2026-08', + keys: [ + { pepperKeyId: 'legacy-v1', pepper: PEPPER }, + { pepperKeyId: 'rotation-2026-08', pepper: NEXT_PEPPER }, + ], + }; + const records = new Map([ + [ + 'legacy', + credential({ + credentialId: 'legacy', + secretDigest: apiCredentialSecretDigest(PEPPER, 'legacy', SECRET), + }), + ], + [ + 'next', + credential({ + credentialId: 'next', + pepperKeyId: 'rotation-2026-08', + secretDigest: apiCredentialSecretDigest( + NEXT_PEPPER, + 'next', + SECRET, + ), + }), + ], + ['unknown', credential({ credentialId: 'unknown', pepperKeyId: 'missing' })], + ]); + const verifier = createClusterControlApiCredentialAuthenticator( + { async resolve(credentialId) { return records.get(credentialId) ?? null; } }, + keyring, + { now: () => NOW }, + ); + const request = (credentialId) => + metadata(`Bearer ql3c_${credentialId}_${SECRET}`); + + assert.equal((await verifier.authenticate(request('legacy'))).subject.id, 'app_primary'); + assert.equal((await verifier.authenticate(request('next'))).subject.id, 'app_primary'); + await assert.rejects( + verifier.authenticate(request('unknown')), + ClusterControlApiCredentialUnavailableError, + ); +}); + test('rejects missing, malformed, wrong, inactive and disabled credentials', async () => { let repositoryCalls = 0; const strict = createClusterControlApiCredentialAuthenticator( diff --git a/packages/ql3-cluster-control/test/config.test.cjs b/packages/ql3-cluster-control/test/config.test.cjs index 22ac717a..6def93f3 100644 --- a/packages/ql3-cluster-control/test/config.test.cjs +++ b/packages/ql3-cluster-control/test/config.test.cjs @@ -1,4 +1,6 @@ const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const { test } = require('node:test'); const { @@ -112,7 +114,16 @@ test('builds an exact runtime-only TLS-verified Pool configuration', async () => }, }); assert.deepEqual(config.security, { - apiCredentialPepper: BASE_ENV.QL3_API_CREDENTIAL_PEPPER, + apiCredentialPepperKeyring: { + schemaVersion: 1, + activePepperKeyId: 'legacy-v1', + keys: [ + { + pepperKeyId: 'legacy-v1', + pepper: BASE_ENV.QL3_API_CREDENTIAL_PEPPER, + }, + ], + }, }); assert.deepEqual(config.logRetention, { enabled: true, @@ -133,6 +144,50 @@ test('builds an exact runtime-only TLS-verified Pool configuration', async () => await database.close(); }); +test('loads an exact private dual-generation pepper keyring file', (context) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-keyring-')); + context.after(() => fs.rmSync(directory, { recursive: true, force: true })); + fs.chmodSync(directory, 0o700); + const keyringFile = path.join(directory, 'api-credential-keyring.json'); + const nextPepper = Buffer.alloc(32, 2).toString('base64url'); + fs.writeFileSync( + keyringFile, + JSON.stringify({ + schemaVersion: 1, + activePepperKeyId: 'rotation-2026-08', + keys: [ + { pepperKeyId: 'legacy-v1', pepper: 'A'.repeat(43) }, + { pepperKeyId: 'rotation-2026-08', pepper: nextPepper }, + ], + }), + { mode: 0o600 }, + ); + const environment = { + ...BASE_ENV, + QL3_API_CREDENTIAL_PEPPER: undefined, + QL3_API_CREDENTIAL_PEPPER_KEYRING_FILE: keyringFile, + }; + + assert.equal( + loadClusterControlConfig(environment).security.apiCredentialPepperKeyring + .activePepperKeyId, + 'rotation-2026-08', + ); + assert.throws( + () => + loadClusterControlConfig({ + ...environment, + QL3_API_CREDENTIAL_PEPPER: 'A'.repeat(43), + }), + /exactly one API credential pepper source/, + ); + fs.chmodSync(keyringFile, 0o622); + assert.throws( + () => loadClusterControlConfig(environment), + /file authority is invalid/, + ); +}); + test('loads discrete operator-managed runtime credentials without a DSN copy', () => { const { QL3_POSTGRES_RUNTIME_URL: _connectionString, diff --git a/packages/ql3-cluster-postgres/package.json b/packages/ql3-cluster-postgres/package.json index 15f64e59..1555bfdb 100644 --- a/packages/ql3-cluster-postgres/package.json +++ b/packages/ql3-cluster-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/cluster-postgres", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 cluster-only PostgreSQL driver and schema package", "license": "Apache-2.0", diff --git a/packages/ql3-cluster-postgres/src/entrypoints/admin.ts b/packages/ql3-cluster-postgres/src/entrypoints/admin.ts index 4c2aebf2..745e4916 100644 --- a/packages/ql3-cluster-postgres/src/entrypoints/admin.ts +++ b/packages/ql3-cluster-postgres/src/entrypoints/admin.ts @@ -33,6 +33,7 @@ export { } from '../connection/certificateAuthority'; export * from '../security/identityAdministrationRepository'; export * from '../security/apiCredentialAdministrationRepository'; +export * from '../security/apiCredentialPepperReferenceRepository'; export * from '../security/securityAuditQueryRepository'; export * from '../worker-credential/workerCredentialAdministrationRepository'; export * from '../automation/automationAdministrationRepository'; diff --git a/packages/ql3-cluster-postgres/src/security/apiCredentialPepperReferenceRepository.ts b/packages/ql3-cluster-postgres/src/security/apiCredentialPepperReferenceRepository.ts new file mode 100644 index 00000000..0c73b112 --- /dev/null +++ b/packages/ql3-cluster-postgres/src/security/apiCredentialPepperReferenceRepository.ts @@ -0,0 +1,118 @@ +import { + ApiCredentialPepperReferenceUnavailableError, + normalizeApiCredentialPepperReferenceKeyId, + normalizeApiCredentialPepperReferenceLimit, + type ApiCredentialPepperReferenceInspection, + type ApiCredentialPepperReferenceRepository, +} from '@qinglong/runtime-core/api-credential-pepper-reference'; +import type { PostgresPool } from '@qinglong/runtime-core'; +import { assertApiCredentialId } from '@qinglong/runtime-core/api-credential'; + +interface ReferenceRow extends Record { + observedAtMs: unknown; + credentialId: unknown; +} + +const INSPECT_SQL = ` +WITH clock AS ( + SELECT floor( + extract(epoch FROM statement_timestamp()) * 1000 + )::bigint AS observed_at_ms +), current_references AS ( + SELECT credential.credential_id + FROM "ql3"."api_credentials" AS credential + CROSS JOIN clock + WHERE credential.pepper_key_id = $1 + AND credential.state = 'active' + AND credential.expires_at_ms > clock.observed_at_ms + AND NOT EXISTS ( + SELECT 1 + FROM "ql3"."api_credentials" AS newer + WHERE newer.credential_id = credential.credential_id + AND newer.version > credential.version + ) + ORDER BY credential.credential_id + LIMIT $2 +) +SELECT + clock.observed_at_ms AS "observedAtMs", + reference.credential_id AS "credentialId" +FROM clock +LEFT JOIN current_references AS reference ON true +ORDER BY reference.credential_id +`.trim(); + +function safeInteger(value: unknown): number { + const parsed = typeof value === 'string' ? Number(value) : value; + if (!Number.isSafeInteger(parsed) || (parsed as number) < 0) { + throw new ApiCredentialPepperReferenceUnavailableError(); + } + return parsed as number; +} + +export class PostgresApiCredentialPepperReferenceRepository + implements ApiCredentialPepperReferenceRepository +{ + constructor(private readonly pool: Pick) { + if (!pool || typeof pool.query !== 'function') { + throw new TypeError( + 'PostgreSQL API credential pepper reference pool is invalid', + ); + } + } + + async inspect( + requestedPepperKeyId: string, + requestedLimit?: number, + ): Promise> { + const pepperKeyId = normalizeApiCredentialPepperReferenceKeyId( + requestedPepperKeyId, + ); + const limit = normalizeApiCredentialPepperReferenceLimit(requestedLimit); + try { + const result = await this.pool.query(INSPECT_SQL, [ + pepperKeyId, + limit + 1, + ]); + if (result.rows.length < 1) { + throw new ApiCredentialPepperReferenceUnavailableError(); + } + const observedAtMs = safeInteger(result.rows[0]!.observedAtMs); + const emptyReferenceSet = + result.rows.length === 1 && result.rows[0]!.credentialId === null; + const allCredentialIds = emptyReferenceSet + ? [] + : result.rows.map((row) => { + if (typeof row.credentialId !== 'string') { + throw new ApiCredentialPepperReferenceUnavailableError(); + } + try { + assertApiCredentialId(row.credentialId); + } catch { + throw new ApiCredentialPepperReferenceUnavailableError(); + } + return row.credentialId; + }); + const credentialIds = allCredentialIds.slice(0, limit); + if ( + result.rows.some( + (row) => safeInteger(row.observedAtMs) !== observedAtMs, + ) || + new Set(credentialIds).size !== credentialIds.length + ) { + throw new ApiCredentialPepperReferenceUnavailableError(); + } + return Object.freeze({ + pepperKeyId, + observedAtMs, + credentialIds: Object.freeze(credentialIds), + hasMore: allCredentialIds.length > limit, + }); + } catch (error) { + if (error instanceof ApiCredentialPepperReferenceUnavailableError) { + throw error; + } + throw new ApiCredentialPepperReferenceUnavailableError(); + } + } +} diff --git a/packages/ql3-cluster-postgres/test/apiCredentialPepperReferenceRepository.test.cjs b/packages/ql3-cluster-postgres/test/apiCredentialPepperReferenceRepository.test.cjs new file mode 100644 index 00000000..b0c692ce --- /dev/null +++ b/packages/ql3-cluster-postgres/test/apiCredentialPepperReferenceRepository.test.cjs @@ -0,0 +1,75 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + PostgresApiCredentialPepperReferenceRepository, +} = require('../dist/security/apiCredentialPepperReferenceRepository.js'); + +test('returns bounded current pepper references using database time', async () => { + const calls = []; + const repository = new PostgresApiCredentialPepperReferenceRepository({ + async query(sql, parameters) { + calls.push({ sql, parameters }); + return { + rows: [ + { observedAtMs: '1000', credentialId: 'credential-a' }, + { observedAtMs: '1000', credentialId: 'credential-b' }, + { observedAtMs: '1000', credentialId: 'credential-c' }, + ], + }; + }, + }); + + assert.deepEqual(await repository.inspect('legacy-v1', 2), { + pepperKeyId: 'legacy-v1', + observedAtMs: 1000, + credentialIds: ['credential-a', 'credential-b'], + hasMore: true, + }); + assert.deepEqual(calls[0].parameters, ['legacy-v1', 3]); + assert.match(calls[0].sql, /statement_timestamp\(\)/); + assert.match(calls[0].sql, /newer\.version > credential\.version/); +}); + +test('represents an empty reference set without losing database time', async () => { + const repository = new PostgresApiCredentialPepperReferenceRepository({ + async query() { + return { rows: [{ observedAtMs: 1000, credentialId: null }] }; + }, + }); + assert.deepEqual(await repository.inspect('retired-v1'), { + pepperKeyId: 'retired-v1', + observedAtMs: 1000, + credentialIds: [], + hasMore: false, + }); +}); + +test('fails closed on invalid input and malformed database rows', async () => { + const repository = new PostgresApiCredentialPepperReferenceRepository({ + async query() { + return { rows: [{ observedAtMs: 'invalid', credentialId: null }] }; + }, + }); + await assert.rejects(() => repository.inspect('legacy-v1'), /unavailable/); + await assert.rejects(() => repository.inspect('legacy-v1', 65)); + await assert.rejects(() => repository.inspect('bad key')); + + for (const credentialId of [42, null, 'bad credential']) { + const malformedRepository = + new PostgresApiCredentialPepperReferenceRepository({ + async query() { + return { + rows: [ + { observedAtMs: 1000, credentialId: 'credential-a' }, + { observedAtMs: 1000, credentialId }, + ], + }; + }, + }); + await assert.rejects( + () => malformedRepository.inspect('legacy-v1'), + /unavailable/, + ); + } +}); diff --git a/packages/ql3-local-admin/package.json b/packages/ql3-local-admin/package.json index 85a3bb6b..9736d19b 100644 --- a/packages/ql3-local-admin/package.json +++ b/packages/ql3-local-admin/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-admin", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 short-lived local database adoption authority", "license": "Apache-2.0", diff --git a/packages/ql3-local-api/package.json b/packages/ql3-local-api/package.json index 7cc0e782..6a55542b 100644 --- a/packages/ql3-local-api/package.json +++ b/packages/ql3-local-api/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-api", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 optional single-process authenticated Local HTTP API", "license": "Apache-2.0", diff --git a/packages/ql3-local-application/package.json b/packages/ql3-local-application/package.json index b0122d1b..f9d28318 100644 --- a/packages/ql3-local-application/package.json +++ b/packages/ql3-local-application/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-application", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 local application activation composition root", "license": "Apache-2.0", diff --git a/packages/ql3-local-command-file/package.json b/packages/ql3-local-command-file/package.json index fc13f21a..b1f367f1 100644 --- a/packages/ql3-local-command-file/package.json +++ b/packages/ql3-local-command-file/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-command-file", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 bounded private local file authorities", "license": "Apache-2.0", diff --git a/packages/ql3-local-execution/package.json b/packages/ql3-local-execution/package.json index 4e8de010..1b65d092 100644 --- a/packages/ql3-local-execution/package.json +++ b/packages/ql3-local-execution/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-execution", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 local execution, control, recovery and dispatch runtime", "license": "Apache-2.0", diff --git a/packages/ql3-local-mcp-server/package.json b/packages/ql3-local-mcp-server/package.json index 4c0e817e..f577833f 100644 --- a/packages/ql3-local-mcp-server/package.json +++ b/packages/ql3-local-mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-mcp-server", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 optional authenticated local MCP stdio server", "license": "Apache-2.0", diff --git a/packages/ql3-local-owner-cli/package.json b/packages/ql3-local-owner-cli/package.json index 6391b451..436d793b 100644 --- a/packages/ql3-local-owner-cli/package.json +++ b/packages/ql3-local-owner-cli/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-owner-cli", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 short-lived Local Owner product command-file CLI", "license": "Apache-2.0", diff --git a/packages/ql3-local-owner-console/package.json b/packages/ql3-local-owner-console/package.json index 46b1c0e6..0f7b96dd 100644 --- a/packages/ql3-local-owner-console/package.json +++ b/packages/ql3-local-owner-console/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-owner-console", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 short-lived POSIX local Owner console and internal ceremonies", "license": "Apache-2.0", diff --git a/packages/ql3-local-owner-maintenance/package.json b/packages/ql3-local-owner-maintenance/package.json index b5c41eb4..9ad1a495 100644 --- a/packages/ql3-local-owner-maintenance/package.json +++ b/packages/ql3-local-owner-maintenance/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-owner-maintenance", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 short-lived Local Owner maintenance authorities and GC command adapter", "license": "Apache-2.0", diff --git a/packages/ql3-local-process/package.json b/packages/ql3-local-process/package.json index 0eb0530d..54b664e6 100644 --- a/packages/ql3-local-process/package.json +++ b/packages/ql3-local-process/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-process", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 durable local process launcher and receipt lifecycle", "license": "Apache-2.0", diff --git a/packages/ql3-local-secret/package.json b/packages/ql3-local-secret/package.json index 71c9659a..183d9801 100644 --- a/packages/ql3-local-secret/package.json +++ b/packages/ql3-local-secret/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-secret", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 encrypted local SecretStore and private keyring", "license": "Apache-2.0", diff --git a/packages/ql3-local-sqlite/package.json b/packages/ql3-local-sqlite/package.json index d33834da..46a9e5ee 100644 --- a/packages/ql3-local-sqlite/package.json +++ b/packages/ql3-local-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/local-sqlite", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 Node 24 SQLite storage adapter for local Profiles", "license": "Apache-2.0", diff --git a/packages/ql3-runtime-core/package.json b/packages/ql3-runtime-core/package.json index a93e9fb3..ef7ad091 100644 --- a/packages/ql3-runtime-core/package.json +++ b/packages/ql3-runtime-core/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/runtime-core", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 profile-neutral runtime contracts", "license": "Apache-2.0", @@ -961,6 +961,16 @@ "require": "./dist/security/identity-credential/apiCredentialToken.js", "default": "./dist/security/identity-credential/apiCredentialToken.js" }, + "./api-credential-pepper-keyring": { + "types": "./dist/security/identity-credential/apiCredentialPepperKeyring.d.ts", + "require": "./dist/security/identity-credential/apiCredentialPepperKeyring.js", + "default": "./dist/security/identity-credential/apiCredentialPepperKeyring.js" + }, + "./api-credential-pepper-reference": { + "types": "./dist/security/identity-credential/apiCredentialPepperReference.d.ts", + "require": "./dist/security/identity-credential/apiCredentialPepperReference.js", + "default": "./dist/security/identity-credential/apiCredentialPepperReference.js" + }, "./identity-administration": { "types": "./dist/security/identity-credential/identityAdministration.d.ts", "require": "./dist/security/identity-credential/identityAdministration.js", diff --git a/packages/ql3-runtime-core/src/security/identity-credential/apiCredentialPepperKeyring.ts b/packages/ql3-runtime-core/src/security/identity-credential/apiCredentialPepperKeyring.ts new file mode 100644 index 00000000..ce8050e9 --- /dev/null +++ b/packages/ql3-runtime-core/src/security/identity-credential/apiCredentialPepperKeyring.ts @@ -0,0 +1,139 @@ +import { assertApiCredentialPepperKeyId } from './apiCredential'; +import { assertApiCredentialPepper } from './apiCredentialToken'; + +export const MAX_API_CREDENTIAL_PEPPER_KEYS = 2; + +export interface ApiCredentialPepperKey { + readonly pepperKeyId: string; + readonly pepper: string; +} + +export interface ApiCredentialPepperKeyring { + readonly schemaVersion: 1; + readonly activePepperKeyId: string; + readonly keys: readonly Readonly[]; +} + +export class ApiCredentialPepperKeyringConfigurationError extends TypeError { + constructor(message: string) { + super(`API credential pepper keyring is invalid: ${message}`); + this.name = 'ApiCredentialPepperKeyringConfigurationError'; + } +} + +function exactObject( + value: unknown, + expectedKeys: readonly string[], + label: string, +): asserts value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ApiCredentialPepperKeyringConfigurationError( + `${label} must be an object`, + ); + } + const actual = Object.keys(value).sort(); + const expected = [...expectedKeys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new ApiCredentialPepperKeyringConfigurationError( + `${label} shape is invalid`, + ); + } +} + +function normalizeKey(value: unknown): Readonly { + exactObject(value, ['pepper', 'pepperKeyId'], 'key'); + try { + assertApiCredentialPepperKeyId(value.pepperKeyId as string); + assertApiCredentialPepper(value.pepper as string); + } catch { + throw new ApiCredentialPepperKeyringConfigurationError( + 'key material is invalid', + ); + } + return Object.freeze({ + pepperKeyId: value.pepperKeyId as string, + pepper: value.pepper as string, + }); +} + +export function normalizeApiCredentialPepperKeyring( + value: unknown, +): Readonly { + exactObject( + value, + ['activePepperKeyId', 'keys', 'schemaVersion'], + 'keyring', + ); + if ( + value.schemaVersion !== 1 || + !Array.isArray(value.keys) || + value.keys.length < 1 || + value.keys.length > MAX_API_CREDENTIAL_PEPPER_KEYS + ) { + throw new ApiCredentialPepperKeyringConfigurationError( + 'version or key count is invalid', + ); + } + let activePepperKeyId: string; + try { + assertApiCredentialPepperKeyId(value.activePepperKeyId as string); + activePepperKeyId = value.activePepperKeyId as string; + } catch { + throw new ApiCredentialPepperKeyringConfigurationError( + 'activePepperKeyId is invalid', + ); + } + const keys = value.keys.map(normalizeKey); + const keyIds = new Set(keys.map((key) => key.pepperKeyId)); + if (keyIds.size !== keys.length || !keyIds.has(activePepperKeyId)) { + throw new ApiCredentialPepperKeyringConfigurationError( + 'keys must be unique and contain the active key', + ); + } + return Object.freeze({ + schemaVersion: 1, + activePepperKeyId, + keys: Object.freeze(keys), + }); +} + +export function createSingletonApiCredentialPepperKeyring( + pepper: string, + pepperKeyId: string, +): Readonly { + return normalizeApiCredentialPepperKeyring({ + schemaVersion: 1, + activePepperKeyId: pepperKeyId, + keys: [{ pepperKeyId, pepper }], + }); +} + +export function resolveApiCredentialPepperKey( + keyring: Readonly, + pepperKeyId: string, +): Readonly | null { + const normalized = normalizeApiCredentialPepperKeyring(keyring); + try { + assertApiCredentialPepperKeyId(pepperKeyId); + } catch { + throw new ApiCredentialPepperKeyringConfigurationError( + 'pepperKeyId is invalid', + ); + } + return ( + normalized.keys.find((key) => key.pepperKeyId === pepperKeyId) ?? null + ); +} + +export function activeApiCredentialPepperKey( + keyring: Readonly, +): Readonly { + const normalized = normalizeApiCredentialPepperKeyring(keyring); + return resolveApiCredentialPepperKey( + normalized, + normalized.activePepperKeyId, + )!; +} diff --git a/packages/ql3-runtime-core/src/security/identity-credential/apiCredentialPepperReference.ts b/packages/ql3-runtime-core/src/security/identity-credential/apiCredentialPepperReference.ts new file mode 100644 index 00000000..462786c0 --- /dev/null +++ b/packages/ql3-runtime-core/src/security/identity-credential/apiCredentialPepperReference.ts @@ -0,0 +1,47 @@ +import { assertApiCredentialPepperKeyId } from './apiCredential'; + +export const MAX_API_CREDENTIAL_PEPPER_REFERENCES = 64; + +export interface ApiCredentialPepperReferenceInspection { + readonly pepperKeyId: string; + readonly observedAtMs: number; + readonly credentialIds: readonly string[]; + readonly hasMore: boolean; +} + +export interface ApiCredentialPepperReferenceRepository { + inspect( + pepperKeyId: string, + limit?: number, + ): Promise>; +} + +export class ApiCredentialPepperReferenceUnavailableError extends Error { + readonly code = 'API_CREDENTIAL_PEPPER_REFERENCE_UNAVAILABLE'; + + constructor() { + super('API credential pepper references are unavailable'); + this.name = 'ApiCredentialPepperReferenceUnavailableError'; + } +} + +export function normalizeApiCredentialPepperReferenceLimit( + value: number | undefined, +): number { + const resolved = value ?? MAX_API_CREDENTIAL_PEPPER_REFERENCES; + if ( + !Number.isSafeInteger(resolved) || + resolved < 1 || + resolved > MAX_API_CREDENTIAL_PEPPER_REFERENCES + ) { + throw new TypeError('API credential pepper reference limit is invalid'); + } + return resolved; +} + +export function normalizeApiCredentialPepperReferenceKeyId( + value: string, +): string { + assertApiCredentialPepperKeyId(value); + return value; +} diff --git a/packages/ql3-runtime-core/test/apiCredentialPepperKeyring.test.cjs b/packages/ql3-runtime-core/test/apiCredentialPepperKeyring.test.cjs new file mode 100644 index 00000000..6d76e9ab --- /dev/null +++ b/packages/ql3-runtime-core/test/apiCredentialPepperKeyring.test.cjs @@ -0,0 +1,81 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + activeApiCredentialPepperKey, + createSingletonApiCredentialPepperKeyring, + normalizeApiCredentialPepperKeyring, + resolveApiCredentialPepperKey, +} = require('../dist/security/identity-credential/apiCredentialPepperKeyring.js'); + +const LEGACY = 'A'.repeat(43); +const NEXT = Buffer.alloc(32, 2).toString('base64url'); + +test('normalizes a bounded dual-generation keyring and resolves exact keys', () => { + const keyring = normalizeApiCredentialPepperKeyring({ + schemaVersion: 1, + activePepperKeyId: 'rotation-2026-08', + keys: [ + { pepperKeyId: 'legacy-v1', pepper: LEGACY }, + { pepperKeyId: 'rotation-2026-08', pepper: NEXT }, + ], + }); + + assert.deepEqual(activeApiCredentialPepperKey(keyring), { + pepperKeyId: 'rotation-2026-08', + pepper: NEXT, + }); + assert.deepEqual(resolveApiCredentialPepperKey(keyring, 'legacy-v1'), { + pepperKeyId: 'legacy-v1', + pepper: LEGACY, + }); + assert.equal(resolveApiCredentialPepperKey(keyring, 'missing-v1'), null); +}); + +test('singleton compatibility is explicit and preserves its key id', () => { + assert.deepEqual( + createSingletonApiCredentialPepperKeyring(LEGACY, 'legacy-v1'), + { + schemaVersion: 1, + activePepperKeyId: 'legacy-v1', + keys: [{ pepperKeyId: 'legacy-v1', pepper: LEGACY }], + }, + ); +}); + +test('rejects widened, duplicate, missing-active, empty, and oversized keyrings', () => { + const valid = { + schemaVersion: 1, + activePepperKeyId: 'legacy-v1', + keys: [{ pepperKeyId: 'legacy-v1', pepper: LEGACY }], + }; + for (const candidate of [ + { ...valid, extra: true }, + { ...valid, keys: [] }, + { + ...valid, + keys: [valid.keys[0], valid.keys[0]], + }, + { ...valid, activePepperKeyId: 'missing-v1' }, + { + ...valid, + keys: [ + valid.keys[0], + { pepperKeyId: 'next-v1', pepper: NEXT }, + { + pepperKeyId: 'third-v1', + pepper: Buffer.alloc(32, 3).toString('base64url'), + }, + ], + }, + { + ...valid, + keys: [{ ...valid.keys[0], extra: true }], + }, + ]) { + assert.throws( + () => normalizeApiCredentialPepperKeyring(candidate), + /pepper keyring is invalid/, + ); + } +}); diff --git a/packages/ql3-worker-runtime/package.json b/packages/ql3-worker-runtime/package.json index 7b88ca2d..722779a0 100644 --- a/packages/ql3-worker-runtime/package.json +++ b/packages/ql3-worker-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@qinglong/worker-runtime", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "private": true, "description": "QingLong 3.0 headless Worker profile runtime", "license": "Apache-2.0", diff --git a/ql3-release.json b/ql3-release.json index e1a90630..96c2bb31 100644 --- a/ql3-release.json +++ b/ql3-release.json @@ -2,7 +2,7 @@ "schemaVersion": 2, "schema": "qinglong/release-identity@v2", "product": "qinglong3", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.1", "node": { "version": "24.18.0", "engine": ">=24.18.0 <25" diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 75691e70..0d63abf4 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -299,10 +299,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: runtimeCore.rootSourceFileRoles, }, { - sourceFiles: 174, + sourceFiles: 176, rootSourceFiles: 1, rootSourceLines: 160, - nestedSourceFiles: 173, + nestedSourceFiles: 175, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); @@ -421,10 +421,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterPostgres.rootSourceFileRoles, }, { - sourceFiles: 179, + sourceFiles: 180, rootSourceFiles: 1, rootSourceLines: 126, - nestedSourceFiles: 178, + nestedSourceFiles: 179, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); diff --git a/test/back/ql3ReleaseCandidateContract.test.cjs b/test/back/ql3ReleaseCandidateContract.test.cjs index 57ff268a..db859084 100644 --- a/test/back/ql3ReleaseCandidateContract.test.cjs +++ b/test/back/ql3ReleaseCandidateContract.test.cjs @@ -196,7 +196,7 @@ test('rejects tag, version and source identity drift', () => { }), /Git SHA-1/, ); - const nextVersion = `${version.slice(0, version.lastIndexOf('.') + 1)}1`; + const nextVersion = `${version}-drift`; assert.throws( () => createReleaseCandidateContract({