diff --git a/apps/dashboard-api/src/__tests__/configLog.controller.test.js b/apps/dashboard-api/src/__tests__/configLog.controller.test.js new file mode 100644 index 000000000..7194a4e08 --- /dev/null +++ b/apps/dashboard-api/src/__tests__/configLog.controller.test.js @@ -0,0 +1,275 @@ +'use strict'; + +/** + * Tests for configLog.controller.js — getConfigLogs + * + * Coverage: + * - 200 happy path (no filter) + * - 200 happy path with valid category filter + * - 400 on unknown category string + * - 400 on non-string category (object injection attempt) + * - Correct pagination maths (totalPages, skip, limit clamping) + * - DB error forwarded via next() + */ + +// --- Mocks ------------------------------------------------------------------- + +const mockFind = jest.fn(); +const mockCountDocuments = jest.fn(); + +class MockAppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } +} + +jest.mock('@urbackend/common', () => ({ + ProjectConfigLog: { + find: mockFind, + countDocuments: mockCountDocuments, + }, + AppError: MockAppError, +})); + +// ----------------------------------------------------------------------------- + +const { getConfigLogs } = require('../controllers/configLog.controller'); + +// Helpers +const makeReq = (params = {}, query = {}) => ({ + params: { projectId: 'proj_abc123', ...params }, + query, +}); + +const makeRes = () => { + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + return res; +}; + +/** Chainable Mongoose query stub: find().sort().skip().limit().select().lean() */ +const makeQueryChain = (resolvedValue) => ({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(resolvedValue), +}); + +// ----------------------------------------------------------------------------- + +describe('configLog.controller — getConfigLogs', () => { + let next; + + beforeEach(() => { + jest.clearAllMocks(); + next = jest.fn(); + }); + + // --------------------------------------------------------------------------- + // Happy paths + // --------------------------------------------------------------------------- + + it('returns 200 with logs and pagination when no category filter is given', async () => { + const fakeLogs = [ + { _id: 'log1', category: 'auth', label: 'Auth enabled', changedAt: new Date() }, + ]; + mockFind.mockReturnValue(makeQueryChain(fakeLogs)); + mockCountDocuments.mockResolvedValue(1); + + const req = makeReq({}, {}); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.message).toBe('Configuration change logs retrieved successfully.'); + expect(body.data.logs).toEqual(fakeLogs); + expect(body.data.pagination).toEqual({ + page: 1, + limit: 30, + total: 1, + totalPages: 1, + }); + }); + + it('filters by a valid category and passes it to the DB query', async () => { + mockFind.mockReturnValue(makeQueryChain([])); + mockCountDocuments.mockResolvedValue(0); + + const req = makeReq({}, { category: 'auth' }); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + expect(next).not.toHaveBeenCalled(); + // Both DB calls must receive the category in the filter + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'proj_abc123', category: 'auth' }), + ); + expect(mockCountDocuments).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'proj_abc123', category: 'auth' }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('accepts every value in ALLOWED_CATEGORIES without error', async () => { + const ALLOWED = [ + 'project_info', 'api_key', 'auth', 'public_signup', 'auth_providers', + 'allowed_domains', 'byod_db', 'byod_storage', 'collection_schema', + 'collection_rls', 'mail_template', 'resend', 'member', + ]; + + for (const cat of ALLOWED) { + jest.clearAllMocks(); + mockFind.mockReturnValue(makeQueryChain([])); + mockCountDocuments.mockResolvedValue(0); + + const req = makeReq({}, { category: cat }); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + } + }); + + // --------------------------------------------------------------------------- + // Pagination maths + // --------------------------------------------------------------------------- + + it('computes skip correctly for page > 1', async () => { + mockFind.mockReturnValue(makeQueryChain([])); + mockCountDocuments.mockResolvedValue(100); + + const req = makeReq({}, { page: '3', limit: '10' }); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + const chain = mockFind.mock.results[0].value; + expect(chain.skip).toHaveBeenCalledWith(20); // (3-1) * 10 + expect(chain.limit).toHaveBeenCalledWith(10); + + const body = res.json.mock.calls[0][0]; + expect(body.data.pagination).toMatchObject({ page: 3, limit: 10, total: 100, totalPages: 10 }); + }); + + it('clamps limit to a maximum of 100', async () => { + mockFind.mockReturnValue(makeQueryChain([])); + mockCountDocuments.mockResolvedValue(0); + + const req = makeReq({}, { limit: '999' }); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + const chain = mockFind.mock.results[0].value; + expect(chain.limit).toHaveBeenCalledWith(100); + const body = res.json.mock.calls[0][0]; + expect(body.data.pagination.limit).toBe(100); + }); + + it('defaults page to 1 and limit to 30 when query params are absent', async () => { + mockFind.mockReturnValue(makeQueryChain([])); + mockCountDocuments.mockResolvedValue(0); + + const req = makeReq({}, {}); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + const chain = mockFind.mock.results[0].value; + expect(chain.skip).toHaveBeenCalledWith(0); + expect(chain.limit).toHaveBeenCalledWith(30); + }); + + it('floors page to 1 when page=0 or negative is supplied', async () => { + mockFind.mockReturnValue(makeQueryChain([])); + mockCountDocuments.mockResolvedValue(0); + + const req = makeReq({}, { page: '-5' }); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + const chain = mockFind.mock.results[0].value; + expect(chain.skip).toHaveBeenCalledWith(0); + const body = res.json.mock.calls[0][0]; + expect(body.data.pagination.page).toBe(1); + }); + + // --------------------------------------------------------------------------- + // Security — NoSQL injection prevention + // --------------------------------------------------------------------------- + + it('rejects an unknown category string with 400', async () => { + const req = makeReq({}, { category: 'unknown_bad_value' }); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + expect(mockFind).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(MockAppError)); + const err = next.mock.calls[0][0]; + expect(err.statusCode).toBe(400); + expect(err.message).toMatch(/Invalid category/); + }); + + it('rejects a MongoDB operator object injected via query string with 400', async () => { + // Simulates ?category[$ne]=null parsed by Express as { category: { $ne: null } } + const req = makeReq({}, { category: { $ne: null } }); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + expect(mockFind).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(MockAppError)); + const err = next.mock.calls[0][0]; + expect(err.statusCode).toBe(400); + }); + + it('rejects an empty string category with 400', async () => { + const req = makeReq({}, { category: '' }); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + // Empty string is not in ALLOWED_CATEGORIES + expect(next).toHaveBeenCalledWith(expect.any(MockAppError)); + const err = next.mock.calls[0][0]; + expect(err.statusCode).toBe(400); + }); + + // --------------------------------------------------------------------------- + // Error handling + // --------------------------------------------------------------------------- + + it('forwards a DB error to next() without swallowing it', async () => { + const dbError = new Error('MongoDB timeout'); + mockFind.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockRejectedValue(dbError), + }); + mockCountDocuments.mockResolvedValue(0); + + const req = makeReq({}, {}); + const res = makeRes(); + + await getConfigLogs(req, res, next); + + expect(next).toHaveBeenCalledWith(dbError); + expect(res.json).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dashboard-api/src/controllers/configLog.controller.js b/apps/dashboard-api/src/controllers/configLog.controller.js new file mode 100644 index 000000000..5426db415 --- /dev/null +++ b/apps/dashboard-api/src/controllers/configLog.controller.js @@ -0,0 +1,86 @@ +/** + * configLog.controller.js + * + * Handles API requests for the Project Configuration Change Log feature. + */ + +const { ProjectConfigLog, AppError } = require('@urbackend/common'); + +/** + * Exhaustive list of valid category values that can be stored by logConfigChange. + * Used to whitelist the `category` query param and prevent NoSQL operator injection. + */ +const ALLOWED_CATEGORIES = new Set([ + 'project_info', + 'api_key', + 'auth', + 'public_signup', + 'auth_providers', + 'allowed_domains', + 'byod_db', + 'byod_storage', + 'collection_schema', + 'collection_rls', + 'mail_template', + 'resend', + 'member', +]); + +/** + * GET /api/projects/:projectId/config-logs + * + * Returns paginated configuration change logs for a project. + * Accessible by any project member (admin or viewer). + * + * Query params: + * page {number} 1-indexed page number (default: 1) + * limit {number} items per page, max 100 (default: 30) + * category {string} optional filter by category — must be a known category value + */ +module.exports.getConfigLogs = async (req, res, next) => { + try { + const { projectId } = req.params; + + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 30)); + const skip = (page - 1) * limit; + + // Build filter with a safe, scalar projectId (already validated by authorizeProject middleware). + const filter = { projectId }; + + if (req.query.category !== undefined) { + // Reject non-string values (e.g. objects from ?category[$ne]=null) and + // unknown category names to prevent NoSQL operator injection. + if (typeof req.query.category !== 'string' || !ALLOWED_CATEGORIES.has(req.query.category)) { + return next(new AppError(400, `Invalid category. Allowed values: ${[...ALLOWED_CATEGORIES].join(', ')}`)); + } + filter.category = req.query.category; + } + + const [logs, total] = await Promise.all([ + ProjectConfigLog.find(filter) + .sort({ changedAt: -1 }) + .skip(skip) + .limit(limit) + .select('-__v') + .lean(), + ProjectConfigLog.countDocuments(filter), + ]); + + return res.status(200).json({ + success: true, + data: { + logs, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }, + message: 'Configuration change logs retrieved successfully.', + }); + } catch (err) { + next(err); + } +}; diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 2e544aee6..218e879ad 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -42,6 +42,7 @@ const { clearCompiledModel } = require("@urbackend/common"); const { createUniqueIndexes, ApiAnalytics, MailLog } = require("@urbackend/common"); const { getProjectAccessQuery, getProjectRole, Invitation, PROJECT_TEMPLATES } = require("@urbackend/common"); const { emitEvent } = require('../utils/emitEvent'); +const { logConfigChange } = require('../utils/logConfigChange'); const MAX_FILE_SIZE = 10 * 1024 * 1024; const SAFETY_MAX_BYTES = 100 * 1024 * 1024; const CONFIRM_UPLOAD_SIZE_TOLERANCE_BYTES = 64; @@ -787,6 +788,27 @@ module.exports.updateExternalConfig = async (req, res) => { storageRegistry.delete(project._id.toString()); } + // --- Config Audit Log --- + if (updateData["resources.db.config"]) { + await logConfigChange({ + projectId, + user: req.user, + category: 'byod_db', + label: 'External database (BYOD) configuration updated', + diff: [{ field: 'resources.db.uri', to: '••••••••' }], + }); + } + if (updateData["resources.storage.config"]) { + await logConfigChange({ + projectId, + user: req.user, + category: 'byod_storage', + label: 'External storage (BYOD) configuration updated', + diff: [{ field: 'resources.storage.config', to: '••••••••' }], + }); + } + // --- End Config Audit Log --- + res.status(200).json({ success: true, data: {}, message: "External configuration updated successfully." }); } catch (err) { if (err instanceof z.ZodError) { @@ -1935,6 +1957,31 @@ module.exports.updateProject = async (req, res) => { await deleteProjectByApiKeyCache(project.publishableKey); await deleteProjectByApiKeyCache(project.secretKey); + // --- Config Audit Log --- + const changedFields = Object.keys(updateFields).filter(f => f !== 'resendApiKey'); + if (changedFields.length > 0) { + await logConfigChange({ + projectId: req.params.projectId, + user: req.user, + category: 'project_info', + label: `Project info updated: ${changedFields.join(', ')}`, + diff: changedFields.map(field => ({ + field, + to: field === 'resendFromEmail' ? updateFields[field] : updateFields[field], + })), + }); + } + if (updateFields.resendApiKey) { + await logConfigChange({ + projectId: req.params.projectId, + user: req.user, + category: 'resend', + label: 'Resend API key updated', + diff: [{ field: 'resendApiKey', to: '••••••••' }], + }); + } + // --- End Config Audit Log --- + res.json(sanitizeProjectResponse(project.toObject())); } catch (err) { @@ -2385,6 +2432,16 @@ module.exports.updateAllowedDomains = async (req, res) => { await deleteProjectByApiKeyCache(project.publishableKey); await deleteProjectByApiKeyCache(project.secretKey); + // --- Config Audit Log --- + await logConfigChange({ + projectId: req.params.projectId, + user: req.user, + category: 'allowed_domains', + label: `Allowed domains updated (${cleanedDomains.length} domain${cleanedDomains.length !== 1 ? 's' : ''})`, + diff: [{ field: 'allowedDomains', to: cleanedDomains }], + }); + // --- End Config Audit Log --- + res.json({ message: "Allowed domains updated", allowedDomains: project.allowedDomains, @@ -2668,6 +2725,16 @@ module.exports.toggleAuth = async (req, res) => { await deleteProjectByApiKeyCache(project.publishableKey); await deleteProjectByApiKeyCache(project.secretKey); + // --- Config Audit Log --- + await logConfigChange({ + projectId, + user: req.user, + category: 'auth', + label: `Authentication ${project.isAuthEnabled ? 'enabled' : 'disabled'}`, + diff: [{ field: 'isAuthEnabled', to: project.isAuthEnabled }], + }); + // --- End Config Audit Log --- + const projectObj = sanitizeProjectResponse(project.toObject()); res.json({ @@ -2702,6 +2769,16 @@ module.exports.togglePublicSignup = async (req, res, next) => { await deleteProjectByApiKeyCache(project.publishableKey); await deleteProjectByApiKeyCache(project.secretKey); + // --- Config Audit Log --- + await logConfigChange({ + projectId, + user: req.user, + category: 'public_signup', + label: `Public signup ${project.allowPublicSignup ? 'enabled' : 'disabled'}`, + diff: [{ field: 'allowPublicSignup', to: project.allowPublicSignup }], + }); + // --- End Config Audit Log --- + const projectObj = sanitizeProjectResponse(project.toObject()); res.json({ @@ -2780,6 +2857,22 @@ module.exports.updateAuthProviders = async (req, res) => { await deleteProjectByApiKeyCache(project.publishableKey); await deleteProjectByApiKeyCache(project.secretKey); + // --- Config Audit Log --- + const updatedProviders = SOCIAL_PROVIDER_KEYS.filter(p => parsed[p]); + await logConfigChange({ + projectId, + user: req.user, + category: 'auth_providers', + label: `OAuth providers updated: ${updatedProviders.join(', ')}`, + diff: updatedProviders.map(provider => ({ + field: `authProviders.${provider}.enabled`, + // Use the persisted value from the saved document; fall back to the + // request value only if the provider block was newly written. + to: project.authProviders?.[provider]?.enabled ?? !!parsed[provider]?.enabled, + })), + }); + // --- End Config Audit Log --- + return res.json({ message: "Auth providers updated", authProviders: sanitizeAuthProviders(project.toObject().authProviders), @@ -2851,6 +2944,21 @@ module.exports.updateCollectionRls = async (req, res) => { await deleteProjectByApiKeyCache(project.publishableKey); await deleteProjectByApiKeyCache(project.secretKey); + // --- Config Audit Log --- + await logConfigChange({ + projectId, + user: req.user, + category: 'collection_rls', + label: `RLS settings updated for collection '${collectionName}'`, + diff: [ + { field: 'rls.enabled', to: collection.rls.enabled }, + { field: 'rls.mode', to: collection.rls.mode }, + { field: 'rls.ownerField', to: collection.rls.ownerField }, + { field: 'rls.requireAuthForWrite', to: collection.rls.requireAuthForWrite }, + ], + }); + // --- End Config Audit Log --- + res.json({ message: "Collection RLS updated", collection: { diff --git a/apps/dashboard-api/src/routes/projects.js b/apps/dashboard-api/src/routes/projects.js index 9f67e5fdd..db6043538 100644 --- a/apps/dashboard-api/src/routes/projects.js +++ b/apps/dashboard-api/src/routes/projects.js @@ -61,6 +61,8 @@ const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listAdm const exportController = require('../controllers/dbExport.controller'); const { syncSchema } = require('../controllers/syncSchema.controller'); +const { getConfigLogs } = require('../controllers/configLog.controller'); + // POST REQ FOR CREATE PROJECT router.post('/', authMiddleware, planEnforcement.attachDeveloper, planEnforcement.checkProjectLimit, planEnforcement.checkDeveloperCapability('createProject'), createProject); @@ -181,4 +183,7 @@ router.post('/:projectId/collections/:collectionName/export', authMiddleware, au // PUT REQ FOR SCHEMA SYNC (CLI) router.put('/:projectId/sync-schema', authFlexible, authorizeProject('admin'), planEnforcement.attachDeveloper, syncSchema); +// GET REQ FOR PROJECT CONFIG CHANGE LOGS +router.get('/:projectId/config-logs', authMiddleware, authorizeProject(), getConfigLogs); + module.exports = router; diff --git a/apps/dashboard-api/src/utils/logConfigChange.js b/apps/dashboard-api/src/utils/logConfigChange.js new file mode 100644 index 000000000..3015b6ca2 --- /dev/null +++ b/apps/dashboard-api/src/utils/logConfigChange.js @@ -0,0 +1,49 @@ +/** + * logConfigChange.js + * + * Thin helper that inserts a ProjectConfigLog document. + * Call this after any successful project-level configuration mutation. + * + * Usage: + * const { logConfigChange } = require('../utils/logConfigChange'); + * + * await logConfigChange({ + * projectId: req.params.projectId, + * user: req.user, // { _id, email } + * category: 'auth', + * label: 'Authentication toggled ON', + * diff: [{ field: 'isAuthEnabled', from: false, to: true }], + * }); + * + * All DB errors are swallowed so that a logging failure never breaks the + * primary API response. + */ + +const ProjectConfigLog = require('@urbackend/common').ProjectConfigLog; + +/** + * @param {Object} opts + * @param {string|Object} opts.projectId + * @param {{ _id: string|Object, email?: string }} opts.user + * @param {string} opts.category + * @param {string} opts.label + * @param {Array<{field:string,from:*,to:*}>|null} [opts.diff] + * @returns {Promise} + */ +async function logConfigChange({ projectId, user, category, label, diff = null }) { + try { + await ProjectConfigLog.create({ + projectId, + changedBy: user._id, + changedByEmail: user.email || '', + category, + label, + diff, + }); + } catch (err) { + // Never let audit logging crash a successful mutation + console.error('[ConfigLog] Failed to write config log:', err.message); + } +} + +module.exports = { logConfigChange }; diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 7ed9ba17a..b6c8b2951 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -29,6 +29,8 @@ const PlatformEvent = require("./models/PlatformEvent"); const DeveloperActivity = require("./models/DeveloperActivity"); const MailLog = require("./models/MailLog"); const Invitation = require("./models/Invitation"); +const ProjectConfigLog = require("./models/ProjectConfigLog"); + // Queues const { authEmailQueue, initAuthEmailWorker } = require("./queues/authEmailQueue"); @@ -152,6 +154,7 @@ module.exports = { DeveloperActivity, MailLog, Invitation, + ProjectConfigLog, authEmailQueue, exportQueue, emailQueue, diff --git a/packages/common/src/models/ProjectConfigLog.js b/packages/common/src/models/ProjectConfigLog.js new file mode 100644 index 000000000..a88446b87 --- /dev/null +++ b/packages/common/src/models/ProjectConfigLog.js @@ -0,0 +1,86 @@ +const mongoose = require('mongoose'); + +/** + * ProjectConfigLog — Audit trail for all project-level configuration changes. + * + * Every time a developer (or team member) changes a project setting via the + * dashboard-api, a document is inserted here capturing: + * - which project was affected + * - which setting category changed (e.g. "auth", "rls", "allowed_domains") + * - a human-readable label describing the change + * - the before/after values (sensitive values are masked automatically) + * - who made the change (developer reference) + * - their email at the time of the change (denormalized for fast display) + * - the exact timestamp + */ +const projectConfigLogSchema = new mongoose.Schema( + { + projectId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Project', + required: true, + index: true, + }, + + /** Developer who performed the action */ + changedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Developer', + required: true, + }, + + /** Denormalized email so we can display it even if the account is deleted */ + changedByEmail: { + type: String, + default: '', + }, + + /** + * High-level setting category. + * Possible values: + * project_info | api_key | auth | public_signup | auth_providers | + * allowed_domains | byod_db | byod_storage | collection_schema | + * collection_rls | mail_template | member | resend + */ + category: { + type: String, + required: true, + }, + + /** Short human-readable description, e.g. "Enabled GitHub OAuth provider" */ + label: { + type: String, + required: true, + }, + + /** + * Optional structured diff. + * Sensitive values (keys, tokens, URIs) MUST be masked before storage. + * Shape: { field: string, from: any, to: any }[] + */ + diff: { + type: mongoose.Schema.Types.Mixed, + default: null, + }, + + /** ISO timestamp of the change — defaults to insertion time */ + changedAt: { + type: Date, + default: Date.now, + index: true, + }, + }, + { + // No automatic createdAt/updatedAt — changedAt is the single timestamp + timestamps: false, + // Capped collection: max 10 000 entries, 10 MB per project would ideally + // use a partial index but Mongo capped collections don't support TTL. + // We keep it as a regular collection with a compound index for efficient + // per-project queries. + }, +); + +// Compound index: fetch all logs for a project ordered newest-first +projectConfigLogSchema.index({ projectId: 1, changedAt: -1 }); + +module.exports = mongoose.model('ProjectConfigLog', projectConfigLogSchema);