From 35704409ec12343a57b5551a91edccfd8e71426f Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Fri, 14 Aug 2026 23:23:06 +0330 Subject: [PATCH] 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. --- .env.example | 10 +++-- app.js | 21 ++++------- components/auth/authService.js | 5 ++- components/seed/seedController.js | 16 ++++++++ components/seed/seedLockModel.js | 36 ++++++++++++++++++ components/seed/seedRoutes.js | 17 +++++++++ components/seed/seedService.js | 45 ++++++++++++++++++++++ config/config.js | 19 ++++++++-- middlewares/authMiddleware.js | 3 +- middlewares/requireSuperAdmin.js | 18 +++++++++ seed.js | 62 ++++++++++++++++++++++++++----- utils/errors.json | 10 +++++ utils/superAdmin.js | 23 ++++++++++++ 13 files changed, 251 insertions(+), 34 deletions(-) create mode 100644 components/seed/seedController.js create mode 100644 components/seed/seedLockModel.js create mode 100644 components/seed/seedRoutes.js create mode 100644 components/seed/seedService.js create mode 100644 middlewares/requireSuperAdmin.js create mode 100644 utils/superAdmin.js diff --git a/.env.example b/.env.example index 7c4a3fd..6021cb3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app.js b/app.js index 0812b65..cd94c19 100644 --- a/app.js +++ b/app.js @@ -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'); diff --git a/components/auth/authService.js b/components/auth/authService.js index 5179408..6f99d13 100644 --- a/components/auth/authService.js +++ b/components/auth/authService.js @@ -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'); } diff --git a/components/seed/seedController.js b/components/seed/seedController.js new file mode 100644 index 0000000..b06c680 --- /dev/null +++ b/components/seed/seedController.js @@ -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); +}); diff --git a/components/seed/seedLockModel.js b/components/seed/seedLockModel.js new file mode 100644 index 0000000..eafcf8e --- /dev/null +++ b/components/seed/seedLockModel.js @@ -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 +}; diff --git a/components/seed/seedRoutes.js b/components/seed/seedRoutes.js new file mode 100644 index 0000000..cfeddc4 --- /dev/null +++ b/components/seed/seedRoutes.js @@ -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; diff --git a/components/seed/seedService.js b/components/seed/seedService.js new file mode 100644 index 0000000..fbda92e --- /dev/null +++ b/components/seed/seedService.js @@ -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 +}; diff --git a/config/config.js b/config/config.js index 5b45dc3..f9d0b89 100644 --- a/config/config.js +++ b/config/config.js @@ -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' }; diff --git a/middlewares/authMiddleware.js b/middlewares/authMiddleware.js index d2ad6a2..fcd3d40 100644 --- a/middlewares/authMiddleware.js +++ b/middlewares/authMiddleware.js @@ -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')); } diff --git a/middlewares/requireSuperAdmin.js b/middlewares/requireSuperAdmin.js new file mode 100644 index 0000000..d87fa09 --- /dev/null +++ b/middlewares/requireSuperAdmin.js @@ -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; diff --git a/seed.js b/seed.js index eba950a..f86471f 100644 --- a/seed.js +++ b/seed.js @@ -119,27 +119,71 @@ 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 { - superAdminStatus = 'already_exists'; + 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'; + } } } diff --git a/utils/errors.json b/utils/errors.json index b885b89..c1997d6 100644 --- a/utils/errors.json +++ b/utils/errors.json @@ -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": "راه‌اندازی پایگاه داده با خطا مواجه شد." } } diff --git a/utils/superAdmin.js b/utils/superAdmin.js new file mode 100644 index 0000000..7ffb3d8 --- /dev/null +++ b/utils/superAdmin.js @@ -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 +};