From 5448cf06147e7450cd2512f8859a41b5075b3850 Mon Sep 17 00:00:00 2001 From: sravan Date: Mon, 15 Jun 2026 19:02:08 +0530 Subject: [PATCH] fix(auth): BizGaze-only login + admin sees all sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When BIZGAZE_LOGIN_URL is configured, verify credentials ONLY against BizGaze (no local-password fallback) so stale in-app accounts can't shadow a BizGaze login. Everyone is then provisioned into the same tenant, restoring the admin's team-scoped "see all sessions" report. - login: BizGaze-only when the IdP is configured; local path kept for dev/tests - provisionFromBizgaze: keep role in sync with BizGaze (isAdmin) on every login; optional ADMIN_EMAILS allowlist as a lockout safety net - block POST /api/users (add local agent) when BizGaze is the IdP — this is what previously split tenants - scripts/migrate-bizgaze-only.js: one-time, dry-run-by-default cleanup that deletes pre-BizGaze local accounts (no sso_user_created audit entry) Co-Authored-By: Claude Opus 4.8 --- server/repos.js | 1 + server/routes.js | 44 ++++++++++------- server/scripts/migrate-bizgaze-only.js | 67 ++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 server/scripts/migrate-bizgaze-only.js diff --git a/server/repos.js b/server/repos.js index 1e09678..57a8cc8 100644 --- a/server/repos.js +++ b/server/repos.js @@ -37,6 +37,7 @@ const users = { }, enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id), setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, id), + setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id), setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id), setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id), remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id), diff --git a/server/routes.js b/server/routes.js index bb8d543..37d943c 100644 --- a/server/routes.js +++ b/server/routes.js @@ -42,44 +42,49 @@ route('POST', '/api/mfa/enable', async (req, res) => { // Provision (or refresh) a local user from a successful BizGaze identity check. // The local row exists so sessions, audit, and team-scoped data work; BizGaze stays // the source of truth for credentials (the local password is random + unused). +// Emails that must always be admins regardless of what BizGaze returns (safety net so an +// admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional. +const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); function provisionFromBizgaze(email, bz) { + const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician'; const existing = R.users.byEmail(email); if (!existing) { const team = R.teams.first() || R.teams.create('BizGaze'); const { hash, salt } = A.hashPassword(A.token()); - const role = bz.isAdmin ? 'admin' : 'technician'; const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() }); audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' }); return R.users.byId(id); } + // BizGaze is the source of truth: keep name + role in sync on each login. if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name); + if (existing.role !== role) R.users.setRole(existing.id, role); return R.users.byId(existing.id); } -// Login: validates locally first (seeded/bootstrap accounts), then against BizGaze -// (the identity provider) when BIZGAZE_LOGIN_URL is configured. Sets a session cookie. +// Login: when BizGaze (BIZGAZE_LOGIN_URL) is configured it is the ONLY authority — the +// credentials are verified against BizGaze and the user is provisioned/synced locally +// (local passwords are not accepted). Without it (dev/tests) the local password is +// checked. Sets a session cookie. route('POST', '/api/login', async (req, res) => { const { email, password, remember } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and password required' }); const existing = R.users.byEmail(email); if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); - let u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null; - let bzMsg = null; - if (!u) { + let u = null; + if (BZ.isEnabled()) { + // BizGaze is the identity provider: credentials are ALWAYS verified against BizGaze. + // Local passwords are NOT accepted, so stale in-app accounts can't shadow a BizGaze + // login and everyone provisions into the same tenant (admins then see all sessions). const bz = await BZ.validateLogin(email, password); - if (bz.ok) u = provisionFromBizgaze(email, bz); - else if (bz.error) return json(res, 503, { error: bz.error }); - else bzMsg = bz.message || null; // BizGaze was configured and rejected the credentials - } - if (!u) { - // Specific feedback where we can be truthful: - if (existing) return json(res, 401, { error: 'Incorrect password. Please try again.' }); - // No local account. BizGaze (the identity provider) doesn't reveal whether an email - // exists, so when it rejects we surface its own message (covers wrong password + - // any lockout warning). Only when BizGaze isn't in play can we say "not registered". - if (bzMsg) return json(res, 401, { error: bzMsg }); - return json(res, 404, { error: 'This email is not registered.' }); + if (bz.error) return json(res, 503, { error: bz.error }); + if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' }); + u = provisionFromBizgaze(email, bz); + if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); + } else { + // No identity provider configured (local/dev/tests): verify the local password. + u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null; + if (!u) return json(res, existing ? 401 : 404, { error: existing ? 'Incorrect password. Please try again.' : 'This email is not registered.' }); } const tok = A.token(); @@ -177,6 +182,9 @@ route('POST', '/api/users', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' }); + // With BizGaze as the identity provider, logins are created in BizGaze — not here. + // (Creating local accounts is what previously shadowed BizGaze and split tenants.) + if (BZ.isEnabled()) return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user in BizGaze; they appear here on first sign-in.' }); const { email, password, name, role } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and temporary password required' }); if (R.users.emailExists(email)) diff --git a/server/scripts/migrate-bizgaze-only.js b/server/scripts/migrate-bizgaze-only.js new file mode 100644 index 0000000..f65caf8 --- /dev/null +++ b/server/scripts/migrate-bizgaze-only.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// One-time PRODUCTION migration for "BizGaze-only logins". +// +// Deletes the in-app (pre-BizGaze) local accounts. Combined with the BizGaze-only login +// change, every user then signs in through BizGaze and is provisioned into the same +// tenant — which restores the admin's "see all sessions" report. +// +// A "pre-BizGaze" account = a user with NO 'sso_user_created' audit entry for its email +// (i.e. created locally via register/console, not provisioned by a BizGaze login). +// +// SAFE BY DEFAULT: dry-run unless you pass --apply. BACK UP THE DB FIRST. +// Dry run : node scripts/migrate-bizgaze-only.js +// Apply : node scripts/migrate-bizgaze-only.js --apply +// Honors DB_PATH (same env var the server uses). + +const db = require('../db'); +const APPLY = process.argv.includes('--apply'); + +const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name); + +const ssoEmails = new Set( + db.prepare("SELECT DISTINCT lower(user_email) AS e FROM audit_log WHERE action='sso_user_created' AND user_email IS NOT NULL") + .all().map((r) => r.e), +); +const users = db.prepare('SELECT id,email,name,role,team_id,active FROM users').all(); +const keep = users.filter((u) => ssoEmails.has(String(u.email).toLowerCase())); +const remove = users.filter((u) => !ssoEmails.has(String(u.email).toLowerCase())); + +console.log('=== Teams ==='); +for (const t of db.prepare('SELECT id,name FROM teams').all()) { + const uc = db.prepare('SELECT COUNT(*) AS c FROM users WHERE team_id=?').get(t.id).c; + console.log(` ${t.id} ${t.name} (${uc} users)`); +} +console.log('\n=== Users ==='); +console.log(` total: ${users.length} | BizGaze-provisioned (keep): ${keep.length} | local pre-BizGaze (delete): ${remove.length}`); +console.log('\n KEEP (already BizGaze-provisioned):'); +keep.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`)); +console.log('\n DELETE (local / pre-BizGaze):'); +remove.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`)); + +if (!remove.length) { console.log('\nNothing to delete. Done.'); process.exit(0); } + +if (!APPLY) { + console.log('\nDRY RUN — no changes made. Re-run with --apply to delete the local accounts above.'); + console.log('After deletion, those users sign in via BizGaze and are recreated automatically.'); + process.exit(0); +} + +const delAuth = db.prepare('DELETE FROM sessions_auth WHERE user_id=?'); +const delRefresh = tableExists('refresh_tokens') ? db.prepare('DELETE FROM refresh_tokens WHERE user_id=?') : null; +const delUser = db.prepare('DELETE FROM users WHERE id=?'); +let deleted = 0; +db.exec('BEGIN'); +try { + for (const u of remove) { + delAuth.run(u.id); // clear active sessions (FK) — also logs them out + if (delRefresh) delRefresh.run(u.id); + delUser.run(u.id); + deleted++; + } + db.exec('COMMIT'); +} catch (e) { + db.exec('ROLLBACK'); + console.error('FAILED — rolled back, no changes applied:', e.message); + process.exit(1); +} +console.log(`\nDONE. Deleted ${deleted} local account(s). They are recreated via BizGaze on next sign-in.`);