feat: bootstrap SuperAdmin from env and lock one-time seeding

Production creates the SuperAdmin from env credentials, and dashboard seeding is authenticated, SuperAdmin-only, and locked after the first run.
This commit is contained in:
2026-08-14 23:23:06 +03:30
parent 3ca098b74d
commit 35704409ec
13 changed files with 251 additions and 34 deletions
+6 -4
View File
@@ -47,9 +47,11 @@ SMS_TEMPLATE_CLASS_REMINDER=
# Bale Messenger Bot Token # Bale Messenger Bot Token
BALE_BOT_TOKEN=mock_bale_bot_token BALE_BOT_TOKEN=mock_bale_bot_token
# SuperAdmin Seed Credentials # SuperAdmin bootstrap (created automatically in production)
SUPERADMIN_USERNAME=superadmin # Set SUPERADMIN_ENABLED=false to deactivate the bootstrap SuperAdmin and block login
SUPERADMIN_PASSWORD=SuperAdminSecret123! SUPERADMIN_ENABLED=true
SUPERADMIN_EMAIL=admin@institution.com SUPERADMIN_USERNAME=
SUPERADMIN_PASSWORD=
SUPERADMIN_EMAIL=
SUPERADMIN_NATIONAL_ID=0000000000 SUPERADMIN_NATIONAL_ID=0000000000
SUPERADMIN_PHONE=09000000000 SUPERADMIN_PHONE=09000000000
+7 -14
View File
@@ -78,20 +78,7 @@ app.get('/api/health', (req, res) => {
}); });
const seedDatabase = require('./seed'); const seedDatabase = require('./seed');
const { successResponse, errorResponse } = require('./utils/apiResponse'); const seedRoutes = require('./components/seed/seedRoutes');
const handleSeedRequest = async (req, res) => {
try {
const result = await seedDatabase({ disconnectOnComplete: false });
return successResponse(res, 200, 'Database seeded successfully', result);
} catch (error) {
return errorResponse(res, 500, 'SEED_FAILED', error.message);
}
};
app.post('/api/seed', handleSeedRequest);
app.get('/api/seed', handleSeedRequest);
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
@@ -107,6 +94,7 @@ app.use('/api/files', fileRoutes);
app.use('/api/dashboard', dashboardRoutes); app.use('/api/dashboard', dashboardRoutes);
app.use('/api/activity-logs', activityLogRoutes); app.use('/api/activity-logs', activityLogRoutes);
app.use('/api/contact-inquiries', contactInquiryRoutes); app.use('/api/contact-inquiries', contactInquiryRoutes);
app.use('/api/seed', seedRoutes);
// ── Error Handlers ──────────────────────────────────────────────────────────── // ── Error Handlers ────────────────────────────────────────────────────────────
app.use(notFoundHandler); app.use(notFoundHandler);
@@ -118,6 +106,11 @@ const PORT = config.PORT || 3000;
const startServer = async () => { const startServer = async () => {
await connectDB(); await connectDB();
if (config.NODE_ENV === 'production') {
logger.info('Running production bootstrap (roles + SuperAdmin from env)');
await seedDatabase({ disconnectOnComplete: false });
}
const registerEventListeners = require('./events/eventListeners'); const registerEventListeners = require('./events/eventListeners');
const { startPaymentReminderJob } = require('./jobs/paymentReminderJob'); const { startPaymentReminderJob } = require('./jobs/paymentReminderJob');
const { startNotificationRetryJob } = require('./jobs/notificationRetryJob'); const { startNotificationRetryJob } = require('./jobs/notificationRetryJob');
+3 -2
View File
@@ -5,6 +5,7 @@ const bcrypt = require('bcryptjs');
const User = require('../users/userModel'); const User = require('../users/userModel');
const config = require('../../config/config'); const config = require('../../config/config');
const AppError = require('../../utils/AppError'); const AppError = require('../../utils/AppError');
const { isBootstrapSuperAdminDisabled } = require('../../utils/superAdmin');
const generateTokens = (user) => { const generateTokens = (user) => {
const payload = { const payload = {
@@ -26,7 +27,7 @@ const generateTokens = (user) => {
const login = async (username, password) => { const login = async (username, password) => {
const user = await User.findOne({ username }).populate('role'); const user = await User.findOne({ username }).populate('role');
if (!user || !user.isActive) { if (!user || !user.isActive || isBootstrapSuperAdminDisabled(user)) {
throw new AppError('INVALID_CREDENTIALS'); throw new AppError('INVALID_CREDENTIALS');
} }
@@ -63,7 +64,7 @@ const refreshToken = async (refreshTokenString) => {
} }
const user = await User.findById(decoded.id).populate('role'); const user = await User.findById(decoded.id).populate('role');
if (!user || !user.isActive) { if (!user || !user.isActive || isBootstrapSuperAdminDisabled(user)) {
throw new AppError('INVALID_REFRESH_TOKEN'); throw new AppError('INVALID_REFRESH_TOKEN');
} }
+16
View File
@@ -0,0 +1,16 @@
// /components/seed/seedController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const seedService = require('./seedService');
const { successResponse } = require('../../utils/apiResponse');
exports.getStatus = catchAsync(async (req, res) => {
const status = await seedService.getStatus();
return successResponse(res, 200, 'Seed status retrieved', status);
});
exports.runSeed = catchAsync(async (req, res) => {
const result = await seedService.runSeed(req.user?._id);
return successResponse(res, 200, 'Database seeded successfully', result);
});
+36
View File
@@ -0,0 +1,36 @@
// /components/seed/seedLockModel.js
'use strict';
const mongoose = require('mongoose');
const SEED_LOCK_KEY = 'database_seed';
const seedLockSchema = new mongoose.Schema({
key: {
type: String,
required: true,
unique: true,
default: SEED_LOCK_KEY,
index: true
},
locked: {
type: Boolean,
default: false
},
lockedAt: {
type: Date
},
lockedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
}, {
timestamps: true
});
const SeedLock = mongoose.model('SeedLock', seedLockSchema);
module.exports = {
SeedLock,
SEED_LOCK_KEY
};
+17
View File
@@ -0,0 +1,17 @@
// /components/seed/seedRoutes.js
'use strict';
const express = require('express');
const seedController = require('./seedController');
const authMiddleware = require('../../middlewares/authMiddleware');
const requireSuperAdmin = require('../../middlewares/requireSuperAdmin');
const router = express.Router();
router.use(authMiddleware);
router.use(requireSuperAdmin);
router.get('/status', seedController.getStatus);
router.post('/', seedController.runSeed);
module.exports = router;
+45
View File
@@ -0,0 +1,45 @@
// /components/seed/seedService.js
'use strict';
const { SeedLock, SEED_LOCK_KEY } = require('./seedLockModel');
const seedDatabase = require('../../seed');
const AppError = require('../../utils/AppError');
const getStatus = async () => {
const doc = await SeedLock.findOne({ key: SEED_LOCK_KEY }).lean();
return {
locked: Boolean(doc?.locked),
lockedAt: doc?.lockedAt || null
};
};
const runSeed = async (userId) => {
const existing = await SeedLock.findOne({ key: SEED_LOCK_KEY });
if (existing?.locked) {
throw new AppError('SEED_LOCKED');
}
const result = await seedDatabase({ disconnectOnComplete: false });
await SeedLock.findOneAndUpdate(
{ key: SEED_LOCK_KEY },
{
$set: {
locked: true,
lockedAt: new Date(),
lockedBy: userId || undefined
}
},
{ upsert: true, new: true }
);
return {
...result,
locked: true
};
};
module.exports = {
getStatus,
runSeed
};
+15 -4
View File
@@ -5,6 +5,16 @@ const path = require('path');
dotenv.config({ path: path.resolve(process.cwd(), '.env') }); dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const parseBool = (value, defaultValue) => {
if (value === undefined || value === null || String(value).trim() === '') {
return defaultValue;
}
const normalized = String(value).trim().toLowerCase();
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
return defaultValue;
};
const config = { const config = {
NODE_ENV: process.env.NODE_ENV || 'development', NODE_ENV: process.env.NODE_ENV || 'development',
PORT: parseInt(process.env.PORT, 10) || 3000, PORT: parseInt(process.env.PORT, 10) || 3000,
@@ -49,10 +59,11 @@ const config = {
// Bale Messenger Bot Settings // Bale Messenger Bot Settings
BALE_BOT_TOKEN: process.env.BALE_BOT_TOKEN || 'mock_bale_bot_token', BALE_BOT_TOKEN: process.env.BALE_BOT_TOKEN || 'mock_bale_bot_token',
// SuperAdmin Seed Settings // SuperAdmin bootstrap (credentials come from env only; no hardcoded secrets)
SUPERADMIN_USERNAME: process.env.SUPERADMIN_USERNAME || 'superadmin', SUPERADMIN_ENABLED: parseBool(process.env.SUPERADMIN_ENABLED, true),
SUPERADMIN_PASSWORD: process.env.SUPERADMIN_PASSWORD || 'SuperAdminSecret123!', SUPERADMIN_USERNAME: process.env.SUPERADMIN_USERNAME || '',
SUPERADMIN_EMAIL: process.env.SUPERADMIN_EMAIL || 'admin@institution.com', SUPERADMIN_PASSWORD: process.env.SUPERADMIN_PASSWORD || '',
SUPERADMIN_EMAIL: process.env.SUPERADMIN_EMAIL || '',
SUPERADMIN_NATIONAL_ID: process.env.SUPERADMIN_NATIONAL_ID || '0000000000', SUPERADMIN_NATIONAL_ID: process.env.SUPERADMIN_NATIONAL_ID || '0000000000',
SUPERADMIN_PHONE: process.env.SUPERADMIN_PHONE || '09000000000' SUPERADMIN_PHONE: process.env.SUPERADMIN_PHONE || '09000000000'
}; };
+2 -1
View File
@@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken');
const config = require('../config/config'); const config = require('../config/config');
const AppError = require('../utils/AppError'); const AppError = require('../utils/AppError');
const User = require('../components/users/userModel'); const User = require('../components/users/userModel');
const { isBootstrapSuperAdminDisabled } = require('../utils/superAdmin');
const authMiddleware = async (req, res, next) => { const authMiddleware = async (req, res, next) => {
try { try {
@@ -29,7 +30,7 @@ const authMiddleware = async (req, res, next) => {
} }
const user = await User.findById(decoded.id).populate('role'); const user = await User.findById(decoded.id).populate('role');
if (!user || !user.isActive) { if (!user || !user.isActive || isBootstrapSuperAdminDisabled(user)) {
return next(new AppError('UNAUTHORIZED')); return next(new AppError('UNAUTHORIZED'));
} }
+18
View File
@@ -0,0 +1,18 @@
// /middlewares/requireSuperAdmin.js
'use strict';
const AppError = require('../utils/AppError');
const requireSuperAdmin = (req, res, next) => {
if (!req.user) {
return next(new AppError('UNAUTHORIZED'));
}
if (req.user.role?.name !== 'SuperAdmin') {
return next(new AppError('FORBIDDEN'));
}
return next();
};
module.exports = requireSuperAdmin;
+52 -8
View File
@@ -119,29 +119,73 @@ const seedDatabase = async ({ disconnectOnComplete = false } = {}) => {
} }
let superAdminStatus = 'unchanged'; let superAdminStatus = 'unchanged';
// Ensure SuperAdmin user exists
const superAdminRole = await Role.findOne({ name: 'SuperAdmin' }); const superAdminRole = await Role.findOne({ name: 'SuperAdmin' });
if (superAdminRole) { const username = config.SUPERADMIN_USERNAME && String(config.SUPERADMIN_USERNAME).trim();
const adminUser = await User.findOne({ username: config.SUPERADMIN_USERNAME });
if (!superAdminRole) {
superAdminStatus = 'role_missing';
} else if (!config.SUPERADMIN_ENABLED) {
if (username) {
const adminUser = await User.findOne({ username });
if (adminUser && (adminUser.isActive || adminUser.refreshTokens.length > 0)) {
adminUser.isActive = false;
adminUser.refreshTokens = [];
await adminUser.save();
superAdminStatus = 'disabled';
console.log(`SuperAdmin user disabled (${username}).`);
} else {
superAdminStatus = adminUser ? 'already_disabled' : 'skipped_disabled';
}
} else {
superAdminStatus = 'skipped_disabled';
}
} else if (!username || !config.SUPERADMIN_PASSWORD) {
throw new Error('SUPERADMIN_USERNAME and SUPERADMIN_PASSWORD must be set in env when SUPERADMIN_ENABLED is true');
} else {
const adminUser = await User.findOne({ username });
if (!adminUser) { if (!adminUser) {
const passwordHash = await bcrypt.hash(config.SUPERADMIN_PASSWORD, 10); const passwordHash = await bcrypt.hash(config.SUPERADMIN_PASSWORD, 10);
await User.create({ await User.create({
name: 'مدیر', name: 'مدیر',
surname: 'ارشد', surname: 'ارشد',
nationalId: config.SUPERADMIN_NATIONAL_ID, nationalIdCode: config.SUPERADMIN_NATIONAL_ID,
phone: config.SUPERADMIN_PHONE, phoneNumber: config.SUPERADMIN_PHONE,
email: config.SUPERADMIN_EMAIL, email: config.SUPERADMIN_EMAIL || undefined,
username: config.SUPERADMIN_USERNAME, username,
passwordHash, passwordHash,
role: superAdminRole._id, role: superAdminRole._id,
isActive: true isActive: true
}); });
superAdminStatus = 'created'; superAdminStatus = 'created';
console.log(`SuperAdmin user created (${config.SUPERADMIN_USERNAME}).`); console.log(`SuperAdmin user created (${username}).`);
} else {
let changed = false;
if (!adminUser.isActive) {
adminUser.isActive = true;
changed = true;
}
const passwordMatches = await bcrypt.compare(config.SUPERADMIN_PASSWORD, adminUser.passwordHash);
if (!passwordMatches) {
adminUser.passwordHash = await bcrypt.hash(config.SUPERADMIN_PASSWORD, 10);
adminUser.refreshTokens = [];
changed = true;
}
if (config.SUPERADMIN_EMAIL && adminUser.email !== config.SUPERADMIN_EMAIL.toLowerCase()) {
adminUser.email = config.SUPERADMIN_EMAIL;
changed = true;
}
if (changed) {
await adminUser.save();
superAdminStatus = 'updated';
console.log(`SuperAdmin user updated (${username}).`);
} else { } else {
superAdminStatus = 'already_exists'; superAdminStatus = 'already_exists';
} }
} }
}
console.log('Database seeding completed successfully.'); console.log('Database seeding completed successfully.');
return { return {
+10
View File
@@ -138,5 +138,15 @@
"statusCode": 400, "statusCode": 400,
"en": "File is required.", "en": "File is required.",
"fa": "ارسال فایل الزامی است." "fa": "ارسال فایل الزامی است."
},
"SEED_LOCKED": {
"statusCode": 409,
"en": "Database seeding has already been completed and is locked.",
"fa": "راه‌اندازی پایگاه داده قبلاً انجام شده و قفل است."
},
"SEED_FAILED": {
"statusCode": 500,
"en": "Database seeding failed.",
"fa": "راه‌اندازی پایگاه داده با خطا مواجه شد."
} }
} }
+23
View File
@@ -0,0 +1,23 @@
// /utils/superAdmin.js
const config = require('../config/config');
const usernamesMatch = (left, right) => {
if (!left || !right) return false;
return String(left).trim().toLowerCase() === String(right).trim().toLowerCase();
};
const isBootstrapSuperAdmin = (userOrUsername) => {
const username = typeof userOrUsername === 'string'
? userOrUsername
: userOrUsername?.username;
return usernamesMatch(username, config.SUPERADMIN_USERNAME);
};
const isBootstrapSuperAdminDisabled = (userOrUsername) => {
return !config.SUPERADMIN_ENABLED && isBootstrapSuperAdmin(userOrUsername);
};
module.exports = {
isBootstrapSuperAdmin,
isBootstrapSuperAdminDisabled
};