[object Object]

← back to Rentv 2026

PR intel (b2 partial): scope outreach reads by tenant + completion checklist

108b73926c6efd5648ed0d85cc3559f6226d7715 · 2026-08-06 09:45:58 -0700 · Steve

Scoped outreach.list + route by tenant_id (joins the org/people lists already
scoped). Added MULTITENANT_TODO.md: the exact remaining reads to scope
(campaigns/tasks/sources/relationships/suppression/runs/letters/dashboard/
export) + an RLS alternative + the verification gate. The PR_MULTI_TENANT_READY
guard stays until that pass is complete + verified, so no false isolation ships.

Files touched

Diff

commit 108b73926c6efd5648ed0d85cc3559f6226d7715
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 09:45:58 2026 -0700

    PR intel (b2 partial): scope outreach reads by tenant + completion checklist
    
    Scoped outreach.list + route by tenant_id (joins the org/people lists already
    scoped). Added MULTITENANT_TODO.md: the exact remaining reads to scope
    (campaigns/tasks/sources/relationships/suppression/runs/letters/dashboard/
    export) + an RLS alternative + the verification gate. The PR_MULTI_TENANT_READY
    guard stays until that pass is complete + verified, so no false isolation ships.
---
 src/pr/MULTITENANT_TODO.md  | 41 +++++++++++++++++++++++++++++++++++++++++
 src/pr/index.js             |  2 +-
 src/pr/services/outreach.js |  1 +
 3 files changed, 43 insertions(+), 1 deletion(-)

diff --git a/src/pr/MULTITENANT_TODO.md b/src/pr/MULTITENANT_TODO.md
new file mode 100644
index 00000000..322515a0
--- /dev/null
+++ b/src/pr/MULTITENANT_TODO.md
@@ -0,0 +1,41 @@
+# Multi-tenant isolation — completion checklist (b2)
+
+Status: the AUTH/RBAC platform is done + live (tenants, users, sessions, roles, router
+capability gate, clean client login, credentials page, Gmail sync). Tenant DATA-filtering
+is PARTIAL — a `PR_MULTI_TENANT_READY=1` guard in `auth.createUser` blocks onboarding any
+tenant > 1 until this list is complete + verified, so no false isolation can ship.
+
+## Done (record-returning list reads scoped by tenant_id)
+- [x] `organizations.list` + org list/get routes (get-by-id 404s cross-tenant)
+- [x] `people.list` + people list/get routes
+- [x] `outreach.list` + `/api/pr/outreach` route
+- [x] `/people/:id/timeline` (tenant-scoped in the route query)
+
+## Remaining before lifting the guard (each: add `WHERE/AND <alias>.tenant_id = $` + pass
+##   `req.prAuth.tenant.id` from the route). All tables already carry `tenant_id`.
+- [ ] `campaigns.list` (`FROM pr_campaigns c` → `WHERE c.tenant_id`)
+- [ ] `tasks.list` (`FROM pr_tasks t` → add `AND t.tenant_id`)
+- [ ] `sources.listSources` (`FROM pr_sources s` → add to WHERE)
+- [ ] `relationships.list` (`FROM pr_org_relationships r` → add to WHERE)
+- [ ] `suppression.list` (+ its `total` count)
+- [ ] `runs.list` (`pr_research_runs`) and the jobs list (`pr_jobs`)
+- [ ] `letters` templates/blocks/versions lists
+- [ ] `review/queue`, `activities`, `audit` list routes
+- [ ] get-by-id + evidence routes: add a `tenant_id === req.prAuth.tenant.id` check (like org/people)
+- [ ] **Dashboard + meta + search** (`src/pr/index.js`): ~12 hard-coded `FROM pr_organizations|
+      pr_people|pr_outreach_messages` aggregate queries — thread `req.prAuth.tenant.id` into each.
+      (These leak COUNTS, not records — lower sensitivity, but required for full isolation.)
+- [ ] EXPORT routes (`/export/*`) — scope by tenant.
+
+## Recommended alternative (lower-risk, scopes everything at once)
+Postgres **Row-Level Security**: `ALTER TABLE … ENABLE ROW LEVEL SECURITY` + a policy
+`USING (tenant_id = current_setting('app.tenant_id')::bigint)` on every entity table, and in
+`db.js` run `SET app.tenant_id = <req tenant>` per transaction. One migration + a per-request
+GUC instead of ~25 query edits — and impossible to forget a filter. Caveat: needs per-request
+connection binding (the current pool would need `SET LOCAL` inside a tx wrapper), and the
+Basic-admin/service callers must resolve to tenant 1.
+
+## Verification gate (before setting PR_MULTI_TENANT_READY=1)
+Create a throwaway tenant 2 + a few tenant-2 rows, log in as a tenant-2 user, and confirm
+EVERY list/get/dashboard/search/export returns ONLY tenant-2 data (0 tenant-1 rows). Only
+then lift the guard.
diff --git a/src/pr/index.js b/src/pr/index.js
index 583cf655..17b17f73 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -496,7 +496,7 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
   }));
 
   // ── Outreach ───────────────────────────────────────────────────────────────
-  app.get('/api/pr/outreach', adminOnly, h(async (req, res) => res.json(await outreach.list(req.query))));
+  app.get('/api/pr/outreach', adminOnly, h(async (req, res) => res.json(await outreach.list({ ...req.query, tenant_id: req.prAuth.tenant.id }))));
   app.get('/api/pr/outreach/:id', adminOnly, h(async (req, res) => {
     const m = await outreach.get(Number(req.params.id));
     if (!m) return res.status(404).json({ error: 'not found' });
diff --git a/src/pr/services/outreach.js b/src/pr/services/outreach.js
index c67f4f7c..67be9acf 100644
--- a/src/pr/services/outreach.js
+++ b/src/pr/services/outreach.js
@@ -101,6 +101,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('m.tenant_id = ?', f.tenant_id); // multi-tenant isolation
   if (f.status) add('m.status = ?::pr_outreach_status', f.status);
   if (f.campaign_id) add('m.campaign_id = ?', Number(f.campaign_id));
   if (f.person_id) add('m.person_id = ?', Number(f.person_id));

← f6e1cf9d PR intel (a): also delegate the no-trailing-slash /admin/pr-  ·  back to Rentv 2026  ·  chore: bump version to 0.21.1 (session close — cycles 16-40 89d927a8 →