mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add direct Vault KV worker secret custody
This commit is contained in:
@@ -150,6 +150,11 @@
|
||||
"require": "./dist/remote-execution/mountedSecretProvider.js",
|
||||
"default": "./dist/remote-execution/mountedSecretProvider.js"
|
||||
},
|
||||
"./vault-kv-secret-provider": {
|
||||
"types": "./dist/remote-execution/vaultKvSecretProvider.d.ts",
|
||||
"require": "./dist/remote-execution/vaultKvSecretProvider.js",
|
||||
"default": "./dist/remote-execution/vaultKvSecretProvider.js"
|
||||
},
|
||||
"./trusted-tool-result-keyring": {
|
||||
"types": "./dist/trusted-tool/key-management/toolResultProjectedKeyring.d.ts",
|
||||
"require": "./dist/trusted-tool/key-management/toolResultProjectedKeyring.js",
|
||||
|
||||
@@ -128,8 +128,11 @@ async function createWorkerArtifactBinding(
|
||||
async function createWorkerSecretProvider(
|
||||
config: NonNullable<EnabledClusterWorkerIngressConfig['secret']>,
|
||||
): Promise<Readonly<RemoteWorkerSecretValueProvider>> {
|
||||
if (config.provider !== 'mounted-files') {
|
||||
throw new TypeError('Cluster Worker Secret provider is unsupported');
|
||||
if (config.provider === 'vault-kv-v2') {
|
||||
const provider = await import(
|
||||
'../remote-execution/vaultKvSecretProvider.js'
|
||||
);
|
||||
return provider.createClusterVaultKvSecretProvider(config);
|
||||
}
|
||||
const provider = await import('../remote-execution/mountedSecretProvider.js');
|
||||
return provider.createClusterMountedSecretProvider({
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
// Remote Execution owns direct external Secret custody resolution.
|
||||
import { Buffer } from 'node:buffer';
|
||||
import https from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import { basename, dirname, isAbsolute, normalize } from 'node:path';
|
||||
import type { TLSSocket } from 'node:tls';
|
||||
|
||||
import {
|
||||
MAX_REMOTE_ENVIRONMENT_BUNDLE_VALUE_BYTES,
|
||||
MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES,
|
||||
MAX_REMOTE_SECRET_VALUE_BYTES,
|
||||
normalizeRemoteWorkerSecretDeliveryAuthority,
|
||||
type RemoteWorkerSecretDeliveryAuthority,
|
||||
type RemoteWorkerSecretResolution,
|
||||
type RemoteWorkerSecretValueProvider,
|
||||
} from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection';
|
||||
|
||||
import { PrivateProjectedFileReader } from '../security/privateProjectedFile';
|
||||
|
||||
const MAX_CA_BYTES = 1024 * 1024;
|
||||
const MAX_TOKEN_BYTES = 4096;
|
||||
const MAX_RESPONSE_BYTES =
|
||||
MAX_REMOTE_ENVIRONMENT_BUNDLE_VALUE_BYTES + 16 * 1024;
|
||||
const TOKEN_PATTERN = /^[\x21-\x7e]{1,4096}$/;
|
||||
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
||||
const PREFIX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_/-]{0,511}$/;
|
||||
const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/;
|
||||
const BASE64_PATTERN =
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||
|
||||
export interface ClusterVaultKvSecretProviderOptions {
|
||||
readonly endpoint: string;
|
||||
readonly caFile: string;
|
||||
readonly tokenFile: string;
|
||||
readonly kvMount: string;
|
||||
readonly pathPrefix: string;
|
||||
readonly expectedPolicy: string;
|
||||
readonly maximumTokenTtlSeconds: number;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly maximumConcurrency: number;
|
||||
readonly namespace?: string;
|
||||
}
|
||||
|
||||
interface NormalizedClusterVaultKvSecretProviderOptions
|
||||
extends ClusterVaultKvSecretProviderOptions {
|
||||
readonly endpoint: string;
|
||||
readonly caRootDirectory: string;
|
||||
readonly caFileName: string;
|
||||
readonly tokenRootDirectory: string;
|
||||
readonly tokenFileName: string;
|
||||
}
|
||||
|
||||
export interface ClusterVaultKvRequest {
|
||||
readonly endpoint: string;
|
||||
readonly path: string;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly namespace?: string;
|
||||
}
|
||||
|
||||
export type ClusterVaultKvRequester = (
|
||||
request: Readonly<ClusterVaultKvRequest>,
|
||||
material: Readonly<{ readonly ca: Buffer; readonly token: string }>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
export interface ClusterVaultKvSecretProviderDependencies {
|
||||
readonly request?: ClusterVaultKvRequester;
|
||||
}
|
||||
|
||||
export class ClusterVaultKvSecretProviderError extends Error {
|
||||
readonly code = 'QL3_CLUSTER_VAULT_KV_SECRET_UNAVAILABLE';
|
||||
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'trust_unavailable'
|
||||
| 'authentication_unavailable'
|
||||
| 'material_unavailable',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Cluster Vault KV Secret provider failed: ${reason}`, options);
|
||||
this.name = 'ClusterVaultKvSecretProviderError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalidConfiguration(cause?: unknown): never {
|
||||
throw new ClusterVaultKvSecretProviderError('invalid_configuration', {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < minimum ||
|
||||
value > maximum
|
||||
) {
|
||||
return invalidConfiguration();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeName(value: unknown): string {
|
||||
if (typeof value !== 'string' || !NAME_PATTERN.test(value)) {
|
||||
return invalidConfiguration();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safePrefix(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!PREFIX_PATTERN.test(value) ||
|
||||
value.includes('//') ||
|
||||
value.split('/').some((part) => part === '.' || part === '..')
|
||||
) {
|
||||
return invalidConfiguration();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function absoluteProjectedFile(value: unknown): Readonly<{
|
||||
rootDirectory: string;
|
||||
fileName: string;
|
||||
}> {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!isAbsolute(value) ||
|
||||
normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 4096
|
||||
) {
|
||||
return invalidConfiguration();
|
||||
}
|
||||
const fileName = basename(value);
|
||||
const rootDirectory = dirname(value);
|
||||
if (
|
||||
!FILE_NAME_PATTERN.test(fileName) ||
|
||||
rootDirectory === value ||
|
||||
rootDirectory === dirname(rootDirectory)
|
||||
) {
|
||||
return invalidConfiguration();
|
||||
}
|
||||
return Object.freeze({ rootDirectory, fileName });
|
||||
}
|
||||
|
||||
export function normalizeClusterVaultKvSecretProviderOptions(
|
||||
value: ClusterVaultKvSecretProviderOptions,
|
||||
): Readonly<NormalizedClusterVaultKvSecretProviderOptions> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalidConfiguration();
|
||||
}
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = new URL(value.endpoint);
|
||||
} catch (cause) {
|
||||
return invalidConfiguration(cause);
|
||||
}
|
||||
if (
|
||||
endpoint.protocol !== 'https:' ||
|
||||
endpoint.username ||
|
||||
endpoint.password ||
|
||||
endpoint.pathname !== '/' ||
|
||||
endpoint.search ||
|
||||
endpoint.hash
|
||||
) {
|
||||
return invalidConfiguration();
|
||||
}
|
||||
const ca = absoluteProjectedFile(value.caFile);
|
||||
const token = absoluteProjectedFile(value.tokenFile);
|
||||
const namespace =
|
||||
value.namespace === undefined ? undefined : safePrefix(value.namespace);
|
||||
return Object.freeze({
|
||||
endpoint: endpoint.toString(),
|
||||
caFile: value.caFile,
|
||||
caRootDirectory: ca.rootDirectory,
|
||||
caFileName: ca.fileName,
|
||||
tokenFile: value.tokenFile,
|
||||
tokenRootDirectory: token.rootDirectory,
|
||||
tokenFileName: token.fileName,
|
||||
kvMount: safeName(value.kvMount),
|
||||
pathPrefix: safePrefix(value.pathPrefix),
|
||||
expectedPolicy: safeName(value.expectedPolicy),
|
||||
maximumTokenTtlSeconds: boundedInteger(
|
||||
value.maximumTokenTtlSeconds,
|
||||
30,
|
||||
3600,
|
||||
),
|
||||
requestTimeoutMs: boundedInteger(value.requestTimeoutMs, 100, 30_000),
|
||||
maximumConcurrency: boundedInteger(value.maximumConcurrency, 1, 8),
|
||||
...(namespace === undefined ? {} : { namespace }),
|
||||
});
|
||||
}
|
||||
|
||||
function exactKeys(value: unknown, expected: readonly string[]): boolean {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value).sort()) ===
|
||||
JSON.stringify([...expected].sort())
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalBase64(value: unknown): Buffer {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length >
|
||||
Math.ceil(MAX_REMOTE_ENVIRONMENT_BUNDLE_VALUE_BYTES / 3) * 4 + 4 ||
|
||||
!BASE64_PATTERN.test(value)
|
||||
) {
|
||||
throw new ClusterVaultKvSecretProviderError('material_unavailable');
|
||||
}
|
||||
const bytes = Buffer.from(value, 'base64');
|
||||
if (bytes.toString('base64') !== value) {
|
||||
bytes.fill(0);
|
||||
throw new ClusterVaultKvSecretProviderError('material_unavailable');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseVaultKvValue(
|
||||
value: unknown,
|
||||
expectedSecretRefDigest: string,
|
||||
maximumBytes: number,
|
||||
): Buffer {
|
||||
const envelope = value as {
|
||||
readonly data?: {
|
||||
readonly data?: unknown;
|
||||
readonly metadata?: {
|
||||
readonly deletion_time?: unknown;
|
||||
readonly destroyed?: unknown;
|
||||
readonly version?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
const material = envelope?.data?.data as {
|
||||
readonly schemaVersion?: unknown;
|
||||
readonly secretRefDigest?: unknown;
|
||||
readonly encoding?: unknown;
|
||||
readonly value?: unknown;
|
||||
};
|
||||
const metadata = envelope?.data?.metadata;
|
||||
if (
|
||||
!exactKeys(material, [
|
||||
'encoding',
|
||||
'schemaVersion',
|
||||
'secretRefDigest',
|
||||
'value',
|
||||
]) ||
|
||||
material.schemaVersion !== 1 ||
|
||||
material.secretRefDigest !== expectedSecretRefDigest ||
|
||||
material.encoding !== 'base64' ||
|
||||
metadata?.destroyed !== false ||
|
||||
metadata?.deletion_time !== '' ||
|
||||
!Number.isSafeInteger(metadata?.version) ||
|
||||
Number(metadata?.version) < 1
|
||||
) {
|
||||
throw new ClusterVaultKvSecretProviderError('material_unavailable');
|
||||
}
|
||||
const bytes = canonicalBase64(material.value);
|
||||
if (bytes.byteLength > maximumBytes) {
|
||||
bytes.fill(0);
|
||||
throw new ClusterVaultKvSecretProviderError('material_unavailable');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseToken(bytes: Buffer): string {
|
||||
const raw = bytes.toString('utf8');
|
||||
const token = raw.endsWith('\n') ? raw.slice(0, -1) : raw;
|
||||
if (!TOKEN_PATTERN.test(token) || (raw !== token && raw !== `${token}\n`)) {
|
||||
throw new ClusterVaultKvSecretProviderError('authentication_unavailable');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function secretValue(bytes: Buffer): string {
|
||||
try {
|
||||
const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
if (value.includes('\0')) throw new Error('Secret contains NUL');
|
||||
return value;
|
||||
} catch (cause) {
|
||||
throw new ClusterVaultKvSecretProviderError('material_unavailable', {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateTokenLookup(
|
||||
value: unknown,
|
||||
expectedPolicy: string,
|
||||
maximumTokenTtlSeconds: number,
|
||||
): void {
|
||||
const data = (value as { readonly data?: Record<string, unknown> })?.data;
|
||||
if (
|
||||
!data ||
|
||||
!Array.isArray(data.policies) ||
|
||||
JSON.stringify(data.policies) !== JSON.stringify([expectedPolicy]) ||
|
||||
data.orphan !== true ||
|
||||
data.renewable !== false ||
|
||||
data.type !== 'service' ||
|
||||
!Number.isSafeInteger(data.ttl) ||
|
||||
Number(data.ttl) < 1 ||
|
||||
Number(data.ttl) > maximumTokenTtlSeconds
|
||||
) {
|
||||
throw new ClusterVaultKvSecretProviderError('authentication_unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
async function requestVaultJson(
|
||||
request: Readonly<ClusterVaultKvRequest>,
|
||||
material: Readonly<{ readonly ca: Buffer; readonly token: string }>,
|
||||
): Promise<unknown> {
|
||||
const target = new URL(request.path, request.endpoint);
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestHandle = https.request(
|
||||
target,
|
||||
{
|
||||
method: 'GET',
|
||||
ca: material.ca,
|
||||
rejectUnauthorized: true,
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
...(isIP(target.hostname) === 0 ? { servername: target.hostname } : {}),
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'x-vault-token': material.token,
|
||||
...(request.namespace === undefined
|
||||
? {}
|
||||
: { 'x-vault-namespace': request.namespace }),
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const socket = response.socket as TLSSocket;
|
||||
const peerAuthorized = socket.authorized;
|
||||
const tlsProtocol = socket.getProtocol();
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
const dispose = () =>
|
||||
chunks.splice(0).forEach((chunk) => chunk.fill(0));
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
length += chunk.byteLength;
|
||||
if (length > MAX_RESPONSE_BYTES) {
|
||||
response.destroy(
|
||||
new ClusterVaultKvSecretProviderError('material_unavailable'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
chunks.push(Buffer.from(chunk));
|
||||
});
|
||||
response.once('error', (cause) => {
|
||||
dispose();
|
||||
reject(cause);
|
||||
});
|
||||
response.on('end', () => {
|
||||
const bytes = Buffer.concat(chunks);
|
||||
dispose();
|
||||
try {
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
peerAuthorized !== true ||
|
||||
tlsProtocol !== 'TLSv1.3'
|
||||
) {
|
||||
throw new ClusterVaultKvSecretProviderError(
|
||||
'material_unavailable',
|
||||
);
|
||||
}
|
||||
resolve(JSON.parse(bytes.toString('utf8')));
|
||||
} catch (cause) {
|
||||
reject(cause);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
requestHandle.setTimeout(request.requestTimeoutMs, () => {
|
||||
requestHandle.destroy(
|
||||
new ClusterVaultKvSecretProviderError('material_unavailable'),
|
||||
);
|
||||
});
|
||||
requestHandle.once('error', reject);
|
||||
requestHandle.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-cache, no-watcher Vault KV v2 adapter. Every authorized delivery reads
|
||||
* the projected short-lived token again, revalidates its least-privilege
|
||||
* policy, and resolves only digest-derived paths over pinned TLS 1.3.
|
||||
*/
|
||||
export class ClusterVaultKvSecretProvider
|
||||
implements RemoteWorkerSecretValueProvider
|
||||
{
|
||||
readonly #options: Readonly<NormalizedClusterVaultKvSecretProviderOptions>;
|
||||
readonly #caReader: PrivateProjectedFileReader;
|
||||
readonly #tokenReader: PrivateProjectedFileReader;
|
||||
readonly #request: ClusterVaultKvRequester;
|
||||
|
||||
constructor(
|
||||
options: ClusterVaultKvSecretProviderOptions,
|
||||
dependencies: ClusterVaultKvSecretProviderDependencies = {},
|
||||
) {
|
||||
this.#options = normalizeClusterVaultKvSecretProviderOptions(options);
|
||||
if (
|
||||
!dependencies ||
|
||||
typeof dependencies !== 'object' ||
|
||||
Array.isArray(dependencies) ||
|
||||
(dependencies.request !== undefined &&
|
||||
typeof dependencies.request !== 'function')
|
||||
) {
|
||||
invalidConfiguration();
|
||||
}
|
||||
this.#request = dependencies.request ?? requestVaultJson;
|
||||
try {
|
||||
this.#caReader = new PrivateProjectedFileReader({
|
||||
rootDirectory: this.#options.caRootDirectory,
|
||||
minimumBytes: 32,
|
||||
maximumBytes: MAX_CA_BYTES,
|
||||
access: 'read_only_keyring',
|
||||
});
|
||||
this.#tokenReader = new PrivateProjectedFileReader({
|
||||
rootDirectory: this.#options.tokenRootDirectory,
|
||||
minimumBytes: 1,
|
||||
maximumBytes: MAX_TOKEN_BYTES + 1,
|
||||
access: 'private_material',
|
||||
});
|
||||
} catch (cause) {
|
||||
invalidConfiguration(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async #withCredentials<T>(
|
||||
action: (ca: Buffer, token: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
let ca: Buffer | undefined;
|
||||
let tokenBytes: Buffer | undefined;
|
||||
let token: string | undefined;
|
||||
try {
|
||||
[ca, tokenBytes] = await Promise.all([
|
||||
this.#caReader.read(this.#options.caFileName),
|
||||
this.#tokenReader.read(this.#options.tokenFileName),
|
||||
]);
|
||||
token = parseToken(tokenBytes);
|
||||
return await action(ca, token);
|
||||
} catch (cause) {
|
||||
if (cause instanceof ClusterVaultKvSecretProviderError) throw cause;
|
||||
throw new ClusterVaultKvSecretProviderError('trust_unavailable', {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
} finally {
|
||||
ca?.fill(0);
|
||||
tokenBytes?.fill(0);
|
||||
token = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async #requestJson(
|
||||
path: string,
|
||||
ca: Buffer,
|
||||
token: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await this.#request(
|
||||
Object.freeze({
|
||||
endpoint: this.#options.endpoint,
|
||||
path,
|
||||
requestTimeoutMs: this.#options.requestTimeoutMs,
|
||||
...(this.#options.namespace === undefined
|
||||
? {}
|
||||
: { namespace: this.#options.namespace }),
|
||||
}),
|
||||
Object.freeze({ ca, token }),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof ClusterVaultKvSecretProviderError) throw cause;
|
||||
throw new ClusterVaultKvSecretProviderError('material_unavailable', {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #authenticate(ca: Buffer, token: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.#requestJson(
|
||||
'/v1/auth/token/lookup-self',
|
||||
ca,
|
||||
token,
|
||||
);
|
||||
validateTokenLookup(
|
||||
response,
|
||||
this.#options.expectedPolicy,
|
||||
this.#options.maximumTokenTtlSeconds,
|
||||
);
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof ClusterVaultKvSecretProviderError &&
|
||||
cause.reason === 'authentication_unavailable'
|
||||
) {
|
||||
throw cause;
|
||||
}
|
||||
throw new ClusterVaultKvSecretProviderError(
|
||||
'authentication_unavailable',
|
||||
{ cause: cause instanceof Error ? cause : undefined },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async verify(): Promise<void> {
|
||||
await this.#withCredentials(async (ca, token) => {
|
||||
await this.#authenticate(ca, token);
|
||||
});
|
||||
}
|
||||
|
||||
async resolve(
|
||||
authority: Readonly<RemoteWorkerSecretDeliveryAuthority>,
|
||||
): Promise<Readonly<RemoteWorkerSecretResolution>> {
|
||||
let normalized: Readonly<RemoteWorkerSecretDeliveryAuthority>;
|
||||
try {
|
||||
normalized = normalizeRemoteWorkerSecretDeliveryAuthority(authority);
|
||||
} catch (cause) {
|
||||
throw new ClusterVaultKvSecretProviderError('material_unavailable', {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
return this.#withCredentials(async (ca, token) => {
|
||||
await this.#authenticate(ca, token);
|
||||
const inputs = [
|
||||
...normalized.secretRefs.map((secretRef) =>
|
||||
Object.freeze({
|
||||
kind: 'secret' as const,
|
||||
secretRef,
|
||||
maximumBytes: MAX_REMOTE_SECRET_VALUE_BYTES,
|
||||
}),
|
||||
),
|
||||
...normalized.environmentBundleRefs.map((secretRef) =>
|
||||
Object.freeze({
|
||||
kind: 'environment-bundle' as const,
|
||||
secretRef,
|
||||
maximumBytes: MAX_REMOTE_ENVIRONMENT_BUNDLE_VALUE_BYTES,
|
||||
}),
|
||||
),
|
||||
];
|
||||
const buffers: Buffer[] = [];
|
||||
try {
|
||||
const resolved: Array<
|
||||
Readonly<{
|
||||
kind: 'secret' | 'environment-bundle';
|
||||
secretRef: string;
|
||||
value: string;
|
||||
byteLength: number;
|
||||
}>
|
||||
> = [];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < inputs.length;
|
||||
offset += this.#options.maximumConcurrency
|
||||
) {
|
||||
const batch = inputs.slice(
|
||||
offset,
|
||||
offset + this.#options.maximumConcurrency,
|
||||
);
|
||||
const values = await Promise.all(
|
||||
batch.map(async (input) => {
|
||||
const digest = secretProjectionFileName(input.secretRef);
|
||||
const response = await this.#requestJson(
|
||||
`/v1/${encodeURIComponent(
|
||||
this.#options.kvMount,
|
||||
)}/data/${this.#options.pathPrefix
|
||||
.split('/')
|
||||
.map(encodeURIComponent)
|
||||
.join('/')}/${digest}`,
|
||||
ca,
|
||||
token,
|
||||
);
|
||||
const bytes = parseVaultKvValue(
|
||||
response,
|
||||
digest,
|
||||
input.maximumBytes,
|
||||
);
|
||||
buffers.push(bytes);
|
||||
return Object.freeze({
|
||||
kind: input.kind,
|
||||
secretRef: input.secretRef,
|
||||
value: secretValue(bytes),
|
||||
byteLength: bytes.byteLength,
|
||||
});
|
||||
}),
|
||||
);
|
||||
resolved.push(...values);
|
||||
}
|
||||
const values = resolved
|
||||
.filter(({ kind }) => kind === 'secret')
|
||||
.map(({ secretRef, value }) => Object.freeze({ secretRef, value }));
|
||||
const totalBytes = resolved
|
||||
.filter(({ kind }) => kind === 'secret')
|
||||
.reduce((sum, item) => sum + item.byteLength, 0);
|
||||
if (totalBytes > MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES) {
|
||||
throw new ClusterVaultKvSecretProviderError('material_unavailable');
|
||||
}
|
||||
const environmentBundles = resolved
|
||||
.filter(({ kind }) => kind === 'environment-bundle')
|
||||
.map(({ secretRef, value }) => Object.freeze({ secretRef, value }));
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
values: Object.freeze(values),
|
||||
environmentBundles: Object.freeze(environmentBundles),
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
buffers.splice(0).forEach((bytes) => bytes.fill(0));
|
||||
},
|
||||
});
|
||||
} catch (cause) {
|
||||
buffers.splice(0).forEach((bytes) => bytes.fill(0));
|
||||
if (cause instanceof ClusterVaultKvSecretProviderError) throw cause;
|
||||
throw new ClusterVaultKvSecretProviderError('material_unavailable', {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createClusterVaultKvSecretProvider(
|
||||
options: ClusterVaultKvSecretProviderOptions,
|
||||
): Promise<Readonly<ClusterVaultKvSecretProvider>> {
|
||||
const provider = new ClusterVaultKvSecretProvider(options);
|
||||
await provider.verify();
|
||||
return provider;
|
||||
}
|
||||
@@ -52,6 +52,24 @@ export interface ClusterWorkerMountedSecretConfig {
|
||||
readonly rootDirectory: string;
|
||||
}
|
||||
|
||||
export interface ClusterWorkerVaultKvSecretConfig {
|
||||
readonly provider: 'vault-kv-v2';
|
||||
readonly endpoint: string;
|
||||
readonly caFile: string;
|
||||
readonly tokenFile: string;
|
||||
readonly kvMount: string;
|
||||
readonly pathPrefix: string;
|
||||
readonly expectedPolicy: string;
|
||||
readonly maximumTokenTtlSeconds: number;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly maximumConcurrency: number;
|
||||
readonly namespace?: string;
|
||||
}
|
||||
|
||||
export type ClusterWorkerSecretConfig =
|
||||
| ClusterWorkerMountedSecretConfig
|
||||
| ClusterWorkerVaultKvSecretConfig;
|
||||
|
||||
export interface EnabledClusterWorkerIngressConfig {
|
||||
readonly enabled: true;
|
||||
readonly profile: 'cluster-control';
|
||||
@@ -70,7 +88,7 @@ export interface EnabledClusterWorkerIngressConfig {
|
||||
readonly workerCredentialPepper: string;
|
||||
}>;
|
||||
readonly artifact: Readonly<ClusterWorkerArtifactS3Config>;
|
||||
readonly secret?: Readonly<ClusterWorkerMountedSecretConfig>;
|
||||
readonly secret?: Readonly<ClusterWorkerSecretConfig>;
|
||||
}
|
||||
|
||||
export type ClusterWorkerIngressConfig =
|
||||
@@ -327,13 +345,11 @@ function workerArtifactS3(
|
||||
);
|
||||
if (
|
||||
prefix !== undefined &&
|
||||
(
|
||||
!/^[A-Za-z0-9][A-Za-z0-9/_=-]{0,254}$/.test(prefix) ||
|
||||
(!/^[A-Za-z0-9][A-Za-z0-9/_=-]{0,254}$/.test(prefix) ||
|
||||
prefix.startsWith('/') ||
|
||||
prefix.endsWith('/') ||
|
||||
prefix.includes('//') ||
|
||||
prefix.split('/').some((segment) => segment === '.' || segment === '..')
|
||||
)
|
||||
prefix.split('/').some((segment) => segment === '.' || segment === '..'))
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_PREFIX is invalid',
|
||||
@@ -388,11 +404,7 @@ function workerArtifactS3(
|
||||
endpoint = parsed.origin;
|
||||
}
|
||||
const encryptionMode =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_ARTIFACT_S3_ENCRYPTION',
|
||||
3,
|
||||
) ?? 's3';
|
||||
boundedValue(environment, 'QL3_WORKER_ARTIFACT_S3_ENCRYPTION', 3) ?? 's3';
|
||||
if (encryptionMode !== 's3' && encryptionMode !== 'kms') {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_ARTIFACT_S3_ENCRYPTION must be s3 or kms',
|
||||
@@ -415,9 +427,7 @@ function workerArtifactS3(
|
||||
bucket,
|
||||
region,
|
||||
...(prefix === undefined ? {} : { prefix }),
|
||||
...(expectedBucketOwner === undefined
|
||||
? {}
|
||||
: { expectedBucketOwner }),
|
||||
...(expectedBucketOwner === undefined ? {} : { expectedBucketOwner }),
|
||||
...(endpoint === undefined ? {} : { endpoint }),
|
||||
forcePathStyle: booleanValue(
|
||||
environment,
|
||||
@@ -433,18 +443,96 @@ function workerArtifactS3(
|
||||
|
||||
function workerSecret(
|
||||
environment: ClusterWorkerIngressEnvironment,
|
||||
): Readonly<ClusterWorkerMountedSecretConfig> | undefined {
|
||||
const provider = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_PROVIDER',
|
||||
32,
|
||||
);
|
||||
): Readonly<ClusterWorkerSecretConfig> | undefined {
|
||||
const provider = boundedValue(environment, 'QL3_WORKER_SECRET_PROVIDER', 32);
|
||||
if (provider === undefined || provider === 'disabled') return undefined;
|
||||
if (provider !== 'mounted-files') {
|
||||
if (provider !== 'mounted-files' && provider !== 'vault-kv-v2') {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_SECRET_PROVIDER must be disabled or mounted-files',
|
||||
'QL3_WORKER_SECRET_PROVIDER must be disabled, mounted-files or vault-kv-v2',
|
||||
);
|
||||
}
|
||||
if (provider === 'vault-kv-v2') {
|
||||
const endpoint = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_VAULT_ENDPOINT',
|
||||
2048,
|
||||
true,
|
||||
)!;
|
||||
let parsedEndpoint: URL;
|
||||
try {
|
||||
parsedEndpoint = new URL(endpoint);
|
||||
} catch {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_SECRET_VAULT_ENDPOINT must be an HTTPS authority',
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsedEndpoint.protocol !== 'https:' ||
|
||||
parsedEndpoint.username ||
|
||||
parsedEndpoint.password ||
|
||||
parsedEndpoint.pathname !== '/' ||
|
||||
parsedEndpoint.search ||
|
||||
parsedEndpoint.hash
|
||||
) {
|
||||
throw new ClusterWorkerIngressConfigError(
|
||||
'QL3_WORKER_SECRET_VAULT_ENDPOINT must be an HTTPS authority',
|
||||
);
|
||||
}
|
||||
const namespace = boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_VAULT_NAMESPACE',
|
||||
512,
|
||||
);
|
||||
return Object.freeze({
|
||||
provider,
|
||||
endpoint: parsedEndpoint.toString(),
|
||||
caFile: absoluteFile(environment, 'QL3_WORKER_SECRET_VAULT_CA_FILE'),
|
||||
tokenFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_VAULT_TOKEN_FILE',
|
||||
),
|
||||
kvMount: boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_VAULT_KV_MOUNT',
|
||||
128,
|
||||
true,
|
||||
)!,
|
||||
pathPrefix: boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_VAULT_PATH_PREFIX',
|
||||
512,
|
||||
true,
|
||||
)!,
|
||||
expectedPolicy: boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_VAULT_EXPECTED_POLICY',
|
||||
128,
|
||||
true,
|
||||
)!,
|
||||
maximumTokenTtlSeconds: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS',
|
||||
900,
|
||||
30,
|
||||
3600,
|
||||
),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_VAULT_REQUEST_TIMEOUT_MS',
|
||||
5000,
|
||||
100,
|
||||
30_000,
|
||||
),
|
||||
maximumConcurrency: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_SECRET_VAULT_MAX_CONCURRENCY',
|
||||
4,
|
||||
1,
|
||||
8,
|
||||
),
|
||||
...(namespace === undefined ? {} : { namespace }),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
provider,
|
||||
rootDirectory: absoluteFile(
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { chmod, mkdtemp, rename, rm, writeFile } = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
ClusterVaultKvSecretProvider,
|
||||
ClusterVaultKvSecretProviderError,
|
||||
normalizeClusterVaultKvSecretProviderOptions,
|
||||
} = require('@qinglong/cluster-control/vault-kv-secret-provider');
|
||||
const {
|
||||
secretProjectionFileName,
|
||||
} = require('@qinglong/runtime-core/secret-projection');
|
||||
|
||||
const SECRET_REF = createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'api-token',
|
||||
});
|
||||
const SECOND_SECRET_REF = createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'certificate',
|
||||
version: 3,
|
||||
});
|
||||
const ENVIRONMENT_BUNDLE_REF = createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'legacy-env-bundle',
|
||||
version: 4,
|
||||
});
|
||||
|
||||
function authority(secretRefs = [SECRET_REF], environmentBundleRefs = []) {
|
||||
return {
|
||||
workerId: 'worker-1',
|
||||
workerSessionId: '018f0000-0000-7000-8000-000000000001',
|
||||
workerGeneration: 1,
|
||||
runId: 'run-1',
|
||||
attemptId: 'attempt-1',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
executionDigest: 'a'.repeat(64),
|
||||
offerId: 'offer-1',
|
||||
leaseGeneration: 1,
|
||||
leaseVersion: 1,
|
||||
secretRefs,
|
||||
environmentBundleRefs,
|
||||
};
|
||||
}
|
||||
|
||||
function lookup(policy = 'ql3-worker-secret-read', overrides = {}) {
|
||||
return {
|
||||
data: {
|
||||
policies: [policy],
|
||||
orphan: true,
|
||||
renewable: false,
|
||||
type: 'service',
|
||||
ttl: 600,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(secretRef, value, overrides = {}) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
schemaVersion: 1,
|
||||
secretRefDigest: secretProjectionFileName(secretRef),
|
||||
encoding: 'base64',
|
||||
value: Buffer.from(value).toString('base64'),
|
||||
...overrides,
|
||||
},
|
||||
metadata: {
|
||||
deletion_time: '',
|
||||
destroyed: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function projectedFile(file, value, mode) {
|
||||
await writeFile(file, value);
|
||||
await chmod(file, mode);
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'ql3-vault-kv-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
await chmod(directory, 0o700);
|
||||
const caFile = path.join(directory, 'ca.pem');
|
||||
const tokenFile = path.join(directory, 'token');
|
||||
await projectedFile(caFile, 'x'.repeat(64), 0o440);
|
||||
await projectedFile(tokenFile, 'token-generation-one\n', 0o440);
|
||||
return {
|
||||
directory,
|
||||
caFile,
|
||||
tokenFile,
|
||||
options: {
|
||||
endpoint: 'https://vault.internal:8200',
|
||||
caFile,
|
||||
tokenFile,
|
||||
kvMount: 'worker-secrets',
|
||||
pathPrefix: 'values/production',
|
||||
expectedPolicy: 'ql3-worker-secret-read',
|
||||
maximumTokenTtlSeconds: 900,
|
||||
requestTimeoutMs: 5000,
|
||||
maximumConcurrency: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes only a pinned HTTPS Vault KV v2 authority', async (t) => {
|
||||
const { options } = await fixture(t);
|
||||
assert.deepEqual(
|
||||
normalizeClusterVaultKvSecretProviderOptions({
|
||||
...options,
|
||||
namespace: 'organization/team-a',
|
||||
}),
|
||||
{
|
||||
...options,
|
||||
endpoint: 'https://vault.internal:8200/',
|
||||
caRootDirectory: path.dirname(options.caFile),
|
||||
caFileName: path.basename(options.caFile),
|
||||
tokenRootDirectory: path.dirname(options.tokenFile),
|
||||
tokenFileName: path.basename(options.tokenFile),
|
||||
namespace: 'organization/team-a',
|
||||
},
|
||||
);
|
||||
for (const invalid of [
|
||||
{ ...options, endpoint: 'http://vault.internal:8200' },
|
||||
{ ...options, endpoint: 'https://user@vault.internal:8200' },
|
||||
{ ...options, endpoint: 'https://vault.internal:8200/v1' },
|
||||
{ ...options, caFile: 'relative.pem' },
|
||||
{ ...options, kvMount: '../secret' },
|
||||
{ ...options, pathPrefix: 'values//production' },
|
||||
{ ...options, maximumTokenTtlSeconds: 3601 },
|
||||
{ ...options, maximumConcurrency: 9 },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeClusterVaultKvSecretProviderOptions(invalid),
|
||||
ClusterVaultKvSecretProviderError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('revalidates the short-lived token and reads digest paths without a cache', async (t) => {
|
||||
const { options, tokenFile, directory } = await fixture(t);
|
||||
const requests = [];
|
||||
let generation = 1;
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
const values = new Map([
|
||||
[secretProjectionFileName(SECRET_REF), () => `token-${generation}`],
|
||||
[secretProjectionFileName(SECOND_SECRET_REF), () => 'certificate-value'],
|
||||
[
|
||||
secretProjectionFileName(ENVIRONMENT_BUNDLE_REF),
|
||||
() =>
|
||||
JSON.stringify({
|
||||
schema: 'qinglong/environment-bundle@v1',
|
||||
entries: [{ name: 'LEGACY_VALUE', value: `bundle-${generation}` }],
|
||||
}),
|
||||
],
|
||||
]);
|
||||
const provider = new ClusterVaultKvSecretProvider(options, {
|
||||
async request(request, material) {
|
||||
requests.push({ ...request, token: material.token });
|
||||
if (request.path === '/v1/auth/token/lookup-self') return lookup();
|
||||
active += 1;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
active -= 1;
|
||||
const digest = request.path.split('/').at(-1);
|
||||
const selected = values.get(digest);
|
||||
assert.ok(selected, 'only digest-derived reviewed paths are requested');
|
||||
const secretRef =
|
||||
digest === secretProjectionFileName(SECRET_REF)
|
||||
? SECRET_REF
|
||||
: digest === secretProjectionFileName(SECOND_SECRET_REF)
|
||||
? SECOND_SECRET_REF
|
||||
: ENVIRONMENT_BUNDLE_REF;
|
||||
return envelope(secretRef, selected());
|
||||
},
|
||||
});
|
||||
await provider.verify();
|
||||
const first = await provider.resolve(
|
||||
authority([SECRET_REF, SECOND_SECRET_REF], [ENVIRONMENT_BUNDLE_REF]),
|
||||
);
|
||||
assert.deepEqual(first.values, [
|
||||
{ secretRef: SECRET_REF, value: 'token-1' },
|
||||
{ secretRef: SECOND_SECRET_REF, value: 'certificate-value' },
|
||||
]);
|
||||
assert.equal(first.environmentBundles[0].secretRef, ENVIRONMENT_BUNDLE_REF);
|
||||
assert.match(first.environmentBundles[0].value, /bundle-1/);
|
||||
await first.dispose();
|
||||
await first.dispose();
|
||||
assert.equal(maximumActive, 2);
|
||||
|
||||
generation = 2;
|
||||
const replacement = path.join(directory, 'token.replacement');
|
||||
await projectedFile(replacement, 'token-generation-two\n', 0o440);
|
||||
await rename(replacement, tokenFile);
|
||||
const second = await provider.resolve(authority());
|
||||
assert.deepEqual(second.values, [
|
||||
{ secretRef: SECRET_REF, value: 'token-2' },
|
||||
]);
|
||||
await second.dispose();
|
||||
assert.equal(
|
||||
requests.filter(({ path: requestPath }) =>
|
||||
requestPath.endsWith(secretProjectionFileName(SECRET_REF)),
|
||||
).length,
|
||||
2,
|
||||
);
|
||||
assert.equal(requests.at(-2).token, 'token-generation-two');
|
||||
assert.equal(
|
||||
requests.some(({ path: requestPath }) =>
|
||||
requestPath.includes(encodeURIComponent(SECRET_REF)),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves an authorized empty Secret value', async (t) => {
|
||||
const { options } = await fixture(t);
|
||||
const provider = new ClusterVaultKvSecretProvider(options, {
|
||||
request(request) {
|
||||
return Promise.resolve(
|
||||
request.path === '/v1/auth/token/lookup-self'
|
||||
? lookup()
|
||||
: envelope(SECRET_REF, ''),
|
||||
);
|
||||
},
|
||||
});
|
||||
const resolution = await provider.resolve(authority());
|
||||
assert.deepEqual(resolution.values, [{ secretRef: SECRET_REF, value: '' }]);
|
||||
await resolution.dispose();
|
||||
});
|
||||
|
||||
test('fails closed for broad tokens, unsafe projections and untrusted material', async (t) => {
|
||||
const { options, tokenFile } = await fixture(t);
|
||||
const broad = new ClusterVaultKvSecretProvider(options, {
|
||||
request(request) {
|
||||
return Promise.resolve(
|
||||
request.path === '/v1/auth/token/lookup-self'
|
||||
? lookup('ql3-worker-secret-read', {
|
||||
policies: ['default', 'ql3-worker-secret-read'],
|
||||
})
|
||||
: envelope(SECRET_REF, 'not-reached'),
|
||||
);
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
broad.verify(),
|
||||
(error) =>
|
||||
error instanceof ClusterVaultKvSecretProviderError &&
|
||||
error.reason === 'authentication_unavailable',
|
||||
);
|
||||
|
||||
const mismatched = new ClusterVaultKvSecretProvider(options, {
|
||||
request(request) {
|
||||
return Promise.resolve(
|
||||
request.path === '/v1/auth/token/lookup-self'
|
||||
? lookup()
|
||||
: envelope(SECRET_REF, 'private-value', {
|
||||
secretRefDigest: 'f'.repeat(64),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
mismatched.resolve(authority()),
|
||||
(error) =>
|
||||
error instanceof ClusterVaultKvSecretProviderError &&
|
||||
error.reason === 'material_unavailable' &&
|
||||
error.message.includes('private-value') === false,
|
||||
);
|
||||
|
||||
await chmod(tokenFile, 0o666);
|
||||
const unsafe = new ClusterVaultKvSecretProvider(options, {
|
||||
request() {
|
||||
throw new Error('unsafe token must fail before transport');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
unsafe.verify(),
|
||||
(error) =>
|
||||
error instanceof ClusterVaultKvSecretProviderError &&
|
||||
error.reason === 'trust_unavailable',
|
||||
);
|
||||
await assert.rejects(
|
||||
mismatched.resolve({ ...authority(), projectId: 'other-project' }),
|
||||
ClusterVaultKvSecretProviderError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ClusterWorkerIngressConfigError,
|
||||
loadClusterWorkerIngressConfig,
|
||||
} = require('@qinglong/cluster-control/worker-ingress-config');
|
||||
|
||||
const FIXTURES = path.join(__dirname, 'fixtures', 'mtls');
|
||||
const BASE_ENV = Object.freeze({
|
||||
QL3_WORKER_INGRESS_ENABLED: 'true',
|
||||
QL_DEPLOYMENT_PROFILE: 'cluster-control',
|
||||
QL3_POSTGRES_WORKER_INGRESS_URL:
|
||||
'postgresql://ql3_worker_ingress:secret@database.internal:5432/qinglong',
|
||||
QL3_WORKER_INGRESS_POSTGRES_TLS_SERVERNAME: 'database.internal',
|
||||
QL3_WORKER_CREDENTIAL_PEPPER: 'A'.repeat(43),
|
||||
QL3_WORKER_ARTIFACT_S3_BUCKET: 'qinglong-worker-artifacts',
|
||||
QL3_WORKER_ARTIFACT_S3_REGION: 'us-east-1',
|
||||
QL3_WORKER_INGRESS_TLS_PRIVATE_KEY_FILE: path.join(
|
||||
FIXTURES,
|
||||
'server-key.pem',
|
||||
),
|
||||
QL3_WORKER_INGRESS_TLS_CERTIFICATE_FILE: path.join(
|
||||
FIXTURES,
|
||||
'server-cert.pem',
|
||||
),
|
||||
QL3_WORKER_INGRESS_TLS_CLIENT_CA_FILE: path.join(FIXTURES, 'ca-cert.pem'),
|
||||
});
|
||||
|
||||
test('loads the explicit Vault KV v2 Worker Secret provider', () => {
|
||||
const config = loadClusterWorkerIngressConfig({
|
||||
...BASE_ENV,
|
||||
QL3_WORKER_SECRET_PROVIDER: 'vault-kv-v2',
|
||||
QL3_WORKER_SECRET_VAULT_ENDPOINT: 'https://vault.internal:8200',
|
||||
QL3_WORKER_SECRET_VAULT_CA_FILE: '/run/vault/trust/ca.pem',
|
||||
QL3_WORKER_SECRET_VAULT_TOKEN_FILE: '/run/vault/auth/token',
|
||||
QL3_WORKER_SECRET_VAULT_KV_MOUNT: 'worker-secrets',
|
||||
QL3_WORKER_SECRET_VAULT_PATH_PREFIX: 'values/production',
|
||||
QL3_WORKER_SECRET_VAULT_EXPECTED_POLICY: 'ql3-worker-secret-read',
|
||||
QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS: '600',
|
||||
QL3_WORKER_SECRET_VAULT_REQUEST_TIMEOUT_MS: '4000',
|
||||
QL3_WORKER_SECRET_VAULT_MAX_CONCURRENCY: '3',
|
||||
QL3_WORKER_SECRET_VAULT_NAMESPACE: 'organization/team-a',
|
||||
});
|
||||
assert.deepEqual(config.secret, {
|
||||
provider: 'vault-kv-v2',
|
||||
endpoint: 'https://vault.internal:8200/',
|
||||
caFile: '/run/vault/trust/ca.pem',
|
||||
tokenFile: '/run/vault/auth/token',
|
||||
kvMount: 'worker-secrets',
|
||||
pathPrefix: 'values/production',
|
||||
expectedPolicy: 'ql3-worker-secret-read',
|
||||
maximumTokenTtlSeconds: 600,
|
||||
requestTimeoutMs: 4000,
|
||||
maximumConcurrency: 3,
|
||||
namespace: 'organization/team-a',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects incomplete or non-HTTPS Vault Worker Secret configuration', () => {
|
||||
const base = {
|
||||
...BASE_ENV,
|
||||
QL3_WORKER_SECRET_PROVIDER: 'vault-kv-v2',
|
||||
QL3_WORKER_SECRET_VAULT_ENDPOINT: 'https://vault.internal:8200',
|
||||
QL3_WORKER_SECRET_VAULT_CA_FILE: '/run/vault/trust/ca.pem',
|
||||
QL3_WORKER_SECRET_VAULT_TOKEN_FILE: '/run/vault/auth/token',
|
||||
QL3_WORKER_SECRET_VAULT_KV_MOUNT: 'worker-secrets',
|
||||
QL3_WORKER_SECRET_VAULT_PATH_PREFIX: 'values/production',
|
||||
QL3_WORKER_SECRET_VAULT_EXPECTED_POLICY: 'ql3-worker-secret-read',
|
||||
};
|
||||
for (const environment of [
|
||||
{ ...base, QL3_WORKER_SECRET_VAULT_ENDPOINT: 'http://vault.internal' },
|
||||
{ ...base, QL3_WORKER_SECRET_VAULT_CA_FILE: 'relative.pem' },
|
||||
{ ...base, QL3_WORKER_SECRET_VAULT_TOKEN_FILE: undefined },
|
||||
{ ...base, QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS: '3601' },
|
||||
{ ...base, QL3_WORKER_SECRET_VAULT_MAX_CONCURRENCY: '9' },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => loadClusterWorkerIngressConfig(environment),
|
||||
ClusterWorkerIngressConfigError,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user