Files

298 lines
9.0 KiB
JavaScript

'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,
);
});