Plan OIDC integration and audit
This commit is contained in:
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require("dotenv").config();
|
||||
|
||||
const path = require("path");
|
||||
const { execSync } = require("child_process");
|
||||
const { PrismaClient } = require("../src/generated/client");
|
||||
|
||||
const BOOTSTRAP_USER_ID = "bootstrap-admin";
|
||||
const DEFAULT_SYSTEM_CONFIG_ID = "default";
|
||||
const backendRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const resolveDatabaseUrl = (rawUrl) => {
|
||||
const backendRoot = path.resolve(__dirname, "..");
|
||||
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
|
||||
|
||||
if (!rawUrl || String(rawUrl).trim().length === 0) {
|
||||
return `file:${defaultDbPath}`;
|
||||
}
|
||||
|
||||
if (!String(rawUrl).startsWith("file:")) {
|
||||
return String(rawUrl);
|
||||
}
|
||||
|
||||
const filePath = String(rawUrl).replace(/^file:/, "");
|
||||
const prismaDir = path.resolve(backendRoot, "prisma");
|
||||
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
||||
const hasLeadingPrismaDir =
|
||||
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
|
||||
|
||||
const absolutePath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
||||
|
||||
return `file:${absolutePath}`;
|
||||
};
|
||||
|
||||
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const parsed = {
|
||||
scenario: "",
|
||||
dryRun: false,
|
||||
allowProd: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--scenario") {
|
||||
parsed.scenario = String(argv[i + 1] || "").trim().toLowerCase();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--dry-run") {
|
||||
parsed.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--allow-production") {
|
||||
parsed.allowProd = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--help" || token === "-h") {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const usage = () => {
|
||||
console.log(`Usage:
|
||||
node scripts/simulate-auth-onboarding.cjs --scenario fresh
|
||||
node scripts/simulate-auth-onboarding.cjs --scenario migration
|
||||
|
||||
Options:
|
||||
--dry-run Show what would change without modifying data
|
||||
--allow-production Override production safety check (not recommended)
|
||||
--help, -h Show this help
|
||||
`);
|
||||
};
|
||||
|
||||
const assertScenario = (scenario) => {
|
||||
if (scenario !== "fresh" && scenario !== "migration") {
|
||||
throw new Error("Invalid --scenario. Use 'fresh' or 'migration'.");
|
||||
}
|
||||
};
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const run = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
assertScenario(args.scenario);
|
||||
|
||||
const nodeEnv = process.env.NODE_ENV || "development";
|
||||
if (nodeEnv === "production" && !args.allowProd) {
|
||||
throw new Error(
|
||||
"Refusing to run in production. Pass --allow-production only if you explicitly intend this."
|
||||
);
|
||||
}
|
||||
|
||||
// Keep migration history authoritative to avoid drift between db push and deploy.
|
||||
// Includes a self-heal path for the known duplicate-column failure on
|
||||
// 20260210153000_add_auth_onboarding_completed in local dev databases.
|
||||
if (nodeEnv !== "production") {
|
||||
const runDeploy = () =>
|
||||
execSync("npx prisma migrate deploy", {
|
||||
cwd: backendRoot,
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
runDeploy();
|
||||
} catch (error) {
|
||||
const stdout =
|
||||
error && error.stdout
|
||||
? Buffer.isBuffer(error.stdout)
|
||||
? error.stdout.toString("utf8")
|
||||
: String(error.stdout)
|
||||
: "";
|
||||
const stderr =
|
||||
error && error.stderr
|
||||
? Buffer.isBuffer(error.stderr)
|
||||
? error.stderr.toString("utf8")
|
||||
: String(error.stderr)
|
||||
: "";
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
|
||||
const canAutoResolve =
|
||||
combined.includes("Error: P3009") &&
|
||||
combined.includes("20260210153000_add_auth_onboarding_completed") &&
|
||||
combined.includes("duplicate column name: authOnboardingCompleted");
|
||||
|
||||
if (!canAutoResolve) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
execSync(
|
||||
"npx prisma migrate resolve --applied 20260210153000_add_auth_onboarding_completed",
|
||||
{
|
||||
cwd: backendRoot,
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
},
|
||||
}
|
||||
);
|
||||
runDeploy();
|
||||
}
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
const before = {
|
||||
activeUsers: await prisma.user.count({ where: { isActive: true } }),
|
||||
users: await prisma.user.count(),
|
||||
drawings: await prisma.drawing.count(),
|
||||
collections: await prisma.collection.count(),
|
||||
auth: await prisma.systemConfig.findUnique({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
select: {
|
||||
authEnabled: true,
|
||||
authOnboardingCompleted: true,
|
||||
registrationEnabled: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
console.log(`[simulate-auth-onboarding] DATABASE_URL=${process.env.DATABASE_URL}`);
|
||||
console.log(`[simulate-auth-onboarding] NODE_ENV=${nodeEnv}`);
|
||||
console.log(`[simulate-auth-onboarding] scenario=${args.scenario}`);
|
||||
console.log("[simulate-auth-onboarding] before:", before);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log("[simulate-auth-onboarding] dry-run only. No data changed.");
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.systemConfig.upsert({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
update: {
|
||||
authEnabled: false,
|
||||
authOnboardingCompleted: false,
|
||||
registrationEnabled: false,
|
||||
},
|
||||
create: {
|
||||
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||
authEnabled: false,
|
||||
authOnboardingCompleted: false,
|
||||
registrationEnabled: false,
|
||||
authLoginRateLimitEnabled: true,
|
||||
authLoginRateLimitWindowMs: 15 * 60 * 1000,
|
||||
authLoginRateLimitMax: 20,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.user.updateMany({
|
||||
data: {
|
||||
isActive: false,
|
||||
mustResetPassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.user.upsert({
|
||||
where: { id: BOOTSTRAP_USER_ID },
|
||||
update: {
|
||||
email: "bootstrap@excalidash.local",
|
||||
username: null,
|
||||
passwordHash: "",
|
||||
name: "Bootstrap Admin",
|
||||
role: "ADMIN",
|
||||
mustResetPassword: true,
|
||||
isActive: false,
|
||||
},
|
||||
create: {
|
||||
id: BOOTSTRAP_USER_ID,
|
||||
email: "bootstrap@excalidash.local",
|
||||
username: null,
|
||||
passwordHash: "",
|
||||
name: "Bootstrap Admin",
|
||||
role: "ADMIN",
|
||||
mustResetPassword: true,
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (args.scenario === "fresh") {
|
||||
await tx.drawing.deleteMany({});
|
||||
await tx.collection.deleteMany({});
|
||||
await tx.library.deleteMany({});
|
||||
await tx.user.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
not: BOOTSTRAP_USER_ID,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Migration simulation:
|
||||
// 1) Reassign existing data ownership to bootstrap user
|
||||
// 2) Ensure at least one drawing+collection exists so UI shows migration messaging
|
||||
await tx.collection.updateMany({
|
||||
data: { userId: BOOTSTRAP_USER_ID },
|
||||
});
|
||||
await tx.drawing.updateMany({
|
||||
data: { userId: BOOTSTRAP_USER_ID },
|
||||
});
|
||||
|
||||
const collectionCount = await tx.collection.count();
|
||||
let targetCollectionId = null;
|
||||
|
||||
if (collectionCount === 0) {
|
||||
targetCollectionId = `sim-migration-col-${Date.now()}`;
|
||||
await tx.collection.create({
|
||||
data: {
|
||||
id: targetCollectionId,
|
||||
name: "Migrated Collection",
|
||||
userId: BOOTSTRAP_USER_ID,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const existing = await tx.collection.findFirst({
|
||||
where: { userId: BOOTSTRAP_USER_ID },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
targetCollectionId = existing ? existing.id : null;
|
||||
}
|
||||
|
||||
const drawingCount = await tx.drawing.count();
|
||||
if (drawingCount === 0) {
|
||||
await tx.drawing.create({
|
||||
data: {
|
||||
id: `sim-migration-draw-${Date.now()}`,
|
||||
name: "Migrated Drawing",
|
||||
elements: "[]",
|
||||
appState: "{}",
|
||||
files: "{}",
|
||||
preview: null,
|
||||
version: 1,
|
||||
userId: BOOTSTRAP_USER_ID,
|
||||
collectionId: targetCollectionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const after = {
|
||||
activeUsers: await prisma.user.count({ where: { isActive: true } }),
|
||||
users: await prisma.user.count(),
|
||||
drawings: await prisma.drawing.count(),
|
||||
collections: await prisma.collection.count(),
|
||||
auth: await prisma.systemConfig.findUnique({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
select: {
|
||||
authEnabled: true,
|
||||
authOnboardingCompleted: true,
|
||||
registrationEnabled: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
console.log("[simulate-auth-onboarding] after:", after);
|
||||
console.log(`[simulate-auth-onboarding] completed at ${nowIso()}`);
|
||||
console.log(
|
||||
"[simulate-auth-onboarding] If your backend is already running, wait ~5 seconds (auth cache TTL) or restart before refreshing the UI."
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("simulate-auth-onboarding failed:", error.message || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user