Selaa lähdekoodia

fix(auth): BizGaze-only login + admin sees all sessions

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 <noreply@anthropic.com>
Sravan 6 päivää sitten
vanhempi
commit
5448cf0614
3 muutettua tiedostoa jossa 94 lisäystä ja 18 poistoa
  1. 1
    0
      server/repos.js
  2. 26
    18
      server/routes.js
  3. 67
    0
      server/scripts/migrate-bizgaze-only.js

+ 1
- 0
server/repos.js Näytä tiedosto

@@ -37,6 +37,7 @@ const users = {
37 37
   },
38 38
   enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id),
39 39
   setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, id),
40
+  setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id),
40 41
   setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id),
41 42
   setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id),
42 43
   remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id),

+ 26
- 18
server/routes.js Näytä tiedosto

@@ -42,44 +42,49 @@ route('POST', '/api/mfa/enable', async (req, res) => {
42 42
 // Provision (or refresh) a local user from a successful BizGaze identity check.
43 43
 // The local row exists so sessions, audit, and team-scoped data work; BizGaze stays
44 44
 // the source of truth for credentials (the local password is random + unused).
45
+// Emails that must always be admins regardless of what BizGaze returns (safety net so an
46
+// admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional.
47
+const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
45 48
 function provisionFromBizgaze(email, bz) {
49
+  const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician';
46 50
   const existing = R.users.byEmail(email);
47 51
   if (!existing) {
48 52
     const team = R.teams.first() || R.teams.create('BizGaze');
49 53
     const { hash, salt } = A.hashPassword(A.token());
50
-    const role = bz.isAdmin ? 'admin' : 'technician';
51 54
     const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() });
52 55
     audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' });
53 56
     return R.users.byId(id);
54 57
   }
58
+  // BizGaze is the source of truth: keep name + role in sync on each login.
55 59
   if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name);
60
+  if (existing.role !== role) R.users.setRole(existing.id, role);
56 61
   return R.users.byId(existing.id);
57 62
 }
58 63
 
