mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): cut alpha.1 candidate milestone
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" \
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)" \
|
||||
.
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)" \
|
||||
.
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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 和大规模引用查询计划也仍未完成。
|
||||
@@ -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 |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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-<commit>-local-amd64` 与 `ql3-alpha-<commit>-local-arm64`;
|
||||
- `ql3-alpha-<commit>-control-<arch>`、`control-ai-<arch>`、`admin-<arch>`、`worker-<arch>`。
|
||||
|
||||
每个 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 或“理论上可构建”都不算阶段性可用产物。
|
||||
@@ -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)。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
createSingletonApiCredentialPepperKeyring(
|
||||
options.apiCredentialPepper,
|
||||
LEGACY_API_CREDENTIAL_PEPPER_KEY_ID,
|
||||
).keys[0]!,
|
||||
{
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
...(options.randomBytes ? { randomBytes: options.randomBytes } : {}),
|
||||
|
||||
@@ -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<ApiCredentialPepperKey> | 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,
|
||||
|
||||
@@ -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,7 +14,8 @@ function argumentsFrom(argv: readonly string[]) {
|
||||
}
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match = /^--(command|assertion|keyset|pepper|delivery)=(\/.+)$/.exec(
|
||||
const match =
|
||||
/^--(command|assertion|keyset|pepper|pepper-keyring|delivery)=(\/.+)$/.exec(
|
||||
argument,
|
||||
);
|
||||
if (!match || values.has(match[1]!)) {
|
||||
@@ -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')! }
|
||||
: {}),
|
||||
|
||||
+100
-10
@@ -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<SecurityAuditQueryPage>;
|
||||
}>
|
||||
| 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<void>;
|
||||
}
|
||||
|
||||
export interface ClusterAdministrationCommandDependencies {
|
||||
readonly openAuthority: (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
pepper: string,
|
||||
pepperKeyring: Readonly<ApiCredentialPepperKeyring>,
|
||||
) => Promise<Readonly<ClusterAdministrationCommandAuthority>>;
|
||||
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<SecurityAuditQuery>;
|
||||
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<ApiCredentialPepperKeyring>;
|
||||
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();
|
||||
}
|
||||
},
|
||||
|
||||
+39
-7
@@ -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<Record<string, string | undefined>>,
|
||||
pepper: string,
|
||||
pepperKeyring: Readonly<ApiCredentialPepperKeyring>,
|
||||
): Promise<Readonly<ClusterAdministrationCommandAuthority>> {
|
||||
assertApiCredentialPepper(pepper);
|
||||
const activePepperKey = activeApiCredentialPepperKey(pepperKeyring);
|
||||
const database = await defaultDatabaseOpener(environment)();
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
@@ -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',
|
||||
),
|
||||
...(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<string, unknown>;
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ApiCredentialPepperKeyring>;
|
||||
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<ApiCredentialPepperKeyring>;
|
||||
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
|
||||
? {}
|
||||
|
||||
@@ -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<ApiCredentialPepperKeyring>;
|
||||
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<ApiCredentialPepperKeyring>
|
||||
| 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,
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -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<ApiCredentialPepperKeyring> | 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');
|
||||
}
|
||||
let pepperKeyring: Readonly<ApiCredentialPepperKeyring>;
|
||||
try {
|
||||
if (typeof pepperKeyringValue === 'string') {
|
||||
const pepperKeyId =
|
||||
options.pepperKeyId ?? LEGACY_API_CREDENTIAL_PEPPER_KEY_ID;
|
||||
try {
|
||||
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<Readonly<SecurityPrincipal> | 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);
|
||||
|
||||
@@ -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<ApiCredentialPepperKeyring>;
|
||||
}>;
|
||||
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<ApiCredentialPepperKeyring> {
|
||||
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(
|
||||
'exactly one API credential pepper source is required',
|
||||
);
|
||||
}
|
||||
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',
|
||||
);
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
if (decoded.byteLength !== 32 || decoded.toString('base64url') !== value) {
|
||||
}
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<PostgresPool, 'query'>) {
|
||||
if (!pool || typeof pool.query !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL API credential pepper reference pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(
|
||||
requestedPepperKeyId: string,
|
||||
requestedLimit?: number,
|
||||
): Promise<Readonly<ApiCredentialPepperReferenceInspection>> {
|
||||
const pepperKeyId = normalizeApiCredentialPepperReferenceKeyId(
|
||||
requestedPepperKeyId,
|
||||
);
|
||||
const limit = normalizeApiCredentialPepperReferenceLimit(requestedLimit);
|
||||
try {
|
||||
const result = await this.pool.query<ReferenceRow>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+139
@@ -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<ApiCredentialPepperKey>[];
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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<ApiCredentialPepperKey> {
|
||||
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<ApiCredentialPepperKeyring> {
|
||||
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<ApiCredentialPepperKeyring> {
|
||||
return normalizeApiCredentialPepperKeyring({
|
||||
schemaVersion: 1,
|
||||
activePepperKeyId: pepperKeyId,
|
||||
keys: [{ pepperKeyId, pepper }],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveApiCredentialPepperKey(
|
||||
keyring: Readonly<ApiCredentialPepperKeyring>,
|
||||
pepperKeyId: string,
|
||||
): Readonly<ApiCredentialPepperKey> | 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<ApiCredentialPepperKeyring>,
|
||||
): Readonly<ApiCredentialPepperKey> {
|
||||
const normalized = normalizeApiCredentialPepperKeyring(keyring);
|
||||
return resolveApiCredentialPepperKey(
|
||||
normalized,
|
||||
normalized.activePepperKeyId,
|
||||
)!;
|
||||
}
|
||||
+47
@@ -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<Readonly<ApiCredentialPepperReferenceInspection>>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user