← back to Rentv
PR intel: tenant-scope core entity reads + 2nd-tenant safety guard
f0ce5eb3cb0a406461f1562dd268250e6c77dfa5 · 2026-08-06 09:15:24 -0700 · Steve
- organizations.list / people.list accept tenant_id; org/people list+get routes
inject req.prAuth.tenant.id (requireCap('read')); get-by-id 404s cross-tenant.
- auth.createUser refuses to onboard tenant>1 until PR_MULTI_TENANT_READY=1 —
prevents shipping FALSE isolation while the remaining entity queries
(campaigns/messages/tasks/sources/dashboard) are still un-scoped.
- Verified: tenant-1 reads flow via the Basic-admin fallback; guard blocks tenant 2.
Files touched
M src/pr/index.jsM src/pr/services/auth.jsM src/pr/services/organizations.jsM src/pr/services/people.js
Diff
commit f0ce5eb3cb0a406461f1562dd268250e6c77dfa5
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 09:15:24 2026 -0700
PR intel: tenant-scope core entity reads + 2nd-tenant safety guard
- organizations.list / people.list accept tenant_id; org/people list+get routes
inject req.prAuth.tenant.id (requireCap('read')); get-by-id 404s cross-tenant.
- auth.createUser refuses to onboard tenant>1 until PR_MULTI_TENANT_READY=1 —
prevents shipping FALSE isolation while the remaining entity queries
(campaigns/messages/tasks/sources/dashboard) are still un-scoped.
- Verified: tenant-1 reads flow via the Basic-admin fallback; guard blocks tenant 2.
---
src/pr/index.js | 12 ++++++------
src/pr/services/auth.js | 6 ++++++
src/pr/services/organizations.js | 1 +
src/pr/services/people.js | 1 +
4 files changed, 14 insertions(+), 6 deletions(-)
diff --git a/src/pr/index.js b/src/pr/index.js
index 15be1d95..047fefd5 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -285,10 +285,10 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
}));
// ── Organizations ──────────────────────────────────────────────────────────
- app.get('/api/pr/organizations', adminOnly, h(async (req, res) => res.json(await organizations.list(req.query))));
- app.get('/api/pr/organizations/:id', adminOnly, h(async (req, res) => {
+ app.get('/api/pr/organizations', adminOnly, requireCap('read'), h(async (req, res) => res.json(await organizations.list({ ...req.query, tenant_id: req.prAuth.tenant.id }))));
+ app.get('/api/pr/organizations/:id', adminOnly, requireCap('read'), h(async (req, res) => {
const org = await organizations.get(Number(req.params.id));
- if (!org) return res.status(404).json({ error: 'not found' });
+ if (!org || String(org.tenant_id) !== String(req.prAuth.tenant.id)) return res.status(404).json({ error: 'not found' });
res.json(org);
}));
app.post('/api/pr/organizations', adminOnly, h(async (req, res) => {
@@ -309,10 +309,10 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
}));
// ── People ─────────────────────────────────────────────────────────────────
- app.get('/api/pr/people', adminOnly, h(async (req, res) => res.json(await people.list(req.query))));
- app.get('/api/pr/people/:id', adminOnly, h(async (req, res) => {
+ app.get('/api/pr/people', adminOnly, requireCap('read'), h(async (req, res) => res.json(await people.list({ ...req.query, tenant_id: req.prAuth.tenant.id }))));
+ app.get('/api/pr/people/:id', adminOnly, requireCap('read'), h(async (req, res) => {
const p = await people.get(Number(req.params.id));
- if (!p) return res.status(404).json({ error: 'not found' });
+ if (!p || String(p.tenant_id) !== String(req.prAuth.tenant.id)) return res.status(404).json({ error: 'not found' });
res.json(p);
}));
app.post('/api/pr/people', adminOnly, h(async (req, res) => {
diff --git a/src/pr/services/auth.js b/src/pr/services/auth.js
index 020f0a7b..a9a7a632 100644
--- a/src/pr/services/auth.js
+++ b/src/pr/services/auth.js
@@ -39,6 +39,12 @@ function can(role, cap) { return !!(CAPS[role] && CAPS[role].has(cap)); }
// ── Users ────────────────────────────────────────────────────────────────────
async function createUser({ tenant_id = 1, email, name, role = 'user', password }, actor) {
+ // SAFETY GUARD: full per-query tenant isolation isn't complete yet (only the core
+ // org/people reads are scoped). Refuse to onboard a 2nd tenant until PR_MULTI_TENANT_READY=1,
+ // so we never ship FALSE isolation where tenant 2 could read tenant 1's data.
+ if (Number(tenant_id) !== 1 && !process.env.PR_MULTI_TENANT_READY) {
+ throw new Error('multi-tenant isolation not yet complete — onboarding tenant ' + tenant_id + ' is blocked until every entity query is tenant-scoped (set PR_MULTI_TENANT_READY=1 to override)');
+ }
const em = String(email || '').trim().toLowerCase();
if (!em) throw new Error('email required');
if (!['admin', 'developer', 'pro', 'user'].includes(role)) throw new Error('invalid role');
diff --git a/src/pr/services/organizations.js b/src/pr/services/organizations.js
index 6c9ff6cc..cdd673c6 100644
--- a/src/pr/services/organizations.js
+++ b/src/pr/services/organizations.js
@@ -143,6 +143,7 @@ async function get(id) {
async function list(f = {}) {
const where = []; const params = [];
const add = (sql, v) => { params.push(v); where.push(sql.replace('?', '$' + params.length)); };
+ if (f.tenant_id) add(`tenant_id = ?`, f.tenant_id); // multi-tenant isolation
if (f.q) {
params.push('%' + String(f.q).toLowerCase() + '%');
const n = params.length;
diff --git a/src/pr/services/people.js b/src/pr/services/people.js
index b3958bce..7ef6d124 100644
--- a/src/pr/services/people.js
+++ b/src/pr/services/people.js
@@ -124,6 +124,7 @@ async function get(id) {
async function list(f = {}) {
const where = []; const params = [];
const add = (sql, v) => { params.push(v); where.push(sql.replace('?', '$' + params.length)); };
+ if (f.tenant_id) add(`p.tenant_id = ?`, f.tenant_id); // multi-tenant isolation
if (f.q) {
params.push('%' + String(f.q).toLowerCase() + '%');
const n = params.length;
← eacf3831 News map: add Street/Satellite base-layer toggle (Esri World
·
back to Rentv
·
harden(deals): Cody gate — scope parseOccupancy 'pre-' to 'l f3b16410 →