Territory filtering was deferred from v1, so every account saw and could write every row. With the sales team about to adjust their own territories that is the thing standing in the way, and it is also why a rep would wait fifteen seconds to load 2.7M rows to work on a few thousand. The list lives on pf.app_user.territory with is_admin beside it, and col_meta.is_territory marks which column of a source the values belong to -- flagged rather than named in code, so a second source can be divided by something other than a sales rep. Fail closed: buildTerritoryClause returns FALSE for an empty list or an unflagged source. An account nobody configured sees nothing, rather than everything because a column was left null. Built from the session, never the request. That is what separates it from `scope`, which the browser sends and should: a filter the user chose belongs in the payload, a permission cannot come from the thing it restrains. It is ANDed on last, where nothing in the request can undo it. Enforced on /data (the cursor and the count behind X-Row-Count), on /agg before the GROUP BY since the territory column need not be in the grain, on every operation through sliceUnits, and on the value completion endpoint -- which reads the source table, so without it a dropdown enumerates every customer and rep in the business to someone shown none of their rows. Undo is gated by owner rather than territory: it removes an entry's rows wholesale, so half-undoing one would leave a state nothing describes. Recode refuses to set the territory column unless you are an admin, since moving a row between territories is reassignment, not forecasting. ./pf.sh gains set-territory, set-admin and orphan-territory. The last lists territory values no account owns -- work under one is invisible to everybody but an admin, which a typo causes easily and nothing in the app reveals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
93 lines
3.6 KiB
JavaScript
93 lines
3.6 KiB
JavaScript
// Password hashing and the route guard.
|
|
//
|
|
// Hashes are scrypt, from node's own crypto — no native build step, and the
|
|
// stored form carries its own parameters so they can be raised later without
|
|
// invalidating existing rows:
|
|
//
|
|
// scrypt$<N>$<r>$<p>$<salt base64>$<derived key base64>
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
|
|
|
|
function hashPassword(password, params = SCRYPT) {
|
|
const { N, r, p, keylen } = params;
|
|
const salt = crypto.randomBytes(16);
|
|
const dk = crypto.scryptSync(password, salt, keylen, { N, r, p, maxmem: 256 * 1024 * 1024 });
|
|
return `scrypt$${N}$${r}$${p}$${salt.toString('base64')}$${dk.toString('base64')}`;
|
|
}
|
|
|
|
// Constant-time compare. Returns false rather than throwing on a malformed or
|
|
// legacy hash, so one bad row can't 500 the login route.
|
|
function verifyPassword(password, stored) {
|
|
if (typeof stored !== 'string') return false;
|
|
const parts = stored.split('$');
|
|
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
|
|
|
const [, N, r, p, saltB64, dkB64] = parts;
|
|
try {
|
|
const salt = Buffer.from(saltB64, 'base64');
|
|
const expected = Buffer.from(dkB64, 'base64');
|
|
const actual = crypto.scryptSync(password, salt, expected.length, {
|
|
N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024,
|
|
});
|
|
return crypto.timingSafeEqual(actual, expected);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Every /api route except the auth ones sits behind this.
|
|
function requireAuth(req, res, next) {
|
|
if (req.session?.user?.username) return next();
|
|
res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
// The identity used for pf.log.pf_user and the created_by/closed_by columns.
|
|
// Read from the session only — never from the request body, which the browser
|
|
// controls and which used to carry a hardcoded 'admin'.
|
|
function sessionUser(req) {
|
|
return req.session?.user?.username || null;
|
|
}
|
|
|
|
// What this account may see and change, read from the session for the same
|
|
// reason the username is: the browser must not be able to widen it.
|
|
//
|
|
// Returns { admin: true } for an account that sees everything, or
|
|
// { admin: false, values: [...] } for a scoped one. An empty list is a real
|
|
// answer meaning "nothing", not a missing one meaning "everything" -- an
|
|
// account created without a territory sees no rows until it is granted some.
|
|
function sessionTerritory(req) {
|
|
const u = req.session?.user;
|
|
if (!u) return { admin: false, values: [] };
|
|
if (u.is_admin) return { admin: true, values: null };
|
|
return { admin: false, values: Array.isArray(u.territory) ? u.territory : [] };
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
if (req.session?.user?.is_admin) return next();
|
|
res.status(403).json({ error: 'Administrator access required' });
|
|
}
|
|
|
|
module.exports = {
|
|
hashPassword, verifyPassword, requireAuth, requireAdmin,
|
|
sessionUser, sessionTerritory, SCRYPT,
|
|
};
|
|
|
|
// CLI: `node lib/auth.js hash` reads a password on stdin and prints its hash,
|
|
// so ./pf.sh can create users without the plaintext touching argv or psql.
|
|
if (require.main === module) {
|
|
if (process.argv[2] !== 'hash') {
|
|
console.error('usage: node lib/auth.js hash (password on stdin)');
|
|
process.exit(2);
|
|
}
|
|
let input = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', chunk => { input += chunk; });
|
|
process.stdin.on('end', () => {
|
|
const password = input.replace(/\r?\n$/, '');
|
|
if (!password) { console.error('empty password'); process.exit(2); }
|
|
process.stdout.write(hashPassword(password) + '\n');
|
|
});
|
|
}
|