From 4df5df37c4919f635b39bcad172ab87aa0733842 Mon Sep 17 00:00:00 2001 From: vivektekwani021-bot Date: Fri, 31 Jul 2026 11:03:06 +0530 Subject: [PATCH 1/5] feat project changelog --- ISSUE.md | 157 ++++++++++++++++++ .../src/controllers/configLog.controller.js | 58 +++++++ .../src/controllers/project.controller.js | 102 ++++++++++++ apps/dashboard-api/src/routes/projects.js | 5 + .../src/utils/logConfigChange.js | 49 ++++++ packages/common/src/index.js | 3 + .../common/src/models/ProjectConfigLog.js | 86 ++++++++++ 7 files changed, 460 insertions(+) create mode 100644 ISSUE.md create mode 100644 apps/dashboard-api/src/controllers/configLog.controller.js create mode 100644 apps/dashboard-api/src/utils/logConfigChange.js create mode 100644 packages/common/src/models/ProjectConfigLog.js diff --git a/ISSUE.md b/ISSUE.md new file mode 100644 index 000000000..f19639e91 --- /dev/null +++ b/ISSUE.md @@ -0,0 +1,157 @@ +--- +name: Feature – Project Configuration Change Log +about: Track all project-level settings changes with a full audit trail (who, what, when) +title: "feat: Project Configuration Change Log (Audit Trail)" +labels: enhancement, audit, dashboard +assignees: '' +--- + +## Summary + +As a developer using urBackend, I need a way to see a **full audit trail of every configuration change** made to my project — including who made the change, what setting was changed, and when it happened. + +This is critical for team projects where multiple members have admin access, and essential for debugging unexpected behavior caused by a settings change. + +--- + +## Problem + +Currently, there is **no way to know**: +- Who changed the project's allowed domains, auth settings, or RLS rules +- When the BYOD database/storage configuration was last updated +- Whether a team member enabled/disabled authentication or OAuth providers + +This makes it extremely difficult to audit changes in team environments and trace the root cause of behavioral regressions. + +--- + +## Proposed Solution + +Implement a **Project Config Change Log** system with the following components: + +### 1. New MongoDB Model — `ProjectConfigLog` + +Store an audit entry every time a project-level setting is mutated. + +**Schema fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `projectId` | ObjectId | Reference to the affected project | +| `changedBy` | ObjectId | Reference to the developer who made the change | +| `changedByEmail` | String | Denormalized email (for display even if account is deleted) | +| `category` | String | Category of the change (see table below) | +| `label` | String | Human-readable description of the change | +| `diff` | Mixed | Array of `{ field, from, to }` objects (sensitive values masked) | +| `changedAt` | Date | Timestamp of the change | + +**Categories:** + +| Category | Trigger | +|---|---| +| `project_info` | Name, siteUrl, or resendFromEmail updated | +| `api_key` | Publishable or secret API key regenerated | +| `auth` | `isAuthEnabled` toggled | +| `public_signup` | `allowPublicSignup` toggled | +| `auth_providers` | GitHub/Google OAuth providers updated | +| `allowed_domains` | CORS allowed domains list changed | +| `byod_db` | External MongoDB URI configured | +| `byod_storage` | External storage (Supabase/S3/R2/GCS) configured | +| `collection_schema` | Collection schema added or updated | +| `collection_rls` | Row-Level Security rules changed on a collection | +| `mail_template` | Mail template created, updated, or deleted | +| `resend` | Resend API key updated | +| `member` | Team member invited, role changed, or removed | + +### 2. New API Endpoint + +``` +GET /api/projects/:projectId/config-logs +``` + +**Access:** Any project member (admin or viewer) + +**Query Parameters:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `page` | number | 1 | Page number (1-indexed) | +| `limit` | number | 30 | Items per page (max 100) | +| `category` | string | — | Filter by category | + +**Response:** +```json +{ + "success": true, + "data": { + "logs": [ + { + "_id": "...", + "projectId": "...", + "changedBy": "...", + "changedByEmail": "dev@example.com", + "category": "auth", + "label": "Authentication enabled", + "diff": [{ "field": "isAuthEnabled", "to": true }], + "changedAt": "2026-07-31T10:00:00.000Z" + } + ], + "pagination": { + "page": 1, + "limit": 30, + "total": 42, + "totalPages": 2 + } + } +} +``` + +### 3. Dashboard UI Page + +A new **"Config History"** tab (or page) in the web-dashboard for each project showing the log entries in a clear, scannable timeline format. + +--- + +## Instrumented Actions (Phase 1) + +The following controller functions should write a log entry on success: + +- [x] `updateProject` — name, siteUrl, resendFromEmail, resendApiKey +- [x] `toggleAuth` — isAuthEnabled toggle +- [x] `togglePublicSignup` — allowPublicSignup toggle +- [x] `updateAuthProviders` — GitHub/Google OAuth config +- [x] `updateAllowedDomains` — CORS allowed domains +- [x] `updateExternalConfig` — BYOD DB / storage config +- [x] `updateCollectionRls` — per-collection RLS settings +- [ ] `regenerateApiKey` — API key rotation (Phase 2) +- [ ] `createCollection` / `deleteCollection` — schema changes (Phase 2) +- [ ] `createMailTemplate` / `updateMailTemplate` / `deleteMailTemplate` (Phase 2) +- [ ] `inviteMember` / `updateMemberRole` / `removeMember` (Phase 2) + +--- + +## Security & Privacy + +- **No sensitive values are stored in plain text.** All secrets (API keys, DB URIs, client secrets) are masked with `••••••••` in the `diff` field. +- The `changedByEmail` is denormalized at write time to prevent data loss if a developer account is deleted. +- Config logs are **read-only** — there is no API to delete or modify them. + +--- + +## Implementation Notes + +- The `logConfigChange` helper is designed to **never throw** — a logging failure will only produce a `console.error` and will not affect the primary API response. +- Logs are indexed on `(projectId, changedAt)` for efficient paginated queries. +- The model lives in `packages/common/src/models/ProjectConfigLog.js` and is exported from `@urbackend/common`. + +--- + +## Acceptance Criteria + +- [ ] All Phase 1 config mutations write a `ProjectConfigLog` document +- [ ] `GET /api/projects/:projectId/config-logs` returns paginated logs correctly +- [ ] Logs are filterable by `category` +- [ ] No sensitive value (key, URI, secret) is stored in plaintext in the log +- [ ] A logging failure does not affect the primary mutation response +- [ ] Any project member (admin or viewer) can read the log +- [ ] A "Config History" UI page is visible in the web-dashboard per project 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..c2a544aa9 --- /dev/null +++ b/apps/dashboard-api/src/controllers/configLog.controller.js @@ -0,0 +1,58 @@ +/** + * configLog.controller.js + * + * Handles API requests for the Project Configuration Change Log feature. + */ + +const { ProjectConfigLog, AppError, getProjectAccessQuery } = require('@urbackend/common'); + +/** + * 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 + */ +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; + + const filter = { projectId }; + if (req.query.category) { + 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), + }, + }, + }); + } 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..b82056366 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,23 @@ module.exports.updateExternalConfig = async (req, res) => { storageRegistry.delete(project._id.toString()); } + // --- Config Audit Log --- + const byodChanges = []; + if (updateData["resources.db.config"]) byodChanges.push({ field: 'resources.db.uri', to: '••••••••' }); + if (updateData["resources.storage.config"]) byodChanges.push({ field: 'resources.storage.config', to: '••••••••' }); + if (byodChanges.length > 0) { + await logConfigChange({ + projectId, + user: req.user, + category: updateData["resources.db.config"] ? 'byod_db' : 'byod_storage', + label: updateData["resources.db.config"] + ? 'External database (BYOD) configuration updated' + : 'External storage (BYOD) configuration updated', + diff: byodChanges, + }); + } + // --- 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 +1953,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 +2428,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 +2721,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 +2765,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 +2853,20 @@ 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`, + to: !!parsed[provider]?.enabled, + })), + }); + // --- End Config Audit Log --- + return res.json({ message: "Auth providers updated", authProviders: sanitizeAuthProviders(project.toObject().authProviders), @@ -2851,6 +2938,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); From 37bf8cd417a7f5d0d53c67b79f6d981a6e7ddf67 Mon Sep 17 00:00:00 2001 From: vivektekwani021-bot Date: Fri, 31 Jul 2026 11:12:34 +0530 Subject: [PATCH 2/5] fix: audit log review fixes - split byod logs, fix oauth diff, add response message, fix issue code fence --- ISSUE.md | 5 ++-- .../src/controllers/configLog.controller.js | 1 + .../src/controllers/project.controller.js | 26 ++++++++++++------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/ISSUE.md b/ISSUE.md index f19639e91..967664152 100644 --- a/ISSUE.md +++ b/ISSUE.md @@ -65,7 +65,7 @@ Store an audit entry every time a project-level setting is mutated. ### 2. New API Endpoint -``` +```http GET /api/projects/:projectId/config-logs ``` @@ -102,7 +102,8 @@ GET /api/projects/:projectId/config-logs "total": 42, "totalPages": 2 } - } + }, + "message": "Configuration change logs retrieved successfully." } ``` diff --git a/apps/dashboard-api/src/controllers/configLog.controller.js b/apps/dashboard-api/src/controllers/configLog.controller.js index c2a544aa9..9b6dea6b8 100644 --- a/apps/dashboard-api/src/controllers/configLog.controller.js +++ b/apps/dashboard-api/src/controllers/configLog.controller.js @@ -51,6 +51,7 @@ module.exports.getConfigLogs = async (req, res, next) => { 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 b82056366..218e879ad 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -789,18 +789,22 @@ module.exports.updateExternalConfig = async (req, res) => { } // --- Config Audit Log --- - const byodChanges = []; - if (updateData["resources.db.config"]) byodChanges.push({ field: 'resources.db.uri', to: '••••••••' }); - if (updateData["resources.storage.config"]) byodChanges.push({ field: 'resources.storage.config', to: '••••••••' }); - if (byodChanges.length > 0) { + if (updateData["resources.db.config"]) { await logConfigChange({ projectId, user: req.user, - category: updateData["resources.db.config"] ? 'byod_db' : 'byod_storage', - label: updateData["resources.db.config"] - ? 'External database (BYOD) configuration updated' - : 'External storage (BYOD) configuration updated', - diff: byodChanges, + 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 --- @@ -2862,7 +2866,9 @@ module.exports.updateAuthProviders = async (req, res) => { label: `OAuth providers updated: ${updatedProviders.join(', ')}`, diff: updatedProviders.map(provider => ({ field: `authProviders.${provider}.enabled`, - to: !!parsed[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 --- From 90cf4d2575e9be8c447b33d1e4567b5fcf05a0d0 Mon Sep 17 00:00:00 2001 From: vivektekwani021-bot Date: Fri, 31 Jul 2026 11:27:01 +0530 Subject: [PATCH 3/5] fix(security): prevent NoSQL injection in config-logs category filter (CodeQL) --- .../src/controllers/configLog.controller.js | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/dashboard-api/src/controllers/configLog.controller.js b/apps/dashboard-api/src/controllers/configLog.controller.js index 9b6dea6b8..5426db415 100644 --- a/apps/dashboard-api/src/controllers/configLog.controller.js +++ b/apps/dashboard-api/src/controllers/configLog.controller.js @@ -4,7 +4,27 @@ * Handles API requests for the Project Configuration Change Log feature. */ -const { ProjectConfigLog, AppError, getProjectAccessQuery } = require('@urbackend/common'); +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 @@ -15,7 +35,7 @@ const { ProjectConfigLog, AppError, getProjectAccessQuery } = require('@urbacken * 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 + * category {string} optional filter by category — must be a known category value */ module.exports.getConfigLogs = async (req, res, next) => { try { @@ -25,8 +45,15 @@ module.exports.getConfigLogs = async (req, res, next) => { 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) { + + 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; } From 68051cbf320fc8f0b08472e8a4cf3ad22de354b8 Mon Sep 17 00:00:00 2001 From: vivektekwani021-bot Date: Fri, 31 Jul 2026 11:33:33 +0530 Subject: [PATCH 4/5] test: add unit tests for configLog.controller (11 cases) --- .../__tests__/configLog.controller.test.js | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 apps/dashboard-api/src/__tests__/configLog.controller.test.js 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(); + }); +}); From 16c6e28157ba8b1f8b7c2b974d2d8167906dc1e8 Mon Sep 17 00:00:00 2001 From: vivektekwani021-bot Date: Fri, 31 Jul 2026 11:35:44 +0530 Subject: [PATCH 5/5] feat project changelog --- ISSUE.md | 158 ------------------------------------------------------- 1 file changed, 158 deletions(-) delete mode 100644 ISSUE.md diff --git a/ISSUE.md b/ISSUE.md deleted file mode 100644 index 967664152..000000000 --- a/ISSUE.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -name: Feature – Project Configuration Change Log -about: Track all project-level settings changes with a full audit trail (who, what, when) -title: "feat: Project Configuration Change Log (Audit Trail)" -labels: enhancement, audit, dashboard -assignees: '' ---- - -## Summary - -As a developer using urBackend, I need a way to see a **full audit trail of every configuration change** made to my project — including who made the change, what setting was changed, and when it happened. - -This is critical for team projects where multiple members have admin access, and essential for debugging unexpected behavior caused by a settings change. - ---- - -## Problem - -Currently, there is **no way to know**: -- Who changed the project's allowed domains, auth settings, or RLS rules -- When the BYOD database/storage configuration was last updated -- Whether a team member enabled/disabled authentication or OAuth providers - -This makes it extremely difficult to audit changes in team environments and trace the root cause of behavioral regressions. - ---- - -## Proposed Solution - -Implement a **Project Config Change Log** system with the following components: - -### 1. New MongoDB Model — `ProjectConfigLog` - -Store an audit entry every time a project-level setting is mutated. - -**Schema fields:** - -| Field | Type | Description | -|-------|------|-------------| -| `projectId` | ObjectId | Reference to the affected project | -| `changedBy` | ObjectId | Reference to the developer who made the change | -| `changedByEmail` | String | Denormalized email (for display even if account is deleted) | -| `category` | String | Category of the change (see table below) | -| `label` | String | Human-readable description of the change | -| `diff` | Mixed | Array of `{ field, from, to }` objects (sensitive values masked) | -| `changedAt` | Date | Timestamp of the change | - -**Categories:** - -| Category | Trigger | -|---|---| -| `project_info` | Name, siteUrl, or resendFromEmail updated | -| `api_key` | Publishable or secret API key regenerated | -| `auth` | `isAuthEnabled` toggled | -| `public_signup` | `allowPublicSignup` toggled | -| `auth_providers` | GitHub/Google OAuth providers updated | -| `allowed_domains` | CORS allowed domains list changed | -| `byod_db` | External MongoDB URI configured | -| `byod_storage` | External storage (Supabase/S3/R2/GCS) configured | -| `collection_schema` | Collection schema added or updated | -| `collection_rls` | Row-Level Security rules changed on a collection | -| `mail_template` | Mail template created, updated, or deleted | -| `resend` | Resend API key updated | -| `member` | Team member invited, role changed, or removed | - -### 2. New API Endpoint - -```http -GET /api/projects/:projectId/config-logs -``` - -**Access:** Any project member (admin or viewer) - -**Query Parameters:** - -| Param | Type | Default | Description | -|-------|------|---------|-------------| -| `page` | number | 1 | Page number (1-indexed) | -| `limit` | number | 30 | Items per page (max 100) | -| `category` | string | — | Filter by category | - -**Response:** -```json -{ - "success": true, - "data": { - "logs": [ - { - "_id": "...", - "projectId": "...", - "changedBy": "...", - "changedByEmail": "dev@example.com", - "category": "auth", - "label": "Authentication enabled", - "diff": [{ "field": "isAuthEnabled", "to": true }], - "changedAt": "2026-07-31T10:00:00.000Z" - } - ], - "pagination": { - "page": 1, - "limit": 30, - "total": 42, - "totalPages": 2 - } - }, - "message": "Configuration change logs retrieved successfully." -} -``` - -### 3. Dashboard UI Page - -A new **"Config History"** tab (or page) in the web-dashboard for each project showing the log entries in a clear, scannable timeline format. - ---- - -## Instrumented Actions (Phase 1) - -The following controller functions should write a log entry on success: - -- [x] `updateProject` — name, siteUrl, resendFromEmail, resendApiKey -- [x] `toggleAuth` — isAuthEnabled toggle -- [x] `togglePublicSignup` — allowPublicSignup toggle -- [x] `updateAuthProviders` — GitHub/Google OAuth config -- [x] `updateAllowedDomains` — CORS allowed domains -- [x] `updateExternalConfig` — BYOD DB / storage config -- [x] `updateCollectionRls` — per-collection RLS settings -- [ ] `regenerateApiKey` — API key rotation (Phase 2) -- [ ] `createCollection` / `deleteCollection` — schema changes (Phase 2) -- [ ] `createMailTemplate` / `updateMailTemplate` / `deleteMailTemplate` (Phase 2) -- [ ] `inviteMember` / `updateMemberRole` / `removeMember` (Phase 2) - ---- - -## Security & Privacy - -- **No sensitive values are stored in plain text.** All secrets (API keys, DB URIs, client secrets) are masked with `••••••••` in the `diff` field. -- The `changedByEmail` is denormalized at write time to prevent data loss if a developer account is deleted. -- Config logs are **read-only** — there is no API to delete or modify them. - ---- - -## Implementation Notes - -- The `logConfigChange` helper is designed to **never throw** — a logging failure will only produce a `console.error` and will not affect the primary API response. -- Logs are indexed on `(projectId, changedAt)` for efficient paginated queries. -- The model lives in `packages/common/src/models/ProjectConfigLog.js` and is exported from `@urbackend/common`. - ---- - -## Acceptance Criteria - -- [ ] All Phase 1 config mutations write a `ProjectConfigLog` document -- [ ] `GET /api/projects/:projectId/config-logs` returns paginated logs correctly -- [ ] Logs are filterable by `category` -- [ ] No sensitive value (key, URI, secret) is stored in plaintext in the log -- [ ] A logging failure does not affect the primary mutation response -- [ ] Any project member (admin or viewer) can read the log -- [ ] A "Config History" UI page is visible in the web-dashboard per project