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/.claude/test-cases/CompliancePosture.md b/.claude/test-cases/CompliancePosture.md new file mode 100644 index 00000000..68e93549 --- /dev/null +++ b/.claude/test-cases/CompliancePosture.md @@ -0,0 +1,21 @@ +# Compliance Posture — Review Checklist + +**Feature:** SEC-07 — compliance posture (SOC 2 / ISO 27001 / HIPAA) +**Type:** docs / process (no code) — "tests" are a review checklist. +**Files:** `docs/compliance/{README,security-whitepaper,control-mapping,shared-responsibility,gaps}.md`, linked from `SECURITY.md`. +**Legend:** ✅ Pass · ❌ Fail · ⬜ Not run + +| ID | Title | Check | Expected | Status | +|----|-------|-------|----------|--------| +| COMP-01 | Docs published | `docs/compliance/` exists with 5 markdown files | All present; render on GitHub + sync to GitBook | ✅ | +| COMP-02 | Discoverable | SECURITY.md links the pack | Link to `docs/compliance/README.md` present | ✅ | +| COMP-03 | Whitepaper coverage | Read security-whitepaper.md | Covers authn, authz/RBAC, tenant isolation, audit, data protection, hardening, vuln mgmt | ✅ | +| COMP-04 | All three frameworks mapped | Read control-mapping.md | Separate tables for SOC 2 TSC, ISO 27001:2022 Annex A, HIPAA Security Rule | ✅ | +| COMP-05 | Shared-responsibility | Read shared-responsibility.md | Responsibility matrix + operator hardening checklist | ✅ | +| COMP-06 | Gaps tracked | Read gaps.md | Gap register with ID, owner, severity, status, target | ✅ | +| COMP-07 | Accuracy — controls real | Cross-check cited controls vs. code | Each cited control is actually implemented (bcrypt, 2FA, SSO, SCIM, RBAC, audit logs, companyId isolation, Helmet, rate limiting) | ⬜ | +| COMP-08 | Accuracy — no over-claim | Read framing | States AlianHub OSS is NOT certified; self-hosting is shared responsibility; no false certification claims | ✅ | +| COMP-09 | Links resolve | Click internal links | README ↔ whitepaper ↔ mapping ↔ shared-responsibility ↔ gaps ↔ SECURITY.md all resolve | ⬜ | +| COMP-10 | Maintainable | Read gaps.md footer | Defines review cadence + how to close a gap | ✅ | + +**Total:** 10 checks. COMP-07 (control-accuracy cross-check) and COMP-09 (link rendering) to be confirmed by a human reviewer before publishing externally. The docs are intentionally honest about gaps rather than aspirational. 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). 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/.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/.claude/test-cases/PtoCalendar.md b/.claude/test-cases/PtoCalendar.md new file mode 100644 index 00000000..1c76d0a0 --- /dev/null +++ b/.claude/test-cases/PtoCalendar.md @@ -0,0 +1,28 @@ +# Time-off / PTO — Test Cases + +**Feature:** SEC-08 — time-off / PTO; approved entries reduce available capacity +**Backend:** `Modules/Pto/{helpers/ptoRules.js,controller.js,routes.js,init.js}`, `pto_entries` collection, endpoints `POST/GET /api/v1/pto`, `GET /api/v1/pto/capacity`, `PUT /api/v1/pto/:id/status`, `DELETE /api/v1/pto/:id` +**Frontend:** `frontend/src/views/Settings/TimeOff/TimeOff.vue` (Settings → Time Off) +**Capacity:** available hours = working days (Mon–Fri) × hours/day − approved PTO hours in range (feeds REP-06 capacity planning). +**Unit-tested:** `tests/pto-rules.test.js` (validation, working-day count, overlap hours, capacity math) — in the Node suite (32 suites / 369 tests green). +**Legend:** ✅ Pass · ❌ Fail · ⬜ Not run + +| ID | Title | Precondition | Steps | Expected | Actual | Status | +|----|-------|--------------|-------|----------|--------|--------| +| PTO-01 | Request time off | Logged in | Settings → Time Off; pick type, dates, hours/day; Request | Entry created with status **Pending**; appears in the list | | ⬜ | +| PTO-02 | Validation | — | Submit with end before start, or missing dates | Rejected with a clear message; nothing created | | ⬜ | +| PTO-03 | Member sees only own | Member (role 3) with others' PTO present | Open Time Off | Only the member's own entries are listed | | ⬜ | +| PTO-04 | Admin sees team | Owner/admin | Open Time Off | All members' entries listed; Approve/Reject shown on pending | | ⬜ | +| PTO-05 | Approve | Admin; a pending entry | Click Approve | Status → **Approved**; recorded in the audit log (`pto.approved`) | | ⬜ | +| PTO-06 | Reject | Admin; a pending entry | Click Reject | Status → **Rejected**; not counted against capacity | | ⬜ | +| PTO-07 | Approve is admin-only | Member | `PUT /api/v1/pto/:id/status` | `403` Owner/admin only | | ⬜ | +| PTO-08 | Capacity reduced by approved PTO | An approved entry this month | View "My capacity"; or `GET /pto/capacity?from=&to=` | Available = working-hours − approved PTO hours (e.g. 184 − 40 = 144 for a 5-day leave in a 23-weekday month) | | ⬜ | +| PTO-09 | Pending/rejected don't reduce | Pending or rejected entries only | Check capacity | `ptoHours` = 0; available = full capacity | | ⬜ | +| PTO-10 | Weekends excluded | Leave spanning a weekend | Check capacity | Only Mon–Fri days within the leave count | | ⬜ | +| PTO-11 | Delete own | Member; own entry | Delete | Entry soft-removed (deletedStatusKey=1); gone from list | | ⬜ | +| PTO-12 | Delete others blocked | Member; another user's entry id | `DELETE /api/v1/pto/:id` | `403` — can only remove own | | ⬜ | +| PTO-13 | Tenant isolation | Two companies | List/capacity as company A | Only company A's PTO visible (companyId-scoped) | | ⬜ | +| PTO-14 | Capacity never negative | Leave longer than the range | Check capacity | `availableHours` floors at 0 | | ⬜ | +| PTO-15 | Rules (unit) | — | `npx jest tests/pto-rules.test.js` | All green — validation, working-day count, overlap hours, approved-only capacity reduction, non-negative floor | | ✅ | + +**Total:** 15 cases (1 unit-automated, 14 runtime/manual). Done-when met: PTO entries reduce a user's available capacity. A visual month-grid calendar is a possible follow-up; the schedule list + capacity readout cover the requirement. 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/.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 1c11ccc0..6db42e89 100644 --- a/Config/collections.js +++ b/Config/collections.js @@ -73,6 +73,10 @@ const dbCollections = { RECURRING_TASKS: "recurring_tasks", TIMESHEET_APPROVAL: "timesheet_approval", BILLING_RATES: "billing_rates", + SSO_CONFIGS: "sso_configs", + AUDIT_LOGS: "audit_logs", + SCIM_CONFIGS: "scim_configs", + PTO_ENTRIES: "pto_entries", } /** DOCUMENT ID'S NAME WHICH IS USED IN THE "SETTINGS" COLLECTION NAME **/ 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/Config/schemaType.js b/Config/schemaType.js index 4e12321c..236ef481 100644 --- a/Config/schemaType.js +++ b/Config/schemaType.js @@ -72,6 +72,10 @@ const SCHEMA_TYPE = { RECURRING_TASKS: "recurring_tasks", TIMESHEET_APPROVAL: "timesheet_approval", BILLING_RATES: "billing_rates", + SSO_CONFIGS: "sso_configs", + AUDIT_LOGS: "audit_logs", + SCIM_CONFIGS: "scim_configs", + PTO_ENTRIES: "pto_entries", } module.exports = { diff --git a/Config/setMiddleware.js b/Config/setMiddleware.js index 7723f201..be7bb895 100644 --- a/Config/setMiddleware.js +++ b/Config/setMiddleware.js @@ -168,6 +168,19 @@ 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', + '/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', + // Time-off / PTO (Modules/Pto) — JWT+company; prefix-matches /pto, /pto/:id, + // /pto/:id/status, /pto/capacity. Role checks are enforced in-controller. + '/api/v1/pto', ]; 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/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/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/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..acc56389 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); @@ -25,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/Pto/controller.js b/Modules/Pto/controller.js new file mode 100644 index 00000000..a36ce393 --- /dev/null +++ b/Modules/Pto/controller.js @@ -0,0 +1,130 @@ +const mongoose = require('mongoose'); +const { SCHEMA_TYPE } = require('../../Config/schemaType'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const { getRoleType, isPrivileged } = require('../../Config/permissionGuard'); +const { removeCache } = require('../../utils/commonFunctions'); +const logger = require('../../Config/loggerConfig'); +const R = require('./helpers/ptoRules'); + +const companyOf = (req) => req.headers['companyid'] || (req.body && req.body.companyId) || (req.query && req.query.companyId); +const audit = (req, entry) => { try { require('../Audit/recorder').recordAuditFromReq(req, entry); } catch (e) { /* best-effort */ } }; + +// POST /api/v1/pto — create a PTO entry. A member creates their own (pending); +// owner/admin may create for another user and/or set status (e.g. auto-approve). +exports.createPto = 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); + const privileged = isPrivileged(roleType); + const body = req.body || {}; + const targetUser = privileged && body.userId ? String(body.userId) : String(req.uid); + const status = privileged && body.status ? body.status : 'pending'; + const check = R.validatePtoEntry({ ...body, userId: targetUser, status }); + if (!check.valid) return res.status(400).json({ status: false, statusText: check.errors.join('; ') }); + const data = { ...check.value, createdBy: String(req.uid || ''), deletedStatusKey: 0 }; + if (check.value.status === 'approved') data.approvedBy = String(req.uid || ''); + const saved = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.PTO_ENTRIES, data }, 'save'); + removeCache(`pto:${companyId}`); + audit(req, { action: 'pto.create', entityType: 'pto', entityId: String(saved._id), meta: { type: data.type, userId: targetUser } }); + return res.status(201).json({ status: true, statusText: 'PTO entry created.', data: saved }); + } catch (e) { logger.error(`createPto: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); } +}; + +// GET /api/v1/pto?userId=&from=&to=&status= — list (calendar feed). A member sees +// their own; owner/admin see everyone (optionally filtered by userId). +exports.listPto = 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); + const privileged = isPrivileged(roleType); + const q = req.query || {}; + const match = { deletedStatusKey: { $ne: 1 } }; + if (privileged) { if (q.userId) match.userId = String(q.userId); } + else { match.userId = String(req.uid); } + if (q.status) match.status = String(q.status); + if (q.from || q.to) { + const and = []; + if (q.to) and.push({ startDate: { $lte: new Date(q.to) } }); + if (q.from) and.push({ endDate: { $gte: new Date(q.from) } }); + if (and.length) match.$and = and; + } + const rows = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PTO_ENTRIES, data: [match, {}, { sort: { startDate: -1 } }], + }, 'find'); + return res.json({ status: true, data: rows || [] }); + } catch (e) { logger.error(`listPto: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); } +}; + +// PUT /api/v1/pto/:id/status — approve / reject (owner/admin only). +exports.updatePtoStatus = 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 status = String(req.body.status || ''); + if (!R.PTO_STATUS.includes(status)) return res.status(400).json({ status: false, statusText: 'Invalid status.' }); + const id = req.params.id; + const updated = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PTO_ENTRIES, + data: [{ _id: new mongoose.Types.ObjectId(String(id)) }, { $set: { status, approvedBy: String(req.uid || '') } }, { returnDocument: 'after' }], + }, 'findOneAndUpdate'); + if (!updated) return res.status(404).json({ status: false, statusText: 'Not found.' }); + removeCache(`pto:${companyId}`); + audit(req, { action: `pto.${status}`, entityType: 'pto', entityId: String(id) }); + return res.json({ status: true, statusText: `PTO ${status}.`, data: updated }); + } catch (e) { logger.error(`updatePtoStatus: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); } +}; + +// DELETE /api/v1/pto/:id — soft-delete. The entry's owner, or any owner/admin. +exports.deletePto = 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); + const privileged = isPrivileged(roleType); + const id = req.params.id; + const entry = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PTO_ENTRIES, data: [{ _id: new mongoose.Types.ObjectId(String(id)) }], + }, 'findOne'); + if (!entry) return res.status(404).json({ status: false, statusText: 'Not found.' }); + if (!privileged && String(entry.userId) !== String(req.uid)) { + return res.status(403).json({ status: false, statusText: 'You can only remove your own time off.' }); + } + await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PTO_ENTRIES, + data: [{ _id: new mongoose.Types.ObjectId(String(id)) }, { $set: { deletedStatusKey: 1 } }], + }, 'updateOne'); + removeCache(`pto:${companyId}`); + return res.json({ status: true, statusText: 'PTO entry removed.' }); + } catch (e) { logger.error(`deletePto: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); } +}; + +// GET /api/v1/pto/capacity?userId=&from=&to=&hoursPerDay= — available capacity for +// a user over a range, with APPROVED PTO subtracted. This is the SEC-08 done-when: +// PTO entries reduce a user's available capacity (feeds REP-06 capacity planning). +exports.getCapacity = 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); + const privileged = isPrivileged(roleType); + const q = req.query || {}; + const userId = privileged && q.userId ? String(q.userId) : String(req.uid); + if (!q.from || !q.to) return res.status(400).json({ status: false, statusText: 'from and to are required.' }); + const workingHoursPerDay = Number(q.hoursPerDay) > 0 ? Number(q.hoursPerDay) : R.DEFAULT_HOURS_PER_DAY; + const entries = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PTO_ENTRIES, + data: [{ + userId, status: 'approved', deletedStatusKey: { $ne: 1 }, + startDate: { $lte: new Date(q.to) }, endDate: { $gte: new Date(q.from) }, + }], + }, 'find'); + const capacity = R.computeAvailableCapacity({ + rangeStart: q.from, rangeEnd: q.to, ptoEntries: entries || [], workingHoursPerDay, + }); + return res.json({ status: true, data: { userId, from: q.from, to: q.to, ...capacity } }); + } catch (e) { logger.error(`getCapacity: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); } +}; diff --git a/Modules/Pto/helpers/ptoRules.js b/Modules/Pto/helpers/ptoRules.js new file mode 100644 index 00000000..4230cfba --- /dev/null +++ b/Modules/Pto/helpers/ptoRules.js @@ -0,0 +1,123 @@ +// SEC-08 — pure time-off / PTO rules (no DB, no I/O). The capacity math here is +// the heart of the feature: approved PTO reduces a user's available capacity. +// Unit-tested in tests/pto-rules.test.js. + +const PTO_TYPES = ['vacation', 'sick', 'holiday', 'personal', 'unpaid']; +const PTO_STATUS = ['pending', 'approved', 'rejected']; +const DEFAULT_HOURS_PER_DAY = 8; +const DEFAULT_WEEKEND = [0, 6]; // Sun, Sat + +const toDate = (v) => { + if (v instanceof Date) return isNaN(v.getTime()) ? null : v; + if (v === null || v === undefined || v === '') return null; + const d = new Date(v); + return isNaN(d.getTime()) ? null : d; +}; + +// Normalize to UTC midnight — PTO is tracked at whole-day granularity. +const startOfDay = (v) => { + const d = toDate(v); + if (!d) return null; + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); +}; + +const validatePtoEntry = (entry = {}) => { + const errors = []; + const start = startOfDay(entry.startDate); + const end = startOfDay(entry.endDate); + if (!entry.userId) errors.push('userId is required'); + if (!start) errors.push('a valid startDate is required'); + if (!end) errors.push('a valid endDate is required'); + if (start && end && end < start) errors.push('endDate must be on or after startDate'); + const type = PTO_TYPES.includes(entry.type) ? entry.type : 'vacation'; + let hoursPerDay = Number(entry.hoursPerDay); + if (!hoursPerDay || hoursPerDay <= 0 || hoursPerDay > 24) hoursPerDay = DEFAULT_HOURS_PER_DAY; + const status = PTO_STATUS.includes(entry.status) ? entry.status : 'pending'; + return { + valid: errors.length === 0, + errors, + value: errors.length ? null : { + userId: String(entry.userId), + type, + status, + startDate: start, + endDate: end, + hoursPerDay, + reason: entry.reason ? String(entry.reason).slice(0, 500) : '', + }, + }; +}; + +// Whole days, inclusive. +const inclusiveDays = (start, end) => { + const s = startOfDay(start); + const e = startOfDay(end); + if (!s || !e || e < s) return 0; + return Math.round((e - s) / 86400000) + 1; +}; + +// Working days (Mon–Fri by default), inclusive. +const workingDaysBetween = (start, end, weekendDays = DEFAULT_WEEKEND) => { + const s = startOfDay(start); + const e = startOfDay(end); + if (!s || !e || e < s) return 0; + let count = 0; + const cur = new Date(s); + while (cur <= e) { + if (!weekendDays.includes(cur.getUTCDay())) count++; + cur.setUTCDate(cur.getUTCDate() + 1); + } + return count; +}; + +// Inclusive overlap of an entry with [rangeStart, rangeEnd] → { start, end } | null. +const overlapRange = (entry, rangeStart, rangeEnd) => { + const es = startOfDay(entry && entry.startDate); + const ee = startOfDay(entry && entry.endDate); + const rs = startOfDay(rangeStart); + const re = startOfDay(rangeEnd); + if (!es || !ee || !rs || !re) return null; + const start = es > rs ? es : rs; + const end = ee < re ? ee : re; + if (end < start) return null; + return { start, end }; +}; + +// Hours an entry consumes within a window = working days in the overlap × hoursPerDay. +const ptoHoursInRange = (entry, rangeStart, rangeEnd, weekendDays = DEFAULT_WEEKEND) => { + const ov = overlapRange(entry, rangeStart, rangeEnd); + if (!ov) return 0; + const hpd = Number(entry && entry.hoursPerDay) > 0 ? Number(entry.hoursPerDay) : DEFAULT_HOURS_PER_DAY; + return workingDaysBetween(ov.start, ov.end, weekendDays) * hpd; +}; + +// Available capacity over [rangeStart, rangeEnd], subtracting APPROVED PTO only. +const computeAvailableCapacity = ({ + rangeStart, + rangeEnd, + ptoEntries = [], + workingHoursPerDay = DEFAULT_HOURS_PER_DAY, + weekendDays = DEFAULT_WEEKEND, +} = {}) => { + const workingDays = workingDaysBetween(rangeStart, rangeEnd, weekendDays); + const totalCapacityHours = workingDays * workingHoursPerDay; + const ptoHours = (ptoEntries || []) + .filter((e) => e && e.status === 'approved') + .reduce((sum, e) => sum + ptoHoursInRange(e, rangeStart, rangeEnd, weekendDays), 0); + const availableHours = Math.max(0, totalCapacityHours - ptoHours); + return { workingDays, totalCapacityHours, ptoHours, availableHours }; +}; + +module.exports = { + PTO_TYPES, + PTO_STATUS, + DEFAULT_HOURS_PER_DAY, + DEFAULT_WEEKEND, + startOfDay, + validatePtoEntry, + inclusiveDays, + workingDaysBetween, + overlapRange, + ptoHoursInRange, + computeAvailableCapacity, +}; diff --git a/Modules/Pto/init.js b/Modules/Pto/init.js new file mode 100644 index 00000000..be6fc26a --- /dev/null +++ b/Modules/Pto/init.js @@ -0,0 +1,5 @@ +const routes = require('./routes'); + +exports.init = (app) => { + routes.init(app); +} diff --git a/Modules/Pto/routes.js b/Modules/Pto/routes.js new file mode 100644 index 00000000..910603c8 --- /dev/null +++ b/Modules/Pto/routes.js @@ -0,0 +1,12 @@ +const ctrl = require('./controller'); + +exports.init = (app) => { + // JWT + companyId enforced in setMiddleware (prefix /api/v1/pto). Role checks + // (approve = owner/admin; members manage their own) are enforced in-controller. + app.post('/api/v1/pto', ctrl.createPto); + app.get('/api/v1/pto', ctrl.listPto); + // Registered before any ':id' route so "capacity" isn't captured as an id. + app.get('/api/v1/pto/capacity', ctrl.getCapacity); + app.put('/api/v1/pto/:id/status', ctrl.updatePtoStatus); + app.delete('/api/v1/pto/:id', ctrl.deletePto); +}; diff --git a/Modules/SSO/config.js b/Modules/SSO/config.js new file mode 100644 index 00000000..6d5c3eab --- /dev/null +++ b/Modules/SSO/config.js @@ -0,0 +1,82 @@ +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'); + // 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}`); + 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..87f60784 --- /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-xsd-schema-validator')); + } catch (e) { + logger.error(`samlify schema validator missing — install @authenio/samlify-xsd-schema-validator: ${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/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/Modules/Tasks/routes.js b/Modules/Tasks/routes.js index fd93c437..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 } = 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); @@ -151,9 +151,9 @@ 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); + 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..94538941 100644 --- a/Modules/settings/Members/controller.js +++ b/Modules/settings/Members/controller.js @@ -255,6 +255,19 @@ 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); + } + // 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/SECURITY.md b/SECURITY.md index 7cee951e..4f80b181 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,9 @@ # Security Policy +> **Compliance & security posture:** AlianHub's security whitepaper, control mapping for +> **SOC 2 / ISO 27001 / HIPAA**, the self-hosted **shared-responsibility model**, and the +> tracked **compliance gap register** live in [`docs/compliance/`](docs/compliance/README.md). + ## Supported Versions This project follows Semantic Versioning (SemVer), but **does not provide guaranteed security support for specific versions**. 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/docs/compliance/README.md b/docs/compliance/README.md new file mode 100644 index 00000000..6bc766b9 --- /dev/null +++ b/docs/compliance/README.md @@ -0,0 +1,49 @@ +# AlianHub Compliance & Security Posture + +**Last reviewed:** 2026-06-20 · **Applies to:** AlianHub v14.5.0+ · **Status:** Living document + +This folder is AlianHub's compliance pack: what the software does for security, how +its controls map to the major frameworks, what a self-hosting operator must do, and +where the gaps are. + +> **Read this first — the self-hosted nuance.** +> ClickUp and other SaaS tools hold their *own* certifications because they run the +> infrastructure. AlianHub is **open-source and self-hosted**: you (the operator) run +> it on your own servers. That means compliance is a **shared responsibility** — +> AlianHub provides application-layer controls; you own the infrastructure, process, +> and organizational controls. **Running AlianHub does not make a deployment certified.** +> These documents help you get there and document the boundary clearly. + +## What's here + +| Document | Purpose | +|---|---| +| [security-whitepaper.md](security-whitepaper.md) | How AlianHub is secured — architecture, authn/z, tenancy isolation, auditing, data protection, hardening. | +| [control-mapping.md](control-mapping.md) | AlianHub controls mapped to **SOC 2** (Trust Services Criteria), **ISO/IEC 27001:2022** (Annex A), and **HIPAA** (Security Rule). | +| [shared-responsibility.md](shared-responsibility.md) | Who owns what (AlianHub vs. operator) + an operator hardening checklist. | +| [gaps.md](gaps.md) | The certification gap register — honest, tracked, with status and owner. | + +For **reporting a vulnerability**, see the repository [SECURITY.md](../../SECURITY.md) +(GitHub Security Advisories, responsible disclosure). + +## How to use this pack + +- **Evaluating AlianHub for a regulated environment?** Start with the whitepaper, then + the control mapping to see which framework controls AlianHub satisfies out of the box + vs. which you must implement. +- **Operating AlianHub?** Work through [shared-responsibility.md](shared-responsibility.md)'s + hardening checklist and own the operator-side controls. +- **Pursuing certification (SOC 2 / ISO 27001 / HIPAA)?** Use the control mapping as your + control inventory and [gaps.md](gaps.md) as your remediation backlog. + +## Honest statement of certification + +AlianHub (the open-source distribution) is **not itself certified** for SOC 2, ISO 27001, +or HIPAA, and cannot be — certification attaches to an *organization running a system*, +not to source code. AlianHub provides the technical controls that make a compliant +deployment achievable. Certification of the **AlianHub-hosted/demo offering** is tracked +as a roadmap item in [gaps.md](gaps.md). + +If you deploy AlianHub to process **PHI** (HIPAA) you are the Covered Entity / Business +Associate; AlianHub the project is not a Business Associate and signs no BAA for +self-hosted deployments. diff --git a/docs/compliance/control-mapping.md b/docs/compliance/control-mapping.md new file mode 100644 index 00000000..f24e58c8 --- /dev/null +++ b/docs/compliance/control-mapping.md @@ -0,0 +1,65 @@ +# Control Mapping — SOC 2 / ISO 27001 / HIPAA + +**Last reviewed:** 2026-06-20 · **Applies to:** AlianHub v14.5.0+ + +Maps AlianHub's built-in controls to the major frameworks. **Responsibility** is +`AlianHub` (software provides it), `Operator` (you must implement it), or `Shared`. +**Status** reflects the AlianHub-provided portion: `Met`, `Partial` (provided but see +[gaps.md](gaps.md)), or `Operator` (AlianHub enables; operator owns). + +## SOC 2 — Trust Services Criteria + +| TSC | Criterion | AlianHub control | Responsibility | Status | +|---|---|---|---|---| +| CC6.1 | Logical access — authentication | JWT, bcrypt passwords, 2FA (TOTP), OAuth, SAML/OIDC SSO | AlianHub | Met | +| CC6.2 | Provisioning / authorization | RBAC roles + SCIM provisioning; Owner-only privilege grant | AlianHub | Met | +| CC6.3 | Access removal / modification | SCIM deactivation, role change with cache invalidation | AlianHub | Met | +| CC6.1 | Least privilege / segregation | Per-key permission rules engine, guest project scoping | AlianHub | Met | +| CC6.6 | Boundary protection | Helmet headers, `express-rate-limit`, CORS | Shared | Partial | +| CC6.7 | Data in transit | TLS support | Operator | Operator | +| CC6.1 | Tenant data segregation | `companyId` scoping + per-company DBs + JWT audience | AlianHub | Met | +| CC7.2 | Monitoring of controls | Immutable `audit_logs`; Winston app logs | Shared | Met | +| CC7.3–7.4 | Incident response | Coordinated disclosure ([SECURITY.md](../../SECURITY.md)) | Shared | Partial | +| CC8.1 | Change management | Git history, CI gates, semantic versioning | Operator | Operator | +| A1.1–A1.2 | Availability / backups | Stateless app tier (scalable); backups operator-owned | Operator | Operator | +| C1.1–C1.2 | Confidentiality | Tenant isolation; encryption at rest (operator) | Shared | Partial | +| P1–P8 | Privacy | Export/edit/delete tooling; operator is controller | Shared | Operator | + +## ISO/IEC 27001:2022 — Annex A + +| Control | Title | AlianHub control | Responsibility | Status | +|---|---|---|---|---| +| A.5.15 | Access control | RBAC + server-side rules engine | AlianHub | Met | +| A.5.16 | Identity management | SSO + SCIM lifecycle | AlianHub | Met | +| A.5.17 | Authentication information | bcrypt hashing, 2FA, hashed PATs | AlianHub | Met | +| A.5.18 | Access rights (grant/review/revoke) | Role mgmt, SCIM deprovisioning; reviews operator-run | Shared | Partial | +| A.8.2 | Privileged access rights | Owner-only Owner/Admin grant | AlianHub | Met | +| A.8.3 | Information access restriction | `companyId` isolation; guest scoping | AlianHub | Met | +| A.8.5 | Secure authentication | 2FA, SSO, short-lived JWT + refresh | AlianHub | Met | +| A.8.15 | Logging | Immutable `audit_logs` + retention | AlianHub | Met | +| A.8.16 | Monitoring activities | Winston → operator SIEM | Operator | Operator | +| A.8.24 | Use of cryptography | TLS (transit), bcrypt; at-rest operator-config | Shared | Partial | +| A.8.8 | Technical vulnerability mgmt | Disclosure policy; `npm audit`/SCA operator-run | Shared | Partial | +| A.5.23 | Cloud services security | Operator-chosen hosting/storage | Operator | Operator | +| A.5.30 | ICT readiness / continuity | Operator backups & DR | Operator | Operator | + +## HIPAA — Security Rule (45 CFR §164.3xx) + +> AlianHub the project is **not a Business Associate** and signs no BAA for self-hosted +> deployments. If you process PHI, you are the Covered Entity / Business Associate. + +| Citation | Safeguard | AlianHub control | Responsibility | Status | +|---|---|---|---|---| +| §164.312(a)(1) | Access control | RBAC, tenant isolation, unique accounts | AlianHub | Met | +| §164.312(a)(2)(i) | Unique user identification | Per-user identities (no shared logins) | AlianHub | Met | +| §164.312(a)(2)(iii) | Automatic logoff | Short-lived JWT; UI inactivity | Shared | Partial | +| §164.312(a)(2)(iv) | Encryption/decryption (at rest) | Operator storage/DB encryption | Operator | Operator | +| §164.312(b) | Audit controls | Immutable `audit_logs` + viewer | AlianHub | Met | +| §164.312(c)(1) | Integrity | Insert-only audit trail; input validation | AlianHub | Partial | +| §164.312(d) | Person/entity authentication | 2FA, SSO, JWT | AlianHub | Met | +| §164.312(e)(1) | Transmission security | TLS | Operator | Operator | +| §164.308 | Administrative safeguards | SCIM aids access mgmt; policies operator-owned | Operator | Operator | +| §164.310 | Physical safeguards | Data-center / host controls | Operator | Operator | +| §164.314 | Business associate contracts | Operator signs BAAs with its sub-processors | Operator | Operator | + +See [gaps.md](gaps.md) for every `Partial`/`Operator` item that AlianHub plans to strengthen. diff --git a/docs/compliance/gaps.md b/docs/compliance/gaps.md new file mode 100644 index 00000000..06942357 --- /dev/null +++ b/docs/compliance/gaps.md @@ -0,0 +1,32 @@ +# Compliance Gap Register + +**Last reviewed:** 2026-06-20 · **Applies to:** AlianHub v14.5.0+ + +Honest, tracked list of gaps between AlianHub today and a fully-certifiable posture. +This is the remediation backlog referenced by [control-mapping.md](control-mapping.md). + +**Owner:** `AlianHub` (product/code change) · `Operator` (deployment responsibility) · `Shared`. +**Status:** `Open` · `Planned` · `By design` · `Operator-responsibility`. + +| ID | Area | Gap | Frameworks | Owner | Severity | Status | Notes / target | +|---|---|---|---|---|---|---|---| +| GAP-01 | Certification | AlianHub OSS holds no SOC 2 / ISO 27001 report (certification attaches to an org running a system, not to code). | All | AlianHub | Info | By design | Certify the **AlianHub-hosted/demo** offering; track separately. | +| GAP-02 | Encryption at rest | Not enabled by default; depends on operator's DB/volume/object-storage config. | SOC 2 C1, ISO A.8.24, HIPAA §164.312(a)(2)(iv) | Operator | High | Operator-responsibility | Documented in [shared-responsibility.md](shared-responsibility.md); add a deploy preflight check. | +| GAP-03 | Secret-at-rest (2FA/SSO) | 2FA TOTP secrets and SSO IdP client secrets are stored server-side; not yet encrypted with a dedicated app-level key (PATs and SCIM tokens **are** hashed). | SOC 2 CC6.1, ISO A.8.24 | AlianHub | Medium | Planned | Envelope-encrypt secret fields with a `SECRET_ENC_KEY`. | +| GAP-04 | Audit tamper-evidence | `audit_logs` is insert-only at the app layer but not cryptographically tamper-evident (no hash-chain / WORM export). | SOC 2 CC7.2, ISO A.8.15, HIPAA §164.312(b)(c) | AlianHub | Medium | Planned | Add per-row hash-chaining + signed export to WORM storage. | +| GAP-05 | Penetration test | No published independent pen-test / SAST-DAST report. | SOC 2 CC4, ISO A.8.8 | AlianHub | Medium | Planned | Commission a third-party test per release train; publish summary. | +| GAP-06 | SBOM & SCA | No published SBOM; dependency scanning is operator-run. | ISO A.8.8 | Shared | Medium | Planned | Generate CycloneDX SBOM in CI; enable Dependabot/`npm audit` gate. | +| GAP-07 | Session revocation | No global "sign out everywhere" / server-side token revocation list. | SOC 2 CC6.3, HIPAA §164.312(a)(2)(iii) | AlianHub | Medium | Planned | Refresh-token revocation list + force-logout on role removal. | +| GAP-08 | Access reviews | No in-app periodic access-review/attestation workflow. | SOC 2 CC6.2, ISO A.5.18 | AlianHub | Low | Planned | Owner/admin "review members" reminder + export. | +| GAP-09 | Data residency | Residency depends entirely on the operator's hosting choice. | SOC 2 C1, ISO A.5.23, GDPR | Operator | Info | Operator-responsibility | Documented; no in-app region pinning. | +| GAP-10 | Backups / DR | No built-in scheduled backup or restore tooling. | SOC 2 A1, ISO A.5.30 | Operator | High | Operator-responsibility | Operator must automate + test restores; consider a documented backup script. | +| GAP-11 | Automatic logoff | Inactivity logoff is client-side; no enforced server idle timeout. | HIPAA §164.312(a)(2)(iii) | AlianHub | Low | Planned | Configurable server-side idle-token TTL. | +| GAP-12 | DPA / BAA | No standard DPA/BAA template for self-hosters to adapt. | HIPAA §164.314, GDPR Art.28 | AlianHub | Low | Planned | Publish template DPA/BAA the operator can use with *their* sub-processors. | + +## How this register is maintained + +- Reviewed each release train (and at minimum quarterly); `Last reviewed` updated above. +- New gaps from audits, pen-tests, or advisories are appended with an ID. +- `Planned` items should link to a tracker issue/PR as they're scheduled. +- Closing a gap: change status to a dated `Closed (YYYY-MM-DD, vX.Y.Z)` row and reflect it + in [control-mapping.md](control-mapping.md). diff --git a/docs/compliance/security-whitepaper.md b/docs/compliance/security-whitepaper.md new file mode 100644 index 00000000..5a3d07f5 --- /dev/null +++ b/docs/compliance/security-whitepaper.md @@ -0,0 +1,114 @@ +# AlianHub Security Whitepaper + +**Last reviewed:** 2026-06-20 · **Applies to:** AlianHub v14.5.0+ + +This whitepaper describes the security controls **built into AlianHub**. Infrastructure +and operational controls are the operator's responsibility — see +[shared-responsibility.md](shared-responsibility.md). + +--- + +## 1. Architecture overview + +AlianHub is a multi-tenant Node.js/Express application backed by MongoDB, with a Vue.js +web client and an optional Electron desktop time-tracker. Real-time updates use Socket.io. + +- **Multi-tenancy:** every tenant is a *company*. Application data is **scoped by + `companyId`** on every query; user identity and authentication records live in a + separate **`global`** database, while per-company databases hold that tenant's + projects, tasks, comments, and files. +- **Stateless API:** authentication is via signed JWTs; there is no server session state + to compromise at rest. +- **Storage:** file attachments are stored in S3-compatible object storage (Wasabi) or + on the local filesystem, selected by the operator. + +## 2. Data classification & flows + +| Data | Examples | Where it lives | +|---|---|---| +| Authentication data | password hashes, 2FA secrets, SSO/SCIM identifiers | `global` DB | +| Tenant business data | projects, tasks, comments, time logs | per-company DB | +| Files | attachments, screenshots | object storage / filesystem | +| Audit trail | sensitive-action log | per-company DB (`audit_logs`) | + +Data in transit is protected by TLS (operator-terminated). Data at rest is protected by +the operator's storage/database encryption configuration. + +## 3. Authentication + +- **Passwords** are hashed with **bcrypt** (never stored or logged in plaintext). +- **Two-factor authentication (2FA)** via TOTP (RFC 6238, `otplib`) with a validation + step decoupled from the main session token. +- **OAuth** sign-in: Google, GitHub, GitLab. +- **Enterprise SSO** (SEC-02): **SAML 2.0 and OIDC**, configured per company. IdP secrets + are stored server-side and never exposed to the client; the public login endpoint + returns only non-secret metadata. +- **JWT** access tokens are short-lived and paired with refresh tokens; tokens carry the + company *audience*, which is re-checked server-side (`requireCompanyAud`). +- **Personal API tokens (PATs)** are hashed at rest; token-management endpoints are + themselves protected and PATs cannot manage other tokens. + +## 4. Authorization & access control (RBAC) + +- Roles: **Owner (1), Admin (2), Member (3), Guest (4)**. +- A **rules engine** (`Config/permissionGuard.js`) evaluates per-key permissions + (read/none/write) and is the **single source of truth enforced server-side** — not just + in the UI — so API/MCP clients obey the same model. +- **Privilege escalation is constrained:** granting Owner/Admin is Owner-only. +- **Guest scoping (SEC-01):** guests see only explicitly-assigned projects; project, task, + and comment endpoints enforce this server-side for all clients. +- **Automated provisioning (SEC-05):** SCIM 2.0 create/deactivate from the IdP, so access + is granted and **revoked** centrally. + +## 5. Multi-tenant isolation + +- Every data-access path is scoped by `companyId`; cross-company reads are structurally + prevented by separate per-company databases. +- The JWT audience is validated against the requested company on company-scoped routes. +- Role caches are invalidated immediately on role/membership changes. + +## 6. Audit & monitoring (SEC-04) + +- An **immutable, insert-only `audit_logs`** collection records sensitive actions + (e.g. member/role changes, SSO and SCIM configuration changes) with actor, entity, IP, + and timestamp. +- Logs are **owner/admin-readable** via a filterable API + Settings viewer, and are + **retained for a configurable window** (default 365 days) with an automated prune job. +- Structured application logging via Winston (operator ships logs to their SIEM). + +## 7. Data protection + +- **In transit:** TLS, terminated by the operator's reverse proxy / load balancer. +- **At rest:** provided by the operator's database/storage encryption (e.g. encrypted + volumes, MongoDB encryption-at-rest, S3 SSE). See the gap register. +- **Secrets:** supplied via environment variables (`JWT_SECRET`, storage keys, etc.); + never hard-coded. +- **Screenshot retention:** time-tracker screenshots have per-tenant retention with + automated cleanup. + +## 8. Application hardening + +- **Security headers** via Helmet. +- **Rate limiting** via `express-rate-limit` on sensitive endpoints. +- **CORS** configured by the operator. +- **Input validation** at the API boundary; consistent error envelopes that avoid leaking + internals. +- Dependencies are tracked in `package.json`; operators should run `npm audit` / SCA in CI. + +## 9. Availability & resilience + +Availability is **operator-owned**: backups, replication, scaling, and disaster recovery +depend on the deployment. AlianHub is stateless at the app tier (horizontally scalable); +durability rests on the operator's MongoDB and object-storage configuration. + +## 10. Vulnerability management + +Coordinated disclosure via GitHub Security Advisories — see [SECURITY.md](../../SECURITY.md). +Fixes are released best-effort to the latest version; operators are expected to stay current. + +## 11. Sub-processors & privacy + +A self-hosted AlianHub instance has **no AlianHub-operated sub-processors** — the operator +chooses any third parties (object storage, email/SMTP, push, optional AI). For data-subject +rights (GDPR/CCPA), the operator is the controller; AlianHub provides the tooling +(export, edit, delete) to service requests. diff --git a/docs/compliance/shared-responsibility.md b/docs/compliance/shared-responsibility.md new file mode 100644 index 00000000..50c8d237 --- /dev/null +++ b/docs/compliance/shared-responsibility.md @@ -0,0 +1,49 @@ +# Shared Responsibility Model + +**Last reviewed:** 2026-06-20 · **Applies to:** AlianHub v14.5.0+ + +Because AlianHub is **self-hosted**, compliance is split between the software (AlianHub) +and the organization running it (the **operator**). This is the same model cloud providers +use, adapted for an open-source app you host yourself. + +## Responsibility matrix + +| Domain | AlianHub (software) | Operator (you) | +|---|---|---| +| Authentication mechanisms | ✅ Passwords (bcrypt), 2FA, OAuth, SSO, SCIM | Enforce policy (MFA mandate, password rules), manage the IdP | +| Authorization / RBAC | ✅ Roles + server-side rules engine, guest scoping | Assign roles; review access periodically | +| Multi-tenant isolation | ✅ `companyId` scoping, per-company DBs, JWT audience | — | +| Audit logging | ✅ Immutable `audit_logs` + retention | Ship logs to a SIEM; review; set retention to policy | +| Encryption in transit | Supports TLS | ✅ Terminate TLS, manage certificates, enforce HTTPS/HSTS | +| Encryption at rest | Uses operator storage | ✅ Encrypt DB volumes / MongoDB / object storage (SSE) | +| Secrets management | Reads from env vars | ✅ Provide strong secrets; use a vault; rotate | +| Network security | App-level hardening (Helmet, rate limits) | ✅ Firewalls, WAF, network segmentation, DDoS | +| Backups & DR | Stateless app tier | ✅ Back up MongoDB + storage; test restores; define RPO/RTO | +| Availability / scaling | Horizontally scalable | ✅ Redundancy, monitoring, capacity, uptime | +| OS / runtime patching | Tracks dependencies | ✅ Patch host, Node, MongoDB; run SCA | +| Physical security | — | ✅ Data-center / cloud-region controls | +| Personnel & process | — | ✅ Background checks, training, access reviews, policies | +| Vendor / BAA (HIPAA) | Provides controls | ✅ Sign BAAs with *your* sub-processors; you are CE/BA | +| Data-subject rights | ✅ Export / edit / delete tooling | ✅ Receive and fulfill requests; you are the controller | + +✅ = primary owner. + +## Operator hardening checklist + +Minimum steps to run AlianHub securely in a regulated environment: + +- [ ] Serve only over **HTTPS/TLS 1.2+**; enable HSTS; redirect HTTP→HTTPS. +- [ ] Set a strong, unique **`JWT_SECRET`** and rotate on suspected compromise. +- [ ] Enable **encryption at rest** for MongoDB and object storage (S3 SSE / encrypted volumes). +- [ ] Restrict MongoDB to private networking; require auth; least-privilege DB users. +- [ ] Put the app behind a **reverse proxy / WAF**; keep `express-rate-limit` enabled. +- [ ] **Mandate 2FA** and/or SSO; disable local passwords if using SSO exclusively. +- [ ] Configure **SCIM** so deprovisioning is automatic when an employee leaves. +- [ ] Ship application + audit logs to a **SIEM**; set `audit_logs` retention to policy. +- [ ] Automate **encrypted backups** of MongoDB and storage; **test restores** regularly. +- [ ] Run **dependency/SCA scanning** (`npm audit`) and OS patching in CI/cron. +- [ ] Define and document **access review**, **incident response**, and **change management** processes. +- [ ] For PHI: sign **BAAs** with your hosting/storage/email providers; restrict data residency. + +Keep evidence (config, logs, tickets, review records) — auditors assess *operating +effectiveness over time*, not just that a control exists. diff --git a/frontend/public/index.html b/frontend/public/index.html index 57458594..f9492852 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -3,8 +3,16 @@ - + + + + + + + + + @@ -19,6 +27,20 @@
- + 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..24a7d11e --- /dev/null +++ b/frontend/public/service-worker.js @@ -0,0 +1,31 @@ +/* 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', () => self.skipWaiting()); + +self.addEventListener('activate', (event) => { + 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 */ } + })()); +}); + +// No 'fetch' listener — every request goes straight to the network. 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 @@