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:
+6
-4
@@ -47,9 +47,11 @@ SMS_TEMPLATE_CLASS_REMINDER=
|
||||
# Bale Messenger Bot Token
|
||||
BALE_BOT_TOKEN=mock_bale_bot_token
|
||||
|
||||
# SuperAdmin Seed Credentials
|
||||
SUPERADMIN_USERNAME=superadmin
|
||||
SUPERADMIN_PASSWORD=SuperAdminSecret123!
|
||||
SUPERADMIN_EMAIL=admin@institution.com
|
||||
# SuperAdmin bootstrap (created automatically in production)
|
||||
# Set SUPERADMIN_ENABLED=false to deactivate the bootstrap SuperAdmin and block login
|
||||
SUPERADMIN_ENABLED=true
|
||||
SUPERADMIN_USERNAME=
|
||||
SUPERADMIN_PASSWORD=
|
||||
SUPERADMIN_EMAIL=
|
||||
SUPERADMIN_NATIONAL_ID=0000000000
|
||||
SUPERADMIN_PHONE=09000000000
|
||||
|
||||
@@ -78,20 +78,7 @@ app.get('/api/health', (req, res) => {
|
||||
});
|
||||
|
||||
const seedDatabase = require('./seed');
|
||||
const { successResponse, errorResponse } = require('./utils/apiResponse');
|
||||
|
||||
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);
|
||||
|
||||
const seedRoutes = require('./components/seed/seedRoutes');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
@@ -107,6 +94,7 @@ app.use('/api/files', fileRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/activity-logs', activityLogRoutes);
|
||||
app.use('/api/contact-inquiries', contactInquiryRoutes);
|
||||
app.use('/api/seed', seedRoutes);
|
||||
|
||||
// ── Error Handlers ────────────────────────────────────────────────────────────
|
||||
app.use(notFoundHandler);
|
||||
@@ -118,6 +106,11 @@ const PORT = config.PORT || 3000;
|
||||
const startServer = async () => {
|
||||
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 { startPaymentReminderJob } = require('./jobs/paymentReminderJob');
|
||||
const { startNotificationRetryJob } = require('./jobs/notificationRetryJob');
|
||||
|
||||
@@ -5,6 +5,7 @@ const bcrypt = require('bcryptjs');
|
||||
const User = require('../users/userModel');
|
||||
const config = require('../../config/config');
|
||||
const AppError = require('../../utils/AppError');
|
||||
const { isBootstrapSuperAdminDisabled } = require('../../utils/superAdmin');
|
||||
|
||||
const generateTokens = (user) => {
|
||||
const payload = {
|
||||
@@ -26,7 +27,7 @@ const generateTokens = (user) => {
|
||||
|
||||
const login = async (username, password) => {
|
||||
const user = await User.findOne({ username }).populate('role');
|
||||
if (!user || !user.isActive) {
|
||||
if (!user || !user.isActive || isBootstrapSuperAdminDisabled(user)) {
|
||||
throw new AppError('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
@@ -63,7 +64,7 @@ const refreshToken = async (refreshTokenString) => {
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
@@ -5,6 +5,16 @@ const path = require('path');
|
||||
|
||||
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 = {
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
PORT: parseInt(process.env.PORT, 10) || 3000,
|
||||
@@ -49,10 +59,11 @@ const config = {
|
||||
// Bale Messenger Bot Settings
|
||||
BALE_BOT_TOKEN: process.env.BALE_BOT_TOKEN || 'mock_bale_bot_token',
|
||||
|
||||
// SuperAdmin Seed Settings
|
||||
SUPERADMIN_USERNAME: process.env.SUPERADMIN_USERNAME || 'superadmin',
|
||||
SUPERADMIN_PASSWORD: process.env.SUPERADMIN_PASSWORD || 'SuperAdminSecret123!',
|
||||
SUPERADMIN_EMAIL: process.env.SUPERADMIN_EMAIL || 'admin@institution.com',
|
||||
// SuperAdmin bootstrap (credentials come from env only; no hardcoded secrets)
|
||||
SUPERADMIN_ENABLED: parseBool(process.env.SUPERADMIN_ENABLED, true),
|
||||
SUPERADMIN_USERNAME: process.env.SUPERADMIN_USERNAME || '',
|
||||
SUPERADMIN_PASSWORD: process.env.SUPERADMIN_PASSWORD || '',
|
||||
SUPERADMIN_EMAIL: process.env.SUPERADMIN_EMAIL || '',
|
||||
SUPERADMIN_NATIONAL_ID: process.env.SUPERADMIN_NATIONAL_ID || '0000000000',
|
||||
SUPERADMIN_PHONE: process.env.SUPERADMIN_PHONE || '09000000000'
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken');
|
||||
const config = require('../config/config');
|
||||
const AppError = require('../utils/AppError');
|
||||
const User = require('../components/users/userModel');
|
||||
const { isBootstrapSuperAdminDisabled } = require('../utils/superAdmin');
|
||||
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
@@ -29,7 +30,7 @@ const authMiddleware = async (req, res, next) => {
|
||||
}
|
||||
|
||||
const user = await User.findById(decoded.id).populate('role');
|
||||
if (!user || !user.isActive) {
|
||||
if (!user || !user.isActive || isBootstrapSuperAdminDisabled(user)) {
|
||||
return next(new AppError('UNAUTHORIZED'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -119,29 +119,73 @@ const seedDatabase = async ({ disconnectOnComplete = false } = {}) => {
|
||||
}
|
||||
|
||||
let superAdminStatus = 'unchanged';
|
||||
// Ensure SuperAdmin user exists
|
||||
const superAdminRole = await Role.findOne({ name: 'SuperAdmin' });
|
||||
if (superAdminRole) {
|
||||
const adminUser = await User.findOne({ username: config.SUPERADMIN_USERNAME });
|
||||
const username = config.SUPERADMIN_USERNAME && String(config.SUPERADMIN_USERNAME).trim();
|
||||
|
||||
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) {
|
||||
const passwordHash = await bcrypt.hash(config.SUPERADMIN_PASSWORD, 10);
|
||||
await User.create({
|
||||
name: 'مدیر',
|
||||
surname: 'ارشد',
|
||||
nationalId: config.SUPERADMIN_NATIONAL_ID,
|
||||
phone: config.SUPERADMIN_PHONE,
|
||||
email: config.SUPERADMIN_EMAIL,
|
||||
username: config.SUPERADMIN_USERNAME,
|
||||
nationalIdCode: config.SUPERADMIN_NATIONAL_ID,
|
||||
phoneNumber: config.SUPERADMIN_PHONE,
|
||||
email: config.SUPERADMIN_EMAIL || undefined,
|
||||
username,
|
||||
passwordHash,
|
||||
role: superAdminRole._id,
|
||||
isActive: true
|
||||
});
|
||||
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 {
|
||||
superAdminStatus = 'already_exists';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Database seeding completed successfully.');
|
||||
return {
|
||||
|
||||
@@ -138,5 +138,15 @@
|
||||
"statusCode": 400,
|
||||
"en": "File is required.",
|
||||
"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": "راهاندازی پایگاه داده با خطا مواجه شد."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user