[object Object]

← back to Rentv

PR intel: multi-tenant + RBAC auth foundation (template for more clients)

f1043d95ed07d950911a1873f9986202f49a09c7 · 2026-08-06 08:43:37 -0700 · Steve

- migration 005: pr_tenants + pr_users (scrypt hash) + pr_sessions; tenant_id
  added to all 17 entity tables + backfilled to tenant 1 (RENTV/Steve Bloom);
  corresponded_at on outreach_messages for the Gmail-correspondence timeline.
- services/auth.js: scrypt password hash (no new dep), server-side sessions,
  role capability matrix — admin(full+users) / developer(full+settings) /
  pro(view+outreach+export) / user(read-only). Tested: login→session→caps,
  bad-pw rejection, tenant-1 backfill verified.

Files touched

Diff

commit f1043d95ed07d950911a1873f9986202f49a09c7
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 08:43:37 2026 -0700

    PR intel: multi-tenant + RBAC auth foundation (template for more clients)
    
    - migration 005: pr_tenants + pr_users (scrypt hash) + pr_sessions; tenant_id
      added to all 17 entity tables + backfilled to tenant 1 (RENTV/Steve Bloom);
      corresponded_at on outreach_messages for the Gmail-correspondence timeline.
    - services/auth.js: scrypt password hash (no new dep), server-side sessions,
      role capability matrix — admin(full+users) / developer(full+settings) /
      pro(view+outreach+export) / user(read-only). Tested: login→session→caps,
      bad-pw rejection, tenant-1 backfill verified.
---
 src/pr/migrations/005_tenants_users_auth.sql |   4 +-
 src/pr/services/auth.js                      | 102 +++++++++++++++++++++++++++
 2 files changed, 104 insertions(+), 2 deletions(-)

diff --git a/src/pr/migrations/005_tenants_users_auth.sql b/src/pr/migrations/005_tenants_users_auth.sql
index 43ab53f8..f26c85da 100644
--- a/src/pr/migrations/005_tenants_users_auth.sql
+++ b/src/pr/migrations/005_tenants_users_auth.sql
@@ -39,9 +39,9 @@ CREATE TABLE IF NOT EXISTS pr_users (
   active        boolean NOT NULL DEFAULT true,
   last_login_at timestamptz,
   created_at    timestamptz NOT NULL DEFAULT now(),
-  updated_at    timestamptz NOT NULL DEFAULT now(),
-  UNIQUE (tenant_id, lower(email))
+  updated_at    timestamptz NOT NULL DEFAULT now()
 );
+CREATE UNIQUE INDEX IF NOT EXISTS pr_users_tenant_email_uidx ON pr_users (tenant_id, lower(email));
 CREATE INDEX IF NOT EXISTS pr_users_tenant_idx ON pr_users (tenant_id);
 CREATE INDEX IF NOT EXISTS pr_users_email_idx  ON pr_users (lower(email));
 
