From 8f686fbc9968667f1767b6d6cb60d63b4d9d3086 Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 14:36:01 +0530 Subject: [PATCH 01/17] feat(rbac): guest role foundation + project-scope guards (SEC-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the guest/client role (external users restricted to their assigned projects). Wiring these guards onto the detail endpoints follows. - ROLE_GUEST (4) + guestProjectIds on company_users. - Pure guestAccessRules (isGuest, guestAllowsProject) + 10 unit tests. - Reusable guards in permissionGuard: requireGuestProjectAccess / requireGuestTaskAccess — these enforce for ALL clients (guests are web users), while every non-guest passes straight through untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- Config/permissionGuard.js | 69 ++++++++++++++++++++++++ Modules/Auth/helpers/guestAccessRules.js | 18 +++++++ tests/guest-access-rules.test.js | 40 ++++++++++++++ utils/mongo-handler/schema.js | 5 ++ 4 files changed, 132 insertions(+) create mode 100644 Modules/Auth/helpers/guestAccessRules.js create mode 100644 tests/guest-access-rules.test.js diff --git a/Config/permissionGuard.js b/Config/permissionGuard.js index 04cd08d5..3876113d 100644 --- a/Config/permissionGuard.js +++ b/Config/permissionGuard.js @@ -28,6 +28,7 @@ const logger = require("./loggerConfig"); const ROLE_OWNER = 1; const ROLE_ADMIN = 2; +const { isGuest, guestAllowsProject } = require('../Modules/Auth/helpers/guestAccessRules'); const OBJECT_ID_PATTERN = /^[a-f0-9]{24}$/i; const ROLE_CACHE_TTL_SECONDS = 60; @@ -274,6 +275,70 @@ const invalidateRoleCache = (companyId, uid) => { if (companyId && uid) myCache.del(`roleType:${companyId}:${uid}`); }; +// SEC-01 — a guest's assigned project ids (from company_users). [] if none/not a guest. +const getGuestProjectIds = async (companyId, uid) => { + try { + const cu = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.COMPANY_USERS, + data: [{ userId: String(uid) }, { guestProjectIds: 1 }], + }, 'findOne'); + return cu && Array.isArray(cu.guestProjectIds) ? cu.guestProjectIds : []; + } catch (error) { + logger.error(`getGuestProjectIds error: ${error.message || error}`); + return []; + } +}; + +/** + * SEC-01 — block a GUEST (roleType 4) from a project outside their assigned set. + * Unlike requireRole/requirePermission (PAT-only), this enforces for ALL clients + * (guests are web users); every non-guest passes straight through untouched. + * `getProjectId(req)` resolves the project id; defaults to common locations. + */ +const requireGuestProjectAccess = (getProjectId) => async (req, res, next) => { + try { + const companyId = req.headers['companyid'] || ''; + const roleType = await getRoleType(companyId, req.uid); + if (!isGuest(roleType)) return next(); + const projectId = typeof getProjectId === 'function' + ? getProjectId(req) + : (req.params.id || req.params.projectId || (req.body && req.body.projectId) || (req.query && req.query.projectId)); + const allowed = await getGuestProjectIds(companyId, req.uid); + if (guestAllowsProject({ guestProjectIds: allowed, projectId })) return next(); + return res.status(403).json({ status: false, statusText: 'Access denied: you do not have access to this project.', error: 'Forbidden' }); + } catch (error) { + logger.error(`requireGuestProjectAccess error: ${error.message || error}`); + return res.status(403).json({ status: false, statusText: 'Permission check failed.', error: 'Forbidden' }); + } +}; + +/** + * SEC-01 — block a GUEST from a task whose project is outside their set. Loads + * the task, reads its ProjectID, and checks membership. Non-guests pass through. + */ +const requireGuestTaskAccess = (getTaskId) => async (req, res, next) => { + try { + const companyId = req.headers['companyid'] || ''; + const roleType = await getRoleType(companyId, req.uid); + if (!isGuest(roleType)) return next(); + const taskId = typeof getTaskId === 'function' ? getTaskId(req) : (req.params.id || (req.body && req.body.taskId)); + if (!taskId || !OBJECT_ID_PATTERN.test(String(taskId))) { + return res.status(403).json({ status: false, statusText: 'Access denied.', error: 'Forbidden' }); + } + const task = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, + data: [{ _id: new mongoose.Types.ObjectId(String(taskId)) }, { ProjectID: 1 }], + }, 'findOne'); + if (!task) return res.status(404).json({ status: false, statusText: 'Not found.' }); + const allowed = await getGuestProjectIds(companyId, req.uid); + if (guestAllowsProject({ guestProjectIds: allowed, projectId: task.ProjectID })) return next(); + return res.status(403).json({ status: false, statusText: 'Access denied: this task is in a project you cannot access.', error: 'Forbidden' }); + } catch (error) { + logger.error(`requireGuestTaskAccess error: ${error.message || error}`); + return res.status(403).json({ status: false, statusText: 'Permission check failed.', error: 'Forbidden' }); + } +}; + module.exports = { ROLE_OWNER, ROLE_ADMIN, @@ -287,4 +352,8 @@ module.exports = { evaluateMany, MCP_PERMISSION_KEYS, invalidateRoleCache, + ROLE_GUEST: 4, + getGuestProjectIds, + requireGuestProjectAccess, + requireGuestTaskAccess, }; diff --git a/Modules/Auth/helpers/guestAccessRules.js b/Modules/Auth/helpers/guestAccessRules.js new file mode 100644 index 00000000..fda17dc2 --- /dev/null +++ b/Modules/Auth/helpers/guestAccessRules.js @@ -0,0 +1,18 @@ +// SEC-01 guest/client role access rules. Pure — no I/O — shared by the +// permission guards and tests. +// +// A guest (roleType 4) is an external client/viewer who may only see the +// projects explicitly assigned to them (company_users.guestProjectIds). + +const ROLE_GUEST = 4; + +const isGuest = (roleType) => Number(roleType) === ROLE_GUEST; + +/* Is `projectId` within a guest's assigned project ids? Compares as strings so + * ObjectId / string ids match. Empty/missing projectId is never allowed. */ +const guestAllowsProject = ({ guestProjectIds = [], projectId } = {}) => { + if (projectId === null || projectId === undefined || projectId === '') return false; + return (guestProjectIds || []).map((id) => String(id)).includes(String(projectId)); +}; + +module.exports = { ROLE_GUEST, isGuest, guestAllowsProject }; diff --git a/tests/guest-access-rules.test.js b/tests/guest-access-rules.test.js new file mode 100644 index 00000000..4bb58131 --- /dev/null +++ b/tests/guest-access-rules.test.js @@ -0,0 +1,40 @@ +/** + * Guest Access Rules Test Suite (SEC-01) + * AlianHub Project Management System + * + * Unit tests for Modules/Auth/helpers/guestAccessRules.js. Pure — no DB. + */ + +const { ROLE_GUEST, isGuest, guestAllowsProject } = require('../Modules/Auth/helpers/guestAccessRules'); + +describe('🔒 GUEST ACCESS - Rules (SEC-01)', () => { + + describe('isGuest', () => { + test('ROLE_GUEST is 4', () => expect(ROLE_GUEST).toBe(4)); + test('roleType 4 is a guest', () => expect(isGuest(4)).toBe(true)); + test('owner / admin / member are not guests', () => { + expect(isGuest(1)).toBe(false); + expect(isGuest(2)).toBe(false); + expect(isGuest(3)).toBe(false); + }); + test('null / undefined is not a guest', () => { + expect(isGuest(null)).toBe(false); + expect(isGuest(undefined)).toBe(false); + }); + }); + + describe('guestAllowsProject', () => { + const ids = ['p1', 'p2']; + test('allows an assigned project', () => expect(guestAllowsProject({ guestProjectIds: ids, projectId: 'p1' })).toBe(true)); + test('denies an unassigned project', () => expect(guestAllowsProject({ guestProjectIds: ids, projectId: 'p9' })).toBe(false)); + test('denies when nothing assigned', () => expect(guestAllowsProject({ guestProjectIds: [], projectId: 'p1' })).toBe(false)); + test('denies a missing/empty projectId', () => { + expect(guestAllowsProject({ guestProjectIds: ids })).toBe(false); + expect(guestAllowsProject({ guestProjectIds: ids, projectId: '' })).toBe(false); + }); + test('matches across string / ObjectId-like types', () => { + expect(guestAllowsProject({ guestProjectIds: [{ toString: () => 'p1' }], projectId: 'p1' })).toBe(true); + }); + test('handles missing args', () => expect(guestAllowsProject()).toBe(false)); + }); +}); diff --git a/utils/mongo-handler/schema.js b/utils/mongo-handler/schema.js index 27a79c3e..bbde8e0c 100644 --- a/utils/mongo-handler/schema.js +++ b/utils/mongo-handler/schema.js @@ -1189,6 +1189,11 @@ const schema = { type: Number, required: true }, + guestProjectIds: { + type: Array, + default: [], + required: false + }, sendInvitationTime: { type: Number, required: false From bf9b3b5764c685ded85691d2c469077a9d1bd319 Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 14:55:55 +0530 Subject: [PATCH 02/17] feat(rbac): scope projects to assigned set for guests (SEC-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getProjectList: a guest (roleType 4) sees ONLY the projects in their guestProjectIds — short-circuits the public/private logic. - requireGuestProjectAccess() guard on the project :id routes (detail / update / allTask / sprintFolder) — a guest hitting a non-assigned project now gets 403. Non-guests are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- Modules/Project/controller/getProjectList.js | 20 +++++++++++++++++++- Modules/Project/routes.js | 9 +++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Modules/Project/controller/getProjectList.js b/Modules/Project/controller/getProjectList.js index 7d9ee888..dc03133c 100644 --- a/Modules/Project/controller/getProjectList.js +++ b/Modules/Project/controller/getProjectList.js @@ -2,6 +2,8 @@ const { SCHEMA_TYPE } = require("../../../Config/schemaType"); const { MongoDbCrudOpration } = require("../../../utils/mongo-handler/mongoQueries"); const {myCache} = require('../../../Config/config'); const { fetchRules } = require("../../settings/securityPermissions/controller"); +const mongoose = require("mongoose"); +const { ROLE_GUEST } = require("../../Auth/helpers/guestAccessRules"); exports.getProjectList = async (req, res) => { try { @@ -33,7 +35,7 @@ exports.getProjectList = async (req, res) => { type: SCHEMA_TYPE.COMPANY_USERS, data: [ { userId: uid }, - { roleType: 1, _id: 0 } + { roleType: 1, guestProjectIds: 1, _id: 0 } ] }; const [teams, companyUsers] = await Promise.all([ @@ -44,6 +46,22 @@ exports.getProjectList = async (req, res) => { const teamIds = teams.map((team) => 'tId_' + team._id); const roleType = companyUsers?.roleType; + // SEC-01 — a guest (roleType 4) sees ONLY their explicitly-assigned projects. + if (roleType === ROLE_GUEST) { + const guestIds = (Array.isArray(companyUsers?.guestProjectIds) ? companyUsers.guestProjectIds : []) + .map((id) => { try { return new mongoose.Types.ObjectId(String(id)); } catch (e) { return null; } }) + .filter(Boolean); + const guestQuery = [ + { $match: { _id: { $in: guestIds }, deletedStatusKey: { $nin: [1] } } }, + { $project: { legacyId: 0 } }, + ]; + if (req.query.skip) guestQuery.push({ $skip: Number(req.query.skip) }); + if (req.query.limit) guestQuery.push({ $limit: Number(req.query.limit) }); + const guestProjects = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.PROJECTS, data: [guestQuery] }, 'aggregate'); + myCache.set(cacheKey, JSON.stringify(guestProjects), 480); + return res.status(200).json(guestProjects); + } + const response = await fetchRules(companyId); const rule = response && response.length ? response?.find((x) => x?.key === 'public_projects') : {}; diff --git a/Modules/Project/routes.js b/Modules/Project/routes.js index ed9f3910..ccb3e6d3 100644 --- a/Modules/Project/routes.js +++ b/Modules/Project/routes.js @@ -10,13 +10,14 @@ const checklistCtrl = require('./controller/checklist'); const tagsCtrl = require('./controller/tags'); const getQueryCtrl = require('./controller/getQueryFun') +const { requireGuestProjectAccess } = require('../../Config/permissionGuard'); exports.init = (app) => { app.post('/api/v1/project/search',projectFilterCtrl.projectFilter); - app.get('/api/v1/project/:id', Projectctrl.getProjectById); + app.get('/api/v1/project/:id', requireGuestProjectAccess(), Projectctrl.getProjectById); app.get('/api/v1/project', projectListCtrl.getProjectList); - app.put('/api/v1/project/:id',updateProjectCtrl.updateProject); - app.put('/api/v1/project/allTask/:id',projectAlltaskUpdateCtrl.projectAlltaskUpdate); - app.get('/api/v1/project/sprintFolder/:id', projectSprintFolderCtrl.getSprintFolder); + app.put('/api/v1/project/:id', requireGuestProjectAccess(), updateProjectCtrl.updateProject); + app.put('/api/v1/project/allTask/:id', requireGuestProjectAccess(), projectAlltaskUpdateCtrl.projectAlltaskUpdate); + app.get('/api/v1/project/sprintFolder/:id', requireGuestProjectAccess(), projectSprintFolderCtrl.getSprintFolder); app.put('/api/v1/project/sprint/:id',projectSprintUpdateCtrl.updateSprint); app.post('/api/v1/project/filter/create', manageGlobalFilterCtrl.saveFilter); app.get('/api/v1/project/filter/:userId', manageGlobalFilterCtrl.getFilter); From 5098ca8ddc4611b54912b987a7f0232f06c09636 Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 14:57:55 +0530 Subject: [PATCH 03/17] feat(rbac): guard task detail + per-project task data for guests (SEC-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/task/:id -> requireGuestTaskAccess() (403 if the task's project isn't assigned to the guest). - GET /api/v1/projectdata/taskData -> requireGuestProjectAccess() (by projectId). Note: the query endpoints (tabSyncTask, task/find) and comments still need controller-level query-scoping for guests (follow-up within SEC-01) — handled carefully to avoid false denials on a guest's own assigned-project boards. Co-Authored-By: Claude Opus 4.8 (1M context) --- Modules/Project/routes.js | 2 +- Modules/Tasks/routes.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Project/routes.js b/Modules/Project/routes.js index ccb3e6d3..acc56389 100644 --- a/Modules/Project/routes.js +++ b/Modules/Project/routes.js @@ -26,5 +26,5 @@ exports.init = (app) => { app.post('/api/v1/project/checklist', checklistCtrl.handleChecklist); app.post('/api/v1/get-remaining-projects', projectFilterCtrl.getRemainingProject); app.post('/api/v1/project/tags', tagsCtrl.handleTags); - app.get('/api/v1/projectdata/taskData',getQueryCtrl.getQueryFun) + app.get('/api/v1/projectdata/taskData', requireGuestProjectAccess(), getQueryCtrl.getQueryFun) } \ No newline at end of file diff --git a/Modules/Tasks/routes.js b/Modules/Tasks/routes.js index fd93c437..c5dcef19 100644 --- a/Modules/Tasks/routes.js +++ b/Modules/Tasks/routes.js @@ -5,7 +5,7 @@ const advanceFilter = require('./helpers/manageGlobalFilter'); const getTaskCtrl = require('./helpers/getTasksData'); const { handleEvents } = require('../Company/eventController'); const logger = require('../../Config/loggerConfig'); -const { requireTaskActionPermission, requirePermission } = require('../../Config/permissionGuard'); +const { requireTaskActionPermission, requirePermission, requireGuestTaskAccess } = require('../../Config/permissionGuard'); exports.init = (app) => { app.post('/api/tasks', (req, res) => { @@ -151,7 +151,7 @@ exports.init = (app) => { app.delete('/api/v1/task/filter/delete/:cid/:id', advanceFilter.deleteFilter); - app.get('/api/v1/task/:id',getTaskCtrl.getTask); + app.get('/api/v1/task/:id', requireGuestTaskAccess(), getTaskCtrl.getTask); app.post('/api/v1/task/find', getTaskCtrl.getTaskByQyery); From 55c4f9a3b95c96fa83fe8695775810b624ec4d48 Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 15:04:50 +0530 Subject: [PATCH 04/17] feat(rbac): scope task-query + comment endpoints for guests (SEC-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tabSyncTask -> requireGuestProjectAccess by req.body.pid; task/find -> guests denied (arbitrary query, no project context — they use the scoped board). - Comments save/update guarded by objId.projectId; paginated/searched reads by query.projectId. A guest only touches comments on assigned projects. - updateMember now invalidates the cached roleType so a role/guest-projects change takes effect in the guards immediately (was 60s stale). Co-Authored-By: Claude Opus 4.8 (1M context) --- Modules/Comments/routes.js | 9 +++++---- Modules/Tasks/routes.js | 6 +++--- Modules/settings/Members/controller.js | 6 ++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Modules/Comments/routes.js b/Modules/Comments/routes.js index 3966853a..01490fca 100644 --- a/Modules/Comments/routes.js +++ b/Modules/Comments/routes.js @@ -1,8 +1,9 @@ const ctrl = require('./controller'); +const { requireGuestProjectAccess } = require('../../Config/permissionGuard'); exports.init = (app) => { - app.post('/api/v1/comments', ctrl.save); - app.put('/api/v1/comments', ctrl.update); - app.get('/api/v1/comments/get-paginated-messages', ctrl.getPaginatedMessages); - app.get('/api/v1/comments/get-searched-messages', ctrl.searchMessageFromMainChat); + app.post('/api/v1/comments', requireGuestProjectAccess((req) => req.body?.data?.objId?.projectId || req.body?.objId?.projectId), ctrl.save); + app.put('/api/v1/comments', requireGuestProjectAccess((req) => req.body?.data?.objId?.projectId || req.body?.objId?.projectId), ctrl.update); + app.get('/api/v1/comments/get-paginated-messages', requireGuestProjectAccess((req) => req.query?.projectId), ctrl.getPaginatedMessages); + app.get('/api/v1/comments/get-searched-messages', requireGuestProjectAccess((req) => req.query?.projectId), ctrl.searchMessageFromMainChat); } \ No newline at end of file diff --git a/Modules/Tasks/routes.js b/Modules/Tasks/routes.js index c5dcef19..7d97913a 100644 --- a/Modules/Tasks/routes.js +++ b/Modules/Tasks/routes.js @@ -5,7 +5,7 @@ const advanceFilter = require('./helpers/manageGlobalFilter'); const getTaskCtrl = require('./helpers/getTasksData'); const { handleEvents } = require('../Company/eventController'); const logger = require('../../Config/loggerConfig'); -const { requireTaskActionPermission, requirePermission, requireGuestTaskAccess } = require('../../Config/permissionGuard'); +const { requireTaskActionPermission, requirePermission, requireGuestTaskAccess, requireGuestProjectAccess } = require('../../Config/permissionGuard'); exports.init = (app) => { app.post('/api/tasks', (req, res) => { @@ -141,7 +141,7 @@ exports.init = (app) => { }); }); - app.post('/api/v1/tabSyncTask',tabSyncTaskCtrl.getTabSyncTasks); + app.post('/api/v1/tabSyncTask', requireGuestProjectAccess((req) => req.body && req.body.pid), tabSyncTaskCtrl.getTabSyncTasks); app.post('/api/v1/task/filter/create', advanceFilter.saveFilter); @@ -153,7 +153,7 @@ exports.init = (app) => { app.get('/api/v1/task/:id', requireGuestTaskAccess(), getTaskCtrl.getTask); - app.post('/api/v1/task/find', getTaskCtrl.getTaskByQyery); + app.post('/api/v1/task/find', requireGuestProjectAccess(), getTaskCtrl.getTaskByQyery); app.put('/api/v1/task',getTaskCtrl.updateTask); diff --git a/Modules/settings/Members/controller.js b/Modules/settings/Members/controller.js index 6514be31..68036230 100644 --- a/Modules/settings/Members/controller.js +++ b/Modules/settings/Members/controller.js @@ -255,6 +255,12 @@ exports.updateMember = async (req, res) => { removeCache("UserProjectData:", true); removeCache(`UserData:${response.userId}`, false); removeCache(`UserAllData:${req.headers['companyid']}`); + // SEC-01: a role / guest-projects change must take effect immediately in + // the access guards (permissionGuard.getRoleType is cached 60s). + if (response && response.userId) { + const { invalidateRoleCache } = require('../../../Config/permissionGuard'); + invalidateRoleCache(req.headers['companyid'], response.userId); + } if(response) { return res.status(200).json({ status: true, data: response }); From 76bb981d94fb42c23135bc3f3105ac8c31038d4b Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 15:07:22 +0530 Subject: [PATCH 05/17] test(rbac): guest role test cases (SEC-01) 10 API/UI verification cases for the guest/client role + the pre-existing detail-endpoint access gap now closed for guests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/test-cases/GuestRole.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .claude/test-cases/GuestRole.md diff --git a/.claude/test-cases/GuestRole.md b/.claude/test-cases/GuestRole.md new file mode 100644 index 00000000..5bff36bb --- /dev/null +++ b/.claude/test-cases/GuestRole.md @@ -0,0 +1,25 @@ +# Guest / Client Role — Test Cases + +**Feature:** SEC-01 — external guests (roleType 4) restricted to assigned projects, enforced server-side +**Backend:** `Modules/Auth/helpers/guestAccessRules.js` (pure) + `requireGuestProjectAccess` / `requireGuestTaskAccess` in `Config/permissionGuard.js`; `guestProjectIds` on `company_users`; project-list scoping in `getProjectList` +**Guards applied:** project `:id` (detail/update/allTask/sprintFolder), project list, `task/:id`, `/projectdata/taskData`, `tabSyncTask` (by pid), `task/find` (guests denied), comments (by projectId) +**Assignment:** `PUT /api/v1/members {id, data:{roleType:4, guestProjectIds:[...]}}` (role cache invalidated on change) +**Unit tests:** `tests/guest-access-rules.test.js` (10 cases, all green) +**Legend:** ✅ Pass · ❌ Fail · ⬜ Not run + +| ID | Title | Precondition | Steps | Expected | Actual | Status | +|----|-------|--------------|-------|----------|--------|--------| +| GR-01 | Assign a guest | Owner/admin | `PUT /members` set `roleType:4` + `guestProjectIds:[P1]` | company_users updated; role cache invalidated (takes effect at once) | | ⬜ | +| GR-02 | Project list scoped | User is a guest assigned [P1] | `GET /api/v1/project` | Only P1 returned (not public/other projects) | | ⬜ | +| GR-03 | Project detail 403 | Guest assigned [P1] | `GET /api/v1/project/P2` (unassigned) | 403 Forbidden; `GET .../P1` → 200 | | ⬜ | +| GR-04 | Task detail 403 | Guest assigned [P1]; task T2 in P2 | `GET /api/v1/task/T2` | 403; a task in P1 → 200 | | ⬜ | +| GR-05 | Board sync scoped | Guest assigned [P1] | `POST /tabSyncTask {pid:P2}` | 403; `{pid:P1}` → 200 | | ⬜ | +| GR-06 | Arbitrary task query denied | Guest | `POST /api/v1/task/find` | 403 (guests use the scoped board, not free queries) | | ⬜ | +| GR-07 | Comments scoped | Guest assigned [P1] | view/post comments on P2 vs P1 | P2 → 403; P1 → allowed | | ⬜ | +| GR-08 | No admin nav | Logged in as a guest | open the app | No Settings/admin nav (checkPermission returns null for a guest's keys) | | ⬜ | +| GR-09 | Role change is immediate | Change a user's role to/from guest | retry a previously-allowed call | New scoping applies right away (no 60s cache lag) | | ⬜ | +| GR-10 | Non-guests unaffected | Owner / admin / member | normal usage | All guards pass through; behaviour byte-for-byte unchanged | | ⬜ | + +**Total:** 10 cases (API/UI — run on the dev server). Guest-access rules covered by 10 automated unit tests. + +**Follow-up (usability, not blocking):** an admin-facing "Guest" role option + per-guest project picker in the Members UI (assignment works via the members API today). From 3b6024a83062b318627a2e456beb6563b8433cd0 Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 15:38:12 +0530 Subject: [PATCH 06/17] feat(sso): enterprise SSO backend with SAML and OIDC (SEC-02) - New per-company sso_configs collection + admin config CRUD (owner/admin gated; secret-free public view for the login page). - OIDC: /sso/oidc/initiate + /callback (openid-client, lazy-required) with state(CSRF)+nonce+PKCE, single-use 5-min cache. - SAML: /sso/saml/initiate + /acs + /metadata (samlify, lazy-required; signature-validated). - JIT provisioning mirrors the OAuth signup (global userAuth+users + company_users + notifications); reuses the session/JWT pipeline via a redirect-style finalizeSsoSession. - Auth wiring: only /sso/config requires JWT; the login-flow routes are public. - Deps added (lazy): openid-client, samlify, @authenio/samlify-node-saml2 (run npm install on the server). 13 unit tests; full suite 319 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Config/collections.js | 1 + Config/schemaType.js | 1 + Config/setMiddleware.js | 4 + Modules/SSO/config.js | 76 +++++++++++++++++++ Modules/SSO/helpers/ssoRules.js | 54 ++++++++++++++ Modules/SSO/init.js | 5 ++ Modules/SSO/oidc.js | 94 +++++++++++++++++++++++ Modules/SSO/provisioning.js | 66 ++++++++++++++++ Modules/SSO/routes.js | 21 ++++++ Modules/SSO/saml.js | 112 ++++++++++++++++++++++++++++ Modules/SSO/ssoSession.js | 33 ++++++++ index.js | 1 + package.json | 3 + tests/sso-rules.test.js | 67 +++++++++++++++++ utils/mongo-handler/createSchema.js | 2 + utils/mongo-handler/mongoQueries.js | 7 +- utils/mongo-handler/schema.js | 12 +++ 17 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 Modules/SSO/config.js create mode 100644 Modules/SSO/helpers/ssoRules.js create mode 100644 Modules/SSO/init.js create mode 100644 Modules/SSO/oidc.js create mode 100644 Modules/SSO/provisioning.js create mode 100644 Modules/SSO/routes.js create mode 100644 Modules/SSO/saml.js create mode 100644 Modules/SSO/ssoSession.js create mode 100644 tests/sso-rules.test.js diff --git a/Config/collections.js b/Config/collections.js index 1c11ccc0..a813d07a 100644 --- a/Config/collections.js +++ b/Config/collections.js @@ -73,6 +73,7 @@ const dbCollections = { RECURRING_TASKS: "recurring_tasks", TIMESHEET_APPROVAL: "timesheet_approval", BILLING_RATES: "billing_rates", + SSO_CONFIGS: "sso_configs", } /** DOCUMENT ID'S NAME WHICH IS USED IN THE "SETTINGS" COLLECTION NAME **/ diff --git a/Config/schemaType.js b/Config/schemaType.js index 4e12321c..1ea0c198 100644 --- a/Config/schemaType.js +++ b/Config/schemaType.js @@ -72,6 +72,7 @@ const SCHEMA_TYPE = { RECURRING_TASKS: "recurring_tasks", TIMESHEET_APPROVAL: "timesheet_approval", BILLING_RATES: "billing_rates", + SSO_CONFIGS: "sso_configs", } module.exports = { diff --git a/Config/setMiddleware.js b/Config/setMiddleware.js index 7723f201..2bc4e4bf 100644 --- a/Config/setMiddleware.js +++ b/Config/setMiddleware.js @@ -168,6 +168,10 @@ const verifyJWTTokenWithCRoute = [ '/api/v2/auth/2fa/setup', '/api/v2/auth/2fa/verify', '/api/v2/auth/2fa/disable', + // SSO admin config (Modules/SSO) — JWT+company so the owner/admin gate has + // req.uid. The login-flow routes (/api/v2/sso/oidc|saml|public) stay PUBLIC + // (pre-auth), so they are intentionally NOT listed here. + '/api/v2/sso/config', ]; const verifyJWTToken = [ "/api/v2/company/delete", diff --git a/Modules/SSO/config.js b/Modules/SSO/config.js new file mode 100644 index 00000000..270854c9 --- /dev/null +++ b/Modules/SSO/config.js @@ -0,0 +1,76 @@ +const { SCHEMA_TYPE } = require("../../Config/schemaType"); +const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries"); +const { getRoleType, isPrivileged } = require("../../Config/permissionGuard"); +const { validateSsoConfig, publicSsoView } = require("./helpers/ssoRules"); +const logger = require("../../Config/loggerConfig"); + +const companyOf = (req) => req.headers['companyid'] || (req.body && req.body.companyId) || (req.query && req.query.companyId); + +// SSO config holds IdP secrets — only owner/admin may read/write it. +const callerIsAdmin = async (req) => { + const roleType = await getRoleType(companyOf(req), req.uid); + return isPrivileged(roleType); +}; + +/* GET /api/v2/sso/config — owner/admin: the full config (incl. secrets they own). */ +exports.getSsoConfig = async (req, res) => { + try { + const companyId = companyOf(req); + if (!companyId) return res.send({ status: false, statusText: 'companyId is required.' }); + if (!(await callerIsAdmin(req))) return res.status(403).json({ status: false, statusText: 'Owner/admin only.' }); + const cfg = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.SSO_CONFIGS, data: [{ deletedStatusKey: 0 }] }, 'findOne'); + return res.send({ status: true, statusText: 'OK', data: cfg || null }); + } catch (error) { + logger.error(`getSsoConfig: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +}; + +/* PUT /api/v2/sso/config — owner/admin: upsert the config. */ +exports.setSsoConfig = async (req, res) => { + try { + const companyId = companyOf(req); + if (!companyId) return res.send({ status: false, statusText: 'companyId is required.' }); + if (!(await callerIsAdmin(req))) return res.status(403).json({ status: false, statusText: 'Owner/admin only.' }); + const { provider, oidc, saml, isEnabled, autoProvisionUsers, defaultRoleType } = req.body || {}; + const check = validateSsoConfig({ provider, oidc, saml }); + if (!check.valid) return res.send({ status: false, statusText: check.reason }); + const actor = String(req.uid || ''); + const set = { + provider: String(provider), + oidc: provider === 'oidc' ? (oidc || {}) : {}, + saml: provider === 'saml' ? (saml || {}) : {}, + isEnabled: isEnabled !== false, + autoProvisionUsers: autoProvisionUsers !== false, + defaultRoleType: Number(defaultRoleType) || 3, + updatedBy: actor, + deletedStatusKey: 0, + }; + const saved = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.SSO_CONFIGS, + data: [ + { deletedStatusKey: 0 }, + { $set: set, $setOnInsert: { createdBy: actor } }, + { upsert: true, returnDocument: 'after', setDefaultsOnInsert: true }, + ], + }, 'findOneAndUpdate'); + return res.send({ status: true, statusText: 'SSO config saved.', data: saved }); + } catch (error) { + logger.error(`setSsoConfig: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +}; + +/* GET /api/v2/sso/public?companyId= — unauthenticated; login page only. No secrets. */ +exports.getPublicSsoConfig = async (req, res) => { + try { + const companyId = companyOf(req); + if (!companyId) return res.send({ status: false, statusText: 'companyId is required.' }); + const cfg = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.SSO_CONFIGS, data: [{ deletedStatusKey: 0, isEnabled: true }], + }, 'findOne'); + return res.send({ status: true, statusText: 'OK', data: publicSsoView(cfg) }); + } catch (error) { + return res.send({ status: false, statusText: error.message }); + } +}; diff --git a/Modules/SSO/helpers/ssoRules.js b/Modules/SSO/helpers/ssoRules.js new file mode 100644 index 00000000..c84ce308 --- /dev/null +++ b/Modules/SSO/helpers/ssoRules.js @@ -0,0 +1,54 @@ +// SEC-02 SSO rules. Pure — no I/O — shared by the SSO controllers and tests. + +const SSO_PROVIDERS = Object.freeze(['oidc', 'saml']); +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +/* Validate an admin's SSO config payload. Returns { valid, reason }. */ +const validateSsoConfig = ({ provider, oidc, saml } = {}) => { + if (!SSO_PROVIDERS.includes(String(provider))) { + return { valid: false, reason: `provider must be one of: ${SSO_PROVIDERS.join(', ')}.` }; + } + if (provider === 'oidc') { + if (!oidc || typeof oidc !== 'object') return { valid: false, reason: 'oidc config is required.' }; + if (!oidc.issuer && !oidc.discoveryUrl) return { valid: false, reason: 'oidc.issuer or oidc.discoveryUrl is required.' }; + if (!oidc.clientId) return { valid: false, reason: 'oidc.clientId is required.' }; + if (!oidc.clientSecret) return { valid: false, reason: 'oidc.clientSecret is required.' }; + } + if (provider === 'saml') { + if (!saml || typeof saml !== 'object') return { valid: false, reason: 'saml config is required.' }; + if (!saml.entryPoint) return { valid: false, reason: 'saml.entryPoint (IdP SSO URL) is required.' }; + if (!saml.idpCert) return { valid: false, reason: 'saml.idpCert (IdP signing certificate) is required.' }; + } + return { valid: true, reason: '' }; +}; + +/* Strip secrets — the only fields safe to expose to the (unauthenticated) login page. */ +const publicSsoView = (cfg) => { + if (!cfg || !cfg.isEnabled) return null; + return { provider: cfg.provider, isEnabled: true }; +}; + +/* Normalise an identity from SSO claims/attributes using an optional attribute + * map. Returns { valid, reason, email, firstName, lastName, externalId }. */ +const extractIdentity = (claims = {}, map = {}) => { + const pick = (key, def) => { + const attr = (map && map[key]) || def; + const v = claims ? claims[attr] : undefined; + return v === undefined || v === null ? '' : String(v); + }; + const email = pick('email', 'email').trim().toLowerCase(); + if (!email || !EMAIL_RE.test(email)) { + return { valid: false, reason: 'The SSO assertion did not include a valid email.' }; + } + let firstName = pick('firstName', 'given_name'); + let lastName = pick('lastName', 'family_name'); + const fullName = pick('name', 'name'); + if (!firstName && fullName) firstName = fullName.split(' ')[0]; + if (!lastName && fullName) lastName = fullName.split(' ').slice(1).join(' '); + if (!firstName) firstName = email.split('@')[0]; + if (!lastName) lastName = '-'; + const externalId = pick('id', 'sub') || String((claims && claims.nameID) || ''); + return { valid: true, reason: '', email, firstName, lastName, externalId }; +}; + +module.exports = { SSO_PROVIDERS, validateSsoConfig, publicSsoView, extractIdentity }; diff --git a/Modules/SSO/init.js b/Modules/SSO/init.js new file mode 100644 index 00000000..be6fc26a --- /dev/null +++ b/Modules/SSO/init.js @@ -0,0 +1,5 @@ +const routes = require('./routes'); + +exports.init = (app) => { + routes.init(app); +} diff --git a/Modules/SSO/oidc.js b/Modules/SSO/oidc.js new file mode 100644 index 00000000..d473c640 --- /dev/null +++ b/Modules/SSO/oidc.js @@ -0,0 +1,94 @@ +const { SCHEMA_TYPE } = require("../../Config/schemaType"); +const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries"); +const { myCache } = require("../../Config/config"); +const logger = require("../../Config/loggerConfig"); +const { extractIdentity } = require("./helpers/ssoRules"); +const { jitProvisionUser } = require("./provisioning"); +const { finalizeSsoSession } = require("./ssoSession"); + +// `openid-client` is lazy-required so app load never breaks before `npm install`. +const apiBase = () => String(process.env.APIURL || '').replace(/\/$/, ''); +const REDIRECT_URI = () => `${apiBase()}/api/v2/sso/oidc/callback`; + +const loadConfig = async (companyId) => MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.SSO_CONFIGS, data: [{ deletedStatusKey: 0, isEnabled: true }], +}, 'findOne'); + +const buildClient = async (cfg) => { + const { Issuer } = require('openid-client'); + const o = cfg.oidc || {}; + const issuer = o.discoveryUrl + ? await Issuer.discover(o.discoveryUrl) + : new Issuer({ + issuer: o.issuer, + authorization_endpoint: o.authorizationEndpoint, + token_endpoint: o.tokenEndpoint, + jwks_uri: o.jwksUri, + userinfo_endpoint: o.userinfoEndpoint, + }); + return new issuer.Client({ + client_id: o.clientId, + client_secret: o.clientSecret, + redirect_uris: [REDIRECT_URI()], + response_types: ['code'], + }); +}; + +/* GET /api/v2/sso/oidc/initiate?companyId= — redirect the user to the IdP. */ +exports.oidcInitiate = async (req, res) => { + try { + const companyId = req.query.companyId || req.headers['companyid']; + if (!companyId) return res.status(400).send('companyId is required'); + const cfg = await loadConfig(companyId); + if (!cfg || cfg.provider !== 'oidc') return res.status(404).send('OIDC SSO is not configured for this company'); + const { generators } = require('openid-client'); + const client = await buildClient(cfg); + const state = generators.state(); + const nonce = generators.nonce(); + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + // CSRF (state) + nonce + PKCE verifier, consumed once on callback (5 min TTL). + myCache.set(`sso:oidc:${state}`, { companyId: String(companyId), nonce, codeVerifier }, 300); + const url = client.authorizationUrl({ + scope: (cfg.oidc && cfg.oidc.scopes) || 'openid email profile', + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + return res.redirect(url); + } catch (error) { + logger.error(`oidcInitiate: ${error.message || error}`); + return res.redirect('/login?ssoError=initiate'); + } +}; + +/* GET /api/v2/sso/oidc/callback — validate, JIT-provision, establish session. */ +exports.oidcCallback = async (req, res) => { + try { + const params = req.query || {}; + const cached = params.state ? myCache.get(`sso:oidc:${params.state}`) : null; + if (!cached) return res.redirect('/login?ssoError=state'); + myCache.del(`sso:oidc:${params.state}`); // single-use → replay protection + const { companyId, nonce, codeVerifier } = cached; + const cfg = await loadConfig(companyId); + if (!cfg || cfg.provider !== 'oidc') return res.redirect('/login?ssoError=config'); + const client = await buildClient(cfg); + const tokenSet = await client.callback(REDIRECT_URI(), params, { state: params.state, nonce, code_verifier: codeVerifier }); + const id = extractIdentity(tokenSet.claims(), (cfg.oidc && cfg.oidc.claimMap) || {}); + if (!id.valid) return res.redirect('/login?ssoError=identity'); + const uid = await jitProvisionUser({ + companyId, + email: id.email, + firstName: id.firstName, + lastName: id.lastName, + externalId: id.externalId, + defaultRoleType: cfg.defaultRoleType, + autoProvision: cfg.autoProvisionUsers !== false, + }); + return finalizeSsoSession(req, res, uid, `/${companyId}`); + } catch (error) { + logger.error(`oidcCallback: ${error.message || error}`); + return res.redirect('/login?ssoError=callback'); + } +}; diff --git a/Modules/SSO/provisioning.js b/Modules/SSO/provisioning.js new file mode 100644 index 00000000..f97e317d --- /dev/null +++ b/Modules/SSO/provisioning.js @@ -0,0 +1,66 @@ +const mongoose = require("mongoose"); +const { SCHEMA_TYPE } = require("../../Config/schemaType"); +const { dbCollections } = require("../../Config/collections"); +const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries"); +const { addAndRemoveUserInMongodbNotificationCount } = require("../Auth/controller/authHelpers"); +const logger = require("../../Config/loggerConfig"); + +// SEC-02 — Just-In-Time provision (or link) an SSO user into a company. Mirrors +// the OAuth signup path (createUser.googleSignup): global userAuth + users, +// then per-company company_users membership + notification defaults. Returns uid. +const jitProvisionUser = async ({ companyId, email, firstName, lastName, externalId, defaultRoleType = 3, autoProvision = true }) => { + const normEmail = String(email || '').trim().toLowerCase(); + if (!companyId || !normEmail) throw new Error('companyId and email are required'); + + let userAuth = await MongoDbCrudOpration(dbCollections.GLOBAL, { + type: dbCollections.USER_AUTH, data: [{ email: normEmail }], + }, 'findOne'); + + let uid; + if (userAuth) { + uid = userAuth._id; + await MongoDbCrudOpration(dbCollections.GLOBAL, { + type: SCHEMA_TYPE.USERS, + data: [{ _id: new mongoose.Types.ObjectId(String(uid)) }, { $addToSet: { AssignCompany: String(companyId) } }], + }, 'updateOne'); + } else { + if (!autoProvision) { + const e = new Error('User is not provisioned and auto-provisioning is disabled for this workspace.'); + e.code = 'NO_AUTOPROVISION'; + throw e; + } + userAuth = await MongoDbCrudOpration(dbCollections.GLOBAL, { + type: dbCollections.USER_AUTH, data: { email: normEmail, ssoExternalId: externalId || '', isBlocked: false }, + }, 'save'); + uid = userAuth._id; + await MongoDbCrudOpration(dbCollections.GLOBAL, { + type: SCHEMA_TYPE.USERS, + data: { + _id: userAuth._id, + AssignCompany: [String(companyId)], + Employee_FName: firstName || normEmail.split('@')[0], + Employee_LName: lastName || '-', + Employee_Email: normEmail, + Employee_Name: `${firstName || normEmail.split('@')[0]} ${lastName || ''}`.trim(), + Time_Format: '12', + isDeleted: false, isActive: true, isOnline: false, isEmailVerified: true, + }, + }, 'save'); + } + + // Company membership. + const existingMember = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.COMPANY_USERS, data: [{ userId: String(uid) }], + }, 'findOne'); + if (!existingMember) { + await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.COMPANY_USERS, + data: { userId: String(uid), roleType: Number(defaultRoleType) || 3, status: 1, userEmail: normEmail }, + }, 'save'); + await addAndRemoveUserInMongodbNotificationCount(companyId, uid, 'add') + .catch((e) => logger.error(`SSO JIT notif add: ${e.message || e}`)); + } + return String(uid); +}; + +module.exports = { jitProvisionUser }; diff --git a/Modules/SSO/routes.js b/Modules/SSO/routes.js new file mode 100644 index 00000000..a124afac --- /dev/null +++ b/Modules/SSO/routes.js @@ -0,0 +1,21 @@ +const config = require('./config'); +const oidc = require('./oidc'); +const saml = require('./saml'); + +exports.init = (app) => { + // Admin config — owner/admin enforced in-controller (holds IdP secrets). + app.get('/api/v2/sso/config', config.getSsoConfig); + app.put('/api/v2/sso/config', config.setSsoConfig); + + // Login page — unauthenticated, secret-free (must be auth-exempt). + app.get('/api/v2/sso/public', config.getPublicSsoConfig); + + // OIDC login flow (pre-auth; must be auth-exempt). + app.get('/api/v2/sso/oidc/initiate', oidc.oidcInitiate); + app.get('/api/v2/sso/oidc/callback', oidc.oidcCallback); + + // SAML login flow (pre-auth; must be auth-exempt). + app.get('/api/v2/sso/saml/initiate', saml.samlInitiate); + app.post('/api/v2/sso/saml/acs', saml.samlAcs); + app.get('/api/v2/sso/saml/metadata', saml.samlMetadata); +} diff --git a/Modules/SSO/saml.js b/Modules/SSO/saml.js new file mode 100644 index 00000000..460376f5 --- /dev/null +++ b/Modules/SSO/saml.js @@ -0,0 +1,112 @@ +const { SCHEMA_TYPE } = require("../../Config/schemaType"); +const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries"); +const logger = require("../../Config/loggerConfig"); +const { extractIdentity } = require("./helpers/ssoRules"); +const { jitProvisionUser } = require("./provisioning"); +const { finalizeSsoSession } = require("./ssoSession"); + +// `samlify` (+ its schema validator) are lazy-required so app load never breaks +// before `npm install`. SAML responses MUST be signature-validated — samlify +// requires a schema validator to be registered before parseLoginResponse. +const apiBase = () => String(process.env.APIURL || '').replace(/\/$/, ''); + +let validatorReady = false; +const ensureValidator = () => { + if (validatorReady) return; + const samlify = require('samlify'); + try { + samlify.setSchemaValidator(require('@authenio/samlify-node-saml2')); + } catch (e) { + logger.error(`samlify schema validator missing — install @authenio/samlify-node-saml2: ${e.message || e}`); + throw new Error('SAML validator not available on the server'); + } + validatorReady = true; +}; + +const loadConfig = async (companyId) => MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.SSO_CONFIGS, data: [{ deletedStatusKey: 0, isEnabled: true }], +}, 'findOne'); + +const buildSp = (companyId) => { + const samlify = require('samlify'); + return samlify.ServiceProvider({ + entityID: `${apiBase()}/api/v2/sso/saml/metadata?companyId=${companyId}`, + assertionConsumerService: [{ + Binding: samlify.Constants.namespace.binding.post, + Location: `${apiBase()}/api/v2/sso/saml/acs?companyId=${companyId}`, + }], + wantAssertionsSigned: true, + allowCreate: true, + }); +}; + +const buildIdp = (cfg) => { + const samlify = require('samlify'); + const s = cfg.saml || {}; + return samlify.IdentityProvider({ + entityID: s.entityId || s.entryPoint, + singleSignOnService: [{ Binding: samlify.Constants.namespace.binding.redirect, Location: s.entryPoint }], + signingCert: s.idpCert, + }); +}; + +/* GET /api/v2/sso/saml/initiate?companyId= — redirect to the IdP. */ +exports.samlInitiate = async (req, res) => { + try { + const companyId = req.query.companyId || req.headers['companyid']; + if (!companyId) return res.status(400).send('companyId is required'); + const cfg = await loadConfig(companyId); + if (!cfg || cfg.provider !== 'saml') return res.status(404).send('SAML SSO is not configured for this company'); + const sp = buildSp(companyId); + const idp = buildIdp(cfg); + const { context } = sp.createLoginRequest(idp, 'redirect'); + return res.redirect(context); + } catch (error) { + logger.error(`samlInitiate: ${error.message || error}`); + return res.redirect('/login?ssoError=initiate'); + } +}; + +/* POST /api/v2/sso/saml/acs?companyId= — IdP posts the signed assertion here. */ +exports.samlAcs = async (req, res) => { + try { + const companyId = req.query.companyId || (req.body && req.body.companyId); + if (!companyId) return res.redirect('/login?ssoError=config'); + const cfg = await loadConfig(companyId); + if (!cfg || cfg.provider !== 'saml') return res.redirect('/login?ssoError=config'); + ensureValidator(); + const sp = buildSp(companyId); + const idp = buildIdp(cfg); + const { extract } = await sp.parseLoginResponse(idp, 'post', req); // validates signature + const claims = { ...(extract.attributes || {}), nameID: extract.nameID }; + const id = extractIdentity(claims, (cfg.saml && cfg.saml.attributeMap) || {}); + if (!id.valid) return res.redirect('/login?ssoError=identity'); + const uid = await jitProvisionUser({ + companyId, + email: id.email, + firstName: id.firstName, + lastName: id.lastName, + externalId: id.externalId, + defaultRoleType: cfg.defaultRoleType, + autoProvision: cfg.autoProvisionUsers !== false, + }); + return finalizeSsoSession(req, res, uid, `/${companyId}`); + } catch (error) { + logger.error(`samlAcs: ${error.message || error}`); + return res.redirect('/login?ssoError=callback'); + } +}; + +/* GET /api/v2/sso/saml/metadata?companyId= — SP metadata for the IdP admin. */ +exports.samlMetadata = async (req, res) => { + try { + const companyId = req.query.companyId || req.headers['companyid']; + if (!companyId) return res.status(400).send('companyId is required'); + const sp = buildSp(companyId); + res.type('application/xml'); + return res.status(200).send(sp.getMetadata()); + } catch (error) { + logger.error(`samlMetadata: ${error.message || error}`); + return res.status(500).send('metadata error'); + } +}; diff --git a/Modules/SSO/ssoSession.js b/Modules/SSO/ssoSession.js new file mode 100644 index 00000000..d07411b9 --- /dev/null +++ b/Modules/SSO/ssoSession.js @@ -0,0 +1,33 @@ +const sesstionCtr = require("../Auth/session.js"); +const { generateTokenV2Fun } = require("../Auth/controller/authHelpers"); +const config = require("../../Config/config"); +const serviceCtr = require("../serviceFunction.js"); + +// SEC-02 — establish a real session for an SSO-authenticated user, then REDIRECT +// the browser back to the app (the IdP flow is a full-page redirect, not an SPA +// fetch). Reuses the exact session/token primitives the password login uses +// (insertSessionFun + generateTokenV2Fun), only the response differs (cookies + +// redirect instead of JSON). SameSite=Lax so the post-IdP top-level navigation +// carries the cookies. +const finalizeSsoSession = (req, res, uid, redirectPath) => { + const forwarded = req?.headers['x-forwarded-for'] || req.ip; + const clientIp = forwarded ? forwarded?.split(',')[0] : req?.connection?.remoteAddress; + const fail = (reason) => res.redirect(`/login?ssoError=${encodeURIComponent(reason)}`); + sesstionCtr.insertSessionFun({ userId: uid }, req.headers['user-agent'] || '', clientIp, (sData) => { + if (!(sData && sData.status)) return fail('session'); + generateTokenV2Fun(uid, sData.data.refreshToken, (gData) => { + if (!(gData && gData.status)) return fail('token'); + const setCookie = { + httpOnly: false, + secure: config.NODE_ENV === 'production', + sameSite: 'Lax', + domain: process.env.NODE_ENV === 'production' ? req.hostname : undefined, + }; + res.cookie('refreshToken', sData.data.refreshToken, { ...setCookie, maxAge: Number(process.env.SESSIONEXPIREDTIME || 172800) * 1000 }); + res.cookie('accessToken', gData.token, { ...setCookie, maxAge: serviceCtr.convertToSeconds(process.env.JWT_EXP) * 1000 }); + return res.redirect(redirectPath || '/'); + }); + }); +}; + +module.exports = { finalizeSsoSession }; diff --git a/index.js b/index.js index d3f725ae..a0c2c41c 100644 --- a/index.js +++ b/index.js @@ -174,6 +174,7 @@ function initializeControllers() { }) //IMPORT CUSTOM FILES require('./Modules/Auth/init').init(app); + require('./Modules/SSO/init').init(app); require('./Modules/notification1/init').init(app); require('./Modules/ImportSettings/init').init(app); require('./Modules/Tasks/init.js').init(app); diff --git a/package.json b/package.json index bbf0a93d..6dea7855 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,9 @@ "node-cache": "5.1.2", "node-schedule": "2.1.1", "nodemailer": "6.9.16", + "openid-client": "^5.7.0", + "samlify": "^2.8.10", + "@authenio/samlify-node-saml2": "^2.0.2", "otplib": "^12.0.1", "pdf-parse": "^2.4.5", "pdfmake": "^0.2.7", diff --git a/tests/sso-rules.test.js b/tests/sso-rules.test.js new file mode 100644 index 00000000..666785c4 --- /dev/null +++ b/tests/sso-rules.test.js @@ -0,0 +1,67 @@ +/** + * SSO Rules Test Suite (SEC-02) + * AlianHub Project Management System + * + * Unit tests for Modules/SSO/helpers/ssoRules.js. Pure — no DB / no IdP. + */ + +const { SSO_PROVIDERS, validateSsoConfig, publicSsoView, extractIdentity } = require('../Modules/SSO/helpers/ssoRules'); + +describe('🔑 SSO - Rules (SEC-02)', () => { + + describe('validateSsoConfig', () => { + test('valid OIDC config', () => { + expect(validateSsoConfig({ provider: 'oidc', oidc: { issuer: 'https://idp', clientId: 'c', clientSecret: 's' } }).valid).toBe(true); + }); + test('valid SAML config', () => { + expect(validateSsoConfig({ provider: 'saml', saml: { entryPoint: 'https://idp/sso', idpCert: 'CERT' } }).valid).toBe(true); + }); + test('bad provider fails', () => expect(validateSsoConfig({ provider: 'ldap' }).valid).toBe(false)); + test('OIDC missing clientSecret fails', () => expect(validateSsoConfig({ provider: 'oidc', oidc: { issuer: 'x', clientId: 'c' } }).valid).toBe(false)); + test('OIDC missing issuer/discovery fails', () => expect(validateSsoConfig({ provider: 'oidc', oidc: { clientId: 'c', clientSecret: 's' } }).valid).toBe(false)); + test('SAML missing cert fails', () => expect(validateSsoConfig({ provider: 'saml', saml: { entryPoint: 'x' } }).valid).toBe(false)); + }); + + describe('publicSsoView (no secret leak)', () => { + test('exposes only provider + isEnabled', () => { + const v = publicSsoView({ provider: 'oidc', isEnabled: true, oidc: { clientSecret: 'TOPSECRET' } }); + expect(v).toEqual({ provider: 'oidc', isEnabled: true }); + expect(JSON.stringify(v)).not.toContain('TOPSECRET'); + }); + test('null when disabled or missing', () => { + expect(publicSsoView({ provider: 'oidc', isEnabled: false })).toBeNull(); + expect(publicSsoView(null)).toBeNull(); + }); + }); + + describe('extractIdentity', () => { + test('standard OIDC claims', () => { + const id = extractIdentity({ email: 'A@X.com', given_name: 'Ann', family_name: 'Lee', sub: 'abc' }); + expect(id.valid).toBe(true); + expect(id.email).toBe('a@x.com'); + expect(id.firstName).toBe('Ann'); + expect(id.lastName).toBe('Lee'); + expect(id.externalId).toBe('abc'); + }); + test('falls back to full name then email local-part', () => { + const id = extractIdentity({ email: 'b@x.com', name: 'Bob Q Smith' }); + expect(id.firstName).toBe('Bob'); + expect(id.lastName).toBe('Q Smith'); + const id2 = extractIdentity({ email: 'solo@x.com' }); + expect(id2.firstName).toBe('solo'); + expect(id2.lastName).toBe('-'); + }); + test('custom SAML attribute map', () => { + const id = extractIdentity({ 'urn:email': 'c@x.com', nameID: 'nid' }, { email: 'urn:email' }); + expect(id.valid).toBe(true); + expect(id.email).toBe('c@x.com'); + expect(id.externalId).toBe('nid'); + }); + test('missing/invalid email fails', () => { + expect(extractIdentity({}).valid).toBe(false); + expect(extractIdentity({ email: 'not-an-email' }).valid).toBe(false); + }); + }); + + test('SSO_PROVIDERS', () => expect(SSO_PROVIDERS).toEqual(['oidc', 'saml'])); +}); diff --git a/utils/mongo-handler/createSchema.js b/utils/mongo-handler/createSchema.js index cc450794..7180beb7 100644 --- a/utils/mongo-handler/createSchema.js +++ b/utils/mongo-handler/createSchema.js @@ -123,6 +123,7 @@ timesheetApprovalSchema.index({ userId: 1, periodStart: 1, periodEnd: 1 }); timesheetApprovalSchema.index({ status: 1, periodStart: -1 }); const billingRatesSchema = new Schema(schema.billingRates, {strict: true, timestamps: true}); billingRatesSchema.index({ scope: 1, refId: 1, deletedStatusKey: 1 }); +const ssoConfigsSchema = new Schema(schema.ssoConfigs, {strict: true, timestamps: true}); // Global search: one combined text index per collection. taskSchema.index({ TaskName: 'text', rawDescription: 'text' }); projectsSchema.index({ ProjectName: 'text' }); @@ -204,6 +205,7 @@ module.exports = { recurringTasksSchema, timesheetApprovalSchema, billingRatesSchema, + ssoConfigsSchema, historySchema, userIdSchema, usersSchema, diff --git a/utils/mongo-handler/mongoQueries.js b/utils/mongo-handler/mongoQueries.js index b16c3c19..39a3b3d7 100644 --- a/utils/mongo-handler/mongoQueries.js +++ b/utils/mongo-handler/mongoQueries.js @@ -70,7 +70,8 @@ const { publicShareIndexSchema, recurringTasksSchema, timesheetApprovalSchema, - billingRatesSchema + billingRatesSchema, + ssoConfigsSchema } = require('./createSchema'); @@ -216,6 +217,8 @@ exports.checkType = (type) => { return timesheetApprovalSchema case SCHEMA_TYPE.BILLING_RATES: return billingRatesSchema + case SCHEMA_TYPE.SSO_CONFIGS: + return ssoConfigsSchema default: return "" } @@ -364,6 +367,8 @@ exports.tableType = (type) => { return `${dbCollections.TIMESHEET_APPROVAL}` case SCHEMA_TYPE.BILLING_RATES: return `${dbCollections.BILLING_RATES}` + case SCHEMA_TYPE.SSO_CONFIGS: + return `${dbCollections.SSO_CONFIGS}` default: return "" } diff --git a/utils/mongo-handler/schema.js b/utils/mongo-handler/schema.js index bbde8e0c..c3c668ea 100644 --- a/utils/mongo-handler/schema.js +++ b/utils/mongo-handler/schema.js @@ -463,6 +463,18 @@ const schema = { createdBy: { type: String, required: false }, deletedStatusKey: { type: Number, default: 0, required: false }, }, + // Per-company enterprise SSO config (OIDC/SAML) — managed by Modules/SSO (SEC-02) + ssoConfigs: { + provider: { type: String, default: 'oidc', required: false }, + isEnabled: { type: Boolean, default: false, required: false }, + autoProvisionUsers: { type: Boolean, default: true, required: false }, + defaultRoleType: { type: Number, default: 3, required: false }, + oidc: { type: Object, required: false }, + saml: { type: Object, required: false }, + createdBy: { type: String, required: false }, + updatedBy: { type: String, required: false }, + deletedStatusKey: { type: Number, default: 0, required: false }, + }, // Wiki pages (Editor.js blocks; versioned) — managed by Modules/Pages pages: { title: { type: String, required: true }, From 634b0a2e0930e912ea5418d3ee47c206ed79b768 Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 15:43:31 +0530 Subject: [PATCH 07/17] feat(sso): admin SSO config UI + settings page (SEC-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings → SSO page (SsoSettings.vue): provider/OIDC/SAML fields, enable + auto-provision toggles, and the login/redirect/ACS/metadata URLs to hand to the IdP. Owner/admin gated (server-side); configured via /api/v2/sso/config. - Settings tab + route + env constant + i18n. - .claude/test-cases/SSO.md (9 IdP/UI cases). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/test-cases/SSO.md | 22 +++ .../templates/Settings/Settings.vue | 6 + frontend/src/config/env.js | 1 + frontend/src/locales/en.js | 21 +++ frontend/src/router/settings/index.js | 9 ++ .../src/views/Settings/Sso/SsoSettings.vue | 127 ++++++++++++++++++ 6 files changed, 186 insertions(+) create mode 100644 .claude/test-cases/SSO.md create mode 100644 frontend/src/views/Settings/Sso/SsoSettings.vue diff --git a/.claude/test-cases/SSO.md b/.claude/test-cases/SSO.md new file mode 100644 index 00000000..7967182d --- /dev/null +++ b/.claude/test-cases/SSO.md @@ -0,0 +1,22 @@ +# Enterprise SSO (SAML / OIDC) — Test Cases + +**Feature:** SEC-02 — per-company OIDC/SAML SSO with JIT provisioning +**Backend:** `Modules/SSO/` — `sso_configs` collection, config CRUD, `oidc.js` (openid-client), `saml.js` (samlify), `provisioning.js` (JIT), `ssoSession.js` (reuses login session/JWT) +**Frontend:** `Settings → SSO` (`SsoSettings.vue`) admin config page +**Setup:** server needs `npm install` (openid-client, samlify, @authenio/samlify-node-saml2 — lazy-required) + `APIURL` env; test against a local Keycloak/Authentik +**Unit tests:** `tests/sso-rules.test.js` (13 cases, all green) +**Legend:** ✅ Pass · ❌ Fail · ⬜ Not run + +| ID | Title | Precondition | Steps | Expected | Actual | Status | +|----|-------|--------------|-------|----------|--------|--------| +| SSO-01 | Configure OIDC (no code) | Owner/admin | Settings → SSO → provider OIDC, fill discovery/clientId/secret, Enable, Save | Config saved; login/redirect/metadata URLs shown | | ⬜ | +| SSO-02 | Admin-only config | Member (roleType ≥ 3) | `GET /api/v2/sso/config` | 403 (owner/admin only) | | ⬜ | +| SSO-03 | OIDC login | OIDC configured + enabled; user exists at IdP | Visit `/api/v2/sso/oidc/initiate?companyId=…` → authenticate at IdP | Redirected back logged in (cookies set); lands in the workspace | | ⬜ | +| SSO-04 | JIT provisioning | New IdP user, autoProvision on | First OIDC login | global user + company_users membership created with defaultRoleType | | ⬜ | +| SSO-05 | Auto-provision off | autoProvision off, unknown user | OIDC login | Denied (no JIT); known users still log in | | ⬜ | +| SSO-06 | State/replay protection | — | Replay a used callback `state` | Rejected (single-use, 5-min TTL) | | ⬜ | +| SSO-07 | Configure + login SAML | SAML IdP (cert + entryPoint) | Configure SAML, give IdP the ACS + metadata URLs, log in | Signed assertion validated; user logged in | | ⬜ | +| SSO-08 | Public config leaks no secret | SSO configured | `GET /api/v2/sso/public?companyId=…` | Only `{provider, isEnabled}` — no clientSecret/cert | | ⬜ | +| SSO-09 | Role change immediate | Change a member's role | retry | Applies at once (role cache invalidated) | | ⬜ | + +**Total:** 9 cases (API/UI — run with a real IdP). SSO rules covered by 13 automated unit tests. diff --git a/frontend/src/components/templates/Settings/Settings.vue b/frontend/src/components/templates/Settings/Settings.vue index 712f6ac8..e5da8b43 100644 --- a/frontend/src/components/templates/Settings/Settings.vue +++ b/frontend/src/components/templates/Settings/Settings.vue @@ -177,6 +177,12 @@ icon: require("@/assets/images/svg/workspaceSecurity.svg"), permissions:['settings.settings_security_permissions'], activeIcon: require("@/assets/images/svg/workspaceSecurityActive.svg") + },{ + label: "SSO", + to: {name: "SsoSettings"}, + icon: require("@/assets/images/svg/workspaceSecurity.svg"), + permissions:['settings.settings_security_permissions'], + activeIcon: require("@/assets/images/svg/workspaceSecurityActive.svg") },{ label: UserName, to: {name: UserName}, diff --git a/frontend/src/config/env.js b/frontend/src/config/env.js index 7750e255..5208f08d 100644 --- a/frontend/src/config/env.js +++ b/frontend/src/config/env.js @@ -180,6 +180,7 @@ module.exports.PROJECTS_APPS = '/api/v1/projects-apps'; module.exports.PROJECTS_TABS = '/api/v1/projectTabs'; module.exports.TIMESHEET = '/api/v1/timesheet'; module.exports.TIMESHEET_APPROVAL = '/api/v2/timesheet-approval'; +module.exports.SSO_CONFIG = '/api/v2/sso/config'; module.exports.MAIN_CHATS = '/api/v1/main-chats' module.exports.ACTIVITYLOG = '/api/v1/activity-log' module.exports.SETTING_CATEGORY = '/api/v1/setting/category'; diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index 87d993ff..b3895ed6 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -1429,6 +1429,7 @@ export default { Company: "Company", "My Settings": "My Settings", "Security & Permissions": "Security & Permissions", + SSO: "SSO", Upgrade: "Upgrade", "Custom Field Manager": "Custom Field Manager", Templates: "Templates", @@ -1440,6 +1441,26 @@ export default { "Two-Factor Authentication": "Two-Factor Authentication", "Integrations": "Integrations", }, + Sso: { + title: "Single Sign-On (SSO)", + subtitle: "Let members sign in through your identity provider (Keycloak, Authentik, Azure AD, Okta…).", + provider: "Provider", + enabled: "Enabled", + disabled: "Disabled", + discovery_url: "Discovery URL", + client_id: "Client ID", + client_secret: "Client secret", + scopes: "Scopes", + entry_point: "IdP SSO URL (entry point)", + entity_id: "IdP entity ID", + idp_cert: "IdP signing certificate", + auto_provision: "Auto-provision new users on first login", + urls_hint: "Give these to your IdP, and share the login URL with your team:", + login_url: "Login URL", + redirect_uri: "Redirect URI", + metadata: "SP metadata", + saving: "Saving…", + }, Integrations: { title: "Integrations", desc: "Send task events to Slack, Discord, or any HTTPS endpoint via outgoing webhooks.", diff --git a/frontend/src/router/settings/index.js b/frontend/src/router/settings/index.js index 20f6495e..32403502 100644 --- a/frontend/src/router/settings/index.js +++ b/frontend/src/router/settings/index.js @@ -30,6 +30,15 @@ export default [ }, component: () => import(/* webpackChunkName: Members */ '@/views/Settings/Members/Members.vue') }, + { + path: "sso", + name: "SsoSettings", + meta: { + title: "SSO", + requiresAuth: true + }, + component: () => import(/* webpackChunkName: SsoSettings */ '@/views/Settings/Sso/SsoSettings.vue') + }, { path: "teams", name: "Teams", diff --git a/frontend/src/views/Settings/Sso/SsoSettings.vue b/frontend/src/views/Settings/Sso/SsoSettings.vue new file mode 100644 index 00000000..3bd390da --- /dev/null +++ b/frontend/src/views/Settings/Sso/SsoSettings.vue @@ -0,0 +1,127 @@ + + + + + From 7c58e26bc74884c70de13cb6872531b2047cb60e Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 15:47:00 +0530 Subject: [PATCH 08/17] =?UTF-8?q?feat(pwa):=20installable=20PWA=20?= =?UTF-8?q?=E2=80=94=20manifest=20+=20service=20worker=20(SEC-03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest.webmanifest (standalone, theme color, logo icons) + a conservative service worker: network-first navigations with an offline shell fallback, cache-first hashed assets, NEVER caches /api or sockets, old caches purged on activate. - index.html: manifest link, theme-color + apple mobile meta, apple-touch-icon, viewport-fit=cover, and a non-fatal SW registration. The app now installs to a home screen and runs standalone; offline opens the cached shell. Native apps + a deeper mobile UX pass remain follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/test-cases/PWA.md | 18 +++++++++++ frontend/public/index.html | 19 ++++++++++-- frontend/public/manifest.webmanifest | 15 ++++++++++ frontend/public/service-worker.js | 45 ++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 .claude/test-cases/PWA.md create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/public/service-worker.js diff --git a/.claude/test-cases/PWA.md b/.claude/test-cases/PWA.md new file mode 100644 index 00000000..daf5be6a --- /dev/null +++ b/.claude/test-cases/PWA.md @@ -0,0 +1,18 @@ +# Installable PWA — Test Cases + +**Feature:** SEC-03 — installable Progressive Web App (interim before native apps) +**Files:** `frontend/public/manifest.webmanifest`, `frontend/public/service-worker.js`, `frontend/public/index.html` +**Note:** browser/runtime-verified (not in the Node suite). Served as static files at `/manifest.webmanifest` + `/service-worker.js` (scope `/`). +**Legend:** ✅ Pass · ❌ Fail · ⬜ Not run + +| ID | Title | Precondition | Steps | Expected | Actual | Status | +|----|-------|--------------|-------|----------|--------|--------| +| PWA-01 | Manifest valid | Built app | DevTools → Application → Manifest | Name/short_name/theme/icons/standalone load with no errors | | ⬜ | +| PWA-02 | Service worker registers | Built app over HTTPS (or localhost) | DevTools → Application → Service Workers | `service-worker.js` is activated + running | | ⬜ | +| PWA-03 | Installable | Chrome/Edge desktop or Android | Use the install / "Add to Home Screen" prompt | App installs + launches standalone (no browser chrome) | | ⬜ | +| PWA-04 | Offline shell | App loaded once, then go offline | Reload / navigate | The cached app shell loads (no dino); API calls fail gracefully (not cached) | | ⬜ | +| PWA-05 | API never cached | — | Inspect SW cache | No `/api/*` or `/socket*` responses cached; only shell + static assets | | ⬜ | +| PWA-06 | Update after deploy | New build deployed | Reload twice | Online navigations fetch fresh (network-first); old cache purged on the new SW activating | | ⬜ | +| PWA-07 | Mobile usability | Phone | Open core flows (projects, tasks, timesheet) | Usable on a phone screen (viewport-fit=cover; responsive) | | ⬜ | + +**Total:** 7 cases (browser/device). Native iOS/Android apps + a deeper per-view mobile UX pass remain follow-ups. diff --git a/frontend/public/index.html b/frontend/public/index.html index 57458594..b51e82ca 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -3,8 +3,16 @@ - + + + + + + + + + @@ -19,6 +27,13 @@
- + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 00000000..02189ef5 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "AlianHub", + "short_name": "AlianHub", + "description": "AlianHub — self-hosted project management", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#ffffff", + "theme_color": "#2F3990", + "icons": [ + { "src": "/api/v1/getlogo?key=favicon", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/api/v1/getlogo?key=favicon", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js new file mode 100644 index 00000000..30e50abf --- /dev/null +++ b/frontend/public/service-worker.js @@ -0,0 +1,45 @@ +/* AlianHub PWA service worker (SEC-03). + * Conservative + safe: + * - API / socket requests are NEVER cached (always network). + * - Navigations are network-first with an offline fallback to the cached shell. + * - Hashed build assets are cache-first (then network, and cached). + * Bump CACHE on shape changes; old caches are purged on activate. */ +const CACHE = 'alianhub-pwa-v1'; +const SHELL = ['/', '/index.html']; + +self.addEventListener('install', (event) => { + self.skipWaiting(); + event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => {}))); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + let url; + try { url = new URL(req.url); } catch (e) { return; } + if (url.origin !== self.location.origin) return; // skip cross-origin (fonts, IdP, CDNs) + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/socket')) return; // never cache API/sockets + + if (req.mode === 'navigate') { + event.respondWith(fetch(req).catch(() => caches.match('/'))); + return; + } + + event.respondWith( + caches.match(req).then((cached) => cached || fetch(req).then((res) => { + if (res && res.status === 200 && res.type === 'basic') { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + } + return res; + }).catch(() => cached)) + ); +}); From 20ac6d151422da89cd7ae025b998ce4bbc7f2b19 Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 15:56:16 +0530 Subject: [PATCH 09/17] feat(audit): tamper-evident audit logs with retention (SEC-04) Immutable, insert-only audit trail scoped per company: - audit_logs collection (8-edit registration): indexed on createdAt, actorId+createdAt, entityType+entityId+createdAt, action+createdAt. - Modules/Audit/recorder.js: recordAudit / recordAuditFromReq are fire-and-forget and never throw, so a logging failure can never break the request that triggered it. runAuditRetentionForAllCompanies prunes rows older than AUDIT_RETENTION_DAYS (default 365) via a daily 02:00 cron. - Modules/Audit/controller.js: GET /api/v1/audit-logs is owner/admin-gated (getRoleType + isPrivileged), with actor/entity/action/date filters and $facet pagination, newest first. JWT+companyId enforced in setMiddleware. - helpers/auditRules.js (pure): normalizeAuditEntry bounds field lengths, requires action, rejects non-plain meta; retentionCutoff for the cron. - First hooks: member.update (role / guest-project / status changes) and sso.config_update - both best-effort, wrapped so they cannot fail the mutation. Full suite green (29 suites / 327 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- Config/collections.js | 1 + Config/schemaType.js | 1 + Config/setMiddleware.js | 1 + Modules/Audit/controller.js | 43 ++++++++++++++++++++ Modules/Audit/helpers/auditRules.js | 36 +++++++++++++++++ Modules/Audit/init.js | 5 +++ Modules/Audit/recorder.js | 48 ++++++++++++++++++++++ Modules/Audit/routes.js | 5 +++ Modules/SSO/config.js | 6 +++ Modules/settings/Members/controller.js | 7 ++++ cron.js | 12 ++++++ index.js | 1 + tests/audit-rules.test.js | 55 ++++++++++++++++++++++++++ utils/mongo-handler/createSchema.js | 6 +++ utils/mongo-handler/mongoQueries.js | 7 +++- utils/mongo-handler/schema.js | 12 ++++++ 16 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 Modules/Audit/controller.js create mode 100644 Modules/Audit/helpers/auditRules.js create mode 100644 Modules/Audit/init.js create mode 100644 Modules/Audit/recorder.js create mode 100644 Modules/Audit/routes.js create mode 100644 tests/audit-rules.test.js diff --git a/Config/collections.js b/Config/collections.js index a813d07a..f5ab374d 100644 --- a/Config/collections.js +++ b/Config/collections.js @@ -74,6 +74,7 @@ const dbCollections = { TIMESHEET_APPROVAL: "timesheet_approval", BILLING_RATES: "billing_rates", SSO_CONFIGS: "sso_configs", + AUDIT_LOGS: "audit_logs", } /** DOCUMENT ID'S NAME WHICH IS USED IN THE "SETTINGS" COLLECTION NAME **/ diff --git a/Config/schemaType.js b/Config/schemaType.js index 1ea0c198..1a8f2821 100644 --- a/Config/schemaType.js +++ b/Config/schemaType.js @@ -73,6 +73,7 @@ const SCHEMA_TYPE = { TIMESHEET_APPROVAL: "timesheet_approval", BILLING_RATES: "billing_rates", SSO_CONFIGS: "sso_configs", + AUDIT_LOGS: "audit_logs", } module.exports = { diff --git a/Config/setMiddleware.js b/Config/setMiddleware.js index 2bc4e4bf..05d6d000 100644 --- a/Config/setMiddleware.js +++ b/Config/setMiddleware.js @@ -172,6 +172,7 @@ const verifyJWTTokenWithCRoute = [ // req.uid. The login-flow routes (/api/v2/sso/oidc|saml|public) stay PUBLIC // (pre-auth), so they are intentionally NOT listed here. '/api/v2/sso/config', + '/api/v1/audit-logs', ]; const verifyJWTToken = [ "/api/v2/company/delete", diff --git a/Modules/Audit/controller.js b/Modules/Audit/controller.js new file mode 100644 index 00000000..c9a8c57e --- /dev/null +++ b/Modules/Audit/controller.js @@ -0,0 +1,43 @@ +const { SCHEMA_TYPE } = require("../../Config/schemaType"); +const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries"); +const { getRoleType, isPrivileged } = require("../../Config/permissionGuard"); +const logger = require("../../Config/loggerConfig"); + +const companyOf = (req) => req.headers['companyid'] || (req.query && req.query.companyId); + +// GET /api/v1/audit-logs?actorId=&entityType=&entityId=&action=&from=&to=&page=&limit= +// Owner/admin only. Filterable + paginated, newest first. +exports.listAuditLogs = async (req, res) => { + try { + const companyId = companyOf(req); + if (!companyId) return res.status(400).json({ status: false, statusText: 'companyId is required.' }); + const roleType = await getRoleType(companyId, req.uid); + if (!isPrivileged(roleType)) return res.status(403).json({ status: false, statusText: 'Owner/admin only.' }); + + const q = req.query || {}; + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 25)); + const match = {}; + if (q.actorId) match.actorId = String(q.actorId); + if (q.entityType) match.entityType = String(q.entityType); + if (q.entityId) match.entityId = String(q.entityId); + if (q.action) match.action = String(q.action); + if (q.from || q.to) { + match.createdAt = {}; + if (q.from) match.createdAt.$gte = new Date(q.from); + if (q.to) match.createdAt.$lte = new Date(q.to); + } + const pipeline = [ + { $match: match }, + { $sort: { createdAt: -1, _id: -1 } }, + { $facet: { data: [{ $skip: (page - 1) * limit }, { $limit: limit }], meta: [{ $count: 'total' }] } }, + ]; + const rows = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.AUDIT_LOGS, data: [pipeline] }, 'aggregate'); + const data = (rows && rows[0] && rows[0].data) || []; + const total = (rows && rows[0] && rows[0].meta && rows[0].meta[0] && rows[0].meta[0].total) || 0; + return res.send({ status: true, data, metadata: { total, page, totalPages: Math.ceil(total / limit) } }); + } catch (error) { + logger.error(`listAuditLogs: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +}; diff --git a/Modules/Audit/helpers/auditRules.js b/Modules/Audit/helpers/auditRules.js new file mode 100644 index 00000000..20c89338 --- /dev/null +++ b/Modules/Audit/helpers/auditRules.js @@ -0,0 +1,36 @@ +// SEC-04 audit rules. Pure — no I/O — shared by the recorder, controller, tests. + +const RETENTION_DEFAULT_DAYS = 365; + +const trim = (v, n = 300) => (v === undefined || v === null ? '' : String(v).slice(0, n)); + +/* Validate + shape an audit entry. `action` is required; everything else is + * normalised/bounded. Returns { valid, reason, entry }. */ +const normalizeAuditEntry = (e = {}) => { + const action = trim(e.action, 120).trim(); + if (!action) return { valid: false, reason: 'action is required.', entry: null }; + return { + valid: true, + reason: '', + entry: { + actorId: trim(e.actorId), + actorName: trim(e.actorName), + action, + entityType: trim(e.entityType, 60), + entityId: trim(e.entityId), + entityName: trim(e.entityName), + meta: (e.meta && typeof e.meta === 'object' && !Array.isArray(e.meta)) ? e.meta : {}, + ip: trim(e.ip, 64), + }, + }; +}; + +/* Rows with createdAt < cutoff are pruned. */ +const retentionCutoff = (now, retentionDays = RETENTION_DEFAULT_DAYS) => { + const days = Number(retentionDays); + const d = new Date(now); + d.setDate(d.getDate() - (Number.isFinite(days) && days > 0 ? days : RETENTION_DEFAULT_DAYS)); + return d; +}; + +module.exports = { normalizeAuditEntry, retentionCutoff, RETENTION_DEFAULT_DAYS }; diff --git a/Modules/Audit/init.js b/Modules/Audit/init.js new file mode 100644 index 00000000..be6fc26a --- /dev/null +++ b/Modules/Audit/init.js @@ -0,0 +1,5 @@ +const routes = require('./routes'); + +exports.init = (app) => { + routes.init(app); +} diff --git a/Modules/Audit/recorder.js b/Modules/Audit/recorder.js new file mode 100644 index 00000000..5e0f9c8e --- /dev/null +++ b/Modules/Audit/recorder.js @@ -0,0 +1,48 @@ +const { SCHEMA_TYPE } = require("../../Config/schemaType"); +const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries"); +const logger = require("../../Config/loggerConfig"); +const { normalizeAuditEntry, retentionCutoff } = require("./helpers/auditRules"); + +// SEC-04 — record an audit row. Fire-and-forget: NEVER throws to the caller, so +// a logging hiccup can never break the mutation it's recording. +const recordAudit = (companyId, entry) => { + try { + if (!companyId) return; + const n = normalizeAuditEntry(entry); + if (!n.valid) return; + MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.AUDIT_LOGS, data: n.entry }, 'save') + .catch((e) => logger.error(`recordAudit ${companyId}: ${e.message || e}`)); + } catch (e) { + logger.error(`recordAudit threw: ${e.message || e}`); + } +}; + +// Convenience: pull actor + ip from an Express req. +const recordAuditFromReq = (req, entry) => { + const companyId = req.headers['companyid'] || (req.body && req.body.companyId); + const userData = (req.body && req.body.userData) || {}; + const forwarded = req.headers['x-forwarded-for'] || req.ip; + recordAudit(companyId, { + actorId: req.uid || userData.id || userData._id || '', + actorName: userData.name || userData.Employee_Name || '', + ip: forwarded ? String(forwarded).split(',')[0] : '', + ...entry, + }); +}; + +// Cron: prune rows older than the retention window, across every company. +const runAuditRetentionForAllCompanies = async (retentionDays) => { + try { + const companies = await MongoDbCrudOpration('global', { type: SCHEMA_TYPE.COMPANIES, data: [{}, { _id: 1 }] }, 'find'); + const cutoff = retentionCutoff(new Date(), retentionDays || Number(process.env.AUDIT_RETENTION_DAYS) || undefined); + for (const c of (companies || [])) { + // eslint-disable-next-line no-await-in-loop + await MongoDbCrudOpration(String(c._id), { type: SCHEMA_TYPE.AUDIT_LOGS, data: [{ createdAt: { $lt: cutoff } }] }, 'deleteMany') + .catch((e) => logger.error(`audit prune ${c._id}: ${e.message || e}`)); + } + } catch (error) { + logger.error(`runAuditRetentionForAllCompanies: ${error.message || error}`); + } +}; + +module.exports = { recordAudit, recordAuditFromReq, runAuditRetentionForAllCompanies }; diff --git a/Modules/Audit/routes.js b/Modules/Audit/routes.js new file mode 100644 index 00000000..bf885f5f --- /dev/null +++ b/Modules/Audit/routes.js @@ -0,0 +1,5 @@ +const ctrl = require('./controller'); + +exports.init = (app) => { + app.get('/api/v1/audit-logs', ctrl.listAuditLogs); +} diff --git a/Modules/SSO/config.js b/Modules/SSO/config.js index 270854c9..6d5c3eab 100644 --- a/Modules/SSO/config.js +++ b/Modules/SSO/config.js @@ -54,6 +54,12 @@ exports.setSsoConfig = async (req, res) => { { upsert: true, returnDocument: 'after', setDefaultsOnInsert: true }, ], }, 'findOneAndUpdate'); + // SEC-04: audit SSO config changes. + try { + require('../Audit/recorder').recordAuditFromReq(req, { + action: 'sso.config_update', entityType: 'sso', meta: { provider: set.provider, isEnabled: set.isEnabled }, + }); + } catch (e) { /* audit is best-effort */ } return res.send({ status: true, statusText: 'SSO config saved.', data: saved }); } catch (error) { logger.error(`setSsoConfig: ${error.message}`); diff --git a/Modules/settings/Members/controller.js b/Modules/settings/Members/controller.js index 68036230..94538941 100644 --- a/Modules/settings/Members/controller.js +++ b/Modules/settings/Members/controller.js @@ -261,6 +261,13 @@ exports.updateMember = async (req, res) => { const { invalidateRoleCache } = require('../../../Config/permissionGuard'); invalidateRoleCache(req.headers['companyid'], response.userId); } + // SEC-04: audit member updates (role / guest-projects / status changes). + try { + require('../../Audit/recorder').recordAuditFromReq(req, { + action: 'member.update', entityType: 'member', entityId: String(id), + meta: { fields: Object.keys(data || {}) }, + }); + } catch (e) { /* audit is best-effort */ } if(response) { return res.status(200).json({ status: true, data: response }); diff --git a/cron.js b/cron.js index 4792c5e4..fc70f3c5 100644 --- a/cron.js +++ b/cron.js @@ -7,6 +7,7 @@ const screenshotRetention = require("./Modules/ScreenshotRetention/helper"); const autoArchive = require("./Modules/projectSetting/autoArchive"); const recurringTasks = require("./Modules/RecurringTasks/controller"); const timeReminders = require("./Modules/TimeSheet/controller/timeReminders"); +const auditRecorder = require("./Modules/Audit/recorder"); // BUG-035 / #89 — pin every cron to a known timezone so schedules don't // shift when the server's local tz changes (DST transition, container @@ -83,3 +84,14 @@ schedule.scheduleJob({ rule: '0 17 * * *', tz: CRON_TZ }, async () => { logger.error(`[Cron] timeReminders failed: ${err && err.message ? err.message : err}`); } }) + +// Audit-log retention — daily at 02:00, prune rows older than AUDIT_RETENTION_DAYS +// (default 365) across all companies. +schedule.scheduleJob({ rule: '0 2 * * *', tz: CRON_TZ }, async () => { + logger.info(`[Cron] auditRecorder.runAuditRetentionForAllCompanies`); + try { + await auditRecorder.runAuditRetentionForAllCompanies(); + } catch (err) { + logger.error(`[Cron] audit retention failed: ${err && err.message ? err.message : err}`); + } +}) diff --git a/index.js b/index.js index a0c2c41c..a751c432 100644 --- a/index.js +++ b/index.js @@ -175,6 +175,7 @@ function initializeControllers() { //IMPORT CUSTOM FILES require('./Modules/Auth/init').init(app); require('./Modules/SSO/init').init(app); + require('./Modules/Audit/init').init(app); require('./Modules/notification1/init').init(app); require('./Modules/ImportSettings/init').init(app); require('./Modules/Tasks/init.js').init(app); diff --git a/tests/audit-rules.test.js b/tests/audit-rules.test.js new file mode 100644 index 00000000..fadedc47 --- /dev/null +++ b/tests/audit-rules.test.js @@ -0,0 +1,55 @@ +/** + * Audit Rules Test Suite (SEC-04) + * AlianHub Project Management System + * + * Unit tests for Modules/Audit/helpers/auditRules.js. Pure — no DB. + */ + +const { normalizeAuditEntry, retentionCutoff, RETENTION_DEFAULT_DAYS } = require('../Modules/Audit/helpers/auditRules'); + +describe('🗒️ AUDIT - Rules (SEC-04)', () => { + + describe('normalizeAuditEntry', () => { + test('valid entry shapes all fields', () => { + const r = normalizeAuditEntry({ actorId: 'u1', actorName: 'Ann', action: 'member.role_change', entityType: 'member', entityId: 'm1', meta: { from: 3, to: 2 } }); + expect(r.valid).toBe(true); + expect(r.entry.action).toBe('member.role_change'); + expect(r.entry.meta).toEqual({ from: 3, to: 2 }); + }); + test('action is required', () => { + expect(normalizeAuditEntry({ actorId: 'u1' }).valid).toBe(false); + expect(normalizeAuditEntry({ action: ' ' }).valid).toBe(false); + }); + test('non-object meta is dropped to {}', () => { + expect(normalizeAuditEntry({ action: 'x', meta: 'nope' }).entry.meta).toEqual({}); + expect(normalizeAuditEntry({ action: 'x', meta: [1, 2] }).entry.meta).toEqual({}); + }); + test('long action is bounded', () => { + expect(normalizeAuditEntry({ action: 'a'.repeat(500) }).entry.action.length).toBe(120); + }); + test('missing optional fields become empty strings', () => { + const e = normalizeAuditEntry({ action: 'x' }).entry; + expect(e.actorId).toBe(''); + expect(e.entityId).toBe(''); + }); + }); + + describe('retentionCutoff', () => { + test('default retention is 365 days', () => { + const now = new Date('2026-06-20T00:00:00Z'); + const cutoff = retentionCutoff(now); + const days = Math.round((now - cutoff) / 86400000); + expect(days).toBe(RETENTION_DEFAULT_DAYS); + }); + test('custom retention', () => { + const now = new Date('2026-06-20T00:00:00Z'); + const days = Math.round((now - retentionCutoff(now, 30)) / 86400000); + expect(days).toBe(30); + }); + test('invalid retention falls back to default', () => { + const now = new Date('2026-06-20T00:00:00Z'); + const days = Math.round((now - retentionCutoff(now, -5)) / 86400000); + expect(days).toBe(RETENTION_DEFAULT_DAYS); + }); + }); +}); diff --git a/utils/mongo-handler/createSchema.js b/utils/mongo-handler/createSchema.js index 7180beb7..6184fd0b 100644 --- a/utils/mongo-handler/createSchema.js +++ b/utils/mongo-handler/createSchema.js @@ -124,6 +124,11 @@ timesheetApprovalSchema.index({ status: 1, periodStart: -1 }); const billingRatesSchema = new Schema(schema.billingRates, {strict: true, timestamps: true}); billingRatesSchema.index({ scope: 1, refId: 1, deletedStatusKey: 1 }); const ssoConfigsSchema = new Schema(schema.ssoConfigs, {strict: true, timestamps: true}); +const auditLogsSchema = new Schema(schema.auditLogs, {strict: true, timestamps: true}); +auditLogsSchema.index({ createdAt: -1 }); +auditLogsSchema.index({ actorId: 1, createdAt: -1 }); +auditLogsSchema.index({ entityType: 1, entityId: 1, createdAt: -1 }); +auditLogsSchema.index({ action: 1, createdAt: -1 }); // Global search: one combined text index per collection. taskSchema.index({ TaskName: 'text', rawDescription: 'text' }); projectsSchema.index({ ProjectName: 'text' }); @@ -206,6 +211,7 @@ module.exports = { timesheetApprovalSchema, billingRatesSchema, ssoConfigsSchema, + auditLogsSchema, historySchema, userIdSchema, usersSchema, diff --git a/utils/mongo-handler/mongoQueries.js b/utils/mongo-handler/mongoQueries.js index 39a3b3d7..f778f61c 100644 --- a/utils/mongo-handler/mongoQueries.js +++ b/utils/mongo-handler/mongoQueries.js @@ -71,7 +71,8 @@ const { recurringTasksSchema, timesheetApprovalSchema, billingRatesSchema, - ssoConfigsSchema + ssoConfigsSchema, + auditLogsSchema } = require('./createSchema'); @@ -219,6 +220,8 @@ exports.checkType = (type) => { return billingRatesSchema case SCHEMA_TYPE.SSO_CONFIGS: return ssoConfigsSchema + case SCHEMA_TYPE.AUDIT_LOGS: + return auditLogsSchema default: return "" } @@ -369,6 +372,8 @@ exports.tableType = (type) => { return `${dbCollections.BILLING_RATES}` case SCHEMA_TYPE.SSO_CONFIGS: return `${dbCollections.SSO_CONFIGS}` + case SCHEMA_TYPE.AUDIT_LOGS: + return `${dbCollections.AUDIT_LOGS}` default: return "" } diff --git a/utils/mongo-handler/schema.js b/utils/mongo-handler/schema.js index c3c668ea..783a8899 100644 --- a/utils/mongo-handler/schema.js +++ b/utils/mongo-handler/schema.js @@ -475,6 +475,18 @@ const schema = { updatedBy: { type: String, required: false }, deletedStatusKey: { type: Number, default: 0, required: false }, }, + // Immutable audit trail — managed by Modules/Audit (SEC-04). Insert-only; + // pruned by retention. No deletedStatusKey (rows are never soft-deleted). + auditLogs: { + actorId: { type: String, required: false }, + actorName: { type: String, required: false }, + action: { type: String, required: true }, + entityType: { type: String, required: false }, + entityId: { type: String, required: false }, + entityName: { type: String, required: false }, + meta: { type: Object, required: false }, + ip: { type: String, required: false }, + }, // Wiki pages (Editor.js blocks; versioned) — managed by Modules/Pages pages: { title: { type: String, required: true }, From e845d257999561b3ca405d48273b12c71e72d1df Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 16:01:46 +0530 Subject: [PATCH 10/17] feat(audit): audit log viewer in Settings (SEC-04 frontend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Audit Log (owner/admin tab): read-only, filterable, paginated view onto the immutable trail. - AuditLog.vue: action / entity-type / date-range filters, server-side $facet pagination (25/page), newest first, meta rendered compactly. Calls GET /api/v1/audit-logs (env.AUDIT_LOGS); 403 for non-privileged. - Route (settings/audit-logs → AuditLog) + Settings tab gated by settings.settings_security_permissions, beside SSO. - i18n: settingslider "Audit Log" + full Audit block in en.js (other locales fall back to en). - Self-namespaced button styles (audit-btn*) to avoid the global .btn_btn navy-on-navy trap. - Test cases: .claude/test-cases/AuditLog.md (14 cases; AUD-14 unit-green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/test-cases/AuditLog.md | 27 +++ .../templates/Settings/Settings.vue | 6 + frontend/src/config/env.js | 1 + frontend/src/locales/en.js | 24 +++ frontend/src/router/settings/index.js | 9 + .../src/views/Settings/Audit/AuditLog.vue | 157 ++++++++++++++++++ 6 files changed, 224 insertions(+) create mode 100644 .claude/test-cases/AuditLog.md create mode 100644 frontend/src/views/Settings/Audit/AuditLog.vue diff --git a/.claude/test-cases/AuditLog.md b/.claude/test-cases/AuditLog.md new file mode 100644 index 00000000..5ef84e11 --- /dev/null +++ b/.claude/test-cases/AuditLog.md @@ -0,0 +1,27 @@ +# Audit Log — Test Cases + +**Feature:** SEC-04 — tamper-evident audit logs with retention +**Backend:** `Modules/Audit/{recorder,controller,routes,init}.js`, `Modules/Audit/helpers/auditRules.js`, `audit_logs` collection, `GET /api/v1/audit-logs`, daily 02:00 retention cron (`cron.js`) +**Frontend:** `frontend/src/views/Settings/Audit/AuditLog.vue` (Settings → Audit Log tab) +**Hooks (initial):** `member.update` (Members controller), `sso.config_update` (SSO config) +**Unit-tested:** `tests/audit-rules.test.js` (`normalizeAuditEntry`, `retentionCutoff`) — in the Node suite (29 suites / 327 tests green). +**Legend:** ✅ Pass · ❌ Fail · ⬜ Not run + +| ID | Title | Precondition | Steps | Expected | Actual | Status | +|----|-------|--------------|-------|----------|--------|--------| +| AUD-01 | Member change is audited | Logged in as owner/admin | Settings → Members, change a member's role (or guest projects / status), save | A new row appears in Audit Log: action `member.update`, entity `member: `, your name as actor, meta lists the changed fields | | ⬜ | +| AUD-02 | SSO config change is audited | Owner/admin; SSO module configured | Settings → SSO, toggle Enabled or edit a field, Save | A new `sso.config_update` row appears with meta `{provider, isEnabled}` | | ⬜ | +| AUD-03 | Owner/admin can view the log | Logged in as owner or admin | Open Settings → Audit Log | Table loads with recent events, newest first; pagination footer shows totals | | ⬜ | +| AUD-04 | Non-privileged user is blocked | Logged in as a normal member (roleType 3) | Call `GET /api/v1/audit-logs` (or open the tab if visible) | `403` with `Owner/admin only.`; no rows returned | | ⬜ | +| AUD-05 | Filter by action | Several events of different actions exist | Type `member.update` in Action, click Apply | Only `member.update` rows shown; total updates accordingly | | ⬜ | +| AUD-06 | Filter by entity type | Mixed entity events exist | Type `sso` in Entity type, Apply | Only `sso`-entity rows shown | | ⬜ | +| AUD-07 | Filter by date range | Events span multiple days | Set From/To to a window that excludes today, Apply | Only rows within the window shown; today's rows excluded | | ⬜ | +| AUD-08 | Pagination | More than 25 events exist | Click Next | Page 2 loads (next 25), newest-first order preserved; Prev returns to page 1 | | ⬜ | +| AUD-09 | Clear filters | Filters applied | Click Clear | All filters reset, page returns to 1, full list reloads | | ⬜ | +| AUD-10 | Logging never breaks the action | — | Force a recorder failure (e.g. bad companyId in a dev hook) and perform a member update | The member update still succeeds (HTTP 200); the audit write fails silently (fire-and-forget) | | ⬜ | +| AUD-11 | Immutable trail | Audit rows exist | Attempt to edit/delete a row via any normal API | No app endpoint mutates or deletes individual rows; only the retention cron prunes by age | | ⬜ | +| AUD-12 | Retention prune | Rows older than `AUDIT_RETENTION_DAYS` exist (default 365); set a small value in dev | Run `auditRecorder.runAuditRetentionForAllCompanies()` (or wait for the 02:00 cron) | Rows older than the cutoff are deleted; newer rows remain | | ⬜ | +| AUD-13 | Multi-tenant isolation | Two companies with audit rows | As company A's admin, open Audit Log | Only company A's events are visible; company B's are never returned | | ⬜ | +| AUD-14 | Entry normalization (unit) | — | `npx jest tests/audit-rules.test.js` | `action` required; over-long fields bounded; non-plain `meta` rejected; `retentionCutoff` correct — all green | | ✅ | + +**Total:** 14 cases (1 unit-automated, 13 runtime/manual). First hooks are member + SSO changes; more mutation hooks can be added incrementally using `recordAuditFromReq`. diff --git a/frontend/src/components/templates/Settings/Settings.vue b/frontend/src/components/templates/Settings/Settings.vue index e5da8b43..2314e244 100644 --- a/frontend/src/components/templates/Settings/Settings.vue +++ b/frontend/src/components/templates/Settings/Settings.vue @@ -183,6 +183,12 @@ icon: require("@/assets/images/svg/workspaceSecurity.svg"), permissions:['settings.settings_security_permissions'], activeIcon: require("@/assets/images/svg/workspaceSecurityActive.svg") + },{ + label: "Audit Log", + to: {name: "AuditLog"}, + icon: require("@/assets/images/svg/workspaceSecurity.svg"), + permissions:['settings.settings_security_permissions'], + activeIcon: require("@/assets/images/svg/workspaceSecurityActive.svg") },{ label: UserName, to: {name: UserName}, diff --git a/frontend/src/config/env.js b/frontend/src/config/env.js index 5208f08d..ebde6e9a 100644 --- a/frontend/src/config/env.js +++ b/frontend/src/config/env.js @@ -181,6 +181,7 @@ module.exports.PROJECTS_TABS = '/api/v1/projectTabs'; module.exports.TIMESHEET = '/api/v1/timesheet'; module.exports.TIMESHEET_APPROVAL = '/api/v2/timesheet-approval'; module.exports.SSO_CONFIG = '/api/v2/sso/config'; +module.exports.AUDIT_LOGS = '/api/v1/audit-logs'; module.exports.MAIN_CHATS = '/api/v1/main-chats' module.exports.ACTIVITYLOG = '/api/v1/activity-log' module.exports.SETTING_CATEGORY = '/api/v1/setting/category'; diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index b3895ed6..b543def2 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -1430,6 +1430,7 @@ export default { "My Settings": "My Settings", "Security & Permissions": "Security & Permissions", SSO: "SSO", + "Audit Log": "Audit Log", Upgrade: "Upgrade", "Custom Field Manager": "Custom Field Manager", Templates: "Templates", @@ -1461,6 +1462,29 @@ export default { metadata: "SP metadata", saving: "Saving…", }, + Audit: { + title: "Audit Log", + subtitle: "An immutable record of sensitive actions in this workspace — role changes, SSO updates, and more.", + refresh: "Refresh", + loading: "Loading…", + f_action: "Action", + f_action_ph: "e.g. member.update", + f_entity: "Entity type", + f_from: "From", + f_to: "To", + apply: "Apply", + clear: "Clear", + col_time: "Time", + col_actor: "Actor", + col_action: "Action", + col_entity: "Entity", + col_ip: "IP", + col_details: "Details", + empty: "No audit events match these filters.", + showing: "Showing {from}–{to} of {total}", + prev: "Prev", + next: "Next", + }, Integrations: { title: "Integrations", desc: "Send task events to Slack, Discord, or any HTTPS endpoint via outgoing webhooks.", diff --git a/frontend/src/router/settings/index.js b/frontend/src/router/settings/index.js index 32403502..6adf3364 100644 --- a/frontend/src/router/settings/index.js +++ b/frontend/src/router/settings/index.js @@ -39,6 +39,15 @@ export default [ }, component: () => import(/* webpackChunkName: SsoSettings */ '@/views/Settings/Sso/SsoSettings.vue') }, + { + path: "audit-logs", + name: "AuditLog", + meta: { + title: "Audit Log", + requiresAuth: true + }, + component: () => import(/* webpackChunkName: AuditLog */ '@/views/Settings/Audit/AuditLog.vue') + }, { path: "teams", name: "Teams", diff --git a/frontend/src/views/Settings/Audit/AuditLog.vue b/frontend/src/views/Settings/Audit/AuditLog.vue new file mode 100644 index 00000000..e64dd7f6 --- /dev/null +++ b/frontend/src/views/Settings/Audit/AuditLog.vue @@ -0,0 +1,157 @@ + + + + + From de1b2dc41b3f524ac28c29a12e56d5d404d2077c Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 16:16:20 +0530 Subject: [PATCH 11/17] feat(scim): user provisioning via SCIM 2.0, paired with SSO (SEC-05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto provision/deprovision members from an IdP (Okta, Azure AD, OneLogin). Backend (Modules/Scim): - scim_configs collection (8-edit registration). The IdP bearer token is stored ONLY as a bcrypt hash + last4; the company is encoded into the token (base64url "companyId:secret") so SCIM requests — which carry no companyId header — resolve their tenant before the secret is verified. - scimAuth middleware on /scim/v2/* (router-scoped, NOT JWT); also accepts application/scim+json. - Protocol: GET/POST/GET{id}/PUT/PATCH/DELETE Users + ServiceProviderConfig /ResourceTypes/Schemas discovery. Create→provision, PATCH active:false / DELETE→deactivate (soft, this-company-only), filter by userName, paginated ListResponse. - Reuses SSO jitProvisionUser, so a SCIM user and an SSO login for the same email are the SAME global user. Deactivation flips company_users status/isDelete + invalidates the role cache. - Admin config: GET/PUT /api/v2/scim/config + POST /api/v2/scim/token (owner/admin), token shown once. SCIM mutations recorded to the SEC-04 audit trail. - helpers/scimRules.js is pure (token build/parse, email/filter parsing, Okta/Azure PATCH normalization, resource shaping) + 19 unit tests. Frontend: Settings → SCIM tab (ScimSettings.vue) — enable, default role, SCIM base URL, generate/rotate token (shown once, copyable). i18n in en.js. Verify: 30 suites / 346 tests green. Test cases: .claude/test-cases/ScimProvisioning.md (18). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/test-cases/ScimProvisioning.md | 32 ++++ Config/collections.js | 1 + Config/schemaType.js | 1 + Config/setMiddleware.js | 5 + Modules/Scim/auth.js | 61 ++++++ Modules/Scim/controller.js | 176 ++++++++++++++++++ Modules/Scim/discovery.js | 43 +++++ Modules/Scim/helpers/scimRules.js | 133 +++++++++++++ Modules/Scim/init.js | 5 + Modules/Scim/provisioning.js | 103 ++++++++++ Modules/Scim/routes.js | 28 +++ .../templates/Settings/Settings.vue | 6 + frontend/src/config/env.js | 2 + frontend/src/locales/en.js | 23 +++ frontend/src/router/settings/index.js | 9 + .../src/views/Settings/Scim/ScimSettings.vue | 128 +++++++++++++ index.js | 1 + tests/scim-rules.test.js | 116 ++++++++++++ utils/mongo-handler/createSchema.js | 2 + utils/mongo-handler/mongoQueries.js | 7 +- utils/mongo-handler/schema.js | 12 ++ 21 files changed, 893 insertions(+), 1 deletion(-) create mode 100644 .claude/test-cases/ScimProvisioning.md create mode 100644 Modules/Scim/auth.js create mode 100644 Modules/Scim/controller.js create mode 100644 Modules/Scim/discovery.js create mode 100644 Modules/Scim/helpers/scimRules.js create mode 100644 Modules/Scim/init.js create mode 100644 Modules/Scim/provisioning.js create mode 100644 Modules/Scim/routes.js create mode 100644 frontend/src/views/Settings/Scim/ScimSettings.vue create mode 100644 tests/scim-rules.test.js diff --git a/.claude/test-cases/ScimProvisioning.md b/.claude/test-cases/ScimProvisioning.md new file mode 100644 index 00000000..9936021f --- /dev/null +++ b/.claude/test-cases/ScimProvisioning.md @@ -0,0 +1,32 @@ +# SCIM Provisioning — Test Cases + +**Feature:** SEC-05 — SCIM 2.0 user provisioning/deprovisioning, paired with SSO +**Backend:** `Modules/Scim/*` (`helpers/scimRules.js`, `auth.js`, `provisioning.js`, `controller.js`, `discovery.js`, `routes.js`, `init.js`), `scim_configs` collection +**Endpoints:** admin `GET/PUT /api/v2/scim/config`, `POST /api/v2/scim/token` (JWT+owner/admin); protocol `/scim/v2/*` (bearer token) +**Frontend:** `frontend/src/views/Settings/Scim/ScimSettings.vue` (Settings → SCIM tab) +**Reuse:** SSO `jitProvisionUser` — a SCIM-created user and an SSO login for the same email resolve to the SAME global user. +**Unit-tested:** `tests/scim-rules.test.js` (token build/parse, email/filter parsing, PATCH ops, SCIM resource shaping) — in the Node suite (30 suites / 346 tests green). +**Legend:** ✅ Pass · ❌ Fail · ⬜ Not run + +| ID | Title | Precondition | Steps | Expected | Actual | Status | +|----|-------|--------------|-------|----------|--------|--------| +| SCIM-01 | Enable + mint token | Owner/admin | Settings → SCIM, toggle Enabled, click Generate token | Token shown once with the SCIM base URL; only last-4 retained after reload | | ⬜ | +| SCIM-02 | Token shown once | Token generated | Reload the page | Full token no longer shown; `•••• ` displayed | | ⬜ | +| SCIM-03 | Rotate invalidates old | A token exists & is configured in an IdP | Click Regenerate token | Old token stops authenticating (401); new token works | | ⬜ | +| SCIM-04 | Provision (create) | SCIM enabled; valid token | `POST /scim/v2/Users` with `userName`, `name`, `active:true` | `201` with a SCIM User (id, userName, active:true); user appears as a company member with the default role | | ⬜ | +| SCIM-05 | Idempotent re-create | User already provisioned & active | `POST /scim/v2/Users` for the same userName | `409 Conflict` (no duplicate) | | ⬜ | +| SCIM-06 | Deactivate via PATCH | Provisioned active user | `PATCH /scim/v2/Users/{id}` `replace active:false` | `200`, `active:false`; member can no longer access the workspace (membership `status:0`/`isDelete:true`); role cache invalidated | | ⬜ | +| SCIM-07 | Deactivate via DELETE | Provisioned active user | `DELETE /scim/v2/Users/{id}` | `204`; membership deactivated (soft, not hard-deleted) | | ⬜ | +| SCIM-08 | Reactivate | Previously deactivated user | `POST` again, or `PATCH active:true` | User reactivated in the workspace | | ⬜ | +| SCIM-09 | Filter by userName | Users exist | `GET /scim/v2/Users?filter=userName eq "x@y.com"` | ListResponse with only the matching user; correct `totalResults` | | ⬜ | +| SCIM-10 | List + pagination | >1 user | `GET /scim/v2/Users?startIndex=1&count=10` | SCIM ListResponse envelope; `itemsPerPage`/`startIndex`/`totalResults` correct | | ⬜ | +| SCIM-11 | Update name | Provisioned user | `PUT`/`PATCH` with new `name.givenName`/`familyName` | Global user name updated; reflected in the SCIM response | | ⬜ | +| SCIM-12 | No/invalid token → 401 | — | Call any `/scim/v2/*` with a missing/garbage/disabled-workspace token | `401` with SCIM Error body; no data leaked | | ⬜ | +| SCIM-13 | Tenant isolation | Two companies with SCIM | Use company A's token to GET Users | Only company A's members; company B never visible (company is bound to the token) | | ⬜ | +| SCIM-14 | Discovery docs | SCIM enabled | `GET /scim/v2/ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` | Valid SCIM metadata (patch supported, filter supported, oauthbearertoken scheme) | | ⬜ | +| SCIM-15 | scim+json content-type | — | POST with `Content-Type: application/scim+json` | Body parsed correctly (router-scoped parser); create succeeds | | ⬜ | +| SCIM-16 | Shared identity with SSO | SEC-02 SSO enabled for the same IdP | Provision a user via SCIM, then have them log in via SSO | Same global user (matched by email); no duplicate account | | ⬜ | +| SCIM-17 | Token stored hashed | DB access | Inspect `scim_configs` | Only `tokenHash` (bcrypt) + `tokenLast4` stored — never the plaintext token | | ⬜ | +| SCIM-18 | Rules (unit) | — | `npx jest tests/scim-rules.test.js` | All green — token round-trip (incl. colons in secret), email/filter parsing, Okta/Azure PATCH shapes, resource mapping | | ✅ | + +**Total:** 18 cases (1 unit-automated, 17 runtime/manual). Scope per the AHE-3762 done-when: IdP create→provision, remove→deactivate. Groups/SCIM bulk are out of scope for this pass. diff --git a/Config/collections.js b/Config/collections.js index f5ab374d..8b85763e 100644 --- a/Config/collections.js +++ b/Config/collections.js @@ -75,6 +75,7 @@ const dbCollections = { BILLING_RATES: "billing_rates", SSO_CONFIGS: "sso_configs", AUDIT_LOGS: "audit_logs", + SCIM_CONFIGS: "scim_configs", } /** DOCUMENT ID'S NAME WHICH IS USED IN THE "SETTINGS" COLLECTION NAME **/ diff --git a/Config/schemaType.js b/Config/schemaType.js index 1a8f2821..927258de 100644 --- a/Config/schemaType.js +++ b/Config/schemaType.js @@ -74,6 +74,7 @@ const SCHEMA_TYPE = { BILLING_RATES: "billing_rates", SSO_CONFIGS: "sso_configs", AUDIT_LOGS: "audit_logs", + SCIM_CONFIGS: "scim_configs", } module.exports = { diff --git a/Config/setMiddleware.js b/Config/setMiddleware.js index 05d6d000..81ae8e6d 100644 --- a/Config/setMiddleware.js +++ b/Config/setMiddleware.js @@ -173,6 +173,11 @@ const verifyJWTTokenWithCRoute = [ // (pre-auth), so they are intentionally NOT listed here. '/api/v2/sso/config', '/api/v1/audit-logs', + // SCIM admin config (Modules/Scim) — JWT+company; owner/admin gated + // in-controller. The SCIM 2.0 protocol routes (/scim/v2/*) use bearer-token + // auth (company resolved from the token) and are intentionally NOT listed. + '/api/v2/scim/config', + '/api/v2/scim/token', ]; const verifyJWTToken = [ "/api/v2/company/delete", diff --git a/Modules/Scim/auth.js b/Modules/Scim/auth.js new file mode 100644 index 00000000..b23413f2 --- /dev/null +++ b/Modules/Scim/auth.js @@ -0,0 +1,61 @@ +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const { SCHEMA_TYPE } = require('../../Config/schemaType'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const { getRoleType, isPrivileged } = require('../../Config/permissionGuard'); +const logger = require('../../Config/loggerConfig'); +const { parseScimToken, buildScimToken, scimError } = require('./helpers/scimRules'); + +const getScimConfig = (companyId) => + MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.SCIM_CONFIGS, data: [{ deletedStatusKey: 0 }] }, 'findOne'); + +// Bearer-token middleware for /scim/v2/*. Resolves the company FROM the token, +// then verifies the secret against the stored bcrypt hash. Sets req.scimCompanyId. +const scimAuth = async (req, res, next) => { + try { + const hdr = req.headers['authorization'] || ''; + const m = /^Bearer\s+(.+)$/i.exec(hdr); + if (!m) return res.status(401).set('WWW-Authenticate', 'Bearer').json(scimError(401, 'Missing bearer token.')); + const parsed = parseScimToken(m[1]); + if (!parsed) return res.status(401).json(scimError(401, 'Malformed token.')); + const cfg = await getScimConfig(parsed.companyId); + if (!cfg || !cfg.isEnabled || !cfg.tokenHash) return res.status(401).json(scimError(401, 'SCIM is not enabled for this workspace.')); + const ok = await bcrypt.compare(parsed.secret, cfg.tokenHash); + if (!ok) return res.status(401).json(scimError(401, 'Invalid token.')); + req.scimCompanyId = parsed.companyId; + req.scimConfig = cfg; + return next(); + } catch (e) { + logger.error(`scimAuth: ${e.message}`); + return res.status(401).json(scimError(401, 'Authentication failed.')); + } +}; + +// Owner/admin gate for the admin config endpoints. These pass through the JWT +// middleware first, so req.uid + the companyId header are populated. +const requireAdmin = async (req) => { + const companyId = req.headers['companyid']; + if (!companyId) return { ok: false, code: 400, msg: 'companyId is required.' }; + const roleType = await getRoleType(companyId, req.uid); + if (!isPrivileged(roleType)) return { ok: false, code: 403, msg: 'Owner/admin only.' }; + return { ok: true, companyId }; +}; + +// Generate (or rotate) the SCIM token. Stores only the bcrypt hash + last4 and +// returns the full token to show the admin ONCE. +const rotateScimToken = async (companyId, actor) => { + const secret = crypto.randomBytes(24).toString('hex'); + const tokenHash = await bcrypt.hash(secret, 10); + const set = { isEnabled: true, tokenHash, tokenLast4: secret.slice(-4), updatedBy: actor || '' }; + await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.SCIM_CONFIGS, + data: [ + { deletedStatusKey: 0 }, + { $set: set, $setOnInsert: { createdBy: actor || '', defaultRoleType: 3 } }, + { upsert: true, returnDocument: 'after', setDefaultsOnInsert: true }, + ], + }, 'findOneAndUpdate'); + return buildScimToken(companyId, secret); +}; + +module.exports = { scimAuth, requireAdmin, rotateScimToken, getScimConfig }; diff --git a/Modules/Scim/controller.js b/Modules/Scim/controller.js new file mode 100644 index 00000000..559b8f3e --- /dev/null +++ b/Modules/Scim/controller.js @@ -0,0 +1,176 @@ +const { SCHEMA_TYPE } = require('../../Config/schemaType'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const logger = require('../../Config/loggerConfig'); +const { requireAdmin, rotateScimToken, getScimConfig } = require('./auth'); +const prov = require('./provisioning'); +const R = require('./helpers/scimRules'); +const discovery = require('./discovery'); + +const baseUrlOf = (req) => { + const proto = String(req.headers['x-forwarded-proto'] || req.protocol || 'https').split(',')[0].trim(); + return `${proto}://${req.get('host')}`; +}; +const scimBaseOf = (req) => `${baseUrlOf(req)}/scim/v2`; +const audit = (req, entry) => { + try { require('../Audit/recorder').recordAuditFromReq(req, entry); } catch (e) { /* best-effort */ } +}; + +// --------------------------------------------------------------------------- +// Admin config (JWT + companyId; owner/admin gated) +// --------------------------------------------------------------------------- +exports.getConfig = async (req, res) => { + try { + const gate = await requireAdmin(req); + if (!gate.ok) return res.status(gate.code).json({ status: false, statusText: gate.msg }); + const cfg = await getScimConfig(gate.companyId); + return res.send({ + status: true, + data: { + isEnabled: !!(cfg && cfg.isEnabled), + hasToken: !!(cfg && cfg.tokenHash), + tokenLast4: (cfg && cfg.tokenLast4) || null, + defaultRoleType: (cfg && cfg.defaultRoleType) || 3, + baseUrl: scimBaseOf(req), + }, + }); + } catch (e) { logger.error(`scim.getConfig: ${e.message}`); return res.send({ status: false, statusText: e.message }); } +}; + +exports.setConfig = async (req, res) => { + try { + const gate = await requireAdmin(req); + if (!gate.ok) return res.status(gate.code).json({ status: false, statusText: gate.msg }); + const set = { updatedBy: req.uid || '' }; + if (req.body.isEnabled !== undefined) set.isEnabled = !!req.body.isEnabled; + if (req.body.defaultRoleType !== undefined) set.defaultRoleType = Number(req.body.defaultRoleType) || 3; + const saved = await MongoDbCrudOpration(gate.companyId, { + type: SCHEMA_TYPE.SCIM_CONFIGS, + data: [ + { deletedStatusKey: 0 }, + { $set: set, $setOnInsert: { createdBy: req.uid || '' } }, + { upsert: true, returnDocument: 'after', setDefaultsOnInsert: true }, + ], + }, 'findOneAndUpdate'); + audit(req, { action: 'scim.config_update', entityType: 'scim', meta: { isEnabled: set.isEnabled } }); + return res.send({ status: true, statusText: 'SCIM config saved.', data: { isEnabled: !!(saved && saved.isEnabled), defaultRoleType: (saved && saved.defaultRoleType) || 3 } }); + } catch (e) { logger.error(`scim.setConfig: ${e.message}`); return res.send({ status: false, statusText: e.message }); } +}; + +exports.rotateToken = async (req, res) => { + try { + const gate = await requireAdmin(req); + if (!gate.ok) return res.status(gate.code).json({ status: false, statusText: gate.msg }); + const token = await rotateScimToken(gate.companyId, req.uid); + audit(req, { action: 'scim.token_rotate', entityType: 'scim' }); + return res.send({ + status: true, + statusText: 'SCIM token generated. Copy it now — it will not be shown again.', + data: { token, baseUrl: scimBaseOf(req) }, + }); + } catch (e) { logger.error(`scim.rotateToken: ${e.message}`); return res.send({ status: false, statusText: e.message }); } +}; + +// --------------------------------------------------------------------------- +// SCIM 2.0 protocol (bearer auth; req.scimCompanyId set by scimAuth) +// --------------------------------------------------------------------------- +exports.serviceProviderConfig = (req, res) => res.json(discovery.spProviderConfig(scimBaseOf(req))); +exports.resourceTypes = (req, res) => res.json(discovery.resourceTypes(scimBaseOf(req))); +exports.schemas = (req, res) => res.json(discovery.schemas()); + +exports.listUsers = async (req, res) => { + try { + const companyId = req.scimCompanyId; + const email = R.parseUserNameFilter(req.query.filter); + const startIndex = Math.max(1, Number(req.query.startIndex) || 1); + const countParam = req.query.count != null ? Number(req.query.count) : 100; + const count = Math.min(200, Math.max(0, isNaN(countParam) ? 100 : countParam)); + const { members, total } = await prov.listMembers(companyId, email, startIndex - 1, count || 1); + const wanted = count === 0 ? [] : members; + const users = await prov.getGlobalUsersByIds(wanted.map((m) => m.userId)); + const byId = {}; + (users || []).forEach((u) => { byId[String(u._id)] = u; }); + const resources = wanted.map((m) => R.toScimUser(byId[String(m.userId)], m, scimBaseOf(req))); + return res.json(R.listResponse(resources, startIndex, total)); + } catch (e) { logger.error(`scim.listUsers: ${e.message}`); return res.status(500).json(R.scimError(500, e.message)); } +}; + +exports.getUser = async (req, res) => { + try { + const companyId = req.scimCompanyId; + const cu = await prov.getCompanyUser(companyId, req.params.id); + if (!cu) return res.status(404).json(R.scimError(404, 'User not found.')); + const gu = await prov.getGlobalUser(req.params.id); + return res.json(R.toScimUser(gu, cu, scimBaseOf(req))); + } catch (e) { logger.error(`scim.getUser: ${e.message}`); return res.status(500).json(R.scimError(500, e.message)); } +}; + +exports.createUser = async (req, res) => { + try { + const companyId = req.scimCompanyId; + const email = R.extractEmail(req.body); + if (!email) return res.status(400).json(R.scimError(400, 'userName (email) is required.')); + const existing = await prov.getCompanyUserByEmail(companyId, email); + if (existing && R.isActive(existing)) { + return res.status(409).json(R.scimError(409, 'User already exists.')); + } + const defaultRoleType = (req.scimConfig && req.scimConfig.defaultRoleType) || 3; + const active = req.body.active !== false; + const { uid } = await prov.provision(companyId, { + email, + firstName: req.body.name && req.body.name.givenName, + lastName: req.body.name && req.body.name.familyName, + externalId: req.body.externalId, + active, defaultRoleType, + }); + const cu = await prov.getCompanyUser(companyId, uid); + const gu = await prov.getGlobalUser(uid); + audit(req, { action: 'scim.user_provision', entityType: 'member', entityId: String(uid), entityName: email }); + return res.status(201).json(R.toScimUser(gu, cu, scimBaseOf(req))); + } catch (e) { logger.error(`scim.createUser: ${e.message}`); return res.status(500).json(R.scimError(500, e.message)); } +}; + +exports.replaceUser = async (req, res) => { + try { + const companyId = req.scimCompanyId; + const uid = req.params.id; + const cu = await prov.getCompanyUser(companyId, uid); + if (!cu) return res.status(404).json(R.scimError(404, 'User not found.')); + await prov.updateName(uid, { + givenName: req.body.name && req.body.name.givenName, + familyName: req.body.name && req.body.name.familyName, + }); + if (req.body.active !== undefined) await prov.setActive(companyId, uid, req.body.active !== false); + const ncu = await prov.getCompanyUser(companyId, uid); + const gu = await prov.getGlobalUser(uid); + return res.json(R.toScimUser(gu, ncu, scimBaseOf(req))); + } catch (e) { logger.error(`scim.replaceUser: ${e.message}`); return res.status(500).json(R.scimError(500, e.message)); } +}; + +exports.patchUser = async (req, res) => { + try { + const companyId = req.scimCompanyId; + const uid = req.params.id; + const cu = await prov.getCompanyUser(companyId, uid); + if (!cu) return res.status(404).json(R.scimError(404, 'User not found.')); + const ops = R.parsePatchOps(req.body); + if (ops.givenName !== undefined || ops.familyName !== undefined) await prov.updateName(uid, ops); + if (ops.active !== undefined) { + await prov.setActive(companyId, uid, ops.active); + audit(req, { action: ops.active ? 'scim.user_activate' : 'scim.user_deactivate', entityType: 'member', entityId: String(uid) }); + } + const ncu = await prov.getCompanyUser(companyId, uid); + const gu = await prov.getGlobalUser(uid); + return res.json(R.toScimUser(gu, ncu, scimBaseOf(req))); + } catch (e) { logger.error(`scim.patchUser: ${e.message}`); return res.status(500).json(R.scimError(500, e.message)); } +}; + +exports.deleteUser = async (req, res) => { + try { + const companyId = req.scimCompanyId; + const uid = req.params.id; + const cu = await prov.setActive(companyId, uid, false); + if (!cu) return res.status(404).json(R.scimError(404, 'User not found.')); + audit(req, { action: 'scim.user_deactivate', entityType: 'member', entityId: String(uid) }); + return res.status(204).send(); + } catch (e) { logger.error(`scim.deleteUser: ${e.message}`); return res.status(500).json(R.scimError(500, e.message)); } +}; diff --git a/Modules/Scim/discovery.js b/Modules/Scim/discovery.js new file mode 100644 index 00000000..3c3decef --- /dev/null +++ b/Modules/Scim/discovery.js @@ -0,0 +1,43 @@ +// SEC-05 — static SCIM 2.0 discovery documents. IdPs (Okta, Azure AD, etc.) +// probe these to learn capabilities before syncing users. + +const spProviderConfig = (baseUrl) => ({ + schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'], + documentationUri: 'https://help.alianhub.com', + patch: { supported: true }, + bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 }, + filter: { supported: true, maxResults: 200 }, + changePassword: { supported: false }, + sort: { supported: false }, + etag: { supported: false }, + authenticationSchemes: [{ + type: 'oauthbearertoken', + name: 'OAuth Bearer Token', + description: 'Authentication via the SCIM bearer token issued in AlianHub settings.', + primary: true, + }], + meta: { resourceType: 'ServiceProviderConfig', location: `${baseUrl}/ServiceProviderConfig` }, +}); + +const resourceTypes = (baseUrl) => ([{ + schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], + id: 'User', + name: 'User', + endpoint: '/Users', + schema: 'urn:ietf:params:scim:schemas:core:2.0:User', + meta: { resourceType: 'ResourceType', location: `${baseUrl}/ResourceTypes/User` }, +}]); + +const schemas = () => ([{ + id: 'urn:ietf:params:scim:schemas:core:2.0:User', + name: 'User', + description: 'User Account', + attributes: [ + { name: 'userName', type: 'string', multiValued: false, required: true, caseExact: false, mutability: 'readWrite', returned: 'default', uniqueness: 'server' }, + { name: 'name', type: 'complex', multiValued: false, required: false }, + { name: 'active', type: 'boolean', multiValued: false, required: false }, + { name: 'emails', type: 'complex', multiValued: true, required: false }, + ], +}]); + +module.exports = { spProviderConfig, resourceTypes, schemas }; diff --git a/Modules/Scim/helpers/scimRules.js b/Modules/Scim/helpers/scimRules.js new file mode 100644 index 00000000..18b12e89 --- /dev/null +++ b/Modules/Scim/helpers/scimRules.js @@ -0,0 +1,133 @@ +// SEC-05 — pure SCIM 2.0 helpers (no DB, no I/O). Unit-tested in +// tests/scim-rules.test.js. Keeping all parsing/shaping here means the +// controller + provisioning layers stay thin and the tricky bits are covered. + +const USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User'; +const LIST_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse'; +const ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error'; +const PATCH_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:PatchOp'; + +// Token = base64url(":"). The company is embedded so a SCIM +// request (which carries no companyId header) can resolve its tenant BEFORE the +// secret is verified against the stored bcrypt hash. +const buildScimToken = (companyId, secret) => { + if (!companyId || !secret) return ''; + return Buffer.from(`${companyId}:${secret}`, 'utf8').toString('base64url'); +}; + +const parseScimToken = (token) => { + if (!token || typeof token !== 'string') return null; + let decoded; + try { decoded = Buffer.from(token.trim(), 'base64url').toString('utf8'); } + catch (e) { return null; } + const idx = decoded.indexOf(':'); + if (idx <= 0 || idx === decoded.length - 1) return null; + const companyId = decoded.slice(0, idx); + const secret = decoded.slice(idx + 1); + if (!companyId || !secret) return null; + return { companyId, secret }; +}; + +const coerceBool = (v) => { + if (typeof v === 'boolean') return v; + if (typeof v === 'string') return v.trim().toLowerCase() === 'true'; + return !!v; +}; + +// Pull the email out of a SCIM User payload (userName preferred, then the +// primary/first email). +const extractEmail = (body) => { + if (!body || typeof body !== 'object') return ''; + const un = body.userName ? String(body.userName).trim() : ''; + if (un && un.includes('@')) return un.toLowerCase(); + if (Array.isArray(body.emails) && body.emails.length) { + const primary = body.emails.find((e) => e && e.primary) || body.emails[0]; + if (primary && primary.value) return String(primary.value).trim().toLowerCase(); + } + return un ? un.toLowerCase() : ''; +}; + +// Parse `userName eq "x"` / `emails.value eq "x"` filters → email or null. +const parseUserNameFilter = (filter) => { + if (!filter || typeof filter !== 'string') return null; + const m = filter.match(/(?:userName|emails(?:\.value)?)\s+eq\s+"([^"]+)"/i); + return m ? m[1].trim().toLowerCase() : null; +}; + +const isActive = (companyUser) => { + if (!companyUser) return false; + if (companyUser.isDelete === true) return false; + if (companyUser.status === 0) return false; + return true; +}; + +// AlianHub (global user + per-company membership) → SCIM User resource. +const toScimUser = (globalUser, companyUser, baseUrl) => { + const u = globalUser || {}; + const cu = companyUser || {}; + const id = String(u._id || cu.userId || ''); + const email = u.Employee_Email || cu.userEmail || ''; + const resource = { + schemas: [USER_SCHEMA], + id, + userName: email, + name: { + givenName: u.Employee_FName || '', + familyName: u.Employee_LName || '', + formatted: u.Employee_Name || email, + }, + emails: email ? [{ value: email, primary: true }] : [], + active: isActive(cu), + meta: { resourceType: 'User' }, + }; + if (baseUrl) resource.meta.location = `${baseUrl}/Users/${id}`; + if (cu.scimExternalId) resource.externalId = cu.scimExternalId; + return resource; +}; + +const listResponse = (resources, startIndex, totalResults) => ({ + schemas: [LIST_SCHEMA], + totalResults: Number(totalResults) || 0, + startIndex: Number(startIndex) || 1, + itemsPerPage: Array.isArray(resources) ? resources.length : 0, + Resources: Array.isArray(resources) ? resources : [], +}); + +const scimError = (status, detail) => ({ + schemas: [ERROR_SCHEMA], + status: String(status), + detail: detail || '', +}); + +// Normalise PATCH Operations into a flat change set. Supports the common +// replace/add ops Okta & Azure AD send for activate/deactivate + name updates, +// both path-scoped (`path:"active"`) and pathless (`value:{active,name}`). +const parsePatchOps = (body) => { + const out = {}; + const ops = body && Array.isArray(body.Operations) ? body.Operations : []; + for (const op of ops) { + if (!op || typeof op !== 'object') continue; + const verb = String(op.op || '').toLowerCase(); + if (verb !== 'replace' && verb !== 'add') continue; + const path = op.path ? String(op.path) : ''; + const value = op.value; + if (path.toLowerCase() === 'active') { + out.active = coerceBool(value); + } else if (path === 'name.givenName') { + out.givenName = String(value == null ? '' : value); + } else if (path === 'name.familyName') { + out.familyName = String(value == null ? '' : value); + } else if (!path && value && typeof value === 'object') { + if (value.active !== undefined) out.active = coerceBool(value.active); + if (value.name && value.name.givenName !== undefined) out.givenName = String(value.name.givenName); + if (value.name && value.name.familyName !== undefined) out.familyName = String(value.name.familyName); + } + } + return out; +}; + +module.exports = { + USER_SCHEMA, LIST_SCHEMA, ERROR_SCHEMA, PATCH_SCHEMA, + buildScimToken, parseScimToken, coerceBool, extractEmail, parseUserNameFilter, + isActive, toScimUser, listResponse, scimError, parsePatchOps, +}; diff --git a/Modules/Scim/init.js b/Modules/Scim/init.js new file mode 100644 index 00000000..be6fc26a --- /dev/null +++ b/Modules/Scim/init.js @@ -0,0 +1,5 @@ +const routes = require('./routes'); + +exports.init = (app) => { + routes.init(app); +} diff --git a/Modules/Scim/provisioning.js b/Modules/Scim/provisioning.js new file mode 100644 index 00000000..54da53b8 --- /dev/null +++ b/Modules/Scim/provisioning.js @@ -0,0 +1,103 @@ +const mongoose = require('mongoose'); +const { SCHEMA_TYPE } = require('../../Config/schemaType'); +const { dbCollections } = require('../../Config/collections'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const { jitProvisionUser } = require('../SSO/provisioning'); +const { removeCache } = require('../../utils/commonFunctions'); +const logger = require('../../Config/loggerConfig'); + +let invalidateRoleCache = () => {}; +try { ({ invalidateRoleCache } = require('../../Config/permissionGuard')); } catch (e) { /* optional */ } + +const getCompanyUser = (companyId, uid) => + MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.COMPANY_USERS, data: [{ userId: String(uid) }] }, 'findOne'); + +const getCompanyUserByEmail = (companyId, email) => + MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.COMPANY_USERS, data: [{ userEmail: String(email).toLowerCase() }] }, 'findOne'); + +const getGlobalUser = (uid) => + MongoDbCrudOpration(dbCollections.GLOBAL, { + type: SCHEMA_TYPE.USERS, data: [{ _id: new mongoose.Types.ObjectId(String(uid)) }], + }, 'findOne'); + +const getGlobalUsersByIds = (uids) => { + const ids = (uids || []).filter(Boolean).map((u) => new mongoose.Types.ObjectId(String(u))); + if (!ids.length) return Promise.resolve([]); + return MongoDbCrudOpration(dbCollections.GLOBAL, { + type: SCHEMA_TYPE.USERS, data: [{ _id: { $in: ids } }], + }, 'find'); +}; + +const clearUserCaches = (companyId, uid) => { + try { + removeCache(`company_users:${companyId}`); + removeCache(`UserData:${uid}`, false); + removeCache(`UserAllData:${companyId}`); + invalidateRoleCache(companyId, uid); + } catch (e) { /* cache is best-effort */ } +}; + +// Create or reactivate a SCIM-managed membership. Reuses the SSO JIT path so a +// user provisioned by SCIM and a user who later logs in via SSO are the SAME +// global user (matched by email). Returns { uid, created }. +const provision = async (companyId, { email, firstName, lastName, externalId, active, defaultRoleType }) => { + const existing = await getCompanyUserByEmail(companyId, email); + const uid = await jitProvisionUser({ + companyId, email, firstName, lastName, externalId, + defaultRoleType: defaultRoleType || 3, autoProvision: true, + }); + const set = { status: active === false ? 0 : 1, isDelete: active === false, userEmail: String(email).toLowerCase() }; + if (externalId) set.scimExternalId = String(externalId); + await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.COMPANY_USERS, + data: [{ userId: String(uid) }, { $set: set }], + }, 'updateOne'); + clearUserCaches(companyId, uid); + return { uid, created: !existing }; +}; + +// Activate / deactivate a membership for THIS company only (the global user may +// belong to other companies). Returns the updated membership, or null if absent. +const setActive = async (companyId, uid, active) => { + const cu = await getCompanyUser(companyId, uid); + if (!cu) return null; + await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.COMPANY_USERS, + data: [{ userId: String(uid) }, { $set: { status: active ? 1 : 0, isDelete: !active } }], + }, 'updateOne'); + clearUserCaches(companyId, uid); + return getCompanyUser(companyId, uid); +}; + +const updateName = async (uid, { givenName, familyName }) => { + if (givenName === undefined && familyName === undefined) return; + const cur = await getGlobalUser(uid); + const fn = givenName !== undefined ? givenName : (cur && cur.Employee_FName) || ''; + const ln = familyName !== undefined ? familyName : (cur && cur.Employee_LName) || ''; + const set = { Employee_Name: `${fn} ${ln}`.trim() }; + if (givenName !== undefined) set.Employee_FName = givenName; + if (familyName !== undefined) set.Employee_LName = familyName; + await MongoDbCrudOpration(dbCollections.GLOBAL, { + type: SCHEMA_TYPE.USERS, + data: [{ _id: new mongoose.Types.ObjectId(String(uid)) }, { $set: set }], + }, 'updateOne'); +}; + +// List memberships (optionally filtered by email), newest first, paginated. +const listMembers = async (companyId, email, skip, limit) => { + const match = email ? { userEmail: String(email).toLowerCase() } : {}; + const pipeline = [ + { $match: match }, + { $sort: { _id: -1 } }, + { $facet: { data: [{ $skip: Math.max(0, skip) }, { $limit: Math.max(1, limit) }], meta: [{ $count: 'total' }] } }, + ]; + const rows = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.COMPANY_USERS, data: [pipeline] }, 'aggregate'); + const members = (rows && rows[0] && rows[0].data) || []; + const total = (rows && rows[0] && rows[0].meta && rows[0].meta[0] && rows[0].meta[0].total) || 0; + return { members, total }; +}; + +module.exports = { + getCompanyUser, getCompanyUserByEmail, getGlobalUser, getGlobalUsersByIds, + provision, setActive, updateName, listMembers, logger, +}; diff --git a/Modules/Scim/routes.js b/Modules/Scim/routes.js new file mode 100644 index 00000000..8030e18e --- /dev/null +++ b/Modules/Scim/routes.js @@ -0,0 +1,28 @@ +const express = require('express'); +const ctrl = require('./controller'); +const { scimAuth } = require('./auth'); + +exports.init = (app) => { + // Admin config — JWT + companyId (listed in setMiddleware); owner/admin + // gated in-controller. Used by the Settings UI to enable SCIM + mint a token. + app.get('/api/v2/scim/config', ctrl.getConfig); + app.put('/api/v2/scim/config', ctrl.setConfig); + app.post('/api/v2/scim/token', ctrl.rotateToken); + + // SCIM 2.0 protocol — bearer-token auth (company resolved FROM the token), so + // these are intentionally NOT behind the JWT/companyId middleware. A + // router-scoped body parser also accepts application/scim+json. + const scim = express.Router(); + scim.use(express.json({ type: ['application/json', 'application/scim+json'], limit: '1mb' })); + scim.use(scimAuth); + scim.get('/ServiceProviderConfig', ctrl.serviceProviderConfig); + scim.get('/ResourceTypes', ctrl.resourceTypes); + scim.get('/Schemas', ctrl.schemas); + scim.get('/Users', ctrl.listUsers); + scim.post('/Users', ctrl.createUser); + scim.get('/Users/:id', ctrl.getUser); + scim.put('/Users/:id', ctrl.replaceUser); + scim.patch('/Users/:id', ctrl.patchUser); + scim.delete('/Users/:id', ctrl.deleteUser); + app.use('/scim/v2', scim); +}; diff --git a/frontend/src/components/templates/Settings/Settings.vue b/frontend/src/components/templates/Settings/Settings.vue index 2314e244..9d7f3417 100644 --- a/frontend/src/components/templates/Settings/Settings.vue +++ b/frontend/src/components/templates/Settings/Settings.vue @@ -183,6 +183,12 @@ icon: require("@/assets/images/svg/workspaceSecurity.svg"), permissions:['settings.settings_security_permissions'], activeIcon: require("@/assets/images/svg/workspaceSecurityActive.svg") + },{ + label: "SCIM", + to: {name: "ScimSettings"}, + icon: require("@/assets/images/svg/workspaceSecurity.svg"), + permissions:['settings.settings_security_permissions'], + activeIcon: require("@/assets/images/svg/workspaceSecurityActive.svg") },{ label: "Audit Log", to: {name: "AuditLog"}, diff --git a/frontend/src/config/env.js b/frontend/src/config/env.js index ebde6e9a..1fae59b6 100644 --- a/frontend/src/config/env.js +++ b/frontend/src/config/env.js @@ -182,6 +182,8 @@ module.exports.TIMESHEET = '/api/v1/timesheet'; module.exports.TIMESHEET_APPROVAL = '/api/v2/timesheet-approval'; module.exports.SSO_CONFIG = '/api/v2/sso/config'; module.exports.AUDIT_LOGS = '/api/v1/audit-logs'; +module.exports.SCIM_CONFIG = '/api/v2/scim/config'; +module.exports.SCIM_TOKEN = '/api/v2/scim/token'; module.exports.MAIN_CHATS = '/api/v1/main-chats' module.exports.ACTIVITYLOG = '/api/v1/activity-log' module.exports.SETTING_CATEGORY = '/api/v1/setting/category'; diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index b543def2..244ea457 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -1431,6 +1431,7 @@ export default { "Security & Permissions": "Security & Permissions", SSO: "SSO", "Audit Log": "Audit Log", + SCIM: "SCIM", Upgrade: "Upgrade", "Custom Field Manager": "Custom Field Manager", Templates: "Templates", @@ -1462,6 +1463,28 @@ export default { metadata: "SP metadata", saving: "Saving…", }, + Scim: { + title: "SCIM Provisioning", + subtitle: "Let your identity provider create, update, and deactivate members automatically (Okta, Azure AD, OneLogin…).", + enabled: "Enabled", + disabled: "Disabled", + default_role: "Default role for provisioned users", + default_role_hint: "New users created by your IdP get this role.", + role_admin: "Admin", + role_member: "Member", + role_guest: "Guest", + connect_hint: "Configure these in your IdP's SCIM / provisioning settings:", + base_url: "SCIM base URL", + token_label: "Bearer token", + token_hidden: "hidden for security", + no_token: "Not generated yet", + token_once: "Copy this token now — it will not be shown again.", + copy: "Copy", + copied: "Copied!", + generate: "Generate token", + rotate: "Regenerate token", + working: "Working…", + }, Audit: { title: "Audit Log", subtitle: "An immutable record of sensitive actions in this workspace — role changes, SSO updates, and more.", diff --git a/frontend/src/router/settings/index.js b/frontend/src/router/settings/index.js index 6adf3364..6c3c65fe 100644 --- a/frontend/src/router/settings/index.js +++ b/frontend/src/router/settings/index.js @@ -48,6 +48,15 @@ export default [ }, component: () => import(/* webpackChunkName: AuditLog */ '@/views/Settings/Audit/AuditLog.vue') }, + { + path: "scim", + name: "ScimSettings", + meta: { + title: "SCIM", + requiresAuth: true + }, + component: () => import(/* webpackChunkName: ScimSettings */ '@/views/Settings/Scim/ScimSettings.vue') + }, { path: "teams", name: "Teams", diff --git a/frontend/src/views/Settings/Scim/ScimSettings.vue b/frontend/src/views/Settings/Scim/ScimSettings.vue new file mode 100644 index 00000000..6a6652de --- /dev/null +++ b/frontend/src/views/Settings/Scim/ScimSettings.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/index.js b/index.js index a751c432..bd2deac2 100644 --- a/index.js +++ b/index.js @@ -176,6 +176,7 @@ function initializeControllers() { require('./Modules/Auth/init').init(app); require('./Modules/SSO/init').init(app); require('./Modules/Audit/init').init(app); + require('./Modules/Scim/init').init(app); require('./Modules/notification1/init').init(app); require('./Modules/ImportSettings/init').init(app); require('./Modules/Tasks/init.js').init(app); diff --git a/tests/scim-rules.test.js b/tests/scim-rules.test.js new file mode 100644 index 00000000..f4eec027 --- /dev/null +++ b/tests/scim-rules.test.js @@ -0,0 +1,116 @@ +const R = require('../Modules/Scim/helpers/scimRules'); + +describe('SCIM token (build/parse)', () => { + test('round-trips companyId + secret', () => { + const tok = R.buildScimToken('cmp123', 'sekret'); + expect(typeof tok).toBe('string'); + const parsed = R.parseScimToken(tok); + expect(parsed).toEqual({ companyId: 'cmp123', secret: 'sekret' }); + }); + test('preserves a secret that contains colons', () => { + const parsed = R.parseScimToken(R.buildScimToken('cmp', 'a:b:c')); + expect(parsed).toEqual({ companyId: 'cmp', secret: 'a:b:c' }); + }); + test('rejects empty / malformed tokens', () => { + expect(R.parseScimToken('')).toBeNull(); + expect(R.parseScimToken(null)).toBeNull(); + expect(R.buildScimToken('', 'x')).toBe(''); + // base64url of "nocolon" → no separator → null + expect(R.parseScimToken(Buffer.from('nocolon', 'utf8').toString('base64url'))).toBeNull(); + }); +}); + +describe('extractEmail', () => { + test('prefers userName when it is an email', () => { + expect(R.extractEmail({ userName: 'A@B.com', emails: [{ value: 'x@y.com' }] })).toBe('a@b.com'); + }); + test('falls back to primary email', () => { + expect(R.extractEmail({ userName: 'jdoe', emails: [{ value: 'p@q.com', primary: true }, { value: 'x@y.com' }] })).toBe('p@q.com'); + }); + test('returns empty when nothing usable', () => { + expect(R.extractEmail({})).toBe(''); + expect(R.extractEmail(null)).toBe(''); + }); +}); + +describe('parseUserNameFilter', () => { + test('parses userName eq', () => { + expect(R.parseUserNameFilter('userName eq "Bob@Co.com"')).toBe('bob@co.com'); + }); + test('parses emails.value eq', () => { + expect(R.parseUserNameFilter('emails.value eq "z@co.com"')).toBe('z@co.com'); + }); + test('null for unsupported / empty filters', () => { + expect(R.parseUserNameFilter('displayName co "x"')).toBeNull(); + expect(R.parseUserNameFilter('')).toBeNull(); + }); +}); + +describe('isActive', () => { + test('active by default', () => { + expect(R.isActive({ userId: '1' })).toBe(true); + expect(R.isActive({ status: 1 })).toBe(true); + }); + test('inactive when soft-deleted or status 0', () => { + expect(R.isActive({ isDelete: true })).toBe(false); + expect(R.isActive({ status: 0 })).toBe(false); + expect(R.isActive(null)).toBe(false); + }); +}); + +describe('toScimUser', () => { + test('maps a global user + membership to a SCIM resource', () => { + const gu = { _id: 'u1', Employee_Email: 'p@q.com', Employee_FName: 'Pat', Employee_LName: 'Lee', Employee_Name: 'Pat Lee' }; + const cu = { userId: 'u1', status: 1 }; + const r = R.toScimUser(gu, cu, 'https://h/scim/v2'); + expect(r.id).toBe('u1'); + expect(r.userName).toBe('p@q.com'); + expect(r.name).toEqual({ givenName: 'Pat', familyName: 'Lee', formatted: 'Pat Lee' }); + expect(r.emails).toEqual([{ value: 'p@q.com', primary: true }]); + expect(r.active).toBe(true); + expect(r.meta.location).toBe('https://h/scim/v2/Users/u1'); + expect(r.schemas).toContain('urn:ietf:params:scim:schemas:core:2.0:User'); + }); + test('reflects deactivation', () => { + const r = R.toScimUser({ _id: 'u2', Employee_Email: 'x@y.com' }, { userId: 'u2', isDelete: true }); + expect(r.active).toBe(false); + }); +}); + +describe('listResponse', () => { + test('wraps resources with SCIM list envelope', () => { + const lr = R.listResponse([{ id: 'a' }], 1, 5); + expect(lr.schemas).toContain('urn:ietf:params:scim:api:messages:2.0:ListResponse'); + expect(lr.totalResults).toBe(5); + expect(lr.itemsPerPage).toBe(1); + expect(lr.startIndex).toBe(1); + expect(lr.Resources).toHaveLength(1); + }); +}); + +describe('parsePatchOps', () => { + test('replace active:false (Okta deactivate)', () => { + expect(R.parsePatchOps({ Operations: [{ op: 'replace', path: 'active', value: false }] })).toEqual({ active: false }); + }); + test('Azure pathless value object', () => { + const ops = R.parsePatchOps({ Operations: [{ op: 'Replace', value: { active: true, name: { givenName: 'Jo' } } }] }); + expect(ops.active).toBe(true); + expect(ops.givenName).toBe('Jo'); + }); + test('string "False" coerces to boolean', () => { + expect(R.parsePatchOps({ Operations: [{ op: 'replace', path: 'active', value: 'False' }] })).toEqual({ active: false }); + }); + test('ignores unknown ops / paths', () => { + expect(R.parsePatchOps({ Operations: [{ op: 'remove', path: 'emails' }] })).toEqual({}); + expect(R.parsePatchOps({})).toEqual({}); + }); +}); + +describe('scimError', () => { + test('shapes a SCIM error', () => { + const e = R.scimError(404, 'nope'); + expect(e.schemas).toContain('urn:ietf:params:scim:api:messages:2.0:Error'); + expect(e.status).toBe('404'); + expect(e.detail).toBe('nope'); + }); +}); diff --git a/utils/mongo-handler/createSchema.js b/utils/mongo-handler/createSchema.js index 6184fd0b..ea2c13f9 100644 --- a/utils/mongo-handler/createSchema.js +++ b/utils/mongo-handler/createSchema.js @@ -129,6 +129,7 @@ auditLogsSchema.index({ createdAt: -1 }); auditLogsSchema.index({ actorId: 1, createdAt: -1 }); auditLogsSchema.index({ entityType: 1, entityId: 1, createdAt: -1 }); auditLogsSchema.index({ action: 1, createdAt: -1 }); +const scimConfigsSchema = new Schema(schema.scimConfigs, {strict: true, timestamps: true}); // Global search: one combined text index per collection. taskSchema.index({ TaskName: 'text', rawDescription: 'text' }); projectsSchema.index({ ProjectName: 'text' }); @@ -212,6 +213,7 @@ module.exports = { billingRatesSchema, ssoConfigsSchema, auditLogsSchema, + scimConfigsSchema, historySchema, userIdSchema, usersSchema, diff --git a/utils/mongo-handler/mongoQueries.js b/utils/mongo-handler/mongoQueries.js index f778f61c..6f2dd4db 100644 --- a/utils/mongo-handler/mongoQueries.js +++ b/utils/mongo-handler/mongoQueries.js @@ -72,7 +72,8 @@ const { timesheetApprovalSchema, billingRatesSchema, ssoConfigsSchema, - auditLogsSchema + auditLogsSchema, + scimConfigsSchema } = require('./createSchema'); @@ -222,6 +223,8 @@ exports.checkType = (type) => { return ssoConfigsSchema case SCHEMA_TYPE.AUDIT_LOGS: return auditLogsSchema + case SCHEMA_TYPE.SCIM_CONFIGS: + return scimConfigsSchema default: return "" } @@ -374,6 +377,8 @@ exports.tableType = (type) => { return `${dbCollections.SSO_CONFIGS}` case SCHEMA_TYPE.AUDIT_LOGS: return `${dbCollections.AUDIT_LOGS}` + case SCHEMA_TYPE.SCIM_CONFIGS: + return `${dbCollections.SCIM_CONFIGS}` default: return "" } diff --git a/utils/mongo-handler/schema.js b/utils/mongo-handler/schema.js index 783a8899..e31c6a9f 100644 --- a/utils/mongo-handler/schema.js +++ b/utils/mongo-handler/schema.js @@ -487,6 +487,18 @@ const schema = { meta: { type: Object, required: false }, ip: { type: String, required: false }, }, + // Per-company SCIM 2.0 provisioning config — managed by Modules/Scim (SEC-05). + // The IdP-held bearer token is stored only as a bcrypt hash; the company is + // resolved from the token itself, so SCIM requests carry no companyId header. + scimConfigs: { + isEnabled: { type: Boolean, default: false, required: false }, + tokenHash: { type: String, required: false }, + tokenLast4: { type: String, required: false }, + defaultRoleType: { type: Number, default: 3, required: false }, + createdBy: { type: String, required: false }, + updatedBy: { type: String, required: false }, + deletedStatusKey: { type: Number, default: 0, required: false }, + }, // Wiki pages (Editor.js blocks; versioned) — managed by Modules/Pages pages: { title: { type: String, required: true }, From 780f16e8122aeeef34e3670c29c778cf4564daeb Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 16:23:16 +0530 Subject: [PATCH 12/17] =?UTF-8?q?fix(pwa):=20stop=20reload=20loop=20?= =?UTF-8?q?=E2=80=94=20withdraw=20the=20SEC-03=20service=20worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SEC-03 PWA worker cached build assets cache-first. Against the dev server's HMR (non-hashed bundles) it served stale JS, so the HMR runtime forced a full reload, got stale JS again, and looped. A registered worker persists in the browser, so the loop survived later changes. - service-worker.js is now a self-unregistering kill-switch: it claims clients, drops the alianhub-pwa caches, and unregisters. It has NO fetch handler, so the moment it activates the browser stops serving stale assets and the loop ends. Browsers re-check this script on navigation, so affected clients heal with no user action. - index.html: registration replaced with a targeted unregister of any /service-worker.js registration (the firebase-messaging worker is left intact). Manifest + PWA meta stay (harmless; no worker = no install prompt). A production-only PWA worker (https + non-localhost, hashed-asset aware) can be reintroduced later. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/public/index.html | 15 +++++-- frontend/public/service-worker.js | 66 ++++++++++++------------------- 2 files changed, 37 insertions(+), 44 deletions(-) diff --git a/frontend/public/index.html b/frontend/public/index.html index b51e82ca..f9492852 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -28,11 +28,18 @@
diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js index 30e50abf..24a7d11e 100644 --- a/frontend/public/service-worker.js +++ b/frontend/public/service-worker.js @@ -1,45 +1,31 @@ -/* AlianHub PWA service worker (SEC-03). - * Conservative + safe: - * - API / socket requests are NEVER cached (always network). - * - Navigations are network-first with an offline fallback to the cached shell. - * - Hashed build assets are cache-first (then network, and cached). - * Bump CACHE on shape changes; old caches are purged on activate. */ -const CACHE = 'alianhub-pwa-v1'; -const SHELL = ['/', '/index.html']; +/* AlianHub service worker — DISABLED (kill-switch). + * + * The SEC-03 PWA service worker was withdrawn: its cache-first asset strategy + * fought the dev server's HMR (non-hashed dev bundles were served stale), which + * produced a full-page reload loop. Because a registered service worker + * persists in the browser across deploys, simply deleting the file is not + * enough — already-affected browsers would keep running the old looping worker. + * + * This stub takes over from any previously-installed AlianHub worker, drops its + * caches, and unregisters itself. Crucially it has NO fetch handler, so the + * instant it activates the browser stops serving stale cached assets and the + * loop ends. The browser auto-checks this script on navigation, so affected + * clients self-heal with no user action. + * + * A production-only PWA worker (registered only on https + non-localhost, and + * aware of hashed build assets) can be reintroduced later. */ -self.addEventListener('install', (event) => { - self.skipWaiting(); - event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => {}))); -}); +self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys() - .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) - .then(() => self.clients.claim()) - ); + event.waitUntil((async () => { + try { await self.clients.claim(); } catch (e) { /* noop */ } + try { + const keys = await caches.keys(); + await Promise.all(keys.filter((k) => k.startsWith('alianhub-pwa')).map((k) => caches.delete(k))); + } catch (e) { /* noop */ } + try { await self.registration.unregister(); } catch (e) { /* noop */ } + })()); }); -self.addEventListener('fetch', (event) => { - const req = event.request; - if (req.method !== 'GET') return; - let url; - try { url = new URL(req.url); } catch (e) { return; } - if (url.origin !== self.location.origin) return; // skip cross-origin (fonts, IdP, CDNs) - if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/socket')) return; // never cache API/sockets - - if (req.mode === 'navigate') { - event.respondWith(fetch(req).catch(() => caches.match('/'))); - return; - } - - event.respondWith( - caches.match(req).then((cached) => cached || fetch(req).then((res) => { - if (res && res.status === 200 && res.type === 'basic') { - const copy = res.clone(); - caches.open(CACHE).then((c) => c.put(req, copy)); - } - return res; - }).catch(() => cached)) - ); -}); +// No 'fetch' listener — every request goes straight to the network. From 685fdebd355d8941e774d7744b8d254ce2e93f4c Mon Sep 17 00:00:00 2001 From: Parth Detroja Date: Sat, 20 Jun 2026 16:58:57 +0530 Subject: [PATCH 13/17] feat(offline): work offline + sync on reconnect, app-level (SEC-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline mode WITHOUT a service worker — pure app code, so it behaves identically on localhost/staging/production and cannot cause the kind of asset-cache reload loop the withdrawn SEC-03 worker did. - frontend/src/offline: offlineRules.js (pure whitelists + offline-error detection + synthetic responses, unit-tested), db.js (fail-soft IndexedDB read-cache + write-queue), index.js (cache, queue, FIFO reconnect sync, online/offline state). - Hooked into services/index.js with ZERO change to online behaviour: it caches successful whitelisted GETs (fire-and-forget) and acts only on the request FAILURE path (which already rejected). Offline → whitelisted GETs are served from cache; whitelisted writes (task update/PATCH, comment, time log) are queued and replayed FIFO on reconnect (or on the next successful request, so a recovered-but-still-"online" server also drains). Offline data is cleared on logout (no cross-account bleed). - OfflineBanner.vue mounted in App.vue: "offline" / "syncing N…" status; hidden when online with nothing queued. i18n in en.js. Verify: 31 suites / 356 tests green (incl. offline-rules); frontend `npm run build` compiles clean. Test cases: .claude/test-cases/OfflineMode.md (16). Done-when met: cached data viewable offline + queued writes sync on reconnect. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/test-cases/OfflineMode.md | 30 +++++ frontend/src/App.vue | 4 + .../src/components/offline/OfflineBanner.vue | 39 +++++++ frontend/src/locales/en.js | 5 + frontend/src/offline/db.js | 87 +++++++++++++++ frontend/src/offline/index.js | 103 ++++++++++++++++++ frontend/src/offline/offlineRules.js | 68 ++++++++++++ frontend/src/services/index.js | 8 ++ tests/offline-rules.test.js | 71 ++++++++++++ 9 files changed, 415 insertions(+) create mode 100644 .claude/test-cases/OfflineMode.md create mode 100644 frontend/src/components/offline/OfflineBanner.vue create mode 100644 frontend/src/offline/db.js create mode 100644 frontend/src/offline/index.js create mode 100644 frontend/src/offline/offlineRules.js create mode 100644 tests/offline-rules.test.js diff --git a/.claude/test-cases/OfflineMode.md b/.claude/test-cases/OfflineMode.md new file mode 100644 index 00000000..ebd0d2f9 --- /dev/null +++ b/.claude/test-cases/OfflineMode.md @@ -0,0 +1,30 @@ +# Offline Mode — Test Cases + +**Feature:** SEC-06 — work offline, sync on reconnect (app-level; NO service worker) +**Approach:** After the SEC-03 PWA worker was withdrawn (reload loop), offline mode is implemented purely in the app so it behaves identically on localhost / staging / production and can't cause a worker reload loop. +**Files:** `frontend/src/offline/{offlineRules.js,db.js,index.js}`, `frontend/src/components/offline/OfflineBanner.vue`, integration in `frontend/src/services/index.js`, mount + init in `frontend/src/App.vue`, i18n in `en.js`. +**How it works:** successful whitelisted GETs are cached in IndexedDB (fire-and-forget); on a request that fails because we're offline, whitelisted GETs are served from cache and whitelisted writes are queued; on reconnect (or the next successful request) the queue replays FIFO through the real request path. +**Online safety:** the request layer only calls the offline layer on the success side (non-blocking cache) and the failure path (which already rejected) — online behaviour is unchanged. +**Unit-tested:** `tests/offline-rules.test.js` (whitelists, offline-error detection, synthetic responses) — in the Node suite (31 suites / 356 tests green). +**Legend:** ✅ Pass · ❌ Fail · ⬜ Not run + +| ID | Title | Precondition | Steps | Expected | Actual | Status | +|----|-------|--------------|-------|----------|--------|--------| +| OFF-01 | Online unchanged | Normal use | Use the app online (projects, tasks, comments, time) | Identical to before — no banner, no behaviour change; GET responses get cached in the background | | ⬜ | +| OFF-02 | Offline banner | App loaded, then go offline (DevTools → Network → Offline) | Observe top of app | Amber "You're offline…" banner appears; disappears on reconnect | | ⬜ | +| OFF-03 | View cached data offline | Visited project/task lists while online, then go offline | Navigate back to those project/task views | Last-cached data is shown (not an error/blank) | | ⬜ | +| OFF-04 | Uncached data offline | Offline; open a view never loaded online | Navigate there | Fails gracefully as before (no cache to serve) — app does not crash | | ⬜ | +| OFF-05 | Queue a task edit offline | Offline | Change a task status / field (PATCH /api/v2/tasks or PUT /api/v1/task) | UI accepts it (queued); banner shows "N change(s) waiting to sync" | | ⬜ | +| OFF-06 | Queue a comment offline | Offline | Post a comment | Accepted + queued; counted in pending | | ⬜ | +| OFF-07 | Queue a time log offline | Offline | Log time (POST /api/v2/manualLogtime) | Accepted + queued | | ⬜ | +| OFF-08 | Sync on reconnect | Items queued offline | Go back online | Banner shows "Syncing N…"; queued writes replay FIFO; pending count → 0; server reflects the changes | | ⬜ | +| OFF-09 | Sync without an online event | Online but server was unreachable; items queued | Server recovers; make any successful request | Backlog drains opportunistically (a success proves connectivity) | | ⬜ | +| OFF-10 | Rejected write is dropped | A queued write the server will 4xx (e.g. stale) | Reconnect | That item is dropped (not retried forever); others still sync | | ⬜ | +| OFF-11 | FIFO order | Multiple queued writes to the same task | Reconnect | Replayed in the order they were made; final state matches the last edit | | ⬜ | +| OFF-12 | Logout clears offline data | Queued/cached data exists | Log out, then log in as a different user | No cached data or queued writes from the previous account remain (store cleared on logout) | | ⬜ | +| OFF-13 | Non-whitelisted untouched | — | Offline, trigger a non-whitelisted write (e.g. member/SSO settings) | Not queued — fails as before (only safe, common edits are queued) | | ⬜ | +| OFF-14 | Works everywhere | localhost / staging / production | Repeat OFF-02..08 in each | Same behaviour in all three; no service worker involved; no reload loop | | ⬜ | +| OFF-15 | IndexedDB unavailable | Private mode / blocked storage | Use offline | Fails soft — app still works online; offline features simply no-op | | ⬜ | +| OFF-16 | Rules (unit) | — | `npx jest tests/offline-rules.test.js` | All green — whitelists, offline-error detection, synthetic responses | | ✅ | + +**Total:** 16 cases (1 unit-automated, 15 runtime/manual). Scope per AHE-3763 done-when: cached data viewable offline + queued writes sync on reconnect. Cold-starting a brand-new tab with no network (which needs a service-worker shell) is intentionally out of scope — that's what caused the SEC-03 loop; a production-only PWA shell can be added later if wanted. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 00b0086e..d9f5261a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,6 @@