forked from wangziqi/gongxue-base
2269 lines
92 KiB
JavaScript
2269 lines
92 KiB
JavaScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import pg from 'pg';
|
|
import {
|
|
loadTenantForeignKeyRelations,
|
|
summarizeTenantForeignKeySchema,
|
|
} from './lib/tenant-foreign-key-audit.js';
|
|
|
|
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
|
const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me';
|
|
const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me';
|
|
const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
|
|
const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
|
|
const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024;
|
|
const PRODUCTION_SMS_PROVIDERS = new Set([
|
|
'aliyun-pnvs',
|
|
'aliyun-pnvs-sms',
|
|
'aliyun-sms-auth',
|
|
]);
|
|
const PRODUCTION_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
|
|
const AUTH_PROVIDER_ALIASES = {
|
|
sms: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'aliyun-pnvs', 'aliyun_pnvs', 'aliyun-sms-auth', 'aliyun_sms_auth', 'tencent', 'tencent-sms', 'tencent_sms']),
|
|
aliyun: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms']),
|
|
aliyunPnvs: new Set(['aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth']),
|
|
tencent: new Set(['tencent', 'tencent-sms', 'tencent_sms']),
|
|
wechatMiniapp: new Set(['wechat-miniapp', 'wechat_miniapp', 'wechat-mini', 'wx-miniapp', 'wx_miniapp']),
|
|
wechatWeb: new Set(['wechat-web', 'wechat_web', 'wechat', 'wechat-oauth', 'wechat_oauth']),
|
|
qq: new Set(['qq', 'qq-oauth', 'qq_oauth']),
|
|
};
|
|
const PAYMENT_PROVIDER_ALIASES = {
|
|
wechatPay: new Set(['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay', 'wx_pay']),
|
|
alipay: new Set(['alipay', 'ali_pay']),
|
|
manual: new Set(['manual']),
|
|
};
|
|
|
|
const args = new Set(process.argv.slice(2));
|
|
const argValues = new Map();
|
|
for (let index = 2; index < process.argv.length; index += 1) {
|
|
const current = process.argv[index];
|
|
if (current.startsWith('--') && process.argv[index + 1] && !process.argv[index + 1].startsWith('--')) {
|
|
argValues.set(current, process.argv[index + 1]);
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
const jsonOutput = args.has('--json');
|
|
const checkDb = args.has('--check-db');
|
|
const skipDb = args.has('--skip-db') || !checkDb;
|
|
const envFile = argValues.get('--env-file') || path.resolve(process.cwd(), '.env');
|
|
const providerConfigFixture = argValues.get('--provider-config-fixture') || '';
|
|
const tenantConfigFixture = argValues.get('--tenant-config-fixture') || '';
|
|
const environmentSafetyFixture = argValues.get('--environment-safety-fixture') || '';
|
|
const migrationHistoryFixture = argValues.get('--migration-history-fixture') || '';
|
|
const checks = [];
|
|
|
|
function loadRepositoryMigrationState() {
|
|
const migrationsDir = path.resolve(process.cwd(), 'supabase', 'migrations');
|
|
if (!fs.existsSync(migrationsDir)) {
|
|
return { error: `Migration directory is missing: ${migrationsDir}` };
|
|
}
|
|
|
|
const files = fs.readdirSync(migrationsDir)
|
|
.filter(file => file.endsWith('.sql'))
|
|
.sort();
|
|
const invalidFiles = [];
|
|
const versions = [];
|
|
for (const file of files) {
|
|
const match = /^(\d+)_.*\.sql$/.exec(file);
|
|
if (!match) invalidFiles.push(file);
|
|
else versions.push(match[1]);
|
|
}
|
|
const duplicateVersions = [...new Set(
|
|
versions.filter((version, index) => versions.indexOf(version) !== index),
|
|
)];
|
|
const latestVersion = versions.reduce((latest, version) => {
|
|
if (!latest) return version;
|
|
if (version.length !== latest.length) return version.length > latest.length ? version : latest;
|
|
return version > latest ? version : latest;
|
|
}, '');
|
|
|
|
return {
|
|
migrationsDir,
|
|
files,
|
|
versions,
|
|
invalidFiles,
|
|
duplicateVersions,
|
|
latestVersion,
|
|
};
|
|
}
|
|
|
|
function compareMigrationVersions(left, right) {
|
|
const normalizedLeft = String(left || '').replace(/^0+(?=\d)/, '');
|
|
const normalizedRight = String(right || '').replace(/^0+(?=\d)/, '');
|
|
if (normalizedLeft.length !== normalizedRight.length) {
|
|
return normalizedLeft.length > normalizedRight.length ? 1 : -1;
|
|
}
|
|
return normalizedLeft === normalizedRight ? 0 : normalizedLeft > normalizedRight ? 1 : -1;
|
|
}
|
|
|
|
function validateMigrationHistory(row, source = 'database') {
|
|
const repository = loadRepositoryMigrationState();
|
|
if (repository.error
|
|
|| repository.files?.length === 0
|
|
|| repository.invalidFiles?.length > 0
|
|
|| repository.duplicateVersions?.length > 0
|
|
|| !repository.latestVersion) {
|
|
block(
|
|
'db.migrations.repository',
|
|
'Repository migrations must use unique numeric Supabase versions',
|
|
{
|
|
source,
|
|
error: repository.error || '',
|
|
migrationCount: repository.files?.length || 0,
|
|
invalidFiles: repository.invalidFiles || [],
|
|
duplicateVersions: repository.duplicateVersions || [],
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
const latestVersion = String(row?.latest_version ?? row?.latestVersion ?? '').trim();
|
|
const appliedCount = Number(row?.applied_count ?? row?.appliedCount);
|
|
const distinctVersionCount = Number(row?.distinct_version_count ?? row?.distinctVersionCount);
|
|
const expectedVersionApplied = row?.expected_version_applied ?? row?.expectedVersionApplied;
|
|
const details = {
|
|
source,
|
|
expectedVersion: repository.latestVersion,
|
|
repositoryMigrationCount: repository.files.length,
|
|
latestAppliedVersion: latestVersion || '(missing)',
|
|
appliedCount,
|
|
distinctVersionCount,
|
|
expectedVersionApplied: expectedVersionApplied === true,
|
|
};
|
|
const validCounts = Number.isInteger(appliedCount)
|
|
&& appliedCount >= repository.files.length
|
|
&& Number.isInteger(distinctVersionCount)
|
|
&& distinctVersionCount === appliedCount;
|
|
const current = /^\d+$/.test(latestVersion)
|
|
&& compareMigrationVersions(latestVersion, repository.latestVersion) >= 0
|
|
&& expectedVersionApplied === true;
|
|
|
|
if (!validCounts || !current) {
|
|
block(
|
|
'db.migrations.current',
|
|
'Database migration history must include the repository latest migration with no duplicate versions',
|
|
details,
|
|
);
|
|
return;
|
|
}
|
|
|
|
pass(
|
|
'db.migrations.current',
|
|
'Database migration history includes the repository latest migration',
|
|
details,
|
|
);
|
|
}
|
|
|
|
function loadEnvFile(filePath) {
|
|
if (!filePath || !fs.existsSync(filePath)) return;
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
for (const line of content.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const index = trimmed.indexOf('=');
|
|
if (index === -1) continue;
|
|
const key = trimmed.slice(0, index).trim();
|
|
const value = trimmed.slice(index + 1).trim().replace(/^"|"$/g, '');
|
|
if (key && process.env[key] === undefined) process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
function env(key, fallback = '') {
|
|
return process.env[key] ?? fallback;
|
|
}
|
|
|
|
function envBool(key, fallback = false) {
|
|
const value = process.env[key];
|
|
if (value === undefined) return fallback;
|
|
return ['true', '1', 'yes', 'y', 'on'].includes(value.toLowerCase());
|
|
}
|
|
|
|
function envNumber(key, fallback) {
|
|
const parsed = Number(process.env[key]);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function envList(key, fallback = '') {
|
|
return env(key, fallback)
|
|
.split(',')
|
|
.map(item => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function add(status, id, message, details = {}) {
|
|
checks.push({ status, id, message, details });
|
|
}
|
|
|
|
function pass(id, message, details = {}) {
|
|
add('pass', id, message, details);
|
|
}
|
|
|
|
function warn(id, message, details = {}) {
|
|
add('warn', id, message, details);
|
|
}
|
|
|
|
function block(id, message, details = {}) {
|
|
add('blocker', id, message, details);
|
|
}
|
|
|
|
function isUnsafeSecret(value, defaultValue) {
|
|
const normalized = String(value || '').trim().toLowerCase();
|
|
return (
|
|
!normalized ||
|
|
value === defaultValue ||
|
|
normalized.length < 32 ||
|
|
normalized.includes('replace_with') ||
|
|
normalized.includes('change-me') ||
|
|
normalized.includes('changeme') ||
|
|
normalized.includes('your_') ||
|
|
normalized.includes('example')
|
|
);
|
|
}
|
|
|
|
function hostFromUrl(value) {
|
|
try {
|
|
return new URL(value).hostname.toLowerCase();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function urlFromValue(value) {
|
|
try {
|
|
return new URL(String(value || ''));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isLocalHost(hostname) {
|
|
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
|
|
}
|
|
|
|
function isProductionHttpsUrl(value) {
|
|
const url = urlFromValue(value);
|
|
return Boolean(url && url.protocol === 'https:' && !isLocalHost(url.hostname.toLowerCase()));
|
|
}
|
|
|
|
function isAllowedHost(hostname, allowedHosts) {
|
|
const normalized = String(hostname || '').toLowerCase();
|
|
return allowedHosts.some(host => normalized === host || normalized.endsWith(`.${host}`));
|
|
}
|
|
|
|
function publicString(configPublic, keys) {
|
|
for (const key of keys) {
|
|
const value = configPublic?.[key];
|
|
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function missingPublicKeys(configPublic, keys) {
|
|
return keys.filter(key => !publicString(configPublic, [key]));
|
|
}
|
|
|
|
function normalizeProvider(value) {
|
|
return String(value || '').trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeSmsProvider(value) {
|
|
return normalizeProvider(value).replace(/[_\s]/g, '-');
|
|
}
|
|
|
|
function providerIn(provider, aliases) {
|
|
return aliases.has(normalizeProvider(provider));
|
|
}
|
|
|
|
function currentSmsProviderAliases() {
|
|
const provider = normalizeSmsProvider(env('AUTH_SMS_PROVIDER', 'mock'));
|
|
if (AUTH_PROVIDER_ALIASES.aliyunPnvs.has(provider)) return { provider: 'aliyun-pnvs', aliases: AUTH_PROVIDER_ALIASES.aliyunPnvs };
|
|
return { provider, aliases: new Set([provider]) };
|
|
}
|
|
|
|
function validateActiveSmsProviderRows(rows, sourceLabel = 'database') {
|
|
const expected = currentSmsProviderAliases();
|
|
if (!PRODUCTION_SMS_PROVIDERS.has(normalizeSmsProvider(env('AUTH_SMS_PROVIDER', 'mock')))) return;
|
|
const activeRows = rows.filter(row => row.source === 'auth' && providerIn(row.provider, expected.aliases));
|
|
if (activeRows.length === 0) {
|
|
block('db.auth_sms_provider_configured', 'AUTH_SMS_PROVIDER must have a matching active/testing tenant_auth_providers row', {
|
|
provider: expected.provider,
|
|
source: sourceLabel,
|
|
});
|
|
return;
|
|
}
|
|
pass('db.auth_sms_provider_configured', 'AUTH_SMS_PROVIDER has matching active/testing tenant auth provider rows', {
|
|
provider: expected.provider,
|
|
count: activeRows.length,
|
|
source: sourceLabel,
|
|
});
|
|
}
|
|
|
|
function validateProviderUrl({
|
|
id,
|
|
value,
|
|
message,
|
|
allowedHosts = [],
|
|
required = false,
|
|
details = {},
|
|
}) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) {
|
|
if (required) block(`${id}.missing`, message || 'Production URL is required', details);
|
|
return;
|
|
}
|
|
const url = urlFromValue(raw);
|
|
if (!url || url.protocol !== 'https:' || isLocalHost(url.hostname.toLowerCase())) {
|
|
block(`${id}.unsafe`, message || 'Production URL must use HTTPS and must not point to localhost', details);
|
|
return;
|
|
}
|
|
if (allowedHosts.length > 0 && !isAllowedHost(url.hostname, allowedHosts)) {
|
|
block(`${id}.host`, 'Provider endpoint host is not allowed for production', {
|
|
...details,
|
|
host: url.hostname,
|
|
allowedHosts,
|
|
});
|
|
}
|
|
}
|
|
|
|
function missingPublicKeyGroups(configPublic, groups) {
|
|
return groups
|
|
.filter(group => !publicString(configPublic, group.keys))
|
|
.map(group => group.label);
|
|
}
|
|
|
|
function pnvsTemplateParamHasCodePlaceholder(configPublic) {
|
|
const value = configPublic?.templateParam;
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return true;
|
|
return Object.values(value).some(item => String(item) === '##code##');
|
|
}
|
|
|
|
function blockMissingPublicConfig(row, missing) {
|
|
if (missing.length === 0) return;
|
|
block(
|
|
`db.${row.source}.${safeProviderName(row.provider)}.public_required`,
|
|
'Provider public config is missing production-required fields',
|
|
{
|
|
tenantId: row.tenant_id,
|
|
provider: row.provider,
|
|
missing,
|
|
},
|
|
);
|
|
}
|
|
|
|
function validateAuthProviderPublicConfig(row) {
|
|
const provider = normalizeProvider(row.provider);
|
|
const configPublic = row.config_public || {};
|
|
const details = { tenantId: row.tenant_id, provider: row.provider };
|
|
|
|
if (provider === 'mock') {
|
|
block('db.auth.mock_provider', 'Active/testing mock auth provider is not allowed in production', details);
|
|
return;
|
|
}
|
|
|
|
if (providerIn(provider, AUTH_PROVIDER_ALIASES.aliyun)) {
|
|
block(
|
|
`db.auth.${safeProviderName(row.provider)}.legacy_sms_provider`,
|
|
'Traditional Aliyun SMS auth provider must not be active/testing in production; use aliyun-pnvs',
|
|
details,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (providerIn(provider, AUTH_PROVIDER_ALIASES.aliyunPnvs)) {
|
|
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
|
{ label: 'signName', keys: ['signName'] },
|
|
{ label: 'templateCode', keys: ['templateCode'] },
|
|
]));
|
|
validateProviderUrl({
|
|
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
|
|
value: publicString(configPublic, ['endpoint']),
|
|
allowedHosts: ['aliyuncs.com'],
|
|
details,
|
|
});
|
|
if (!publicString(configPublic, ['regionId'])) {
|
|
warn(`db.auth.${safeProviderName(row.provider)}.region`, 'Aliyun PNVS regionId is not set; default cn-hangzhou will be used', details);
|
|
}
|
|
if (!pnvsTemplateParamHasCodePlaceholder(configPublic)) {
|
|
warn(
|
|
`db.auth.${safeProviderName(row.provider)}.template_param`,
|
|
'Aliyun PNVS templateParam should include ##code##; backend will add code placeholder automatically',
|
|
details,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (providerIn(provider, AUTH_PROVIDER_ALIASES.tencent)) {
|
|
block(
|
|
`db.auth.${safeProviderName(row.provider)}.legacy_sms_provider`,
|
|
'Tencent SMS auth provider must not be active/testing in production; use aliyun-pnvs',
|
|
details,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (providerIn(provider, AUTH_PROVIDER_ALIASES.wechatMiniapp)) {
|
|
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
|
{ label: 'appId', keys: ['appId'] },
|
|
]));
|
|
validateProviderUrl({
|
|
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
|
|
value: publicString(configPublic, ['endpoint']),
|
|
allowedHosts: ['weixin.qq.com'],
|
|
details,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (providerIn(provider, AUTH_PROVIDER_ALIASES.wechatWeb)) {
|
|
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
|
{ label: 'appId', keys: ['appId'] },
|
|
]));
|
|
validateProviderUrl({
|
|
id: `db.auth.${safeProviderName(row.provider)}.token_endpoint`,
|
|
value: publicString(configPublic, ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint']),
|
|
allowedHosts: ['weixin.qq.com'],
|
|
details,
|
|
});
|
|
validateProviderUrl({
|
|
id: `db.auth.${safeProviderName(row.provider)}.userinfo_endpoint`,
|
|
value: publicString(configPublic, ['userInfoEndpoint', 'userinfoEndpoint']),
|
|
allowedHosts: ['weixin.qq.com'],
|
|
details,
|
|
});
|
|
validateProviderUrl({
|
|
id: `db.auth.${safeProviderName(row.provider)}.redirect_uri`,
|
|
value: publicString(configPublic, ['redirectUri', 'callbackUrl']),
|
|
details,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (providerIn(provider, AUTH_PROVIDER_ALIASES.qq)) {
|
|
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
|
{ label: 'appId/clientId', keys: ['appId', 'clientId'] },
|
|
{ label: 'redirectUri/callbackUrl', keys: ['redirectUri', 'callbackUrl'] },
|
|
]));
|
|
validateProviderUrl({
|
|
id: `db.auth.${safeProviderName(row.provider)}.redirect_uri`,
|
|
value: publicString(configPublic, ['redirectUri', 'callbackUrl']),
|
|
required: true,
|
|
details,
|
|
});
|
|
validateProviderUrl({
|
|
id: `db.auth.${safeProviderName(row.provider)}.token_endpoint`,
|
|
value: publicString(configPublic, ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint']),
|
|
allowedHosts: ['graph.qq.com'],
|
|
details,
|
|
});
|
|
validateProviderUrl({
|
|
id: `db.auth.${safeProviderName(row.provider)}.openid_endpoint`,
|
|
value: publicString(configPublic, ['openIdEndpoint', 'openidEndpoint']),
|
|
allowedHosts: ['graph.qq.com'],
|
|
details,
|
|
});
|
|
validateProviderUrl({
|
|
id: `db.auth.${safeProviderName(row.provider)}.userinfo_endpoint`,
|
|
value: publicString(configPublic, ['userInfoEndpoint', 'userinfoEndpoint']),
|
|
allowedHosts: ['graph.qq.com'],
|
|
details,
|
|
});
|
|
return;
|
|
}
|
|
|
|
block('db.auth.unsupported_provider', 'Active/testing auth provider is not in the supported production provider list', details);
|
|
}
|
|
|
|
function validatePaymentProviderPublicConfig(row) {
|
|
const provider = normalizeProvider(row.provider);
|
|
const configPublic = row.config_public || {};
|
|
const details = { tenantId: row.tenant_id, provider: row.provider };
|
|
|
|
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.manual)) {
|
|
warn('db.payment.manual_provider', 'Manual payment account is active; use only for offline collection or migration operations', details);
|
|
return;
|
|
}
|
|
|
|
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.wechatPay)) {
|
|
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
|
{ label: 'appId', keys: ['appId'] },
|
|
{ label: 'merchantId/mchId', keys: ['merchantId', 'mchId'] },
|
|
{ label: 'merchantSerialNo', keys: ['merchantSerialNo'] },
|
|
{ label: 'notifyUrl', keys: ['notifyUrl'] },
|
|
]));
|
|
validateProviderUrl({
|
|
id: `db.payment.${safeProviderName(row.provider)}.notify_url`,
|
|
value: publicString(configPublic, ['notifyUrl']),
|
|
required: true,
|
|
details,
|
|
});
|
|
validateProviderUrl({
|
|
id: `db.payment.${safeProviderName(row.provider)}.refund_notify_url`,
|
|
value: publicString(configPublic, ['refundNotifyUrl']),
|
|
details,
|
|
});
|
|
for (const [key, allowedHosts] of [
|
|
['endpoint', ['api.mch.weixin.qq.com']],
|
|
['refundEndpoint', ['api.mch.weixin.qq.com']],
|
|
['refundQueryEndpoint', ['api.mch.weixin.qq.com']],
|
|
]) {
|
|
validateProviderUrl({
|
|
id: `db.payment.${safeProviderName(row.provider)}.${key}`,
|
|
value: publicString(configPublic, [key]),
|
|
allowedHosts,
|
|
details,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.alipay)) {
|
|
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
|
{ label: 'appId', keys: ['appId'] },
|
|
{ label: 'notifyUrl', keys: ['notifyUrl'] },
|
|
]));
|
|
validateProviderUrl({
|
|
id: `db.payment.${safeProviderName(row.provider)}.notify_url`,
|
|
value: publicString(configPublic, ['notifyUrl']),
|
|
required: true,
|
|
details,
|
|
});
|
|
validateProviderUrl({
|
|
id: `db.payment.${safeProviderName(row.provider)}.return_url`,
|
|
value: publicString(configPublic, ['returnUrl']),
|
|
details,
|
|
});
|
|
validateProviderUrl({
|
|
id: `db.payment.${safeProviderName(row.provider)}.quit_url`,
|
|
value: publicString(configPublic, ['quitUrl']),
|
|
details,
|
|
});
|
|
for (const [key, allowedHosts] of [
|
|
['endpoint', ['openapi.alipay.com']],
|
|
['refundEndpoint', ['openapi.alipay.com']],
|
|
['refundQueryEndpoint', ['openapi.alipay.com']],
|
|
]) {
|
|
validateProviderUrl({
|
|
id: `db.payment.${safeProviderName(row.provider)}.${key}`,
|
|
value: publicString(configPublic, [key]),
|
|
allowedHosts,
|
|
details,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
block('db.payment.unsupported_provider', 'Active payment account is not in the supported production provider list', details);
|
|
}
|
|
|
|
function validateProviderConfigRows(inputRows) {
|
|
const rows = inputRows.map(row => ({
|
|
source: row.source,
|
|
tenant_id: row.tenant_id || row.tenantId || 'fixture-tenant',
|
|
provider: row.provider,
|
|
config_public: row.config_public || row.configPublic || {},
|
|
}));
|
|
if (rows.length === 0) {
|
|
warn('db.provider_fixture.empty', 'Provider config fixture did not contain any rows');
|
|
return;
|
|
}
|
|
validateActiveSmsProviderRows(rows, 'fixture');
|
|
for (const row of rows) {
|
|
const secretPaths = findSecretLikePaths(row.config_public || {});
|
|
if (secretPaths.length > 0) {
|
|
block(`db.${row.source}.${safeProviderName(row.provider)}.public_secret`, 'Provider public config contains secret-like keys', {
|
|
tenantId: row.tenant_id,
|
|
provider: row.provider,
|
|
secretPathCount: secretPaths.length,
|
|
});
|
|
}
|
|
if (row.source === 'auth') validateAuthProviderPublicConfig(row);
|
|
else if (row.source === 'payment') validatePaymentProviderPublicConfig(row);
|
|
else block('db.provider_fixture.source', 'Provider config fixture row source must be auth or payment', { source: row.source });
|
|
}
|
|
if (rows.every(row => findSecretLikePaths(row.config_public || {}).length === 0)) {
|
|
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
|
|
}
|
|
}
|
|
|
|
function tenantPublicUrlEntries(value, prefix = 'publicConfig') {
|
|
if (!value || typeof value !== 'object') return [];
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item, index) => tenantPublicUrlEntries(item, `${prefix}[${index}]`));
|
|
}
|
|
return Object.entries(value).flatMap(([key, child]) => {
|
|
const path = `${prefix}.${key}`;
|
|
const current = /(?:url|uri)$/i.test(key) && typeof child === 'string' && child.trim()
|
|
? [[path, child.trim()]]
|
|
: [];
|
|
return [...current, ...tenantPublicUrlEntries(child, path)];
|
|
});
|
|
}
|
|
|
|
function validateTenantConfigRows(inputRows, sourceLabel = 'fixture') {
|
|
const rows = inputRows.map(row => ({
|
|
tenantId: row.tenant_id || row.tenantId || 'fixture-tenant',
|
|
slug: row.slug || '',
|
|
name: row.name || '',
|
|
publicConfig: row.public_config || row.publicConfig || {},
|
|
brandingTheme: row.branding_theme || row.brandingTheme || {},
|
|
publishedTheme: row.published_theme || row.publishedTheme || {},
|
|
themeStatus: row.theme_status || row.themeStatus || null,
|
|
publishedAt: row.published_at || row.publishedAt || null,
|
|
}));
|
|
|
|
if (rows.length === 0) {
|
|
warn('db.tenant_config.empty', 'No active tenant configuration rows were found', { source: sourceLabel });
|
|
return;
|
|
}
|
|
|
|
const unsafeUrls = rows.flatMap(row => tenantPublicUrlEntries(row.publicConfig)
|
|
.filter(([, value]) => !isProductionHttpsUrl(value))
|
|
.map(([path, value]) => ({
|
|
tenantId: row.tenantId,
|
|
slug: row.slug,
|
|
path,
|
|
protocol: urlFromValue(value)?.protocol || 'invalid',
|
|
host: hostFromUrl(value) || 'invalid',
|
|
})));
|
|
|
|
if (unsafeUrls.length > 0) {
|
|
block('db.tenant_public_urls', 'Active tenant public URL values must use production HTTPS URLs', {
|
|
count: unsafeUrls.length,
|
|
source: sourceLabel,
|
|
samples: unsafeUrls.slice(0, 10),
|
|
});
|
|
} else {
|
|
pass('db.tenant_public_urls', 'Active tenant public URL values use production HTTPS URLs or are empty', {
|
|
source: sourceLabel,
|
|
});
|
|
}
|
|
|
|
const unpublishedThemeRows = rows.filter(row => {
|
|
const hasPublishedTheme = row.themeStatus === 'published'
|
|
&& Boolean(row.publishedAt)
|
|
&& Object.keys(row.publishedTheme).length > 0;
|
|
const hasBrandingFallback = Object.keys(row.brandingTheme).length > 0;
|
|
return !hasPublishedTheme && !hasBrandingFallback;
|
|
});
|
|
|
|
if (unpublishedThemeRows.length > 0) {
|
|
warn('db.tenant_theme_published', 'Some active tenants have no published theme or tenant branding theme; platform defaults will be used', {
|
|
count: unpublishedThemeRows.length,
|
|
source: sourceLabel,
|
|
samples: unpublishedThemeRows.slice(0, 10).map(row => ({
|
|
tenantId: row.tenantId,
|
|
slug: row.slug,
|
|
name: row.name,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.tenant_theme_published', 'Active tenants have a published or branding fallback theme', {
|
|
source: sourceLabel,
|
|
});
|
|
}
|
|
}
|
|
|
|
function validateEnvironmentSafetyMarker(inputRow, sourceLabel = 'database') {
|
|
if (!inputRow) {
|
|
pass(
|
|
'db.environment.destructive_tests_disabled',
|
|
'No destructive-test authorization marker is present',
|
|
{ source: sourceLabel, markerPresent: false },
|
|
);
|
|
return;
|
|
}
|
|
|
|
const environment = String(inputRow.environment || '').trim().toLowerCase();
|
|
const allowValue = inputRow.allow_destructive_tests ?? inputRow.allowDestructiveTests;
|
|
const details = {
|
|
source: sourceLabel,
|
|
markerPresent: true,
|
|
environment: environment || 'invalid',
|
|
allowDestructiveTests: allowValue === true,
|
|
};
|
|
|
|
if (typeof allowValue !== 'boolean') {
|
|
block(
|
|
'db.environment.destructive_tests_disabled',
|
|
'Database destructive-test marker must contain a boolean allow_destructive_tests value',
|
|
details,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (allowValue || !['production', 'staging'].includes(environment)) {
|
|
block(
|
|
'db.environment.destructive_tests_disabled',
|
|
'Production readiness requires a production/staging marker with destructive tests disabled',
|
|
details,
|
|
);
|
|
return;
|
|
}
|
|
|
|
pass(
|
|
'db.environment.destructive_tests_disabled',
|
|
'Database is classified as production/staging with destructive tests disabled',
|
|
details,
|
|
);
|
|
}
|
|
|
|
function isSecretLikeKey(key) {
|
|
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
|
|
const allowedSecretRef = normalized === 'secretref' || normalized.endsWith('secretref');
|
|
return (
|
|
!allowedSecretRef &&
|
|
(normalized.includes('secret') ||
|
|
normalized.includes('password') ||
|
|
normalized.includes('token') ||
|
|
normalized.includes('privatekey') ||
|
|
normalized.includes('apikey') ||
|
|
normalized.includes('apiv3key') ||
|
|
normalized.includes('mchkey') ||
|
|
normalized.includes('signkey') ||
|
|
normalized.includes('aeskey') ||
|
|
normalized.includes('partnerkey'))
|
|
);
|
|
}
|
|
|
|
function findSecretLikePaths(value, prefix = 'configPublic') {
|
|
if (!value || typeof value !== 'object') return [];
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item, index) => findSecretLikePaths(item, `${prefix}[${index}]`));
|
|
}
|
|
const paths = [];
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const nextPath = `${prefix}.${key}`;
|
|
if (isSecretLikeKey(key)) paths.push(nextPath);
|
|
paths.push(...findSecretLikePaths(child, nextPath));
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function safeProviderName(provider) {
|
|
return String(provider || '').replace(/[^a-zA-Z0-9_.:-]/g, '_');
|
|
}
|
|
|
|
function formatTableName(row) {
|
|
return `${row.table_schema}.${row.table_name}`;
|
|
}
|
|
|
|
function validateRuntimeRoleEnvironment({ required = false } = {}) {
|
|
const expectedRole = env('DB_EXPECTED_RUNTIME_ROLE', '').trim();
|
|
if (!expectedRole) {
|
|
if (required) {
|
|
block(
|
|
'env.db_expected_runtime_role',
|
|
'DB_EXPECTED_RUNTIME_ROLE must be tiku_api or tiku_worker for database readiness',
|
|
);
|
|
} else {
|
|
warn(
|
|
'env.db_expected_runtime_role',
|
|
'DB_EXPECTED_RUNTIME_ROLE is not checked until readiness runs with --check-db',
|
|
);
|
|
}
|
|
return '';
|
|
}
|
|
if (!['tiku_api', 'tiku_worker'].includes(expectedRole)) {
|
|
block(
|
|
'env.db_expected_runtime_role',
|
|
'DB_EXPECTED_RUNTIME_ROLE must be tiku_api or tiku_worker',
|
|
{ expectedRole },
|
|
);
|
|
return expectedRole;
|
|
}
|
|
|
|
let databaseUser = '';
|
|
try {
|
|
databaseUser = decodeURIComponent(new URL(env('DATABASE_URL', DEFAULT_DATABASE_URL)).username || '');
|
|
} catch {
|
|
// DATABASE_URL has its own blocker; keep this check fail-closed as well.
|
|
}
|
|
if (databaseUser !== expectedRole) {
|
|
block(
|
|
'env.database_runtime_role',
|
|
'DATABASE_URL username must match DB_EXPECTED_RUNTIME_ROLE',
|
|
{ expectedRole, databaseUser: databaseUser || '(missing)' },
|
|
);
|
|
} else {
|
|
pass(
|
|
'env.database_runtime_role',
|
|
'DATABASE_URL username matches the expected runtime role',
|
|
{ expectedRole },
|
|
);
|
|
}
|
|
return expectedRole;
|
|
}
|
|
|
|
function validateEnv() {
|
|
const nodeEnv = env('NODE_ENV', 'development');
|
|
if (nodeEnv !== 'production') block('env.node_env', 'NODE_ENV must be production for production readiness checks');
|
|
else pass('env.node_env', 'NODE_ENV is production');
|
|
|
|
const databaseUrl = env('DATABASE_URL', DEFAULT_DATABASE_URL);
|
|
const databaseHost = hostFromUrl(databaseUrl);
|
|
if (!databaseUrl || databaseUrl === DEFAULT_DATABASE_URL) {
|
|
block('env.database_url', 'DATABASE_URL must point to the production Supabase/PostgreSQL database');
|
|
} else if (isLocalHost(databaseHost)) {
|
|
warn('env.database_url.localhost', 'DATABASE_URL points to a local host; confirm this is intentional for self-hosted deployment');
|
|
} else {
|
|
pass('env.database_url', 'DATABASE_URL is not the local development default');
|
|
}
|
|
|
|
const corsOrigins = envList('CORS_ORIGIN', '*');
|
|
if (corsOrigins.includes('*')) {
|
|
block('env.cors_origin', 'CORS_ORIGIN must not include * in production');
|
|
} else if (corsOrigins.length === 0) {
|
|
block('env.cors_origin.empty', 'CORS_ORIGIN must include central platform or operations HTTPS origins');
|
|
} else {
|
|
const unsafeOrigins = corsOrigins.filter(origin => {
|
|
const host = hostFromUrl(origin);
|
|
return !origin.startsWith('https://') || isLocalHost(host);
|
|
});
|
|
if (unsafeOrigins.length > 0) {
|
|
block('env.cors_origin.unsafe', 'CORS_ORIGIN must use production HTTPS origins only', { count: unsafeOrigins.length });
|
|
} else {
|
|
pass('env.cors_origin', 'CORS_ORIGIN is restricted to HTTPS origins', { count: corsOrigins.length });
|
|
}
|
|
}
|
|
|
|
if (!envBool('CORS_TENANT_DOMAINS_ENABLED', false)) {
|
|
block(
|
|
'env.cors_tenant_domains_enabled',
|
|
'CORS_TENANT_DOMAINS_ENABLED must be true for production custom tenant domains',
|
|
);
|
|
} else {
|
|
pass('env.cors_tenant_domains_enabled', 'Dynamic active tenant-domain CORS validation is enabled');
|
|
}
|
|
|
|
const corsCacheNumbers = [
|
|
['CORS_TENANT_DOMAIN_CACHE_TTL_MS', 60_000, 1_000, 600_000],
|
|
['CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS', 10_000, 1_000, 300_000],
|
|
['CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES', 10_000, 100, 100_000],
|
|
];
|
|
for (const [key, fallback, min, max] of corsCacheNumbers) {
|
|
const value = envNumber(key, fallback);
|
|
if (!Number.isFinite(value) || value < min || value > max) {
|
|
block(`env.${key.toLowerCase()}`, `${key} must be between ${min} and ${max}`);
|
|
} else {
|
|
pass(`env.${key.toLowerCase()}`, `${key} is within the production safety range`, { value });
|
|
}
|
|
}
|
|
|
|
validateRuntimeRoleEnvironment({ required: !skipDb });
|
|
|
|
const authSmsProvider = normalizeSmsProvider(env('AUTH_SMS_PROVIDER', 'mock'));
|
|
if (!PRODUCTION_SMS_PROVIDERS.has(authSmsProvider)) {
|
|
block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER must be aliyun-pnvs in production', {
|
|
provider: authSmsProvider || '(empty)',
|
|
});
|
|
} else {
|
|
pass('env.auth_sms_provider', 'AUTH_SMS_PROVIDER is aliyun-pnvs for production SMS authentication', { provider: authSmsProvider });
|
|
}
|
|
|
|
if (isUnsafeSecret(env('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER), DEFAULT_AUTH_CODE_PEPPER)) {
|
|
block('env.auth_code_pepper', 'AUTH_CODE_PEPPER must be a strong production secret');
|
|
} else {
|
|
pass('env.auth_code_pepper', 'AUTH_CODE_PEPPER looks production-grade');
|
|
}
|
|
|
|
if (isUnsafeSecret(env('AUTH_SESSION_SECRET', DEFAULT_AUTH_SESSION_SECRET), DEFAULT_AUTH_SESSION_SECRET)) {
|
|
block('env.auth_session_secret', 'AUTH_SESSION_SECRET must be a strong production secret');
|
|
} else {
|
|
pass('env.auth_session_secret', 'AUTH_SESSION_SECRET looks production-grade');
|
|
}
|
|
|
|
const jwksUrl = env('AUTH_JWT_JWKS_URL', '');
|
|
const jwtSecret = env('AUTH_JWT_SECRET', DEFAULT_AUTH_JWT_SECRET);
|
|
if (jwksUrl) {
|
|
const host = hostFromUrl(jwksUrl);
|
|
if (!jwksUrl.startsWith('https://') || isLocalHost(host)) {
|
|
block('env.auth_jwt_jwks_url', 'AUTH_JWT_JWKS_URL must be an HTTPS production URL');
|
|
} else {
|
|
pass('env.auth_jwt_jwks_url', 'AUTH_JWT_JWKS_URL is configured');
|
|
}
|
|
if (!env('AUTH_JWT_ISSUER', '').trim()) {
|
|
block('env.auth_jwt_issuer', 'AUTH_JWT_ISSUER is required when AUTH_JWT_JWKS_URL is configured');
|
|
} else {
|
|
pass('env.auth_jwt_issuer', 'AUTH_JWT_ISSUER is configured for JWKS verification');
|
|
}
|
|
} else if (isUnsafeSecret(jwtSecret, DEFAULT_AUTH_JWT_SECRET)) {
|
|
block('env.auth_jwt_secret', 'AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification');
|
|
} else {
|
|
pass('env.auth_jwt_secret', 'AUTH_JWT_SECRET looks production-grade');
|
|
}
|
|
|
|
if (envBool('ALLOW_LEGACY_AUTH_HEADERS', false)) {
|
|
block('env.allow_legacy_auth_headers', 'ALLOW_LEGACY_AUTH_HEADERS must be false in production');
|
|
} else {
|
|
pass('env.allow_legacy_auth_headers', 'legacy x-user-id auth headers are disabled');
|
|
}
|
|
|
|
if (envBool('ALLOW_PLATFORM_ADMIN_KEY', false)) {
|
|
block('env.allow_platform_admin_key', 'ALLOW_PLATFORM_ADMIN_KEY must be false in production');
|
|
} else {
|
|
pass('env.allow_platform_admin_key', 'platform admin API key compatibility is disabled');
|
|
}
|
|
|
|
if (isUnsafeSecret(env('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY), DEFAULT_PLATFORM_ADMIN_API_KEY)) {
|
|
warn('env.platform_admin_api_key', 'PLATFORM_ADMIN_API_KEY is weak/default; keep ALLOW_PLATFORM_ADMIN_KEY=false and rotate before any temporary use');
|
|
} else {
|
|
pass('env.platform_admin_api_key', 'PLATFORM_ADMIN_API_KEY is not default');
|
|
}
|
|
|
|
const maxJson = envNumber('MAX_JSON_BODY_BYTES', 1024 * 1024);
|
|
const maxImportJson = envNumber('MAX_IMPORT_JSON_BODY_BYTES', 10 * 1024 * 1024);
|
|
if (maxJson > HARD_MAX_JSON_BODY_BYTES || maxImportJson > HARD_MAX_JSON_BODY_BYTES) {
|
|
block('env.body_size', 'JSON body limits must stay at or below 50MB');
|
|
} else {
|
|
pass('env.body_size', 'JSON body limits are bounded');
|
|
}
|
|
|
|
const storageProvider = env('STORAGE_DEFAULT_PROVIDER', 'local_dev').trim();
|
|
if (storageProvider === 'local_dev') {
|
|
block('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER=local_dev is not allowed for production assets');
|
|
} else if (!PRODUCTION_STORAGE_PROVIDERS.has(storageProvider)) {
|
|
block('env.storage_provider.unsupported', 'STORAGE_DEFAULT_PROVIDER must be aliyun_oss, tencent_cos or supabase_storage in production', {
|
|
provider: storageProvider || '(empty)',
|
|
});
|
|
} else {
|
|
pass('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER is production-capable', { provider: storageProvider });
|
|
}
|
|
if (!env('STORAGE_DEFAULT_BUCKET', '')) {
|
|
block('env.storage_bucket', 'STORAGE_DEFAULT_BUCKET is required');
|
|
} else {
|
|
pass('env.storage_bucket', 'STORAGE_DEFAULT_BUCKET is configured');
|
|
}
|
|
if (!envBool('STORAGE_REQUIRE_TENANT_PREFIX', true)) {
|
|
block('env.storage_tenant_prefix', 'STORAGE_REQUIRE_TENANT_PREFIX must remain true to protect tenant assets');
|
|
} else {
|
|
pass('env.storage_tenant_prefix', 'tenant-prefixed object keys are required');
|
|
}
|
|
if (env('STORAGE_PUBLIC_BASE_URL', '') && !isProductionHttpsUrl(env('STORAGE_PUBLIC_BASE_URL', ''))) {
|
|
block('env.storage_public_base_url', 'STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL when configured');
|
|
}
|
|
|
|
if (storageProvider === 'aliyun_oss') {
|
|
for (const key of ['ALIYUN_OSS_REGION', 'ALIYUN_OSS_ENDPOINT', 'ALIYUN_OSS_ACCESS_KEY_ID', 'ALIYUN_OSS_ACCESS_KEY_SECRET']) {
|
|
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for aliyun_oss`);
|
|
}
|
|
validateProviderUrl({
|
|
id: 'env.aliyun_oss_endpoint',
|
|
value: env('ALIYUN_OSS_ENDPOINT', ''),
|
|
message: 'ALIYUN_OSS_ENDPOINT must be a production HTTPS endpoint',
|
|
allowedHosts: ['aliyuncs.com'],
|
|
required: true,
|
|
});
|
|
if (envBool('ALIYUN_OSS_INTERNAL', false)) {
|
|
block('env.aliyun_oss_internal', 'ALIYUN_OSS_INTERNAL=true is not allowed for user-facing production signed URLs');
|
|
}
|
|
}
|
|
if (storageProvider === 'tencent_cos') {
|
|
for (const key of ['TENCENT_COS_REGION', 'TENCENT_COS_APP_ID', 'TENCENT_COS_SECRET_ID', 'TENCENT_COS_SECRET_KEY']) {
|
|
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for tencent_cos`);
|
|
}
|
|
}
|
|
if (storageProvider === 'supabase_storage') {
|
|
for (const key of ['SUPABASE_STORAGE_URL', 'SUPABASE_STORAGE_SERVICE_KEY']) {
|
|
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for supabase_storage`);
|
|
}
|
|
validateProviderUrl({
|
|
id: 'env.supabase_storage_url',
|
|
value: env('SUPABASE_STORAGE_URL', ''),
|
|
message: 'SUPABASE_STORAGE_URL must be a production HTTPS URL',
|
|
required: true,
|
|
});
|
|
}
|
|
|
|
if (envList('STORAGE_ALLOWED_MIME_TYPES').includes('application/octet-stream')) {
|
|
warn('env.storage_octet_stream', 'application/octet-stream is allowed; consider removing it after import migration is stable');
|
|
}
|
|
|
|
const scannerModes = envList('WORKER_ASSET_SECURITY_SCANNER', 'metadata_rules').map(item => item.toLowerCase());
|
|
const unsupportedScannerModes = scannerModes.filter(mode => mode !== 'metadata_rules' && mode !== 'http');
|
|
if (scannerModes.length === 0) {
|
|
block('env.asset_security_scanner.empty', 'WORKER_ASSET_SECURITY_SCANNER must include metadata_rules and a production scanner');
|
|
} else if (unsupportedScannerModes.length > 0) {
|
|
block('env.asset_security_scanner.unsupported', 'WORKER_ASSET_SECURITY_SCANNER contains unsupported modes', {
|
|
modes: unsupportedScannerModes,
|
|
});
|
|
} else if (!scannerModes.includes('http')) {
|
|
block('env.asset_security_scanner.external', 'Production assets require WORKER_ASSET_SECURITY_SCANNER=http or metadata_rules,http');
|
|
} else {
|
|
pass('env.asset_security_scanner', 'external asset security scanner is enabled', { modes: scannerModes });
|
|
}
|
|
|
|
const scannerEndpoint = env('WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT', '');
|
|
if (scannerModes.includes('http')) {
|
|
const scannerHost = hostFromUrl(scannerEndpoint);
|
|
if (!scannerEndpoint) {
|
|
block('env.asset_security_scan_http_endpoint', 'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT is required when http scanner is enabled');
|
|
} else if (!scannerEndpoint.startsWith('https://') || isLocalHost(scannerHost)) {
|
|
block('env.asset_security_scan_http_endpoint.unsafe', 'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT must be a production HTTPS URL');
|
|
} else {
|
|
pass('env.asset_security_scan_http_endpoint', 'asset security scanner endpoint is HTTPS');
|
|
}
|
|
|
|
if (isUnsafeSecret(env('WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN', ''), '')) {
|
|
block('env.asset_security_scan_http_token', 'WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN must be a strong shared secret or service token');
|
|
} else {
|
|
pass('env.asset_security_scan_http_token', 'asset security scanner token looks production-grade');
|
|
}
|
|
}
|
|
|
|
const scannerTimeout = envNumber('WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS', 10_000);
|
|
if (scannerTimeout <= 0 || scannerTimeout > 60_000) {
|
|
block('env.asset_security_scan_http_timeout', 'WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS must be between 1 and 60000');
|
|
} else {
|
|
pass('env.asset_security_scan_http_timeout', 'asset security scanner timeout is bounded');
|
|
}
|
|
|
|
if (envBool('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN', false)) {
|
|
block('env.asset_security_scan_fail_open', 'WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN must be false in production');
|
|
} else {
|
|
pass('env.asset_security_scan_fail_open', 'asset security scanning fails closed');
|
|
}
|
|
|
|
if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) {
|
|
block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production');
|
|
} else {
|
|
pass('env.worker_crm_insecure_localhost', 'CRM worker insecure localhost webhook mode is disabled');
|
|
}
|
|
|
|
if (envBool('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false)) {
|
|
block('env.worker_platform_audit_notification_insecure_localhost', 'WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST must be false in production');
|
|
} else {
|
|
pass('env.worker_platform_audit_notification_insecure_localhost', 'Platform audit notification worker insecure localhost webhook mode is disabled');
|
|
}
|
|
|
|
if (envBool('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false)) {
|
|
block('env.worker_platform_dunning_notification_insecure_localhost', 'WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST must be false in production');
|
|
} else {
|
|
pass('env.worker_platform_dunning_notification_insecure_localhost', 'Platform dunning notification worker insecure localhost webhook mode is disabled');
|
|
}
|
|
|
|
const requiredPositiveNumbers = [
|
|
'WORKER_CRM_BATCH_SIZE',
|
|
'WORKER_COMMERCE_BATCH_SIZE',
|
|
'WORKER_ASSET_BATCH_SIZE',
|
|
'WORKER_IMPORT_BATCH_SIZE',
|
|
'WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE',
|
|
];
|
|
for (const key of requiredPositiveNumbers) {
|
|
if (envNumber(key, 1) <= 0) block(`env.${key.toLowerCase()}`, `${key} must be greater than 0`);
|
|
}
|
|
|
|
const pollIntervals = {
|
|
WORKER_CRM_POLL_INTERVAL_MS: 10_000,
|
|
WORKER_COMMERCE_POLL_INTERVAL_MS: 30_000,
|
|
WORKER_PROVIDER_BILL_POLL_INTERVAL_MS: 60_000,
|
|
WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS: 30_000,
|
|
WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS: 30_000,
|
|
WORKER_ASSET_POLL_INTERVAL_MS: 30_000,
|
|
WORKER_IMPORT_POLL_INTERVAL_MS: 10_000,
|
|
WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS: 60_000,
|
|
WORKER_EXPORT_POLL_INTERVAL_MS: 10_000,
|
|
};
|
|
for (const [key, fallback] of Object.entries(pollIntervals)) {
|
|
const value = envNumber(key, fallback);
|
|
if (!Number.isFinite(value) || value < 1_000 || value > 3_600_000) {
|
|
block(`env.${key.toLowerCase()}`, `${key} must be between 1000 and 3600000`);
|
|
}
|
|
}
|
|
|
|
const importLeaseSeconds = envNumber('WORKER_IMPORT_LEASE_SECONDS', 120);
|
|
const importHeartbeatIntervalMs = envNumber('WORKER_IMPORT_HEARTBEAT_INTERVAL_MS', 30_000);
|
|
if (!Number.isFinite(importLeaseSeconds) || importLeaseSeconds < 10 || importLeaseSeconds > 86_400) {
|
|
block('env.worker_import_lease_seconds', 'WORKER_IMPORT_LEASE_SECONDS must be between 10 and 86400');
|
|
}
|
|
if (
|
|
!Number.isFinite(importHeartbeatIntervalMs)
|
|
|| importHeartbeatIntervalMs < 1_000
|
|
|| importHeartbeatIntervalMs >= importLeaseSeconds * 500
|
|
) {
|
|
block(
|
|
'env.worker_import_heartbeat_interval_ms',
|
|
'WORKER_IMPORT_HEARTBEAT_INTERVAL_MS must be at least 1000 and less than half the import lease duration',
|
|
);
|
|
}
|
|
}
|
|
|
|
async function validateDatabase() {
|
|
if (skipDb) {
|
|
warn('db.skipped', 'Database readiness checks skipped; run with --check-db after production DATABASE_URL is configured');
|
|
return;
|
|
}
|
|
|
|
const pool = new pg.Pool({ connectionString: env('DATABASE_URL', DEFAULT_DATABASE_URL), max: 2 });
|
|
try {
|
|
const expectedRuntimeRole = env('DB_EXPECTED_RUNTIME_ROLE', '').trim();
|
|
const runtimeIdentityRows = await pool.query(`
|
|
select current_user,
|
|
session_user,
|
|
current_database() as database_name
|
|
`);
|
|
const runtimeIdentity = runtimeIdentityRows.rows[0] || {};
|
|
if (!['tiku_api', 'tiku_worker'].includes(expectedRuntimeRole)
|
|
|| runtimeIdentity.current_user !== expectedRuntimeRole
|
|
|| runtimeIdentity.session_user !== expectedRuntimeRole) {
|
|
block(
|
|
'db.runtime_role.identity',
|
|
'Database readiness must connect directly as DB_EXPECTED_RUNTIME_ROLE without SET ROLE indirection',
|
|
{
|
|
expectedRole: expectedRuntimeRole || '(missing)',
|
|
currentUser: runtimeIdentity.current_user || '(missing)',
|
|
sessionUser: runtimeIdentity.session_user || '(missing)',
|
|
database: runtimeIdentity.database_name || '(missing)',
|
|
},
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.runtime_role.identity',
|
|
'Database connection uses the expected dedicated runtime role',
|
|
{ role: expectedRuntimeRole, database: runtimeIdentity.database_name },
|
|
);
|
|
}
|
|
|
|
const runtimeRoleRows = await pool.query(`
|
|
select r.rolname,
|
|
r.rolsuper,
|
|
r.rolinherit,
|
|
r.rolcreaterole,
|
|
r.rolcreatedb,
|
|
r.rolcanlogin,
|
|
r.rolreplication,
|
|
r.rolbypassrls,
|
|
r.rolconfig,
|
|
exists (
|
|
select 1
|
|
from pg_auth_members membership
|
|
where membership.member = r.oid
|
|
) as has_parent_roles
|
|
from pg_roles r
|
|
where r.rolname = any(array['tiku_api', 'tiku_worker']::name[])
|
|
order by r.rolname
|
|
`);
|
|
const runtimeRoleByName = new Map(runtimeRoleRows.rows.map(row => [row.rolname, row]));
|
|
const unsafeRuntimeRoles = ['tiku_api', 'tiku_worker'].flatMap(roleName => {
|
|
const row = runtimeRoleByName.get(roleName);
|
|
if (!row) return [{ role: roleName, issue: 'missing' }];
|
|
const issues = [];
|
|
if (row.rolsuper) issues.push('SUPERUSER');
|
|
if (row.rolinherit) issues.push('INHERIT');
|
|
if (row.rolcreaterole) issues.push('CREATEROLE');
|
|
if (row.rolcreatedb) issues.push('CREATEDB');
|
|
if (!row.rolcanlogin) issues.push('NOLOGIN');
|
|
if (row.rolreplication) issues.push('REPLICATION');
|
|
if (!row.rolbypassrls) issues.push('missing intentional BYPASSRLS');
|
|
if (row.has_parent_roles) issues.push('role membership');
|
|
const roleConfig = Array.isArray(row.rolconfig) ? row.rolconfig.map(String) : [];
|
|
if (!roleConfig.includes('search_path=pg_catalog, public, extensions')) issues.push('unsafe search_path');
|
|
return issues.map(issue => ({ role: roleName, issue }));
|
|
});
|
|
if (unsafeRuntimeRoles.length > 0) {
|
|
block(
|
|
'db.runtime_role.attributes',
|
|
'Runtime roles must be LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS with no memberships',
|
|
{ issues: unsafeRuntimeRoles },
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.runtime_role.attributes',
|
|
'Dedicated API and worker roles have the required constrained attributes',
|
|
);
|
|
}
|
|
|
|
const runtimeSchemaPrivilegeRows = await pool.query(`
|
|
select r.rolname,
|
|
has_schema_privilege(r.oid, 'public', 'USAGE') as public_usage,
|
|
has_schema_privilege(r.oid, 'public', 'CREATE') as public_create,
|
|
has_schema_privilege(r.oid, 'app', 'USAGE') as app_usage,
|
|
has_schema_privilege(r.oid, 'app', 'CREATE') as app_create,
|
|
has_schema_privilege(r.oid, 'app_private', 'USAGE') as private_usage,
|
|
has_schema_privilege(r.oid, 'app_private', 'CREATE') as private_create,
|
|
has_schema_privilege(r.oid, 'extensions', 'USAGE') as extensions_usage,
|
|
has_schema_privilege(r.oid, 'extensions', 'CREATE') as extensions_create
|
|
from pg_roles r
|
|
where r.rolname = any(array['tiku_api', 'tiku_worker']::name[])
|
|
order by r.rolname
|
|
`);
|
|
const unsafeSchemaPrivileges = runtimeSchemaPrivilegeRows.rows.filter(row => (
|
|
!row.public_usage
|
|
|| !row.private_usage
|
|
|| !row.extensions_usage
|
|
|| row.public_create
|
|
|| row.app_create
|
|
|| row.private_create
|
|
|| row.extensions_create
|
|
|| (row.rolname === 'tiku_api' && !row.app_usage)
|
|
|| (row.rolname === 'tiku_worker' && row.app_usage)
|
|
));
|
|
if (runtimeSchemaPrivilegeRows.rowCount !== 2 || unsafeSchemaPrivileges.length > 0) {
|
|
block(
|
|
'db.runtime_role.schema_acl',
|
|
'Runtime roles must have only the reviewed schema USAGE privileges and no effective CREATE privilege',
|
|
{ roles: runtimeSchemaPrivilegeRows.rows },
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.runtime_role.schema_acl',
|
|
'Runtime roles cannot create persistent objects in application schemas',
|
|
);
|
|
}
|
|
|
|
const requiredExtensionRows = await pool.query(`
|
|
select extension.extname,
|
|
namespace.nspname as schema_name,
|
|
count(procedure_row.oid)::integer as function_count,
|
|
count(procedure_row.oid) filter (
|
|
where has_function_privilege('anon', procedure_row.oid, 'EXECUTE')
|
|
)::integer as anon_execute_count,
|
|
count(procedure_row.oid) filter (
|
|
where has_function_privilege('authenticated', procedure_row.oid, 'EXECUTE')
|
|
)::integer as authenticated_execute_count,
|
|
count(procedure_row.oid) filter (
|
|
where has_function_privilege('tiku_api', procedure_row.oid, 'EXECUTE')
|
|
)::integer as api_execute_count,
|
|
count(procedure_row.oid) filter (
|
|
where has_function_privilege('tiku_worker', procedure_row.oid, 'EXECUTE')
|
|
)::integer as worker_execute_count
|
|
from pg_extension extension
|
|
join pg_namespace namespace on namespace.oid = extension.extnamespace
|
|
left join pg_depend dependency
|
|
on dependency.refclassid = 'pg_extension'::regclass
|
|
and dependency.refobjid = extension.oid
|
|
and dependency.classid = 'pg_proc'::regclass
|
|
and dependency.deptype = 'e'
|
|
left join pg_proc procedure_row on procedure_row.oid = dependency.objid
|
|
where extension.extname = any(array['pgcrypto', 'citext', 'ltree', 'pg_trgm']::name[])
|
|
group by extension.extname, namespace.nspname
|
|
order by extension.extname
|
|
`);
|
|
const requiredExtensionNames = new Set(['pgcrypto', 'citext', 'ltree', 'pg_trgm']);
|
|
const unsafeExtensions = requiredExtensionRows.rows.flatMap(row => {
|
|
const issues = [];
|
|
if (row.schema_name !== 'extensions') issues.push(`schema=${row.schema_name}`);
|
|
const functionCount = Number(row.function_count);
|
|
const backendExecuteCount = row.extname === 'pgcrypto' ? 0 : functionCount;
|
|
if (functionCount <= 0) issues.push('no extension functions found');
|
|
if (Number(row.anon_execute_count) !== 0) issues.push(`anon EXECUTE=${row.anon_execute_count}`);
|
|
if (Number(row.authenticated_execute_count) !== 0) issues.push(`authenticated EXECUTE=${row.authenticated_execute_count}`);
|
|
if (Number(row.api_execute_count) !== backendExecuteCount) issues.push(`tiku_api EXECUTE=${row.api_execute_count}/${backendExecuteCount}`);
|
|
if (Number(row.worker_execute_count) !== backendExecuteCount) issues.push(`tiku_worker EXECUTE=${row.worker_execute_count}/${backendExecuteCount}`);
|
|
return issues.map(issue => ({ extension: row.extname, issue }));
|
|
});
|
|
const observedExtensionNames = new Set(requiredExtensionRows.rows.map(row => row.extname));
|
|
const missingExtensions = [...requiredExtensionNames].filter(name => !observedExtensionNames.has(name));
|
|
if (missingExtensions.length > 0 || unsafeExtensions.length > 0) {
|
|
block(
|
|
'db.extensions.isolation',
|
|
'Required extensions must live outside public with reviewed client/backend function ACLs',
|
|
{ missingExtensions, issues: unsafeExtensions },
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.extensions.isolation',
|
|
'Required extensions are isolated in extensions with reviewed function ACLs',
|
|
);
|
|
}
|
|
|
|
const runtimeTablePrivilegeRows = await pool.query(`
|
|
select r.rolname,
|
|
n.nspname as table_schema,
|
|
c.relname as table_name,
|
|
has_table_privilege(r.oid, c.oid, 'SELECT') as can_select,
|
|
has_table_privilege(r.oid, c.oid, 'INSERT') as can_insert,
|
|
has_table_privilege(r.oid, c.oid, 'UPDATE') as can_update,
|
|
has_table_privilege(r.oid, c.oid, 'DELETE') as can_delete,
|
|
has_table_privilege(r.oid, c.oid, 'TRUNCATE') as can_truncate,
|
|
has_table_privilege(r.oid, c.oid, 'REFERENCES') as can_reference,
|
|
has_table_privilege(r.oid, c.oid, 'TRIGGER') as can_trigger
|
|
from pg_roles r
|
|
cross join pg_class c
|
|
join pg_namespace n on n.oid = c.relnamespace
|
|
where r.rolname = any(array['tiku_api', 'tiku_worker']::name[])
|
|
and n.nspname in ('public', 'app_private')
|
|
and c.relkind in ('r', 'p', 'v', 'm', 'f')
|
|
order by r.rolname, n.nspname, c.relname
|
|
`);
|
|
const apiPrivateWriteMatrix = new Map([
|
|
['auth_sessions', { insert: true, update: true, delete: false }],
|
|
['tenant_secrets', { insert: true, update: true, delete: false }],
|
|
['platform_secrets', { insert: true, update: true, delete: false }],
|
|
['sms_send_rate_limits', { insert: true, update: true, delete: true }],
|
|
]);
|
|
const requiredPrivateTables = new Set([
|
|
'auth_sessions',
|
|
'environment_safety',
|
|
'platform_secrets',
|
|
'sms_send_rate_limits',
|
|
'tenant_secrets',
|
|
]);
|
|
const observedPrivateTables = new Set(
|
|
runtimeTablePrivilegeRows.rows
|
|
.filter(row => row.rolname === 'tiku_api' && row.table_schema === 'app_private')
|
|
.map(row => row.table_name),
|
|
);
|
|
const missingPrivateTables = [...requiredPrivateTables]
|
|
.filter(tableName => !observedPrivateTables.has(tableName));
|
|
const unsafeTablePrivileges = runtimeTablePrivilegeRows.rows.flatMap(row => {
|
|
const issues = [];
|
|
if (!row.can_select) issues.push('missing SELECT');
|
|
if (row.can_truncate) issues.push('TRUNCATE');
|
|
if (row.can_reference) issues.push('REFERENCES');
|
|
if (row.can_trigger) issues.push('TRIGGER');
|
|
|
|
if (row.table_schema === 'public') {
|
|
if (!row.can_insert) issues.push('missing INSERT');
|
|
if (!row.can_update) issues.push('missing UPDATE');
|
|
if (!row.can_delete) issues.push('missing DELETE');
|
|
} else {
|
|
const expectedWrites = row.rolname === 'tiku_api'
|
|
? apiPrivateWriteMatrix.get(row.table_name) || { insert: false, update: false, delete: false }
|
|
: { insert: false, update: false, delete: false };
|
|
if (row.can_insert !== expectedWrites.insert) issues.push(`INSERT=${row.can_insert}`);
|
|
if (row.can_update !== expectedWrites.update) issues.push(`UPDATE=${row.can_update}`);
|
|
if (row.can_delete !== expectedWrites.delete) issues.push(`DELETE=${row.can_delete}`);
|
|
}
|
|
|
|
return issues.map(issue => ({
|
|
role: row.rolname,
|
|
object: `${row.table_schema}.${row.table_name}`,
|
|
issue,
|
|
}));
|
|
});
|
|
if (missingPrivateTables.length > 0 || unsafeTablePrivileges.length > 0) {
|
|
block(
|
|
'db.runtime_role.table_acl',
|
|
'Runtime table privileges must match the API/worker least-privilege matrix',
|
|
{
|
|
checkedObjects: runtimeTablePrivilegeRows.rowCount,
|
|
missingPrivateTables,
|
|
issues: unsafeTablePrivileges.slice(0, 50),
|
|
},
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.runtime_role.table_acl',
|
|
'Runtime table privileges match the API/worker least-privilege matrix',
|
|
{ checkedObjects: runtimeTablePrivilegeRows.rowCount },
|
|
);
|
|
}
|
|
|
|
const runtimeFunctionPrivilegeRows = await pool.query(`
|
|
select r.rolname,
|
|
n.nspname as function_schema,
|
|
p.proname as function_name,
|
|
oidvectortypes(p.proargtypes) as argument_types
|
|
from pg_roles r
|
|
cross join pg_proc p
|
|
join pg_namespace n on n.oid = p.pronamespace
|
|
where r.rolname = any(array['tiku_api', 'tiku_worker']::name[])
|
|
and n.nspname in ('public', 'app', 'app_private')
|
|
and has_function_privilege(r.oid, p.oid, 'EXECUTE')
|
|
order by r.rolname, n.nspname, p.proname, argument_types
|
|
`);
|
|
const allowedRuntimeFunctions = new Map([
|
|
['tiku_api', new Set([
|
|
'app.auth_user_exists(uuid)',
|
|
'app.production_migration_history(text)',
|
|
'app.uuid_array_from_jsonb(jsonb)',
|
|
'app.public_question_bank_grant_allows(uuid[], uuid[], uuid, uuid[])',
|
|
'app.public_question_bank_subscription_allows(jsonb, jsonb, uuid, uuid, uuid[])',
|
|
])],
|
|
['tiku_worker', new Set()],
|
|
]);
|
|
const observedRuntimeFunctions = new Map([
|
|
['tiku_api', new Set()],
|
|
['tiku_worker', new Set()],
|
|
]);
|
|
const unexpectedRuntimeFunctions = [];
|
|
for (const row of runtimeFunctionPrivilegeRows.rows) {
|
|
const signature = `${row.function_schema}.${row.function_name}(${row.argument_types})`;
|
|
observedRuntimeFunctions.get(row.rolname)?.add(signature);
|
|
if (!allowedRuntimeFunctions.get(row.rolname)?.has(signature)) {
|
|
unexpectedRuntimeFunctions.push({ role: row.rolname, function: signature });
|
|
}
|
|
}
|
|
const missingRuntimeFunctions = [];
|
|
for (const [roleName, expectedFunctions] of allowedRuntimeFunctions) {
|
|
for (const signature of expectedFunctions) {
|
|
if (!observedRuntimeFunctions.get(roleName)?.has(signature)) {
|
|
missingRuntimeFunctions.push({ role: roleName, function: signature });
|
|
}
|
|
}
|
|
}
|
|
if (unexpectedRuntimeFunctions.length > 0 || missingRuntimeFunctions.length > 0) {
|
|
block(
|
|
'db.runtime_role.function_acl',
|
|
'Runtime function EXECUTE privileges must match the reviewed allowlist',
|
|
{ unexpectedRuntimeFunctions, missingRuntimeFunctions },
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.runtime_role.function_acl',
|
|
'Runtime function EXECUTE privileges match the reviewed allowlist',
|
|
{ checkedGrants: runtimeFunctionPrivilegeRows.rowCount },
|
|
);
|
|
}
|
|
|
|
const runtimeAuthPrivilegeRows = await pool.query(`
|
|
select r.rolname,
|
|
has_schema_privilege(r.oid, 'auth', 'USAGE') as auth_schema_usage,
|
|
has_table_privilege(r.oid, 'auth.users', 'SELECT') as auth_users_select,
|
|
has_function_privilege(r.oid, 'app.auth_user_exists(uuid)', 'EXECUTE') as auth_user_exists_execute
|
|
from pg_roles r
|
|
where r.rolname = any(array['tiku_api', 'tiku_worker']::name[])
|
|
order by r.rolname
|
|
`);
|
|
const unsafeAuthPrivileges = runtimeAuthPrivilegeRows.rows.filter(row => (
|
|
row.auth_schema_usage
|
|
|| row.auth_users_select
|
|
|| (row.rolname === 'tiku_api' && !row.auth_user_exists_execute)
|
|
|| (row.rolname === 'tiku_worker' && row.auth_user_exists_execute)
|
|
));
|
|
if (runtimeAuthPrivilegeRows.rowCount !== 2 || unsafeAuthPrivileges.length > 0) {
|
|
block(
|
|
'db.runtime_role.auth_acl',
|
|
'Runtime roles must not access auth.users directly; only tiku_api may execute the boolean existence boundary',
|
|
{ roles: runtimeAuthPrivilegeRows.rows },
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.runtime_role.auth_acl',
|
|
'Auth data remains private while tiku_api can validate an Auth UUID through the reviewed boolean boundary',
|
|
);
|
|
}
|
|
|
|
const runtimeOwnerRows = await pool.query(`
|
|
with owned_objects as (
|
|
select c.relowner as owner_oid
|
|
from pg_class c
|
|
join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname in ('public', 'app', 'app_private')
|
|
union all
|
|
select p.proowner
|
|
from pg_proc p
|
|
join pg_namespace n on n.oid = p.pronamespace
|
|
where n.nspname in ('public', 'app', 'app_private')
|
|
union all
|
|
select t.typowner
|
|
from pg_type t
|
|
join pg_namespace n on n.oid = t.typnamespace
|
|
where n.nspname in ('public', 'app', 'app_private')
|
|
union all
|
|
select n.nspowner
|
|
from pg_namespace n
|
|
where n.nspname in ('public', 'app', 'app_private')
|
|
union all
|
|
select d.datdba
|
|
from pg_database d
|
|
where d.datname = current_database()
|
|
)
|
|
select owner_role.rolname, count(*)::int as object_count
|
|
from owned_objects objects
|
|
join pg_roles owner_role on owner_role.oid = objects.owner_oid
|
|
where owner_role.rolname = any(array['tiku_api', 'tiku_worker']::name[])
|
|
group by owner_role.rolname
|
|
`);
|
|
if (runtimeOwnerRows.rowCount > 0) {
|
|
block(
|
|
'db.runtime_role.ownership',
|
|
'Runtime roles must not own the database, schemas, relations, functions or types',
|
|
{ owners: runtimeOwnerRows.rows },
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.runtime_role.ownership',
|
|
'Runtime roles own no database or application schema objects',
|
|
);
|
|
}
|
|
|
|
const runtimeDdlRows = await pool.query(`
|
|
select r.rolname,
|
|
has_database_privilege(r.oid, current_database(), 'CREATE') as database_create,
|
|
has_schema_privilege(r.oid, 'public', 'CREATE') as public_create,
|
|
has_schema_privilege(r.oid, 'app', 'CREATE') as app_create,
|
|
has_schema_privilege(r.oid, 'app_private', 'CREATE') as private_create,
|
|
has_schema_privilege(r.oid, 'extensions', 'CREATE') as extensions_create
|
|
from pg_roles r
|
|
where r.rolname = any(array['tiku_api', 'tiku_worker']::name[])
|
|
order by r.rolname
|
|
`);
|
|
const runtimeDdlAllowed = runtimeDdlRows.rows.filter(row => (
|
|
row.database_create || row.public_create || row.app_create || row.private_create || row.extensions_create
|
|
));
|
|
if (runtimeDdlRows.rowCount !== 2 || runtimeDdlAllowed.length > 0 || runtimeOwnerRows.rowCount > 0) {
|
|
block(
|
|
'db.runtime_role.ddl_denied',
|
|
'Runtime roles must have no effective persistent database/schema CREATE privilege or object ownership',
|
|
{ roles: runtimeDdlRows.rows, owners: runtimeOwnerRows.rows },
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.runtime_role.ddl_denied',
|
|
'Runtime roles are denied persistent DDL by effective privilege and ownership checks',
|
|
);
|
|
}
|
|
|
|
const environmentSafetyTable = await pool.query(`
|
|
select to_regclass('app_private.environment_safety')::text as table_name
|
|
`);
|
|
if (!environmentSafetyTable.rows[0]?.table_name) {
|
|
block(
|
|
'db.environment.destructive_tests_disabled',
|
|
'Database safety marker table is missing; apply all production migrations',
|
|
{ source: 'database', markerPresent: false, tablePresent: false },
|
|
);
|
|
} else {
|
|
const environmentSafetyRows = await pool.query(`
|
|
select environment,
|
|
allow_destructive_tests
|
|
from app_private.environment_safety
|
|
where id = true
|
|
limit 1
|
|
`);
|
|
validateEnvironmentSafetyMarker(environmentSafetyRows.rows[0] || null, 'database');
|
|
}
|
|
|
|
const repositoryMigrationState = loadRepositoryMigrationState();
|
|
const migrationHistoryBoundary = await pool.query(`
|
|
select to_regprocedure('app.production_migration_history(text)')::text as function_name
|
|
`);
|
|
if (!repositoryMigrationState.latestVersion) {
|
|
validateMigrationHistory(null, 'database');
|
|
} else if (!migrationHistoryBoundary.rows[0]?.function_name) {
|
|
block(
|
|
'db.migrations.current',
|
|
'Production migration history boundary is missing; apply the repository migrations',
|
|
{
|
|
source: 'database',
|
|
expectedVersion: repositoryMigrationState.latestVersion,
|
|
helperPresent: false,
|
|
},
|
|
);
|
|
} else {
|
|
try {
|
|
const migrationHistoryRows = await pool.query(`
|
|
select latest_version,
|
|
applied_count,
|
|
distinct_version_count,
|
|
expected_version_applied
|
|
from app.production_migration_history($1::text)
|
|
`, [repositoryMigrationState.latestVersion]);
|
|
validateMigrationHistory(migrationHistoryRows.rows[0] || null, 'database');
|
|
} catch (error) {
|
|
block(
|
|
'db.migrations.current',
|
|
'Production migration history boundary could not be executed',
|
|
{
|
|
source: 'database',
|
|
expectedVersion: repositoryMigrationState.latestVersion,
|
|
errorCode: error && typeof error === 'object' && 'code' in error ? error.code : '',
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
const tenantRows = await pool.query(`
|
|
select id, name, status
|
|
from public.tenants
|
|
where status = 'active'
|
|
order by created_at asc
|
|
`);
|
|
if (tenantRows.rowCount === 0) block('db.tenants', 'No active tenant exists in the production database');
|
|
else pass('db.tenants', 'Active tenants found', { count: tenantRows.rowCount });
|
|
|
|
const tenantConfigRows = await pool.query(`
|
|
select t.id as tenant_id, t.slug::text, t.name,
|
|
coalesce(s.public_config, '{}'::jsonb) as public_config,
|
|
coalesce(b.theme, '{}'::jsonb) as branding_theme,
|
|
coalesce(tc.active_theme, '{}'::jsonb) as published_theme,
|
|
tc.status as theme_status,
|
|
tc.published_at
|
|
from public.tenants t
|
|
left join public.tenant_settings s on s.tenant_id = t.id
|
|
left join public.tenant_branding b on b.tenant_id = t.id
|
|
left join public.tenant_theme_configs tc on tc.tenant_id = t.id
|
|
where t.status = 'active'
|
|
order by t.created_at asc
|
|
`);
|
|
validateTenantConfigRows(tenantConfigRows.rows, 'database');
|
|
|
|
const publicSecretRows = await pool.query(`
|
|
select source, tenant_id, provider, config_public
|
|
from (
|
|
select 'auth' as source, tenant_id, provider, config_public
|
|
from public.tenant_auth_providers
|
|
where status in ('active', 'testing')
|
|
union all
|
|
select 'payment' as source, tenant_id, provider, config_public
|
|
from public.tenant_payment_accounts
|
|
where status = 'active'
|
|
) providers
|
|
`);
|
|
for (const row of publicSecretRows.rows) {
|
|
const secretPaths = findSecretLikePaths(row.config_public || {});
|
|
if (secretPaths.length > 0) {
|
|
block(`db.${row.source}.${safeProviderName(row.provider)}.public_secret`, 'Provider public config contains secret-like keys', {
|
|
tenantId: row.tenant_id,
|
|
provider: row.provider,
|
|
secretPathCount: secretPaths.length,
|
|
});
|
|
}
|
|
if (row.source === 'auth') validateAuthProviderPublicConfig(row);
|
|
if (row.source === 'payment') validatePaymentProviderPublicConfig(row);
|
|
}
|
|
if (publicSecretRows.rows.every(row => findSecretLikePaths(row.config_public || {}).length === 0)) {
|
|
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
|
|
}
|
|
validateActiveSmsProviderRows(publicSecretRows.rows, 'database');
|
|
|
|
const activePlatformAdminRows = await pool.query(`
|
|
select id, username, phone, auth_user_id
|
|
from public.platform_users
|
|
where primary_role = 'platform_admin'
|
|
and status = 'active'
|
|
order by created_at asc
|
|
limit 20
|
|
`);
|
|
if (activePlatformAdminRows.rowCount === 0) {
|
|
block('db.platform_admin_active', 'At least one active platform admin user is required');
|
|
} else {
|
|
pass('db.platform_admin_active', 'Active platform admin users found', { count: activePlatformAdminRows.rowCount });
|
|
}
|
|
|
|
const activePlatformAdminsWithoutAuth = await pool.query(`
|
|
select id, username, phone
|
|
from public.platform_users
|
|
where primary_role = 'platform_admin'
|
|
and status = 'active'
|
|
and auth_user_id is null
|
|
order by created_at asc
|
|
limit 20
|
|
`);
|
|
if (activePlatformAdminsWithoutAuth.rowCount > 0) {
|
|
block('db.platform_admin_auth_binding', 'Active platform admin users must be bound to Supabase Auth users', {
|
|
count: activePlatformAdminsWithoutAuth.rowCount,
|
|
samples: activePlatformAdminsWithoutAuth.rows.map(row => ({
|
|
id: row.id,
|
|
username: row.username,
|
|
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.platform_admin_auth_binding', 'Active platform admin users are bound to Supabase Auth users');
|
|
}
|
|
|
|
const platformAdminsWithoutPermissions = await pool.query(`
|
|
select id, username, phone
|
|
from public.platform_users
|
|
where primary_role = 'platform_admin'
|
|
and status = 'active'
|
|
and (platform_permissions is null or platform_permissions = '{}'::jsonb)
|
|
order by created_at asc
|
|
limit 20
|
|
`);
|
|
if (platformAdminsWithoutPermissions.rowCount > 0) {
|
|
block('db.platform_admin_permissions', 'Platform admin users must have explicit platform_permissions', {
|
|
count: platformAdminsWithoutPermissions.rowCount,
|
|
samples: platformAdminsWithoutPermissions.rows.map(row => ({
|
|
id: row.id,
|
|
username: row.username,
|
|
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.platform_admin_permissions', 'Platform admin users have explicit permission maps');
|
|
}
|
|
|
|
const disabledPlatformAdminsWithActiveSessions = await pool.query(`
|
|
select u.id, u.username, u.phone, count(s.id)::int as active_session_count
|
|
from public.platform_users u
|
|
join app_private.auth_sessions s on s.user_id = u.id
|
|
where u.primary_role = 'platform_admin'
|
|
and u.status = 'disabled'
|
|
and s.revoked_at is null
|
|
and s.expires_at > now()
|
|
group by u.id, u.username, u.phone
|
|
order by active_session_count desc
|
|
limit 20
|
|
`);
|
|
if (disabledPlatformAdminsWithActiveSessions.rowCount > 0) {
|
|
block('db.platform_admin_disabled_sessions', 'Disabled platform admin users must not have active legacy sessions', {
|
|
count: disabledPlatformAdminsWithActiveSessions.rowCount,
|
|
samples: disabledPlatformAdminsWithActiveSessions.rows.map(row => ({
|
|
id: row.id,
|
|
username: row.username,
|
|
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
|
|
activeSessionCount: row.active_session_count,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.platform_admin_disabled_sessions', 'Disabled platform admin users have no active legacy sessions');
|
|
}
|
|
|
|
const missingAuthSecretRows = await pool.query(`
|
|
select p.tenant_id, p.provider
|
|
from public.tenant_auth_providers p
|
|
left join app_private.tenant_secrets s
|
|
on s.tenant_id = p.tenant_id
|
|
and s.secret_scope = case
|
|
when lower(replace(p.provider, '_', '-')) in ('aliyun', 'aliyun-sms', 'aliyun-pnvs', 'aliyun-pnvs-sms', 'aliyun-sms-auth', 'tencent', 'tencent-sms') then 'sms'
|
|
else 'oauth'
|
|
end
|
|
and s.secret_key = coalesce(nullif(split_part(p.config_public->>'secretRef', ':', 3), ''), p.provider)
|
|
where p.status in ('active', 'testing')
|
|
and p.provider not in ('mock')
|
|
and s.id is null
|
|
`);
|
|
if (missingAuthSecretRows.rowCount > 0) {
|
|
block('db.auth_provider_secrets', 'Some active auth providers are missing tenant secret rows', { count: missingAuthSecretRows.rowCount });
|
|
} else {
|
|
pass('db.auth_provider_secrets', 'Active auth providers have private secret rows');
|
|
}
|
|
|
|
const missingPaymentSecretRows = await pool.query(`
|
|
select p.tenant_id, p.provider
|
|
from public.tenant_payment_accounts p
|
|
left join app_private.tenant_secrets s
|
|
on s.tenant_id = p.tenant_id
|
|
and s.secret_scope = 'payment'
|
|
and s.secret_key = coalesce(nullif(split_part(p.config_public->>'secretRef', ':', 3), ''), p.provider)
|
|
where p.status = 'active'
|
|
and p.provider not in ('manual')
|
|
and s.id is null
|
|
`);
|
|
if (missingPaymentSecretRows.rowCount > 0) {
|
|
block('db.payment_provider_secrets', 'Some active payment accounts are missing tenant secret rows', { count: missingPaymentSecretRows.rowCount });
|
|
} else {
|
|
pass('db.payment_provider_secrets', 'Active payment accounts have private secret rows');
|
|
}
|
|
|
|
const unsafePlatformAuditNotificationRows = await pool.query(`
|
|
select id, channel_code, provider, webhook_url
|
|
from public.platform_audit_notification_channels
|
|
where enabled = true
|
|
and (
|
|
webhook_url !~* '^https://'
|
|
or webhook_url ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)'
|
|
)
|
|
`);
|
|
if (unsafePlatformAuditNotificationRows.rowCount > 0) {
|
|
block('db.platform_audit_notification_webhooks', 'Enabled platform audit notification webhooks must use production HTTPS URLs', {
|
|
count: unsafePlatformAuditNotificationRows.rowCount,
|
|
samples: unsafePlatformAuditNotificationRows.rows.slice(0, 5).map(row => ({
|
|
id: row.id,
|
|
channelCode: row.channel_code,
|
|
provider: row.provider,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.platform_audit_notification_webhooks', 'Enabled platform audit notification webhooks use production HTTPS URLs');
|
|
}
|
|
|
|
const missingPlatformAuditNotificationSecretRows = await pool.query(`
|
|
select c.id, c.channel_code, c.provider, c.secret_ref
|
|
from public.platform_audit_notification_channels c
|
|
left join app_private.platform_secrets s
|
|
on s.secret_scope = split_part(c.secret_ref, ':', 2)
|
|
and s.secret_key = split_part(c.secret_ref, ':', 3)
|
|
where c.enabled = true
|
|
and c.provider in ('dingtalk', 'feishu')
|
|
and (c.secret_ref is null or c.secret_ref !~ '^app_private\\.platform_secrets:' or s.id is null)
|
|
`);
|
|
if (missingPlatformAuditNotificationSecretRows.rowCount > 0) {
|
|
block('db.platform_audit_notification_secrets', 'Signed platform audit notification channels require app_private.platform_secrets rows', {
|
|
count: missingPlatformAuditNotificationSecretRows.rowCount,
|
|
samples: missingPlatformAuditNotificationSecretRows.rows.slice(0, 5).map(row => ({
|
|
id: row.id,
|
|
channelCode: row.channel_code,
|
|
provider: row.provider,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.platform_audit_notification_secrets', 'Signed platform audit notification channels have private secret rows');
|
|
}
|
|
|
|
const unsafePlatformDunningNotificationRows = await pool.query(`
|
|
select id, channel_code, provider, webhook_url
|
|
from public.platform_dunning_notification_channels
|
|
where enabled = true
|
|
and (
|
|
webhook_url !~* '^https://'
|
|
or webhook_url ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)'
|
|
)
|
|
`);
|
|
if (unsafePlatformDunningNotificationRows.rowCount > 0) {
|
|
block('db.platform_dunning_notification_webhooks', 'Enabled platform dunning notification webhooks must use production HTTPS URLs', {
|
|
count: unsafePlatformDunningNotificationRows.rowCount,
|
|
samples: unsafePlatformDunningNotificationRows.rows.slice(0, 5).map(row => ({
|
|
id: row.id,
|
|
channelCode: row.channel_code,
|
|
provider: row.provider,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.platform_dunning_notification_webhooks', 'Enabled platform dunning notification webhooks use production HTTPS URLs');
|
|
}
|
|
|
|
const missingPlatformDunningNotificationSecretRows = await pool.query(`
|
|
select c.id, c.channel_code, c.provider, c.secret_ref
|
|
from public.platform_dunning_notification_channels c
|
|
left join app_private.platform_secrets s
|
|
on s.secret_scope = split_part(c.secret_ref, ':', 2)
|
|
and s.secret_key = split_part(c.secret_ref, ':', 3)
|
|
where c.enabled = true
|
|
and c.provider in ('dingtalk', 'feishu')
|
|
and (c.secret_ref is null or c.secret_ref !~ '^app_private\\.platform_secrets:' or s.id is null)
|
|
`);
|
|
if (missingPlatformDunningNotificationSecretRows.rowCount > 0) {
|
|
block('db.platform_dunning_notification_secrets', 'Signed platform dunning notification channels require app_private.platform_secrets rows', {
|
|
count: missingPlatformDunningNotificationSecretRows.rowCount,
|
|
samples: missingPlatformDunningNotificationSecretRows.rows.slice(0, 5).map(row => ({
|
|
id: row.id,
|
|
channelCode: row.channel_code,
|
|
provider: row.provider,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.platform_dunning_notification_secrets', 'Signed platform dunning notification channels have private secret rows');
|
|
}
|
|
|
|
const unverifiedDomainRows = await pool.query(`
|
|
select count(*)::int as count
|
|
from public.tenant_domains
|
|
where status not in ('active', 'verified')
|
|
`);
|
|
const unverifiedDomains = Number(unverifiedDomainRows.rows[0]?.count || 0);
|
|
if (unverifiedDomains > 0) warn('db.tenant_domains', 'Some tenant domains are not active/verified', { count: unverifiedDomains });
|
|
else pass('db.tenant_domains', 'Tenant domains are active/verified or not configured');
|
|
|
|
const clientTablePrivilegeRows = await pool.query(`
|
|
select r.rolname as role_name,
|
|
n.nspname as table_schema,
|
|
c.relname as table_name,
|
|
privilege.privilege_type
|
|
from pg_class c
|
|
join pg_namespace n on n.oid = c.relnamespace
|
|
cross join (values ('anon'), ('authenticated')) as requested_roles(role_name)
|
|
join pg_roles r on r.rolname = requested_roles.role_name
|
|
cross join lateral (
|
|
values
|
|
('SELECT', has_table_privilege(r.oid, c.oid, 'SELECT')),
|
|
('INSERT', has_table_privilege(r.oid, c.oid, 'INSERT')),
|
|
('UPDATE', has_table_privilege(r.oid, c.oid, 'UPDATE')),
|
|
('DELETE', has_table_privilege(r.oid, c.oid, 'DELETE')),
|
|
('TRUNCATE', has_table_privilege(r.oid, c.oid, 'TRUNCATE')),
|
|
('REFERENCES', has_table_privilege(r.oid, c.oid, 'REFERENCES')),
|
|
('TRIGGER', has_table_privilege(r.oid, c.oid, 'TRIGGER'))
|
|
) as privilege(privilege_type, allowed)
|
|
where n.nspname = 'public'
|
|
and c.relkind in ('r', 'p', 'v', 'm', 'f')
|
|
and privilege.allowed
|
|
order by r.rolname, c.relname, privilege.privilege_type
|
|
`);
|
|
if (clientTablePrivilegeRows.rowCount > 0) {
|
|
block('db.data_api.public_table_acl', 'anon/authenticated must not have direct privileges on public business tables or views', {
|
|
count: clientTablePrivilegeRows.rowCount,
|
|
samples: clientTablePrivilegeRows.rows.slice(0, 20).map(row => ({
|
|
role: row.role_name,
|
|
object: `${row.table_schema}.${row.table_name}`,
|
|
privilege: row.privilege_type,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.data_api.public_table_acl', 'anon/authenticated have no direct privileges on public business tables or views');
|
|
}
|
|
|
|
const clientSequencePrivilegeRows = await pool.query(`
|
|
select r.rolname as role_name,
|
|
n.nspname as sequence_schema,
|
|
c.relname as sequence_name,
|
|
privilege.privilege_type
|
|
from pg_class c
|
|
join pg_namespace n on n.oid = c.relnamespace
|
|
cross join (values ('anon'), ('authenticated')) as requested_roles(role_name)
|
|
join pg_roles r on r.rolname = requested_roles.role_name
|
|
cross join lateral (
|
|
values
|
|
('USAGE', has_sequence_privilege(r.oid, c.oid, 'USAGE')),
|
|
('SELECT', has_sequence_privilege(r.oid, c.oid, 'SELECT')),
|
|
('UPDATE', has_sequence_privilege(r.oid, c.oid, 'UPDATE'))
|
|
) as privilege(privilege_type, allowed)
|
|
where n.nspname = 'public'
|
|
and c.relkind = 'S'
|
|
and privilege.allowed
|
|
order by r.rolname, c.relname, privilege.privilege_type
|
|
`);
|
|
if (clientSequencePrivilegeRows.rowCount > 0) {
|
|
block('db.data_api.public_sequence_acl', 'anon/authenticated must not have direct privileges on public sequences', {
|
|
count: clientSequencePrivilegeRows.rowCount,
|
|
samples: clientSequencePrivilegeRows.rows.slice(0, 20).map(row => ({
|
|
role: row.role_name,
|
|
object: `${row.sequence_schema}.${row.sequence_name}`,
|
|
privilege: row.privilege_type,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.data_api.public_sequence_acl', 'anon/authenticated have no direct privileges on public sequences');
|
|
}
|
|
|
|
const clientFunctionPrivilegeRows = await pool.query(`
|
|
select r.rolname as role_name,
|
|
n.nspname as function_schema,
|
|
p.proname as function_name,
|
|
pg_get_function_identity_arguments(p.oid) as arguments
|
|
from pg_proc p
|
|
join pg_namespace n on n.oid = p.pronamespace
|
|
cross join (values ('anon'), ('authenticated')) as requested_roles(role_name)
|
|
join pg_roles r on r.rolname = requested_roles.role_name
|
|
where n.nspname = 'public'
|
|
and has_function_privilege(r.oid, p.oid, 'EXECUTE')
|
|
order by r.rolname, p.proname, arguments
|
|
`);
|
|
if (clientFunctionPrivilegeRows.rowCount > 0) {
|
|
block('db.data_api.public_function_acl', 'anon/authenticated must not execute public RPC functions without an explicit reviewed exception', {
|
|
count: clientFunctionPrivilegeRows.rowCount,
|
|
samples: clientFunctionPrivilegeRows.rows.slice(0, 20).map(row => ({
|
|
role: row.role_name,
|
|
function: `${row.function_schema}.${row.function_name}(${row.arguments})`,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.data_api.public_function_acl', 'anon/authenticated cannot execute public RPC functions');
|
|
}
|
|
|
|
const unsafeDefaultAclRows = await pool.query(`
|
|
with public_object_owners as (
|
|
select distinct c.relowner as owner_oid
|
|
from pg_class c
|
|
join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'public'
|
|
and c.relkind in ('r', 'p', 'S', 'v', 'm', 'f')
|
|
union
|
|
select distinct p.proowner as owner_oid
|
|
from pg_proc p
|
|
join pg_namespace n on n.oid = p.pronamespace
|
|
where n.nspname = 'public'
|
|
), object_types as (
|
|
select 'r'::"char" as object_type, 'TABLES'::text as object_label
|
|
union all select 'S'::"char", 'SEQUENCES'::text
|
|
union all select 'f'::"char", 'FUNCTIONS'::text
|
|
), default_sources as (
|
|
select owners.owner_oid,
|
|
object_types.object_type,
|
|
object_types.object_label,
|
|
'GLOBAL'::text as default_scope,
|
|
coalesce(
|
|
(
|
|
select d.defaclacl
|
|
from pg_default_acl d
|
|
where d.defaclrole = owners.owner_oid
|
|
and d.defaclnamespace = 0
|
|
and d.defaclobjtype = object_types.object_type
|
|
),
|
|
acldefault(object_types.object_type, owners.owner_oid)
|
|
) as acl
|
|
from public_object_owners owners
|
|
cross join object_types
|
|
union all
|
|
select owners.owner_oid,
|
|
object_types.object_type,
|
|
object_types.object_label,
|
|
'PUBLIC_SCHEMA'::text as default_scope,
|
|
d.defaclacl as acl
|
|
from public_object_owners owners
|
|
cross join object_types
|
|
join pg_default_acl d
|
|
on d.defaclrole = owners.owner_oid
|
|
and d.defaclobjtype = object_types.object_type
|
|
join pg_namespace n
|
|
on n.oid = d.defaclnamespace
|
|
and n.nspname = 'public'
|
|
)
|
|
select owner.rolname as owner_name,
|
|
defaults.object_label as object_type,
|
|
defaults.default_scope,
|
|
coalesce(grantee.rolname, 'PUBLIC') as grantee_name,
|
|
acl.privilege_type
|
|
from default_sources defaults
|
|
join pg_roles owner on owner.oid = defaults.owner_oid
|
|
cross join lateral aclexplode(defaults.acl) acl
|
|
left join pg_roles grantee on grantee.oid = acl.grantee
|
|
where acl.grantee = 0
|
|
or grantee.rolname in ('anon', 'authenticated')
|
|
order by owner.rolname, defaults.object_label, grantee_name, acl.privilege_type
|
|
`);
|
|
if (unsafeDefaultAclRows.rowCount > 0) {
|
|
block('db.data_api.public_default_acl', 'public schema default privileges must not expose future tables, sequences or functions to client roles', {
|
|
count: unsafeDefaultAclRows.rowCount,
|
|
samples: unsafeDefaultAclRows.rows.slice(0, 20).map(row => ({
|
|
owner: row.owner_name,
|
|
objectType: row.object_type,
|
|
defaultScope: row.default_scope,
|
|
grantee: row.grantee_name || 'PUBLIC',
|
|
privilege: row.privilege_type,
|
|
})),
|
|
});
|
|
} else {
|
|
pass('db.data_api.public_default_acl', 'public schema default privileges are deny-by-default for client roles');
|
|
}
|
|
|
|
const platformUserWritePolicies = await pool.query(`
|
|
select p.polname as policy_name,
|
|
p.polcmd as policy_command,
|
|
array(
|
|
select coalesce(r.rolname, 'PUBLIC')
|
|
from unnest(p.polroles) policy_role(role_oid)
|
|
left join pg_roles r on r.oid = policy_role.role_oid
|
|
) as policy_roles
|
|
from pg_policy p
|
|
where p.polrelid = 'public.platform_users'::regclass
|
|
and p.polcmd in ('*', 'a', 'w', 'd')
|
|
and (
|
|
p.polroles = '{0}'::oid[]
|
|
or exists (
|
|
select 1
|
|
from unnest(p.polroles) policy_role(role_oid)
|
|
join pg_roles r on r.oid = policy_role.role_oid
|
|
where r.rolname in ('anon', 'authenticated')
|
|
)
|
|
)
|
|
order by p.polname
|
|
`);
|
|
if (platformUserWritePolicies.rowCount > 0) {
|
|
block('db.data_api.platform_users_write_policy', 'platform_users must not expose INSERT/UPDATE/DELETE/FOR ALL policies to client roles', {
|
|
count: platformUserWritePolicies.rowCount,
|
|
samples: platformUserWritePolicies.rows,
|
|
});
|
|
} else {
|
|
pass('db.data_api.platform_users_write_policy', 'platform_users exposes no client write policy');
|
|
}
|
|
|
|
const platformAdminAuthorityRows = await pool.query(`
|
|
select p.oid,
|
|
p.prosecdef as security_definer,
|
|
p.proconfig,
|
|
pg_get_functiondef(p.oid) as definition
|
|
from pg_proc p
|
|
join pg_namespace n on n.oid = p.pronamespace
|
|
where n.nspname = 'app'
|
|
and p.proname = 'is_platform_admin'
|
|
and pg_get_function_identity_arguments(p.oid) = ''
|
|
`);
|
|
const platformAdminAuthority = platformAdminAuthorityRows.rows[0];
|
|
const platformAdminDefinition = String(platformAdminAuthority?.definition || '').toLowerCase();
|
|
const platformAdminSearchPath = Array.isArray(platformAdminAuthority?.proconfig)
|
|
? platformAdminAuthority.proconfig.map(value => String(value).toLowerCase())
|
|
: [];
|
|
const platformAdminAuthoritySafe = platformAdminAuthorityRows.rowCount === 1
|
|
&& platformAdminAuthority.security_definer === true
|
|
&& platformAdminSearchPath.includes('search_path=""')
|
|
&& platformAdminDefinition.includes('from public.platform_users')
|
|
&& platformAdminDefinition.includes("status = 'active'")
|
|
&& !/current_role\(\)\s+in\s*\([^)]*platform_admin/.test(platformAdminDefinition);
|
|
if (!platformAdminAuthoritySafe) {
|
|
block('db.rls.platform_admin_authority', 'app.is_platform_admin() must resolve an active database identity and must not trust a platform_admin JWT claim', {
|
|
functionCount: platformAdminAuthorityRows.rowCount,
|
|
securityDefiner: platformAdminAuthority?.security_definer || false,
|
|
searchPath: platformAdminSearchPath,
|
|
});
|
|
} else {
|
|
pass('db.rls.platform_admin_authority', 'RLS platform authority is backed by an active database identity');
|
|
}
|
|
|
|
const tenantTablesWithoutRls = await pool.query(`
|
|
select c.table_schema, c.table_name
|
|
from information_schema.columns c
|
|
join pg_class cls on cls.relname = c.table_name
|
|
join pg_namespace ns on ns.oid = cls.relnamespace and ns.nspname = c.table_schema
|
|
where c.column_name = 'tenant_id'
|
|
and c.table_schema in ('public', 'app_private')
|
|
and cls.relkind in ('r', 'p')
|
|
and not cls.relrowsecurity
|
|
order by c.table_schema, c.table_name
|
|
`);
|
|
if (tenantTablesWithoutRls.rowCount > 0) {
|
|
block('db.rls.tenant_tables_enabled', 'Every tenant-scoped table must have row level security enabled', {
|
|
count: tenantTablesWithoutRls.rowCount,
|
|
samples: tenantTablesWithoutRls.rows.slice(0, 10).map(formatTableName),
|
|
});
|
|
} else {
|
|
pass('db.rls.tenant_tables_enabled', 'All tenant-scoped tables have RLS enabled');
|
|
}
|
|
|
|
const tenantTablesWithoutPolicies = await pool.query(`
|
|
select c.table_schema, c.table_name
|
|
from information_schema.columns c
|
|
join pg_class cls on cls.relname = c.table_name
|
|
join pg_namespace ns on ns.oid = cls.relnamespace and ns.nspname = c.table_schema
|
|
where c.column_name = 'tenant_id'
|
|
and c.table_schema in ('public', 'app_private')
|
|
and cls.relkind in ('r', 'p')
|
|
and not exists (
|
|
select 1
|
|
from pg_policy p
|
|
where p.polrelid = cls.oid
|
|
)
|
|
order by c.table_schema, c.table_name
|
|
`);
|
|
if (tenantTablesWithoutPolicies.rowCount > 0) {
|
|
block('db.rls.tenant_tables_policy', 'Every tenant-scoped table must have at least one RLS policy', {
|
|
count: tenantTablesWithoutPolicies.rowCount,
|
|
samples: tenantTablesWithoutPolicies.rows.slice(0, 10).map(formatTableName),
|
|
});
|
|
} else {
|
|
pass('db.rls.tenant_tables_policy', 'All tenant-scoped tables have at least one RLS policy');
|
|
}
|
|
|
|
const publicTenantTablesWithoutTenantContextPolicy = await pool.query(`
|
|
select c.table_schema, c.table_name
|
|
from information_schema.columns c
|
|
join pg_class cls on cls.relname = c.table_name
|
|
join pg_namespace ns on ns.oid = cls.relnamespace and ns.nspname = c.table_schema
|
|
where c.column_name = 'tenant_id'
|
|
and c.table_schema = 'public'
|
|
and cls.relkind in ('r', 'p')
|
|
and not exists (
|
|
select 1
|
|
from pg_policy p
|
|
where p.polrelid = cls.oid
|
|
and (
|
|
lower(pg_get_expr(p.polqual, p.polrelid)) like '%app.current_tenant_id()%'
|
|
or lower(pg_get_expr(p.polwithcheck, p.polrelid)) like '%app.current_tenant_id()%'
|
|
)
|
|
)
|
|
and not (
|
|
exists (
|
|
select 1
|
|
from pg_policy platform_policy
|
|
where platform_policy.polrelid = cls.oid
|
|
and (
|
|
lower(coalesce(pg_get_expr(platform_policy.polqual, platform_policy.polrelid), '')) like '%app.is_platform_admin()%'
|
|
or lower(coalesce(pg_get_expr(platform_policy.polwithcheck, platform_policy.polrelid), '')) like '%app.is_platform_admin()%'
|
|
)
|
|
)
|
|
and not exists (
|
|
select 1
|
|
from pg_policy non_platform_policy
|
|
where non_platform_policy.polrelid = cls.oid
|
|
and (
|
|
(
|
|
non_platform_policy.polqual is not null
|
|
and lower(pg_get_expr(non_platform_policy.polqual, non_platform_policy.polrelid)) not like '%app.is_platform_admin()%'
|
|
)
|
|
or (
|
|
non_platform_policy.polwithcheck is not null
|
|
and lower(pg_get_expr(non_platform_policy.polwithcheck, non_platform_policy.polrelid)) not like '%app.is_platform_admin()%'
|
|
)
|
|
)
|
|
)
|
|
)
|
|
order by c.table_schema, c.table_name
|
|
`);
|
|
if (publicTenantTablesWithoutTenantContextPolicy.rowCount > 0) {
|
|
block(
|
|
'db.rls.public_tenant_context',
|
|
'Every public tenant-scoped table must include app.current_tenant_id() in an RLS policy',
|
|
{
|
|
count: publicTenantTablesWithoutTenantContextPolicy.rowCount,
|
|
samples: publicTenantTablesWithoutTenantContextPolicy.rows.slice(0, 10).map(formatTableName),
|
|
},
|
|
);
|
|
} else {
|
|
pass('db.rls.public_tenant_context', 'Public tenant-scoped tables include tenant context in RLS policies');
|
|
}
|
|
|
|
const tenantForeignKeySchema = summarizeTenantForeignKeySchema(
|
|
await loadTenantForeignKeyRelations(pool),
|
|
);
|
|
if (!tenantForeignKeySchema.schemaMatches) {
|
|
block(
|
|
'db.tenant_foreign_keys.schema',
|
|
'Tenant-scoped single-key foreign key schema changed or contains unvalidated constraints; review the relation and update the audited contract',
|
|
tenantForeignKeySchema,
|
|
);
|
|
} else {
|
|
pass(
|
|
'db.tenant_foreign_keys.schema',
|
|
'Tenant-scoped single-key foreign key schema matches the reviewed contract',
|
|
tenantForeignKeySchema,
|
|
);
|
|
}
|
|
} finally {
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
function printSummary() {
|
|
const summary = checks.reduce(
|
|
(acc, item) => {
|
|
acc[item.status] += 1;
|
|
return acc;
|
|
},
|
|
{ blocker: 0, warn: 0, pass: 0 },
|
|
);
|
|
|
|
if (jsonOutput) {
|
|
console.log(JSON.stringify({ summary, checks }, null, 2));
|
|
return;
|
|
}
|
|
|
|
console.log('Production readiness check');
|
|
console.log(`Env file: ${fs.existsSync(envFile) ? envFile : '(not found, using process env)'}`);
|
|
console.log(`Host: ${os.hostname()}`);
|
|
console.log(`Summary: ${summary.blocker} blocker(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
|
|
for (const item of checks) {
|
|
const marker = item.status === 'blocker' ? 'BLOCK' : item.status === 'warn' ? 'WARN ' : 'PASS ';
|
|
console.log(`[${marker}] ${item.id}: ${item.message}`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
loadEnvFile(envFile);
|
|
validateEnv();
|
|
if (providerConfigFixture) {
|
|
const fixturePath = path.resolve(process.cwd(), providerConfigFixture);
|
|
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
|
const rows = Array.isArray(fixture) ? fixture : Array.isArray(fixture.rows) ? fixture.rows : [];
|
|
validateProviderConfigRows(rows);
|
|
}
|
|
if (tenantConfigFixture) {
|
|
const fixturePath = path.resolve(process.cwd(), tenantConfigFixture);
|
|
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
|
const rows = Array.isArray(fixture) ? fixture : Array.isArray(fixture.rows) ? fixture.rows : [];
|
|
validateTenantConfigRows(rows);
|
|
}
|
|
if (environmentSafetyFixture) {
|
|
const fixturePath = path.resolve(process.cwd(), environmentSafetyFixture);
|
|
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
|
const row = fixture && typeof fixture === 'object' && !Array.isArray(fixture) && 'row' in fixture
|
|
? fixture.row
|
|
: fixture;
|
|
validateEnvironmentSafetyMarker(row || null, 'fixture');
|
|
}
|
|
if (migrationHistoryFixture) {
|
|
const fixturePath = path.resolve(process.cwd(), migrationHistoryFixture);
|
|
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
|
const row = fixture && typeof fixture === 'object' && !Array.isArray(fixture) && 'row' in fixture
|
|
? fixture.row
|
|
: fixture;
|
|
validateMigrationHistory(row || null, 'fixture');
|
|
}
|
|
await validateDatabase();
|
|
printSummary();
|
|
|
|
if (checks.some(item => item.status === 'blocker')) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|