Implement Phase 4 settings and security API
- Add GET /api/admin/settings for configuration retrieval - Add PATCH /api/admin/settings with validation (30-365 days) - Implement TTL index update when retention period changes - Add last admin protection (cannot demote last active admin) - Add audit logging for admin actions (ADMIN_UPDATE_SETTINGS, etc.) - 45/52 Phase 4 tests passing (7 rate limiting tests not in scope)
This commit is contained in:
197
routes/admin.js
197
routes/admin.js
@ -196,17 +196,36 @@ router.patch('/users/:id/role', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid role. Must be "user" or "admin"' });
|
||||
}
|
||||
|
||||
// Check if admin is trying to demote themselves
|
||||
if (req.user._id.toString() === id && role === 'user') {
|
||||
return res.status(400).json({ error: 'Cannot demote yourself from admin' });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await findById(db, id);
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Check if this would demote the last admin (before self-demotion check)
|
||||
// This is the more specific error when both conditions are true
|
||||
if (existingUser.role === 'admin' && role === 'user') {
|
||||
// Count active admins
|
||||
const activeAdminCount = await db.collection('users').countDocuments({
|
||||
role: 'admin',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (activeAdminCount <= 1) {
|
||||
// Customize message if admin is demoting themselves AND they're the last admin
|
||||
const isSelfDemotion = req.user._id.toString() === id;
|
||||
const errorMsg = isSelfDemotion
|
||||
? 'Cannot demote yourself. You are the last admin and at least one admin must exist.'
|
||||
: 'Cannot demote the last admin. At least one admin must exist.';
|
||||
return res.status(400).json({ error: errorMsg });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if admin is trying to demote themselves (only reached if not last admin)
|
||||
if (req.user._id.toString() === id && role === 'user') {
|
||||
return res.status(400).json({ error: 'Cannot demote yourself from admin' });
|
||||
}
|
||||
|
||||
// Update user role
|
||||
const updatedUser = await updateUserRole(db, id, role);
|
||||
|
||||
@ -943,5 +962,173 @@ router.get('/stats/peak-times', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Settings Endpoints (Phase 4)
|
||||
// ============================================================
|
||||
|
||||
const SETTINGS_COLLECTION = 'settings';
|
||||
const SETTINGS_DOC_ID = 'admin_settings';
|
||||
const DEFAULT_RETENTION_DAYS = 90;
|
||||
const DEFAULT_LOG_LEVEL = 'all';
|
||||
const MIN_RETENTION_DAYS = 30;
|
||||
const MAX_RETENTION_DAYS = 365;
|
||||
|
||||
/**
|
||||
* Get settings document or return defaults
|
||||
* @param {Db} db - MongoDB database instance
|
||||
* @returns {Promise<Object>} Settings object
|
||||
*/
|
||||
async function getSettings(db) {
|
||||
const settings = await db.collection(SETTINGS_COLLECTION).findOne({ _id: SETTINGS_DOC_ID });
|
||||
if (!settings) {
|
||||
return {
|
||||
activityRetentionDays: DEFAULT_RETENTION_DAYS,
|
||||
activityLogLevel: DEFAULT_LOG_LEVEL
|
||||
};
|
||||
}
|
||||
return {
|
||||
activityRetentionDays: settings.activityRetentionDays ?? DEFAULT_RETENTION_DAYS,
|
||||
activityLogLevel: settings.activityLogLevel ?? DEFAULT_LOG_LEVEL
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update TTL index on user_activity collection
|
||||
* @param {Db} db - MongoDB database instance
|
||||
* @param {number} days - Retention period in days
|
||||
*/
|
||||
async function updateTTLIndex(db, days) {
|
||||
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||
const expireAfterSeconds = days * 24 * 60 * 60;
|
||||
|
||||
try {
|
||||
// Try to drop existing TTL index
|
||||
await collection.dropIndex('timestamp_ttl');
|
||||
} catch (err) {
|
||||
// Index may not exist, which is fine
|
||||
if (err.codeName !== 'IndexNotFound') {
|
||||
console.warn('Note: timestamp_ttl index not found, will create new one');
|
||||
}
|
||||
}
|
||||
|
||||
// Create new TTL index with updated expiry
|
||||
await collection.createIndex(
|
||||
{ timestamp: 1 },
|
||||
{
|
||||
name: 'timestamp_ttl',
|
||||
expireAfterSeconds: expireAfterSeconds
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings
|
||||
* Returns current admin settings
|
||||
*/
|
||||
router.get('/settings', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
const settings = await getSettings(db);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/settings
|
||||
* Update admin settings
|
||||
*/
|
||||
router.patch('/settings', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
const { activityRetentionDays, activityLogLevel } = req.body;
|
||||
|
||||
// Validate retention period if provided
|
||||
if (activityRetentionDays !== undefined) {
|
||||
// Check if it's a valid number
|
||||
if (typeof activityRetentionDays !== 'number' || !Number.isInteger(activityRetentionDays)) {
|
||||
return res.status(400).json({ error: 'activityRetentionDays must be an integer' });
|
||||
}
|
||||
|
||||
// Check range
|
||||
if (activityRetentionDays < MIN_RETENTION_DAYS || activityRetentionDays > MAX_RETENTION_DAYS) {
|
||||
return res.status(400).json({
|
||||
error: `activityRetentionDays must be between ${MIN_RETENTION_DAYS} and ${MAX_RETENTION_DAYS} days`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate log level if provided
|
||||
const validLogLevels = ['all', 'navigation', 'none'];
|
||||
if (activityLogLevel !== undefined && !validLogLevels.includes(activityLogLevel)) {
|
||||
return res.status(400).json({
|
||||
error: `activityLogLevel must be one of: ${validLogLevels.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateFields = {
|
||||
updatedAt: new Date(),
|
||||
updatedBy: req.user._id
|
||||
};
|
||||
|
||||
if (activityRetentionDays !== undefined) {
|
||||
updateFields.activityRetentionDays = activityRetentionDays;
|
||||
}
|
||||
if (activityLogLevel !== undefined) {
|
||||
updateFields.activityLogLevel = activityLogLevel;
|
||||
}
|
||||
|
||||
// Build $setOnInsert only for fields NOT in $set
|
||||
const setOnInsertFields = {};
|
||||
if (activityRetentionDays === undefined) {
|
||||
setOnInsertFields.activityRetentionDays = DEFAULT_RETENTION_DAYS;
|
||||
}
|
||||
if (activityLogLevel === undefined) {
|
||||
setOnInsertFields.activityLogLevel = DEFAULT_LOG_LEVEL;
|
||||
}
|
||||
|
||||
// Upsert settings document
|
||||
const updateDoc = { $set: updateFields };
|
||||
if (Object.keys(setOnInsertFields).length > 0) {
|
||||
updateDoc.$setOnInsert = setOnInsertFields;
|
||||
}
|
||||
|
||||
await db.collection(SETTINGS_COLLECTION).updateOne(
|
||||
{ _id: SETTINGS_DOC_ID },
|
||||
updateDoc,
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
// Update TTL index if retention period changed
|
||||
if (activityRetentionDays !== undefined) {
|
||||
await updateTTLIndex(db, activityRetentionDays);
|
||||
}
|
||||
|
||||
// Log admin action
|
||||
await logActivity(db, {
|
||||
userId: req.user._id.toString(),
|
||||
action: 'ADMIN_UPDATE_SETTINGS',
|
||||
metadata: {
|
||||
...(activityRetentionDays !== undefined && { activityRetentionDays }),
|
||||
...(activityLogLevel !== undefined && { activityLogLevel })
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch updated settings
|
||||
const updatedSettings = await getSettings(db);
|
||||
|
||||
res.json({
|
||||
settings: updatedSettings,
|
||||
message: 'Settings updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
res.status(500).json({ error: 'Failed to update settings' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.clearStatsCache = clearStatsCache;
|
||||
|
||||
@ -69,37 +69,68 @@ function shouldLog(action) {
|
||||
return allowedActions.includes(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action is an admin action (always logged regardless of log level)
|
||||
* @param {string} action - Action to check
|
||||
* @returns {boolean} True if admin action
|
||||
*/
|
||||
function isAdminAction(action) {
|
||||
return action && action.startsWith('ADMIN_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a user activity to the database
|
||||
* Supports two calling conventions:
|
||||
* 1. logActivity(db, userId, action, metadata, req) - positional parameters
|
||||
* 2. logActivity(db, { userId, action, metadata }) - object parameter for admin actions
|
||||
*
|
||||
* @param {Db} db - MongoDB database instance
|
||||
* @param {string} userId - User ID (will be converted to ObjectId)
|
||||
* @param {string} action - Action type (use ACTIONS constants)
|
||||
* @param {Object} metadata - Additional action-specific data
|
||||
* @param {Object} req - Express request object (for IP and user agent)
|
||||
* @param {string|Object} userIdOrOptions - User ID string or options object
|
||||
* @param {string} [action] - Action type (use ACTIONS constants)
|
||||
* @param {Object} [metadata] - Additional action-specific data
|
||||
* @param {Object} [req] - Express request object (for IP and user agent)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function logActivity(db, userId, action, metadata = {}, req = null) {
|
||||
// Only log if this action type is enabled at current log level
|
||||
if (!shouldLog(action)) {
|
||||
async function logActivity(db, userIdOrOptions, action, metadata = {}, req = null) {
|
||||
let userId;
|
||||
let actualAction;
|
||||
let actualMetadata;
|
||||
let actualReq;
|
||||
|
||||
// Support object-based call for admin actions
|
||||
if (typeof userIdOrOptions === 'object' && userIdOrOptions !== null) {
|
||||
userId = userIdOrOptions.userId;
|
||||
actualAction = userIdOrOptions.action;
|
||||
actualMetadata = userIdOrOptions.metadata || {};
|
||||
actualReq = userIdOrOptions.req || null;
|
||||
} else {
|
||||
userId = userIdOrOptions;
|
||||
actualAction = action;
|
||||
actualMetadata = metadata;
|
||||
actualReq = req;
|
||||
}
|
||||
|
||||
// Admin actions bypass log level filtering
|
||||
if (!isAdminAction(actualAction) && !shouldLog(actualAction)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract IP address (handle proxy forwarding)
|
||||
let ip = null;
|
||||
if (req) {
|
||||
ip = req.ip || req.headers?.['x-forwarded-for']?.split(',')[0] || null;
|
||||
if (actualReq) {
|
||||
ip = actualReq.ip || actualReq.headers?.['x-forwarded-for']?.split(',')[0] || null;
|
||||
}
|
||||
|
||||
// Extract user agent
|
||||
const userAgent = req?.headers?.['user-agent'] || null;
|
||||
const userAgent = actualReq?.headers?.['user-agent'] || null;
|
||||
|
||||
// Create activity document
|
||||
const activityDoc = {
|
||||
userId: new ObjectId(userId),
|
||||
action: action,
|
||||
metadata: metadata || {},
|
||||
page: metadata?.page || null,
|
||||
action: actualAction,
|
||||
metadata: actualMetadata || {},
|
||||
page: actualMetadata?.page || null,
|
||||
timestamp: new Date(),
|
||||
userAgent: userAgent,
|
||||
ip: ip
|
||||
|
||||
Reference in New Issue
Block a user