diff --git a/src/pr/services/auth.js b/src/pr/services/auth.js
new file mode 100644
index 00000000..020f0a7b
--- /dev/null
+++ b/src/pr/services/auth.js
@@ -0,0 +1,102 @@
+'use strict';
+// Multi-tenant auth: users (scrypt password hash), server-side sessions, and the
+// role model (admin | developer | pro | user). No external dep — Node's crypto only.
+//
+// Role capabilities (checked via can()):
+//   admin     — everything, incl. user management + tenant settings
+//   developer — everything, incl. API keys / integrations / settings (no user delete)
+//   pro       — view + create/edit contacts + outreach/email + export
+//   user      — read-only (browse contacts/orgs)
+const crypto = require('crypto');
+const { promisify } = require('util');
+const db = require('../db');
+const audit = require('./audit');
+
+const scrypt = promisify(crypto.scrypt);
+const SESSION_TTL_H = Number(process.env.PR_SESSION_TTL_H || 24 * 14); // 14 days
+
+async function hashPassword(pw) {
+  const salt = crypto.randomBytes(16).toString('hex');
+  const dk = await scrypt(String(pw), salt, 32);
+  return salt + ':' + dk.toString('hex');
+}
+async function verifyPassword(pw, stored) {
+  if (!stored || !stored.includes(':')) return false;
+  const [salt, hash] = stored.split(':');
+  const dk = await scrypt(String(pw), salt, 32);
+  const a = Buffer.from(hash, 'hex'); const b = Buffer.from(dk);
+  return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+// ── Role capability matrix ───────────────────────────────────────────────────
+const CAPS = {
+  admin:     new Set(['read', 'write', 'outreach', 'export', 'settings', 'integrations', 'users', 'tenants']),
+  developer: new Set(['read', 'write', 'outreach', 'export', 'settings', 'integrations']),
+  pro:       new Set(['read', 'write', 'outreach', 'export']),
+  user:      new Set(['read']),
+};
+function can(role, cap) { return !!(CAPS[role] && CAPS[role].has(cap)); }
+
+// ── Users ────────────────────────────────────────────────────────────────────
+async function createUser({ tenant_id = 1, email, name, role = 'user', password }, actor) {
+  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');
+  const password_hash = password ? await hashPassword(password) : null;
+  const row = (await db.query(
+    `INSERT INTO pr_users (tenant_id, email, name, role, password_hash)
+     VALUES ($1,$2,$3,$4,$5)
+     ON CONFLICT (tenant_id, lower(email)) DO UPDATE SET name=EXCLUDED.name, role=EXCLUDED.role
+     RETURNING id, tenant_id, email, name, role, active, created_at`,
+    [tenant_id, em, name || null, role, password_hash])).rows[0];
+  await audit.log({ actor: actor || 'system', action: 'user.create', entity_type: 'user', entity_id: row.id, after: { email: em, role, tenant_id } });
+  return row;
+}
+
+async function setPassword(userId, password, actor) {
+  const password_hash = await hashPassword(password);
+  await db.query('UPDATE pr_users SET password_hash=$2 WHERE id=$1', [userId, password_hash]);
+  await audit.log({ actor: actor || 'system', action: 'user.set_password', entity_type: 'user', entity_id: userId });
+}
+
+async function listUsers(tenant_id) {
+  return db.rows('SELECT id, tenant_id, email, name, role, active, last_login_at, created_at FROM pr_users WHERE tenant_id=$1 ORDER BY id', [tenant_id]);
+}
+
+// ── Sessions ─────────────────────────────────────────────────────────────────
+async function login({ email, password, tenant_slug }, ctx = {}) {
+  const em = String(email || '').trim().toLowerCase();
+  const user = await db.one(
+    `SELECT u.* FROM pr_users u JOIN pr_tenants t ON t.id=u.tenant_id
+      WHERE lower(u.email)=$1 AND u.active AND t.status='active'
+        AND ($2::text IS NULL OR t.slug=$2) ORDER BY u.id LIMIT 1`,
+    [em, tenant_slug || null]);
+  if (!user || !(await verifyPassword(password, user.password_hash))) {
+    return { ok: false, error: 'invalid credentials' };
+  }
+  const token = crypto.randomBytes(32).toString('hex');
+  const expires = new Date(Date.now() + SESSION_TTL_H * 3600 * 1000).toISOString();
+  await db.query(
+    `INSERT INTO pr_sessions (token, user_id, tenant_id, expires_at, ip, user_agent) VALUES ($1,$2,$3,$4,$5,$6)`,
+    [token, user.id, user.tenant_id, expires, ctx.ip || null, (ctx.user_agent || '').slice(0, 300)]);
+  await db.query('UPDATE pr_users SET last_login_at=now() WHERE id=$1', [user.id]);
+  await audit.log({ actor: em, action: 'auth.login', entity_type: 'user', entity_id: user.id, detail: { tenant_id: user.tenant_id } });
+  return { ok: true, token, user: pubUser(user) };
+}
+
+async function verifySession(token) {
+  if (!token) return null;
+  const row = await db.one(
+    `SELECT s.token, s.expires_at, u.id, u.tenant_id, u.email, u.name, u.role, u.active, t.slug tenant_slug, t.name tenant_name
+       FROM pr_sessions s JOIN pr_users u ON u.id=s.user_id JOIN pr_tenants t ON t.id=s.tenant_id
+      WHERE s.token=$1 AND s.expires_at > now() AND u.active AND t.status='active'`, [token]);
+  if (!row) return null;
+  db.query('UPDATE pr_sessions SET last_seen_at=now() WHERE token=$1', [token]).catch(() => {});
+  return { user: pubUser(row), tenant: { id: row.tenant_id, slug: row.tenant_slug, name: row.tenant_name } };
+}
+
+async function logout(token) { if (token) await db.query('DELETE FROM pr_sessions WHERE token=$1', [token]); }
+
+function pubUser(u) { return { id: u.id, tenant_id: u.tenant_id, email: u.email, name: u.name, role: u.role }; }
+
+module.exports = { hashPassword, verifyPassword, can, CAPS, createUser, setPassword, listUsers, login, verifySession, logout };

← aa6f0b5e Add public geolocated news map (/map): green<3mo / red older  ·  back to Rentv  ·  harden(deals): Cody gate — parseAmount handles bare $NM/$NB 424c6244 →