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:
2026-01-22 13:25:49 -07:00
parent 40cd0e35bf
commit fd56561aed
2 changed files with 236 additions and 18 deletions

View File

@ -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;