[object Object]

← back to Rentv 2026

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

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 2026  ·  harden(deals): Cody gate — scope parseOccupancy 'pre-' to 'l f3b16410 →