-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathauth.js
More file actions
352 lines (336 loc) · 13.2 KB
/
auth.js
File metadata and controls
352 lines (336 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
"use strict";
/**
* Shared auth helpers + middleware. Promotes the duplicated logic
* previously hand-rolled inside customercontroller.js and
* timeentrycontroller.js into a single source of truth.
*
* Exports
* isMaster(authKey) -> true if authKey matches an unarchived
* ApiMaster row.
* getCompanyId(authKey) -> int company id, or -1 if not found /
* archived / empty.
* requireAuthKey(req,res,next) -> express middleware: 403s the
* request if `authKey` header is absent.
* Sets req.authKey for downstream use.
* resolveAuth(req,res,next) -> express middleware: also resolves
* req.authKey into req.isMaster (bool) and
* req.companyId (int|-1). 403s if non-master
* with an unknown authKey.
*
* Why two middlewares
* Some endpoints only need to know "did the caller send an authKey"
* (e.g. /healthz historically would not pass through this layer);
* others need the resolved {isMaster, companyId} context. Splitting
* keeps the cheap check cheap (one DB hit, not three).
*/
const crypto = require('crypto');
const log = require('../config/logger.js');
/**
* Late-bound + injectable DB accessor. Returns the db.config module
* on every call, or a test-supplied substitute set via
* `_setDbForTesting(stub)`. P5-M.
*
* Why injection: vitest's `vi.mock()` does not intercept CJS
* `require()` in this codebase (the model files use CJS via
* sequelize-cli's conventions, and vitest's mock layer only patches
* ESM imports reliably here). Tests that want to drive the
* "row found" success paths therefore need a way to *explicitly*
* substitute the db. The setter is hidden behind a leading-underscore
* name to make it clear it's a test-only seam; production code
* never calls it.
*
* Production performance: Node caches the require by path, so the
* accessor is effectively a property read on `require.cache` after
* the first call.
*/
let _dbOverride = null;
function getDb() {
return _dbOverride || require('../config/db.config.js');
}
function _setDbForTesting(db) {
// Pass `null` (or omit) to restore the production lookup.
_dbOverride = db || null;
}
/**
* Hash an authKey for lookup. Migration 20260518000000 converted
* ApiKey.akKEY / ApiMaster.amKEY columns from UUID to TEXT and
* replaced row values with SHA-256 hex digests. Operator tokens
* issued before the migration keep working because the API hashes
* the incoming header here before the SQL lookup.
*
* SHA-256 unsalted (vs bcrypt/argon2id) because API tokens are
* high-entropy (UUID v4 = 122 bits); brute force against a leaked
* hash table is impractical. Hashing is to prevent direct replay
* if the DB leaks, not to protect a low-entropy password.
*/
function hashKey(rawKey) {
return crypto.createHash('sha256').update(String(rawKey)).digest('hex');
}
/**
* Why model calls instead of raw `sequelize.query`:
*
* P5-M reworked this module to route every DB hit through the
* Sequelize model layer (`ApiMaster.findOne`, `Customer.findByPk`,
* etc.) for testability. `vi.mock('../config/db.config.js', ...)`
* intercepts the module export; the raw `db.sequelize.query` path
* had a nested CJS-require interplay that often slipped past the
* mock, leaving tests exercising the real (unreachable in tests) DB.
* Going through the models means every code path here is testable
* with the same model-stub fixtures the rest of the api tests use.
*
* Performance is no worse — these are single-row primary-key
* lookups; Sequelize's per-row instantiation overhead is in the
* sub-millisecond range and dwarfed by the network round-trip.
*
* The archive filter (`<arch>: false`) is no longer hand-rolled in
* the WHERE clause. P2-E added `defaultScope` to every model with
* an archive column, so the soft-delete filter is implicit.
*/
async function isMaster(authKey) {
if (!authKey || authKey.length === 0) return false;
try {
const row = await getDb().ApiMaster.findOne({
where: { amKEY: hashKey(authKey) },
attributes: ['amId'],
});
return !!(row && typeof row.amId === 'number' && row.amId > 0);
} catch (error) {
log.error({ err: error }, 'auth.isMaster query failed');
return false;
}
}
async function getCompanyId(authKey) {
if (!authKey || authKey.length === 0) return -1;
try {
const row = await getDb().ApiKey.findOne({
where: { akKEY: hashKey(authKey) },
attributes: ['akCompanyId'],
});
if (!row) return -1;
const cid = row.akCompanyId;
return typeof cid === 'number' && cid > 0 ? cid : -1;
} catch (error) {
log.error({ err: error }, 'auth.getCompanyId query failed');
return -1;
}
}
/**
* Resolve a customer id to its owning company id.
*
* Used by entities that don't have their own *CompId column and instead
* scope auth through their Customer relation (Job, Invoice,
* CustomerPayment). Returns -1 on missing / archived / lookup failure
* so callers can use the same `=== -1` sentinel as getCompanyId().
*/
async function getCompanyIdByCustomerId(customerId) {
const idStr = customerId == null ? '' : String(customerId);
if (idStr.length === 0 || idStr === '0') return -1;
try {
const row = await getDb().Customer.findByPk(customerId, {
attributes: ['custCompId'],
});
if (!row) return -1;
const cid = row.custCompId;
return typeof cid === 'number' && cid > 0 ? cid : -1;
} catch (error) {
log.error({ err: error }, 'auth.getCompanyIdByCustomerId query failed');
return -1;
}
}
/**
* Resolve a PO vendor id to its owning company id. Used by
* PurchaseOrderHeader to scope auth — headers reference a vendor
* (pohPovId), and the vendor's povCompId is the auth boundary.
*/
async function getCompanyIdByPovId(povId) {
const idStr = povId == null ? '' : String(povId);
if (idStr.length === 0 || idStr === '0') return -1;
try {
const row = await getDb().PurchaseOrderVendor.findByPk(povId, {
attributes: ['povCompId'],
});
if (!row) return -1;
const cid = row.povCompId;
return typeof cid === 'number' && cid > 0 ? cid : -1;
} catch (error) {
log.error({ err: error }, 'auth.getCompanyIdByPovId query failed');
return -1;
}
}
/**
* Resolve a PO header id to its owning company id. Used by
* PurchaseOrderLine — lines reference a header (polpoh), and the
* header references a vendor (pohPovId), and the vendor's povCompId
* is the auth boundary. Eager-loaded via the PurchaseOrderHeader →
* PurchaseOrderVendor association so this stays one round-trip.
*/
async function getCompanyIdByPohId(pohId) {
const idStr = pohId == null ? '' : String(pohId);
if (idStr.length === 0 || idStr === '0') return -1;
try {
const row = await getDb().PurchaseOrderHeader.findByPk(pohId, {
attributes: ['pohId'],
include: [{
// db.config.js registers this association with
// `as: 'vendor'`. Without the alias the include
// silently matches nothing — was a latent bug in
// P5-M caught by the cascade integration suite.
model: getDb().PurchaseOrderVendor,
as: 'vendor',
attributes: ['povCompId'],
required: true,
}],
});
if (!row) return -1;
// defaultScope on PurchaseOrderVendor filters archived
// vendors automatically.
const vendor = row.vendor;
if (!vendor) return -1;
const cid = vendor.povCompId;
return typeof cid === 'number' && cid > 0 ? cid : -1;
} catch (error) {
log.error({ err: error }, 'auth.getCompanyIdByPohId query failed');
return -1;
}
}
/**
* Resolve a job id to its owning company id.
*
* Job has no direct *CompId — it scopes through Customer
* (Job.jobCustId → Customer.custCompId). Used by InvoiceJob and
* ProductEntry whose own FKs point into Job.
*/
async function getCompanyIdByJobId(jobId) {
const idStr = jobId == null ? '' : String(jobId);
if (idStr.length === 0 || idStr === '0') return -1;
try {
const row = await getDb().Job.findByPk(jobId, {
attributes: ['jobId'],
include: [{
// db.config.js registers this association with
// `as: 'customer'`. Without the alias the include
// silently matches nothing — was a latent bug in
// P5-M caught by the cascade integration suite.
model: getDb().Customer,
as: 'customer',
attributes: ['custCompId'],
required: true,
}],
});
if (!row) return -1;
const customer = row.customer;
if (!customer) return -1;
const cid = customer.custCompId;
return typeof cid === 'number' && cid > 0 ? cid : -1;
} catch (error) {
log.error({ err: error }, 'auth.getCompanyIdByJobId query failed');
return -1;
}
}
/**
* Express middleware: ensures the authKey header is present and
* stashes it on req.authKey. Does NOT validate the key against the
* database — leaves that to controllers that may have different
* scoping rules (e.g. master vs company match).
*/
function requireAuthKey(req, res, next) {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: 'Authorization key not sent.' });
}
req.authKey = authKey;
return next();
}
/**
* Express middleware: best-effort attach `authKey` + resolved
* `{ isMaster, companyId }` onto the request. Never rejects — endpoints
* like /v1/whoami need to distinguish "header missing" from "header
* present but unknown" themselves, and a strict guard middleware
* would collapse those into a uniform 403.
*
* Always sets:
* req.authKey string | null (raw header value, or null)
* req.isMaster boolean (false on unknown / missing key)
* req.companyId number (-1 sentinel for "no scoped key")
*
* Use `requireAuth` after this on routes that DO want the 403
* behavior (every /v1/* route except /v1/whoami).
*/
async function attachAuth(req, res, next) {
const authKey = req.get('authKey');
req.authKey = authKey || null;
req.isMaster = false;
req.companyId = -1;
if (!authKey) return next();
// No try/catch here even though these are DB-touching calls:
// both `isMaster` and `getCompanyId` already wrap their query
// in try/catch internally and swallow infrastructure errors
// into a `false` / `-1` sentinel (with a `log.error`). So a
// DB outage surfaces as "auth failed" (403 downstream via
// requireAuth or per-controller checks), not "server error"
// — and the outer wrappers we had here were unreachable
// dead code that misled readers into thinking attachAuth
// distinguished the two. If the silent-swallow turns out to
// be the wrong behaviour for operators (DB-down looking like
// auth-fail), the right fix is to surface the throw from
// isMaster / getCompanyId themselves, not to layer a catch
// here that the inner function prevents from firing.
req.isMaster = await isMaster(authKey);
if (req.isMaster) {
// Master keys aren't scoped to a single company. Leave
// companyId at -1; handlers needing a target scope read
// it from req.params / req.body / req.query.
return next();
}
req.companyId = await getCompanyId(authKey);
return next();
}
/**
* Express middleware: 403s requests that aren't authenticated.
* Assumes attachAuth has already run upstream.
*
* - missing authKey header -> 403 "Authorization key not sent."
* - present authKey, not master, no scope -> 403 "Invalid Authorization Key."
* - otherwise -> next()
*/
function requireAuth(req, res, next) {
if (!req.authKey) {
return res.status(403).json({ message: 'Authorization key not sent.' });
}
if (!req.isMaster && req.companyId === -1) {
return res.status(403).json({ message: 'Invalid Authorization Key.' });
}
return next();
}
/**
* Combined middleware kept for backward-compat with anywhere it
* was mounted directly. New mounts should use attachAuth +
* requireAuth as two separate middlewares so endpoints can opt
* out of the strict 403 (like /v1/whoami).
*/
async function resolveAuth(req, res, next) {
await new Promise((resolve) => {
attachAuth(req, res, () => resolve());
});
return requireAuth(req, res, next);
}
module.exports = {
isMaster,
getCompanyId,
getCompanyIdByCustomerId,
getCompanyIdByJobId,
getCompanyIdByPovId,
getCompanyIdByPohId,
requireAuthKey,
attachAuth,
requireAuth,
resolveAuth,
hashKey,
// Test-only seam: call with a stub db to drive auth functions
// through caller-controlled fixtures; call with no args (or null)
// to restore the production lookup. Production code MUST NOT call
// this. P5-M.
_setDbForTesting,
};