forked from EduCraft/curriculum-project-hub
feat: secure organization provider credentials
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import { access, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { access, lstat, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { parse } from "dotenv";
|
||||
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "../security/secretEnvelope.js";
|
||||
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
||||
import {
|
||||
DeploymentPreflightError,
|
||||
validateDeploymentPreflight,
|
||||
@@ -18,6 +20,7 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
interface Options {
|
||||
readonly envFile: string;
|
||||
readonly keyringFile: string;
|
||||
readonly nodeBin: string;
|
||||
readonly runuserBin: string;
|
||||
readonly setprivBin: string;
|
||||
@@ -47,6 +50,7 @@ async function main(): Promise<void> {
|
||||
const canonicalInput = await canonicalizeInput(rawInput);
|
||||
const result = validateDeploymentPreflight(canonicalInput);
|
||||
await validateSecretFile(options.envFile);
|
||||
const secretEnvelope = await validateKeyringFile(options.keyringFile);
|
||||
await validateNodeVersion(options.nodeBin);
|
||||
await validateExecutable(options.runuserBin, "runuser");
|
||||
await validateExecutable(options.setprivBin, "setpriv");
|
||||
@@ -56,7 +60,7 @@ async function main(): Promise<void> {
|
||||
await validateExecutable(options.pgIsReadyBin, "pg_isready");
|
||||
await validateCph(result.cphBin);
|
||||
await validatePostgres(options.pgIsReadyBin, result);
|
||||
await validatePostgresQuery(result.databaseUrl);
|
||||
await validatePostgresQuery(result.databaseUrl, secretEnvelope);
|
||||
|
||||
if (options.serviceUid !== undefined && options.serviceGid !== undefined) {
|
||||
await validateProvisionedDirectories(canonicalInput, options.serviceUid, options.serviceGid);
|
||||
@@ -125,6 +129,22 @@ async function validateSecretFile(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateKeyringFile(path: string): Promise<LocalSecretEnvelope> {
|
||||
const metadata = await lstat(path);
|
||||
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
||||
throw new Error(`local secret keyring is not a regular non-symlink file: ${path}`);
|
||||
}
|
||||
if ((metadata.mode & 0o077) !== 0) {
|
||||
throw new Error(`local secret keyring must not be accessible by group or others: ${path}`);
|
||||
}
|
||||
const keyring = await loadLocalSecretKeyring({
|
||||
NODE_ENV: "preflight",
|
||||
HUB_SECRET_KEYRING_FILE: path,
|
||||
});
|
||||
console.log("[preflight] local secret keyring is valid");
|
||||
return new LocalSecretEnvelope(keyring);
|
||||
}
|
||||
|
||||
async function validateNodeVersion(nodeBin: string): Promise<void> {
|
||||
let stdout: string;
|
||||
try {
|
||||
@@ -189,14 +209,44 @@ async function validatePostgres(pgIsReadyBin: string, result: DeploymentPrefligh
|
||||
console.log("[preflight] PostgreSQL is accepting connections");
|
||||
}
|
||||
|
||||
async function validatePostgresQuery(databaseUrl: string): Promise<void> {
|
||||
async function validatePostgresQuery(
|
||||
databaseUrl: string,
|
||||
secretEnvelope: LocalSecretEnvelope,
|
||||
): Promise<void> {
|
||||
const client = new PrismaClient({
|
||||
datasources: { db: { url: databaseUrl } },
|
||||
log: [],
|
||||
});
|
||||
let queryFailure: unknown;
|
||||
let envelopeFailure: unknown;
|
||||
try {
|
||||
await client.$queryRawUnsafe("SELECT 1");
|
||||
const migrationTable = await client.$queryRaw<Array<{ relation: string | null }>>`
|
||||
SELECT to_regclass('public."_prisma_migrations"')::text AS "relation"
|
||||
`;
|
||||
if (migrationTable[0]?.relation === null || migrationTable[0]?.relation === undefined) {
|
||||
console.log("[preflight] empty database has no migration table or stored provider envelopes");
|
||||
} else {
|
||||
const migration = await client.$queryRaw<Array<{ finished_at: Date | null; rolled_back_at: Date | null }>>`
|
||||
SELECT "finished_at", "rolled_back_at"
|
||||
FROM "_prisma_migrations"
|
||||
WHERE "migration_name" = '20260710220000_org_provider_secret_envelopes'
|
||||
ORDER BY "started_at" DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const applied = migration[0]?.finished_at !== null && migration[0]?.finished_at !== undefined &&
|
||||
migration[0]?.rolled_back_at === null;
|
||||
if (!applied) {
|
||||
console.log("[preflight] provider envelope migration is pending; no stored envelopes to authenticate");
|
||||
} else {
|
||||
try {
|
||||
const verified = await verifyStoredProviderEnvelopes(client, secretEnvelope);
|
||||
console.log(`[preflight] authenticated ${verified} stored provider envelope(s)`);
|
||||
} catch (error) {
|
||||
envelopeFailure = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
queryFailure = error;
|
||||
}
|
||||
@@ -222,6 +272,12 @@ async function validatePostgresQuery(databaseUrl: string): Promise<void> {
|
||||
{ cause: disconnectFailure },
|
||||
);
|
||||
}
|
||||
if (envelopeFailure !== undefined) {
|
||||
throw new Error(
|
||||
`stored provider envelope verification failed (${formatFailure(envelopeFailure)})`,
|
||||
{ cause: envelopeFailure },
|
||||
);
|
||||
}
|
||||
console.log("[preflight] PostgreSQL authenticated query succeeded");
|
||||
}
|
||||
|
||||
@@ -383,6 +439,7 @@ function parseArgs(argv: readonly string[]): Options {
|
||||
|
||||
const allowed = new Set([
|
||||
"--env-file",
|
||||
"--keyring-file",
|
||||
"--node-bin",
|
||||
"--runuser-bin",
|
||||
"--setpriv-bin",
|
||||
@@ -411,6 +468,7 @@ function parseArgs(argv: readonly string[]): Options {
|
||||
|
||||
return {
|
||||
envFile: required(values, "--env-file"),
|
||||
keyringFile: required(values, "--keyring-file"),
|
||||
nodeBin: required(values, "--node-bin"),
|
||||
runuserBin: required(values, "--runuser-bin"),
|
||||
setprivBin: required(values, "--setpriv-bin"),
|
||||
|
||||
Reference in New Issue
Block a user