59
-// Login: validates locally first (seeded/bootstrap accounts), then against BizGaze
60
-// (the identity provider) when BIZGAZE_LOGIN_URL is configured. Sets a session cookie.
64
+// Login: when BizGaze (BIZGAZE_LOGIN_URL) is configured it is the ONLY authority — the
65
+// credentials are verified against BizGaze and the user is provisioned/synced locally
66
+// (local passwords are not accepted). Without it (dev/tests) the local password is
67
+// checked. Sets a session cookie.
61 68
 route('POST', '/api/login', async (req, res) => {
62 69
   const { email, password, remember } = await readBody(req);
63 70
   if (!email || !password) return json(res, 400, { error: 'email and password required' });
64 71
   const existing = R.users.byEmail(email);
65 72
   if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
66 73
 
67
-  let u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
68
-  let bzMsg = null;
69
-  if (!u) {
74
+  let u = null;
75
+  if (BZ.isEnabled()) {
76
+    // BizGaze is the identity provider: credentials are ALWAYS verified against BizGaze.
77
+    // Local passwords are NOT accepted, so stale in-app accounts can't shadow a BizGaze
78
+    // login and everyone provisions into the same tenant (admins then see all sessions).
70 79
     const bz = await BZ.validateLogin(email, password);
71
-    if (bz.ok) u = provisionFromBizgaze(email, bz);
72
-    else if (bz.error) return json(res, 503, { error: bz.error });
73
-    else bzMsg = bz.message || null; // BizGaze was configured and rejected the credentials
74
-  }
75
-  if (!u) {
76
-    // Specific feedback where we can be truthful:
77
-    if (existing) return json(res, 401, { error: 'Incorrect password. Please try again.' });
78
-    // No local account. BizGaze (the identity provider) doesn't reveal whether an email
79
-    // exists, so when it rejects we surface its own message (covers wrong password +
80
-    // any lockout warning). Only when BizGaze isn't in play can we say "not registered".
81
-    if (bzMsg) return json(res, 401, { error: bzMsg });
82
-    return json(res, 404, { error: 'This email is not registered.' });
80
+    if (bz.error) return json(res, 503, { error: bz.error });
81
+    if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' });
82
+    u = provisionFromBizgaze(email, bz);
83
+    if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
84
+  } else {
85
+    // No identity provider configured (local/dev/tests): verify the local password.
86
+    u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
87
+    if (!u) return json(res, existing ? 401 : 404, { error: existing ? 'Incorrect password. Please try again.' : 'This email is not registered.' });
83 88
   }
84 89
 
85 90
   const tok = A.token();
@@ -177,6 +182,9 @@ route('POST', '/api/users', async (req, res) => {
177 182
   const u = currentUser(req);
178 183
   if (!u) return json(res, 401, { error: 'unauthorized' });
179 184
   if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
185
+  // With BizGaze as the identity provider, logins are created in BizGaze — not here.
186
+  // (Creating local accounts is what previously shadowed BizGaze and split tenants.)
187
+  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.' });
180 188
   const { email, password, name, role } = await readBody(req);
181 189
   if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
182 190
   if (R.users.emailExists(email))

+ 67
- 0
server/scripts/migrate-bizgaze-only.js Näytä tiedosto

@@ -0,0 +1,67 @@
1
+#!/usr/bin/env node
2
+// One-time PRODUCTION migration for "BizGaze-only logins".
3
+//
4
+// Deletes the in-app (pre-BizGaze) local accounts. Combined with the BizGaze-only login
5
+// change, every user then signs in through BizGaze and is provisioned into the same
6
+// tenant — which restores the admin's "see all sessions" report.
7
+//
8
+// A "pre-BizGaze" account = a user with NO 'sso_user_created' audit entry for its email
9
+// (i.e. created locally via register/console, not provisioned by a BizGaze login).
10
+//
11
+// SAFE BY DEFAULT: dry-run unless you pass --apply. BACK UP THE DB FIRST.
12
+//   Dry run : node scripts/migrate-bizgaze-only.js
13
+//   Apply   : node scripts/migrate-bizgaze-only.js --apply
14
+// Honors DB_PATH (same env var the server uses).
15
+
16
+const db = require('../db');
17
+const APPLY = process.argv.includes('--apply');
18
+
19
+const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
20
+
21
+const ssoEmails = new Set(
22
+  db.prepare("SELECT DISTINCT lower(user_email) AS e FROM audit_log WHERE action='sso_user_created' AND user_email IS NOT NULL")
23
+    .all().map((r) => r.e),
24
+);
25
+const users = db.prepare('SELECT id,email,name,role,team_id,active FROM users').all();
26
+const keep = users.filter((u) => ssoEmails.has(String(u.email).toLowerCase()));
27
+const remove = users.filter((u) => !ssoEmails.has(String(u.email).toLowerCase()));
28
+
29
+console.log('=== Teams ===');
30
+for (const t of db.prepare('SELECT id,name FROM teams').all()) {
31
+  const uc = db.prepare('SELECT COUNT(*) AS c FROM users WHERE team_id=?').get(t.id).c;
32
+  console.log(`  ${t.id}  ${t.name}  (${uc} users)`);
33
+}
34
+console.log('\n=== Users ===');
35
+console.log(`  total: ${users.length}  |  BizGaze-provisioned (keep): ${keep.length}  |  local pre-BizGaze (delete): ${remove.length}`);
36
+console.log('\n  KEEP (already BizGaze-provisioned):');
37
+keep.forEach((u) => console.log(`    ${u.email}  [${u.role}]  team ${u.team_id}`));
38
+console.log('\n  DELETE (local / pre-BizGaze):');
39
+remove.forEach((u) => console.log(`    ${u.email}  [${u.role}]  team ${u.team_id}`));
40
+
41
+if (!remove.length) { console.log('\nNothing to delete. Done.'); process.exit(0); }
42
+
43
+if (!APPLY) {
44
+  console.log('\nDRY RUN — no changes made. Re-run with --apply to delete the local accounts above.');
45
+  console.log('After deletion, those users sign in via BizGaze and are recreated automatically.');
46
+  process.exit(0);
47
+}
48
+
49
+const delAuth = db.prepare('DELETE FROM sessions_auth WHERE user_id=?');
50
+const delRefresh = tableExists('refresh_tokens') ? db.prepare('DELETE FROM refresh_tokens WHERE user_id=?') : null;
51
+const delUser = db.prepare('DELETE FROM users WHERE id=?');
52
+let deleted = 0;
53
+db.exec('BEGIN');
54
+try {
55
+  for (const u of remove) {
56
+    delAuth.run(u.id);          // clear active sessions (FK) — also logs them out
57
+    if (delRefresh) delRefresh.run(u.id);
58
+    delUser.run(u.id);
59
+    deleted++;
60
+  }
61
+  db.exec('COMMIT');
62
+} catch (e) {
63
+  db.exec('ROLLBACK');
64
+  console.error('FAILED — rolled back, no changes applied:', e.message);
65
+  process.exit(1);
66
+}
67
+console.log(`\nDONE. Deleted ${deleted} local account(s). They are recreated via BizGaze on next sign-in.`);

Loading…
Peruuta
Tallenna