feat: secure organization provider credentials

This commit is contained in:
2026-07-10 22:04:43 +08:00
parent e049cb6880
commit 848f913597
51 changed files with 3280 additions and 230 deletions
+61 -3
View File
@@ -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"),
+4 -20
View File
@@ -83,12 +83,11 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
problems.push("DATABASE_URL must be a valid postgresql:// or postgres:// URL");
}
const providerBaseUrl = readRequired(input.env, "ANTHROPIC_BASE_URL", problems);
if (providerBaseUrl !== undefined && !isHttpUrl(providerBaseUrl)) {
problems.push("ANTHROPIC_BASE_URL must be a valid http(s) URL");
for (const name of ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const) {
if (Object.hasOwn(input.env, name)) {
problems.push(`${name} must not be set; configure Organization Provider Connections`);
}
}
readRequired(input.env, "ANTHROPIC_AUTH_TOKEN", problems);
requirePresentAndEmpty(input.env, "ANTHROPIC_API_KEY", problems);
readRequired(input.env, "FEISHU_APP_ID", problems);
readRequired(input.env, "FEISHU_APP_SECRET", problems);
@@ -139,12 +138,6 @@ function readRequired(env: DeploymentEnv, name: string, problems: string[]): str
return value;
}
function requirePresentAndEmpty(env: DeploymentEnv, name: string, problems: string[]): void {
if (!Object.hasOwn(env, name) || env[name] === undefined || env[name]?.trim() !== "") {
problems.push(`${name} must be present and empty`);
}
}
function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void {
const raw = env[name]?.trim();
if (raw === undefined || raw === "") return;
@@ -184,15 +177,6 @@ function isPostgresUrl(value: string): boolean {
}
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
} catch {
return false;
}
}
function isHttpsUrl(value: string): boolean {
try {
const url = new URL(value);
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { PrismaClient } from "@prisma/client";
import { rotateLocalKek } from "../security/localKekRotation.js";
const execFileAsync = promisify(execFile);
const SERVICE_UNIT = "cph-hub.service";
async function main(): Promise<void> {
if (process.getuid?.() !== 0) {
throw new Error("local KEK rotation must run as root");
}
await requireStoppedService();
const keyringFile = parseKeyringFile(process.argv.slice(2));
const databaseUrl = process.env["DATABASE_URL"]?.trim();
if (databaseUrl === undefined || databaseUrl === "") {
throw new Error("DATABASE_URL is required for local KEK rotation");
}
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
try {
const result = await rotateLocalKek({ keyringFile, prisma });
console.log(
`[secret-kek] active=${result.activeKeyId} rewrapped=${result.rewrapped} verified=${result.verified}`,
);
console.log("[secret-kek] back up the updated keyring separately before retiring any previous key");
} finally {
await prisma.$disconnect();
}
}
async function requireStoppedService(): Promise<void> {
let activeState: string;
try {
const { stdout } = await execFileAsync(
"systemctl",
["show", "--property=ActiveState", "--value", SERVICE_UNIT],
{ timeout: 10_000 },
);
activeState = stdout.trim();
} catch {
throw new Error(`cannot verify that ${SERVICE_UNIT} is stopped`);
}
if (activeState !== "inactive" && activeState !== "failed") {
throw new Error(`${SERVICE_UNIT} must be stopped before local KEK rotation; state=${activeState || "unknown"}`);
}
}
function parseKeyringFile(argv: readonly string[]): string {
if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") {
throw new Error("usage: rotate-secret-kek --keyring-file /absolute/path/to/secret-keyring.json");
}
return argv[1];
}
main().catch((error: unknown) => {
console.error(`[secret-kek] failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});