feat: migrate secrets to Infisical
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, '..');
|
||||
const schedulerToken = 'scheduler-token-with-at-least-32-characters';
|
||||
|
||||
const infisicalStub = `#!/bin/sh
|
||||
set -eu
|
||||
|
||||
case "\${1:-}" in
|
||||
login)
|
||||
printf '%s\n' "$@" > "$STUB_LOGIN_LOG"
|
||||
if [ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID:-}" != "$STUB_EXPECTED_CLIENT_ID" ] || \
|
||||
[ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET:-}" != "$STUB_EXPECTED_CLIENT_SECRET" ]; then
|
||||
printf '%s\n' 'credentials=incorrect' >> "$STUB_LOGIN_LOG"
|
||||
exit 26
|
||||
fi
|
||||
printf '%s\n' 'credentials=ok' >> "$STUB_LOGIN_LOG"
|
||||
case "\${STUB_LOGIN_RESULT:-success}" in
|
||||
fail) exit 23 ;;
|
||||
empty) exit 0 ;;
|
||||
*) printf '%s\n' stub-access-token ;;
|
||||
esac
|
||||
;;
|
||||
export)
|
||||
[ "\${INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN:-}" = stub-access-token ] || exit 27
|
||||
secret_path=
|
||||
output_file=
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
--path=*) secret_path=\${argument#--path=} ;;
|
||||
--output-file=*) output_file=\${argument#--output-file=} ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$secret_path" ] && [ -n "$output_file" ] || exit 28
|
||||
printf '%s|%s\n' "$secret_path" "$output_file" >> "$STUB_EXPORT_LOG"
|
||||
case "$secret_path" in
|
||||
/app) cp "$STUB_APP_EXPORT" "$output_file" ;;
|
||||
/shared/scheduler) cp "$STUB_SHARED_EXPORT" "$output_file" ;;
|
||||
*) exit 29 ;;
|
||||
esac
|
||||
;;
|
||||
*) exit 25 ;;
|
||||
esac
|
||||
`;
|
||||
|
||||
const environmentCapture = `#!/bin/sh
|
||||
env > "$CAPTURE_ENV_PATH"
|
||||
`;
|
||||
|
||||
const gosuStub = `#!/bin/sh
|
||||
set -eu
|
||||
[ "\${1:-}" = shd-runtime ] || exit 30
|
||||
printf '%s\n' 'target=shd-runtime' > "$STUB_GOSU_LOG"
|
||||
shift
|
||||
exec "$@"
|
||||
`;
|
||||
|
||||
const idStub = `#!/bin/sh
|
||||
if [ "\${1:-}" = -u ] && [ "\${2:-}" = shd-runtime ]; then
|
||||
printf '%s\n' 10001
|
||||
exit 0
|
||||
fi
|
||||
exec /usr/bin/id "$@"
|
||||
`;
|
||||
|
||||
const chownStub = `#!/bin/sh
|
||||
exit 0
|
||||
`;
|
||||
|
||||
describe('container runtime startup', () => {
|
||||
let temporaryRoot: string;
|
||||
let clientIdFile: string;
|
||||
let clientSecretFile: string;
|
||||
let loginLog: string;
|
||||
let exportLog: string;
|
||||
let gosuLog: string;
|
||||
let capturedEnvironment: string;
|
||||
let appExport: string;
|
||||
let sharedExport: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
temporaryRoot = await mkdtemp(join(tmpdir(), 'shd-runtime-start-'));
|
||||
const deployDirectory = join(temporaryRoot, 'deploy');
|
||||
const scriptsDirectory = join(temporaryRoot, 'scripts');
|
||||
await mkdir(deployDirectory);
|
||||
await mkdir(scriptsDirectory);
|
||||
await Promise.all([
|
||||
copyFile(
|
||||
join(repositoryRoot, 'deploy/container-entrypoint.sh'),
|
||||
join(deployDirectory, 'container-entrypoint.sh'),
|
||||
),
|
||||
copyFile(
|
||||
join(repositoryRoot, 'scripts/runtime-launcher.mjs'),
|
||||
join(scriptsDirectory, 'runtime-launcher.mjs'),
|
||||
),
|
||||
copyFile(
|
||||
join(repositoryRoot, 'scripts/runtime-environment.js'),
|
||||
join(scriptsDirectory, 'runtime-environment.js'),
|
||||
),
|
||||
]);
|
||||
|
||||
clientIdFile = join(temporaryRoot, 'client-id');
|
||||
clientSecretFile = join(temporaryRoot, 'client-secret');
|
||||
loginLog = join(temporaryRoot, 'login.log');
|
||||
exportLog = join(temporaryRoot, 'export.log');
|
||||
gosuLog = join(temporaryRoot, 'gosu.log');
|
||||
capturedEnvironment = join(temporaryRoot, 'child.env');
|
||||
appExport = join(temporaryRoot, 'app-export.json');
|
||||
sharedExport = join(temporaryRoot, 'shared-export.json');
|
||||
|
||||
await Promise.all([
|
||||
writeFile(clientIdFile, 'universal-client-id\n', { mode: 0o600 }),
|
||||
writeFile(clientSecretFile, 'universal-client-secret\n', { mode: 0o600 }),
|
||||
writeFile(join(temporaryRoot, 'infisical'), infisicalStub, {
|
||||
mode: 0o755,
|
||||
}),
|
||||
writeFile(
|
||||
join(temporaryRoot, 'capture-environment'),
|
||||
environmentCapture,
|
||||
{
|
||||
mode: 0o755,
|
||||
},
|
||||
),
|
||||
writeFile(join(temporaryRoot, 'gosu'), gosuStub, { mode: 0o755 }),
|
||||
writeFile(join(temporaryRoot, 'id'), idStub, { mode: 0o755 }),
|
||||
writeFile(join(temporaryRoot, 'chown'), chownStub, { mode: 0o755 }),
|
||||
writeExport(appExport, [
|
||||
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
||||
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
||||
]),
|
||||
writeExport(sharedExport, [
|
||||
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
|
||||
]),
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(temporaryRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runtimeEnvironment(
|
||||
scope: 'app' | 'scheduler',
|
||||
): Record<string, string> {
|
||||
return {
|
||||
PATH: `${temporaryRoot}:${process.env.PATH ?? ''}`,
|
||||
INFISICAL_PROJECT_ID: 'project-id',
|
||||
INFISICAL_ENVIRONMENT: 'staging',
|
||||
INFISICAL_RUNTIME_SCOPE: scope,
|
||||
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: clientIdFile,
|
||||
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: clientSecretFile,
|
||||
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: 'bootstrap-client-id',
|
||||
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: 'bootstrap-client-secret',
|
||||
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN: 'bootstrap-access-token',
|
||||
INFISICAL_TOKEN: 'bootstrap-token',
|
||||
TOKEN: 'generic-bootstrap-token',
|
||||
STUB_EXPECTED_CLIENT_ID: 'universal-client-id',
|
||||
STUB_EXPECTED_CLIENT_SECRET: 'universal-client-secret',
|
||||
STUB_LOGIN_LOG: loginLog,
|
||||
STUB_EXPORT_LOG: exportLog,
|
||||
STUB_GOSU_LOG: gosuLog,
|
||||
STUB_APP_EXPORT: appExport,
|
||||
STUB_SHARED_EXPORT: sharedExport,
|
||||
CAPTURE_ENV_PATH: capturedEnvironment,
|
||||
SHD_BUILD_NUMBER: '42',
|
||||
SHD_COMMIT_SHA: 'abc123',
|
||||
APP_ORIGIN: 'https://parent.invalid',
|
||||
SCHEDULER_URL: 'https://parent.invalid/scheduler',
|
||||
BASH_ENV: '',
|
||||
CDPATH: temporaryRoot,
|
||||
ENV: '',
|
||||
IFS: 'hostile-ifs',
|
||||
LD_LIBRARY_PATH: temporaryRoot,
|
||||
LD_PRELOAD: '',
|
||||
NODE_OPTIONS: '--no-warnings',
|
||||
NODE_PATH: temporaryRoot,
|
||||
PYTHONPATH: temporaryRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function start(environment: Record<string, string>) {
|
||||
return spawnSync(
|
||||
'/bin/sh',
|
||||
['./deploy/container-entrypoint.sh', 'capture-environment'],
|
||||
{
|
||||
cwd: temporaryRoot,
|
||||
env: environment,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it('exports app and shared secrets into a scrubbed app child environment', async () => {
|
||||
const result = start(runtimeEnvironment('app'));
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(await exportPaths()).toEqual(['/app', '/shared/scheduler']);
|
||||
expect(await readFile(loginLog, 'utf8')).toBe(
|
||||
'login\n--method=universal-auth\n--silent\n--plain\ncredentials=ok\n',
|
||||
);
|
||||
|
||||
const childEnvironment = parseEnvironment(
|
||||
await readFile(capturedEnvironment, 'utf8'),
|
||||
);
|
||||
expect(childEnvironment).toMatchObject({
|
||||
APP_ORIGIN: 'https://stage.shd.build',
|
||||
DISCORD_CLIENT_ID: 'exported-discord-client',
|
||||
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
||||
ORIGIN: 'https://stage.shd.build',
|
||||
PASSKEY_RP_ID: 'shd.build',
|
||||
SCHEDULER_TOKEN: schedulerToken,
|
||||
SHD_BUILD_NUMBER: '42',
|
||||
SHD_COMMIT_SHA: 'abc123',
|
||||
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||
});
|
||||
for (const name of removedEnvironmentNames) {
|
||||
expect(childEnvironment).not.toHaveProperty(name);
|
||||
}
|
||||
await expectSecretFilesRemoved();
|
||||
await expectRootPrivilegeDrop();
|
||||
});
|
||||
|
||||
it('exports only shared secrets into the scheduler child environment', async () => {
|
||||
const result = start(runtimeEnvironment('scheduler'));
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(await exportPaths()).toEqual(['/shared/scheduler']);
|
||||
expect(
|
||||
parseEnvironment(await readFile(capturedEnvironment, 'utf8')),
|
||||
).toMatchObject({
|
||||
SCHEDULER_TOKEN: schedulerToken,
|
||||
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
||||
});
|
||||
await expectSecretFilesRemoved();
|
||||
await expectRootPrivilegeDrop();
|
||||
});
|
||||
|
||||
it('fails closed on an unknown secret before launching the child', async () => {
|
||||
await writeExport(appExport, [
|
||||
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||
{ key: 'UNKNOWN_SECRET', value: 'must-not-reach-child' },
|
||||
]);
|
||||
|
||||
const result = start(runtimeEnvironment('app'));
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain(
|
||||
'Infisical secret UNKNOWN_SECRET is not allowed in this runtime scope.',
|
||||
);
|
||||
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||
await expectSecretFilesRemoved();
|
||||
});
|
||||
|
||||
it('fails closed when a credential file setting is missing', () => {
|
||||
const environment = runtimeEnvironment('app');
|
||||
delete environment.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE;
|
||||
|
||||
const result = start(environment);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain(
|
||||
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE is required',
|
||||
);
|
||||
expect(existsSync(exportLog)).toBe(false);
|
||||
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when either credential is empty', async () => {
|
||||
for (const credentialFile of [clientIdFile, clientSecretFile]) {
|
||||
await writeFile(credentialFile, '');
|
||||
|
||||
const result = start(runtimeEnvironment('app'));
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('credential files must not be empty');
|
||||
expect(existsSync(exportLog)).toBe(false);
|
||||
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||
await writeFile(
|
||||
credentialFile,
|
||||
credentialFile === clientIdFile
|
||||
? 'universal-client-id\n'
|
||||
: 'universal-client-secret\n',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['fail', 'empty'])(
|
||||
'fails closed when login returns %s',
|
||||
(loginResult) => {
|
||||
const result = start({
|
||||
...runtimeEnvironment('app'),
|
||||
STUB_LOGIN_RESULT: loginResult,
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(existsSync(exportLog)).toBe(false);
|
||||
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('fails closed on a malformed export and removes every secret file', async () => {
|
||||
await writeFile(appExport, '{not-json');
|
||||
|
||||
const result = start(runtimeEnvironment('app'));
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain(
|
||||
'Infisical returned an unreadable secret export.',
|
||||
);
|
||||
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||
await expectSecretFilesRemoved();
|
||||
});
|
||||
|
||||
async function exportPaths() {
|
||||
return (await exportRecords()).map(([path]) => path);
|
||||
}
|
||||
|
||||
async function expectSecretFilesRemoved() {
|
||||
for (const [, file] of await exportRecords()) {
|
||||
expect(existsSync(file)).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRecords() {
|
||||
return (await readFile(exportLog, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.split('|'));
|
||||
}
|
||||
|
||||
async function expectRootPrivilegeDrop() {
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||
expect(await readFile(gosuLog, 'utf8')).toBe('target=shd-runtime\n');
|
||||
} else {
|
||||
expect(existsSync(gosuLog)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const removedEnvironmentNames = [
|
||||
'BASH_ENV',
|
||||
'CDPATH',
|
||||
'ENV',
|
||||
'IFS',
|
||||
'INFISICAL_ENVIRONMENT',
|
||||
'INFISICAL_PROJECT_ID',
|
||||
'INFISICAL_RUNTIME_SCOPE',
|
||||
'INFISICAL_TOKEN',
|
||||
'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN',
|
||||
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID',
|
||||
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE',
|
||||
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET',
|
||||
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE',
|
||||
'LD_LIBRARY_PATH',
|
||||
'LD_PRELOAD',
|
||||
'NODE_OPTIONS',
|
||||
'NODE_PATH',
|
||||
'PYTHONPATH',
|
||||
'TOKEN',
|
||||
];
|
||||
|
||||
function writeExport(
|
||||
file: string,
|
||||
entries: Array<{ key: string; value: string }>,
|
||||
) {
|
||||
return writeFile(file, `${JSON.stringify(entries)}\n`);
|
||||
}
|
||||
|
||||
function parseEnvironment(value: string) {
|
||||
return Object.fromEntries(
|
||||
value
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const separator = line.indexOf('=');
|
||||
return [line.slice(0, separator), line.slice(separator + 1)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user