Node.js، RBAC، اور آڈٹ لاگنگ کا استعمال کرتے ہوئے ملٹی کرایہ دار SaaS API کیسے بنایا جائے

ایک ساتھی نے مجھ سے کہا کہ اس کو ڈیبگ کرنے میں مدد کروں جو اس کے SaaS پروجیکٹ مینجمنٹ ٹول میں اجازت کے مسئلے کی طرح لگتا ہے۔ صارفین وہ وسائل دیکھ رہے تھے جو انہوں نے نہیں بنائے تھے۔

میں نے استفسار لاگ باہر نکالا، کچھ ٹھیک ٹھیک کی امید میں۔ ایسا نہیں تھا۔ فہرست کے اختتامی نقطہ میں نہیں ہے۔ tenant_id بالکل بھی فلٹر نہ کریں۔ ڈیٹا بیس میں کوئی بھی کرایہ دار کسی دوسرے کرایہ دار کے منصوبوں کو پڑھ سکتا ہے۔ درخواست میں کوئی غلطی نہیں ہوئی۔ یہ صرف وہی واپس آیا جو وہاں تھا۔

لاپتہ کرایہ دار فلٹرز خرابی کا سبب نہیں بنتے ہیں۔ یہ بغیر کسی شکایت کے غلط ڈیٹا واپس کرتا ہے اور لاگز میں جھنڈا نہیں لگایا جاتا ہے۔ میں نے اسے کچھ ہفتوں تک پروڈکشن میں چلتے دیکھا اس سے پہلے کہ سپورٹ ٹکٹ نے استفسار کے لاگ میں کسی کی نشاندہی کی۔

ایک بار جب یہ منظر عام پر آجائے تو یہ بہت اہم ہے کہ اسے پہلے کون دریافت کرتا ہے۔ وہ صارفین جو اس کو برا سمجھتے ہیں۔ ایک کمپلائنس آڈیٹر جو SOC 2 کے جائزے کے دوران اس کا نوٹس لے رہا ہے ایک مختلف قسم کا مسئلہ ہے۔

شروع سے ہی تنہائی کام کا دن ہے۔ ٹیم نے تعمیل کے جائزے کے بعد اسے بہتر بنانے میں بہت زیادہ وقت صرف کیا، اور اس میں کسی اور کے مقابلے میں زیادہ صارفین کی ای میلز شامل تھیں۔

اسٹیک PostgreSQL کے ساتھ Node.js ہے۔ CRUD آسان حصہ ہے۔ کرایہ داروں کی تنہائی، RBAC، اور آڈٹ لاگنگ پر زیادہ توجہ دینے کی ضرورت ہوتی ہے، اور جہاں وہ چیک اسٹیک میں چلتے ہیں یہ اہم ہے۔ روٹ ہینڈلر کے چلنے سے پہلے میں نے تینوں کو مڈل ویئر میں ڈال دیا۔ ہینڈلرز جو تنہائی کی منطق کو براہ راست نہیں کہتے ہیں وہ غلطی سے تنہائی کی منطق کو نہیں چھوڑ سکتے ہیں۔

شرطیں

جو ہم تعمیر کریں گے۔

ملٹی کرایہ دار ایکسپریس REST API جو لاگو ہوتا ہے:

  1. کرایہ دار کی تنہائی: تمام ڈیٹا بیس سوالات کا دائرہ کار ہے۔ tenant_id ایک توثیق شدہ JWT سے۔ کلائنٹس اس بات پر اثر انداز نہیں ہو سکتے کہ کون سے کرایہ دار استفسار کرتا ہے۔

  2. آر بی اے سی: چار کردار ہیں، ہر ایک عددی سطح کے ساتھ (SuperAdmin سب سے زیادہ ہے، ناظرین سب سے کم ہے)۔ مڈل ویئر ہینڈلر کو پھانسی دینے سے پہلے سطح کی جانچ کرتا ہے۔

  3. آڈٹ لاگنگ: تحریر یا تنقیدی پڑھنا آڈٹ ٹیبل میں ایک قطار کا اضافہ کرتا ہے۔ ایپ بعد میں اس قطار میں ترمیم نہیں کر سکتی۔ ڈیٹا بیس اسے براہ راست نافذ کرتا ہے۔ ایپ میں ایک بگ کی وجہ سے، اگر آپ آڈٹ قطار کو اپ ڈیٹ کرنے کی کوشش کرتے ہیں، تو ڈیٹا بیس اسے مسترد کر دے گا۔ صرف درخواست کی سطح کا نفاذ یہ ضمانتیں فراہم نہیں کر سکتا۔

  4. فی کرایہ دار شرح کی حد: کرایہ دار کے مطابق ریڈیس کی درخواستوں کی تعداد۔ میں نے آئی پی پر مبنی پابندیوں کی وجہ سے کارپوریٹ رول آؤٹ رکے ہوئے دیکھا ہے جب 50 صارفین ایک ہی کارپوریٹ پراکسی کے ذریعے جڑے ہوئے تھے۔

  5. کرایہ دار الگ تھلگ ٹیسٹ: یہ ایک سرشار ٹیسٹ فائل ہے جو ثابت کرتی ہے کہ کرایہ داروں کے درمیان ڈیٹا لیک نہیں کیا جا سکتا۔ اسے CI سے جوڑنے سے اسے بھیجنے سے پہلے ٹوٹا ہوا تنہائی پکڑتا ہے۔

انڈیکس

  1. کثیر کرایہ داری کیسے کام کرتی ہے۔

  2. فن تعمیر کا جائزہ

  3. ڈیٹا بیس اسکیما ڈیزائن

  4. پروجیکٹ کی ترتیبات

  5. کثیر کرایہ داری کے لیے JWT ڈیزائن

  6. توثیق اور RBAC مڈل ویئر

  7. کرایہ دار کے لیے محفوظ ذخیرہ کی پرت

  8. آڈٹ لاگنگ سروس

  9. فی کرایہ دار شرح کی حد

  10. راستے کی تعمیر

  11. کرایہ دار تنہائی کی جانچ

  12. مسئلہ حل

  13. ختم

کثیر کرایہ داری کیسے کام کرتی ہے۔

اس ٹیوٹوریل میں قطار کی سطح کی تنہائی کے ساتھ مشترکہ ڈیٹا بیس: سے tenant_id ہر جدول میں کالم، ہر سوال میں فلٹر۔ ایک ڈیٹا بیس ہر کسی کا ڈیٹا اکٹھا رکھتا ہے۔ درخواست اس بات کا تعین کرتی ہے کہ ہر کرایہ دار کیا دیکھ سکتا ہے۔

دو مختلف طریقے ہیں: فی کرایہ دار اسکیما اور فی کرایہ دار ڈیٹا بیس۔ میں نے ٹیم کے ساتھ کرایہ دار کے مخصوص اسکیموں کے بارے میں بات کی جس کے نتیجے میں ہم نے حقیقی مصنوعات کے مقابلے میں منتقلی کے آلات پر انجینئرنگ کا زیادہ وقت صرف کیا۔ فی کرایہ دار ڈیٹا بیس مضبوط ضمانتیں فراہم کرتے ہیں، لیکن جب بھی کوئی نیا گاہک سائن اپ کرتا ہے تو کنکشن پول پھیلتا ہے۔

نہ ہی سستے ترازو۔ قطار کی سطح کی تنہائی زیادہ تر ٹیموں کی توقع سے کہیں زیادہ بڑھ جاتی ہے۔ کچھ لوگ جن کو میں جانتا ہوں وہ برسوں سے ایسا کر رہے ہیں، اس لیے نہیں کہ طریقہ کار بند کر دیا گیا ہے، بلکہ عام طور پر کچھ ریگولیٹری دباؤ کے تحت۔

ایک چیز جو اس ڈیزائن میں اختیاری نہیں ہے وہ ہے: tenant_id یہ ہمیشہ تصدیق شدہ JWT سے آنا چاہئے۔ درخواست کا باڈی یا URL نہیں۔ صارف کنٹرول کرتا ہے کہ دونوں میں کیا جاتا ہے۔ یہ سرور پر JWT میں لاگ ان ہونے کو کنٹرول نہیں کرتا ہے۔

فن تعمیر کا جائزہ

HTTP Request
     │
     ▼
┌─────────────────────────────────────────┐
│           Express Middleware Stack       │
│                                         │
│  1. Rate Limiter (per tenant_id)        │
│  2. Auth Middleware (verify JWT)        │
│     └─► Extracts: userId, tenantId,    │
│          role, permissions              │
│  3. RBAC Middleware (check role)        │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│           Route Handler                  │
│                                         │
│  1. Call Repository (tenant-safe query) │
│  2. Call Audit Service (fire & forget)  │
│  3. Return response                     │
└──────────────┬──────────────────────────┘
               │
     ┌─────────┴──────────┐
     ▼                    ▼
┌─────────┐        ┌────────────┐
│ Projects│        │ Audit Logs │
│  Table  │        │   Table    │
│(+tenant)│        │(append only│
└─────────┘        └────────────┘

ہینڈلر کی درخواست کی توثیق کرنے سے پہلے شرح کی حد بندی، تصدیق، اور RBAC سبھی کو عمل میں لایا جاتا ہے۔ تحریر آڈٹ سروس سے گزرتی ہے۔ مخزن ہے۔ tenantId سے req.user ہینڈلر کرایہ دار اسکوپنگ کو براہ راست نہیں چھوتا ہے، لہذا اس کے آس پاس کوئی راستہ نہیں ہے۔

ڈیٹا بیس اسکیما ڈیزائن

-- Tenants table
CREATE TABLE tenants (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        VARCHAR(255) NOT NULL,
  plan        VARCHAR(50) NOT NULL DEFAULT 'free', -- 'free', 'pro', 'enterprise'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Users table
CREATE TABLE users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email       VARCHAR(255) NOT NULL,
  role        VARCHAR(50) NOT NULL DEFAULT 'Member', -- 'SuperAdmin','TenantAdmin','Member','Viewer'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- Projects table (example resource — replace with your domain entity)
CREATE TABLE projects (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- Audit log table (append-only — never UPDATE or DELETE rows here)
CREATE TABLE audit_logs (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL,
  user_id     UUID NOT NULL,
  user_email  TEXT NOT NULL,
  user_role   TEXT NOT NULL,        -- role at time of action
  action      TEXT NOT NULL,        -- 'CREATE', 'UPDATE', 'DELETE', 'VIEW'
  resource    TEXT NOT NULL,        -- table name
  resource_id TEXT,
  old_values  JSONB,
  new_values  JSONB,
  ip_address  INET,
  user_agent  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id);
CREATE INDEX idx_audit_created ON audit_logs(created_at DESC);

-- Protect audit log at database level
-- Use a DO block so this runs safely in Docker where app_user is the superuser
DO $$
BEGIN
  IF current_user <> 'app_user' THEN
    REVOKE DELETE, UPDATE ON audit_logs FROM app_user;
  END IF;
END $$;

کہ REVOKE یہ ضروری ہے۔ ایپلیکیشن کیڑے پیدا ہوتے ہیں۔ اگر آپ کے کوڈبیس میں کوئی چیز غلطی سے آڈٹ قطار کو اپ ڈیٹ کرنے کی کوشش کرتی ہے، تو آپ نہیں چاہتے کہ ڈیٹا بیس خود بخود اس کی پیروی کرے اور اسے یکسر مسترد کرے۔

پروجیکٹ کی ترتیبات

mkdir nodejs-multitenant-saas-api
cd nodejs-multitenant-saas-api
npm init -y
npm install express pg jsonwebtoken bcryptjs express-rate-limit rate-limit-redis ioredis dotenv
npm install --save-dev jest supertest

ڈاکر کے ساتھ PostgreSQL اور Redis کے ساتھ شروعات کرنا

مقامی تنصیب کو چھوڑ دیں۔ ایک docker-compose.yml پروجیکٹ روٹ سے PostgreSQL اور Redis دونوں کو لوڈ کریں۔

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: saas_api
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

کہ schema.sql جب کنٹینر پہلی بار شروع ہوتا ہے تو ماؤنٹ خود بخود ایس کیو ایل چلاتا ہے۔ psql کی ضرورت نہیں۔

docker compose up -d

.env منصوبے کی جڑ میں:

DATABASE_URL=postgresql://app_user:app_password@localhost:5432/saas_api
REDIS_URL=redis://localhost:6379
JWT_SECRET=your_random_secret_here
PORT=3000
NODE_ENV=development

درج ذیل درج نہ کریں: JWT_SECRET ہاتھ سے۔ چلا کر ایک بنائیں:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

فائل کی ساخت:

nodejs-multitenant-saas-api/
├── src/
│   ├── middleware/
│   │   ├── auth.js          # JWT verification + tenant extraction
│   │   ├── rbac.js          # Role enforcement
│   │   └── rateLimiter.js   # Per-tenant rate limiting
│   ├── services/
│   │   └── auditService.js  # Append-only audit logger
│   ├── repositories/
│   │   └── projectRepo.js   # Tenant-safe DB queries
│   ├── routes/
│   │   └── projects.js      # Route handlers
│   └── utils/
│       └── token.js         # JWT token generation
├── db/
│   ├── index.js             # PostgreSQL pool
│   └── redis.js             # Redis client
├── docker-compose.yml
├── app.js
├── server.js
└── tests/
    └── tenantIsolation.test.js

بوائلر پلیٹ فائل

چار فائلیں ہیں جن کا ٹیوٹوریل میں تفصیل سے احاطہ نہیں کیا گیا ہے، لیکن آپ کو ٹیسٹ فائلوں کو چلانے کے لیے ان چاروں کی ضرورت ہوگی۔

// db/index.js
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

pool.on('error', (err) => console.error('PostgreSQL error:', err.message));

module.exports = { pool };
// db/redis.js
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

redisClient.on('error', (err) => console.error('Redis error:', err.message));

module.exports = { redisClient };
// app.js
require('dotenv').config();
const express = require('express');
const projectsRouter = require('./src/routes/projects');

const app = express();
app.use(express.json());

app.use('/api/projects', projectsRouter);

// Global error handler — must have 4 parameters to be recognised by Express
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
// server.js
const app = require('./app');

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

bcryptjs لاگ ان اینڈ پوائنٹ میں مناسب پاس ورڈ ہیشنگ کے ساتھ شامل ہے۔ میں یہاں اس حصے کا احاطہ نہیں کروں گا، لیکن GitHub ذخیرہ میں ایک کام کرنے والا حصہ ہے۔ /api/auth/login ہاں

کثیر کرایہ داری کے لیے JWT ڈیزائن

دونوں tenantId اور role JWT پے لوڈ پر جائیں۔ نیچے کی ہر چیز ان دو شعبوں سے پڑھتی ہے۔ اگر آپ اسے غلط طریقے سے درج کرتے ہیں، تو کچھ بھی صحیح طریقے سے کام نہیں کرے گا۔

// Example JWT payload
{
  "userId": "usr_abc123",
  "tenantId": "ten_xyz789",
  "email": "alice@acme.com",
  "role": "TenantAdmin",
  "iat": 1720000000,
  "exp": 1720086400
}

اجازتوں کی ترتیب میں کردار:

  • سپر ایڈمنسٹریٹر: صرف اندرونی ٹیموں کے لیے کراس کرایہ دار تک رسائی

  • کرایہ دار ایڈمنسٹریٹر: کرایہ دار کے اندر مکمل رسائی

  • رکن: اپنے کرایہ دار کے اندر پڑھیں اور لکھیں۔

  • ناظرین: کرایہ دار کے اندر صرف پڑھنے کے لیے

ایک ٹوکن بنائیں (ٹیسٹنگ اور تصدیق کے اختتامی پوائنٹس کے لیے استعمال کیا جاتا ہے):

// src/utils/token.js
const jwt = require('jsonwebtoken');

function generateToken({ userId, tenantId, email, role }) {
  return jwt.sign(
    { userId, tenantId, email, role },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );
}

module.exports = { generateToken };

توثیق اور RBAC مڈل ویئر

توثیق مڈل ویئر دو کام کرتا ہے: JWT دستخط کی تصدیق کریں اور کرایہ دار سیاق و سباق حاصل کریں۔ req.user.

دوسرا حصہ وہ ہے جس پر پورا نظام منحصر ہے۔ تمام سوالات نیچے کی طرف پڑھیں req.user.tenantId. کلائنٹ کو اس بات کا کوئی مطلب نہیں ہے کہ وہ قیمت کیا ہے۔ سرور آپ کو ایک دستخط شدہ ٹوکن بھیجتا ہے، اور سرور واپس پڑھتا ہے جو آپ نے درج کیا ہے۔

// src/middleware/auth.js
const jwt = require('jsonwebtoken');

function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // tenantId always comes from the verified token — never req.body or req.params
    req.user = {
      userId:   decoded.userId,
      tenantId: decoded.tenantId,
      email:    decoded.email,
      role:     decoded.role,
    };

    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { authMiddleware };

RBAC مڈل ویئر ڈیزائن کے لحاظ سے تصدیق سے الگ ہے۔ تصدیق تمام راستوں پر چلتی ہے۔ رول انفورسمنٹ کا اطلاق صرف اس وقت ہوتا ہے جب کم از کم رولز کی ضرورت ہو۔ اجازت یافتہ کرداروں کو منتقل کریں: requireRole() درجہ بندی کے ساتھ صارف کی سطح کا موازنہ کریں۔ کچھ حذف کرنے کی کوشش کرنے والے ناظرین ہینڈلر کے چلنے سے پہلے 403 تک پہنچ جائیں گے۔

// src/middleware/rbac.js
const ROLE_HIERARCHY = {
  SuperAdmin:   4,
  TenantAdmin:  3,
  Member:       2,
  Viewer:       1,
};

// requireRole('TenantAdmin') — user must be TenantAdmin or higher
function requireRole(...roles) {
  return (req, res, next) => {
    const userLevel = ROLE_HIERARCHY[req.user?.role] ?? 0;
    const requiredLevel = Math.min(...roles.map(r => ROLE_HIERARCHY[r] ?? 999));

    if (userLevel < requiredLevel) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: roles,
        current: req.user?.role,
      });
    }

    next();
  };
}

module.exports = { requireRole };

کرایہ دار کے لیے محفوظ ذخیرہ کی پرت

تنہائی یہاں رہتی ہے۔ تمام خصوصیات tenantId سے لیے گئے مطلوبہ دلائل req.user ہینڈلر کی طرف سے. کرایہ دار کی گنجائش فراہم کیے بغیر اسے کال کرنے کا کوئی طریقہ نہیں ہے۔ میں نے ٹیموں کو URL پیرامیٹرز کا استعمال کرتے ہوئے اسے سنبھالنے کی کوشش کرتے دیکھا ہے (GET /api/projects?tenantId=xyz) اور اسے الگ تھلگ کہتے ہیں۔ یہ سچ نہیں ہے۔ ہر کلائنٹ اپنے استفسار کے سلسلے میں جو چاہے بھیجتا ہے۔

// src/repositories/projectRepo.js
const { pool } = require('../../db');

// List all projects for a tenant — tenantId is ALWAYS from the JWT
async function listProjects(tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE tenant_id = $1
     ORDER BY created_at DESC`,
    [tenantId]
  );
  return result.rows;
}

// Get a single project — returns null if it belongs to a different tenant
// NOTE: Returns 404 (not 403) intentionally — don't reveal the resource exists
async function getProject(id, tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE id = $1 AND tenant_id = $2`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

async function createProject({ tenantId, name, description, createdBy }) {
  const result = await pool.query(
    `INSERT INTO projects (tenant_id, name, description, created_by)
     VALUES ($1, $2, $3, $4)
     RETURNING *`,
    [tenantId, name, description, createdBy]
  );
  return result.rows[0];
}

async function updateProject(id, tenantId, updates) {
  const result = await pool.query(
    `UPDATE projects
     SET name = COALESCE($3, name),
         description = COALESCE($4, description),
         updated_at = NOW()
     WHERE id = $1 AND tenant_id = $2
     RETURNING *`,
    [id, tenantId, updates.name, updates.description]
  );
  return result.rows[0] || null;
}

async function deleteProject(id, tenantId) {
  const result = await pool.query(
    `DELETE FROM projects WHERE id = $1 AND tenant_id = $2 RETURNING id`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

module.exports = { listProjects, getProject, createProject, updateProject, deleteProject };

کس چیز پر توجہ دیں۔ getProject اس وقت انجام دیا جاتا ہے جب کرایہ دار A کرایہ دار B کے وسائل حاصل کرنے کی کوشش کرتا ہے۔ استفسار کرایہ دار اے کے ساتھ چلتا ہے۔ tenantId. حالت id = $1 AND tenant_id = $2 کچھ بھی نہیں ملتا۔ null واپس آؤ اور ہینڈلر 404. نہیں 403. 403 کال کرنے والے کو بتاتا ہے کہ وسیلہ موجود ہے لیکن اس تک رسائی حاصل نہیں کی جا سکتی، یہ وہ معلومات ہے جو ان کے پاس نہیں ہونی چاہیے۔

آڈٹ لاگنگ سروس

// src/services/auditService.js
const { pool } = require('../../db');

async function log({
  tenantId,
  userId,
  userEmail,
  userRole,          // role at time of action — roles change, log should not
  action,            // 'CREATE' | 'UPDATE' | 'DELETE' | 'VIEW'
  resource,          // table name
  resourceId = null,
  oldValues = null,
  newValues = null,
  ipAddress = null,
  userAgent = null,
}) {
  const query = `
    INSERT INTO audit_logs
      (tenant_id, user_id, user_email, user_role, action, resource,
       resource_id, old_values, new_values, ip_address, user_agent)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
  `;

  const values = [
    tenantId, userId, userEmail, userRole, action, resource,
    resourceId,
    oldValues  ? JSON.stringify(oldValues)  : null,
    newValues  ? JSON.stringify(newValues)  : null,
    ipAddress,
    userAgent,
  ];

  // Fire-and-forget — audit logging must never block or fail a user request
  pool.query(query, values).catch((err) => {
    console.error('[AuditService] Failed to write log:', err.message);
  });
}

module.exports = { log };

قبضہ userRole لکھنے کا وقت جتنا لگتا ہے اس سے زیادہ اہم ہے۔ حقیقت کے بعد صارف کے کردار بدل جاتے ہیں۔ کسی کی تنزلی اور مراعات منسوخ ہو جاتی ہیں۔ اگر لاگ میں صرف یوزر آئی ڈی ہی ریکارڈ کی گئی ہے، تو اس بات کا سیاق و سباق ختم ہو جائے گا کہ کارروائی کے وقت صارف کے پاس کون سی اجازتیں تھیں۔ اپنے کردار کو محفوظ کریں تاکہ آپ کو ہمیشہ معلوم ہو کہ آپ کس کردار میں کام کر رہے تھے۔

فی کرایہ دار شرح کی حد

SaaS میں IP پر مبنی شرح کو محدود کرنا بند کر دیا گیا ہے۔ انٹرپرائز کے صارفین ایک ہی NAT گیٹ وے کے ذریعے سینکڑوں صارفین کو روٹ کر کے ایک IP ایڈریس کا اشتراک کر سکتے ہیں۔ ایک بھاری کرایہ دار اس پتے پر باقی سب کو محدود کرتا ہے۔

میں نے ٹیموں کو اس مشکل طریقے سے دریافت کرتے ہوئے دیکھا ہے جب ایک انٹرپرائز گاہک اچانک اپنے API میں سیلاب آ جاتا ہے اور دوسرے کرایہ دار بغیر کسی وضاحت کے 429 وصول کرنا شروع کر دیتے ہیں۔ رینج کی پابندیاں: tenant_id اس کے بجائے۔

// src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { redisClient } = require('../../db/redis');

// Rate limits by plan — extend as needed
const PLAN_LIMITS = {
  free:       { max: 100,  windowMs: 15 * 60 * 1000 }, // 100 req / 15 min
  pro:        { max: 500,  windowMs: 15 * 60 * 1000 }, // 500 req / 15 min
  enterprise: { max: 2000, windowMs: 15 * 60 * 1000 }, // 2000 req / 15 min
};

function createTenantRateLimiter(plan = 'free') {
  const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free;

  return rateLimit({
    windowMs: limits.windowMs,
    max: limits.max,
    // Key = tenant_id from verified JWT — NOT the IP address
    keyGenerator: (req) => `tenant:${req.user?.tenantId || req.ip}`,
    store: new RedisStore({
      sendCommand: (...args) => redisClient.call(...args),
    }),
    handler: (req, res) => {
      res.status(429).json({
        error: 'Too many requests',
        retryAfter: Math.ceil(limits.windowMs / 1000),
      });
    },
  });
}

// Default limiter for all API routes
const defaultLimiter = createTenantRateLimiter('free');

module.exports = { defaultLimiter, createTenantRateLimiter };

راستے کی تعمیر

یہ وہ جگہ ہے جہاں سب کچھ جوڑتا ہے۔ تصدیق اور شرح کی حد پورے روٹر پر لاگو ہوتی ہے۔ کردار کی تصدیق انفرادی راستوں پر ہوتی ہے۔ جب بھی تحریر مکمل ہوتی ہے آڈٹ لاگ چلتا ہے۔ tenantId یہ درخواست کے جسم یا URL سے نہیں آتا ہے۔ req.user.tenantId آس پاس کے کوئی راستے نہیں ہیں کیونکہ تصدیق شدہ ٹوکن کے لیے تصدیق مڈل ویئر کے ذریعے قائم کردہ یہ واحد ذریعہ ہے۔

ایکسپریس 4 کی عملی تفصیلات میں سے ایک یہ ہے کہ یہ خود بخود غیر مطابقت پذیر غلطیوں کو نہیں پکڑتا ہے۔ تمام ہینڈلرز اپنی منطق کو ایک کوشش/کیچ میں سمیٹتے ہیں اور ناکامی کو اگلے تک پہنچا دیتے ہیں۔ next(err). اس کو چھوڑنے سے ایک خالی 500 واپس آجائے گا جس میں لاگ انٹریز یا آڈٹ ٹریلز کے بغیر ہینڈل کیے گئے وعدے کے مسترد ہونے کی وجہ سے۔ روٹر کے اوپر دی گئی تفصیل ہمیں یاد دلاتی ہے کہ پیٹرن جان بوجھ کر بنایا گیا ہے۔

// src/routes/projects.js
const express = require('express');
const { authMiddleware }  = require('../middleware/auth');
const { requireRole }     = require('../middleware/rbac');
const { defaultLimiter }  = require('../middleware/rateLimiter');
const audit               = require('../services/auditService');
const repo                = require('../repositories/projectRepo');

const router = express.Router();

// All routes require authentication
router.use(authMiddleware);
router.use(defaultLimiter);

// Express 4 does not catch async errors automatically.
// Every handler must wrap await calls in try/catch and pass errors to next().
// Without this, an unhandled promise rejection silently returns 500
// with no useful message and no audit log entry.

// GET /api/projects — list all (Viewer and above)
router.get('/', async (req, res, next) => {
  try {
    const projects = await repo.listProjects(req.user.tenantId);

    audit.log({
      tenantId:   req.user.tenantId,
      userId:     req.user.userId,
      userEmail:  req.user.email,
      userRole:   req.user.role,
      action:     'VIEW',
      resource:   'projects',
      ipAddress:  req.ip,
      userAgent:  req.headers['user-agent'],
    });

    res.json(projects);
  } catch (err) {
    next(err);
  }
});

// GET /api/projects/:id — single project (Viewer and above)
router.get('/:id', async (req, res, next) => {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });
    res.json(project);
  } catch (err) {
    next(err);
  }
});

// POST /api/projects — create (Member and above)
router.post('/', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) => {
  try {
    const { name, description } = req.body;
    if (!name) return res.status(400).json({ error: 'name is required' });

    const project = await repo.createProject({
      tenantId:    req.user.tenantId,
      name,
      description,
      createdBy:   req.user.userId,
    });

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'CREATE',
      resource:    'projects',
      resourceId:  project.id,
      newValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.status(201).json(project);
  } catch (err) {
    next(err);
  }
});

// PUT /api/projects/:id — update (Member and above)
router.put('/:id', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) => {
  try {
    const oldProject = await repo.getProject(req.params.id, req.user.tenantId);
    if (!oldProject) return res.status(404).json({ error: 'Not found' });

    const updated = await repo.updateProject(req.params.id, req.user.tenantId, req.body);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'UPDATE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   oldProject,
      newValues:   updated,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json(updated);
  } catch (err) {
    next(err);
  }
});

// DELETE /api/projects/:id — TenantAdmin and above only
router.delete('/:id', requireRole('TenantAdmin', 'SuperAdmin'), async (req, res, next) => {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });

    await repo.deleteProject(req.params.id, req.user.tenantId);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'DELETE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json({ deleted: true });
  } catch (err) {
    next(err);
  }
});

module.exports = router;

کرایہ دار تنہائی کی جانچ

اگر آپ قرنطینہ ٹیسٹ چھوڑ دیتے ہیں تو آپ اندھے ہو جائیں گے۔ ایپلیکیشن چلتی رہتی ہے اور کوئی غلطی نہیں ہوتی، لیکن دونوں صارفین ایک دوسرے کا ڈیٹا پڑھ رہے ہیں۔

میں نے اسے مہینوں تک پروڈکشن میں بے پتہ ہوتے دیکھا کیونکہ حقیقت میں کچھ بھی نہیں ٹوٹا۔ غلط ڈیٹا خاموشی سے ظاہر ہوا۔ تمام پل درخواستوں کی خودکار جانچ انہیں جلد پکڑنے کا واحد قابل اعتماد طریقہ ہے۔

// tests/tenantIsolation.test.js
require('dotenv').config();  // must be first — loads DATABASE_URL and REDIS_URL
const request = require('supertest');
const app     = require('../app');
const { generateToken } = require('../src/utils/token');
const { pool }        = require('../db');
const { redisClient } = require('../db/redis');

// Test fixture: two isolated tenants, one project in Tenant B
async function seedTestData() {
  // Clean up from any previous run to avoid unique-constraint failures
  await pool.query(`DELETE FROM projects WHERE name LIKE 'TEST-%'`);
  await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);

  const tenantA = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant A', 'pro') RETURNING id`
  )).rows[0].id;

  const tenantB = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant B', 'pro') RETURNING id`
  )).rows[0].id;

  const userA = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'usera@a.com', 'Member') RETURNING id`,
    [tenantA]
  )).rows[0].id;

  // userB owns the project in Tenant B — satisfies the created_by FK constraint
  const userB = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'userb@b.com', 'Member') RETURNING id`,
    [tenantB]
  )).rows[0].id;

  const projectB = (await pool.query(
    `INSERT INTO projects (tenant_id, name, created_by)
     VALUES ($1, 'TEST-Secret Project', $2) RETURNING id`,
    [tenantB, userB]
  )).rows[0].id;

  return { tenantA, tenantB, userA, projectB };
}

describe('Tenant Isolation', () => {
  let data;

  beforeAll(async () => {
    data = await seedTestData();
  });

  afterAll(async () => {
    await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);
    await pool.end();
    await redisClient.quit();  // close Redis connection so Jest exits cleanly
  });

  test('Tenant A user cannot read Tenant B project', async () => {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,   // ← Tenant A token
      email:    'usera@a.com',
      role:     'Member',
    });

    const res = await request(app)
      .get(`/api/projects/${data.projectB}`)  // ← Tenant B's project ID
      .set('Authorization', `Bearer ${token}`);

    // Must be 404, not 200 or 403
    expect(res.status).toBe(404);
  });

  test('Tenant A user cannot list Tenant B projects', async () => {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'TenantAdmin',
    });

    const res = await request(app)
      .get('/api/projects')
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(200);
    // Response must contain zero Tenant B projects
    const names = res.body.map(p => p.name);
    expect(names).not.toContain('TEST-Secret Project');
  });

  test('Viewer cannot delete a project', async () => {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'Viewer',         // ← Viewer role
    });

    const res = await request(app)
      .delete(`/api/projects/${data.projectB}`)
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(403);
  });
});

ٹیسٹ چلائیں۔

npm test

تین ٹیسٹ، تین حدود کی نشاندہی کی گئی۔ ہر پل کی درخواست پر چلانے کے لیے اسے اپنے CI سے جوڑیں۔ مستقبل کی ری فیکٹرنگ جو خاموشی سے حذف ہو جاتی ہے۔ tenant_id فلٹرز شپنگ سے پہلے پکڑے جائیں گے۔

مسئلہ حل

کرایہ دار A کرایہ دار B کا ڈیٹا دیکھ سکتا ہے۔

ایک سوال میں AND tenant_id = $N I. تمام ریپوزٹری فائلوں کو تلاش کریں۔ SELECT بیانات کو چیک کریں اور ہر ایک کو چیک کریں۔ یہ تقریبا ہمیشہ اس طرح ہے.

403 Forbidden ان راستوں پر جو قابل رسائی ہونا ضروری ہے۔

JWT میں رول سٹرنگ اس سے میل نہیں کھاتی جو یہ ہے۔ requireRole() چیک کر رہا ہے۔ عین مطابق سٹرنگ کے لیے ٹوکن پے لوڈ کو چیک کریں۔ 'member' اور 'Member' یہ ایک ہی چیز نہیں ہے۔ ٹوکن کو jwt.io میں چسپاں کریں اور رول فیلڈ کو براہ راست دیکھیں۔

سپیڈ لیمیٹر کام نہیں کر رہا ہے۔

ایسا لگتا ہے کہ Redis منسلک نہیں ہے۔ لاگ redisClient.status سرور شروع ہونے سے پہلے۔ اگر نہیں readyمحدود کرنے والا یادداشت میں واپس آ گیا ہے۔ اس کا مطلب یہ ہے کہ دوبارہ شروع کرنے سے تمام کاؤنٹرز کو دوبارہ ترتیب دیا جائے گا اور کرایہ دار کے دائرہ کار کی حدود کام کرنا بند کر دیں گی۔

آڈٹ لاگ ٹیبل بہت بڑا ہو رہا ہے۔

یہ متوقع رویہ ہے۔ کلید آڈٹ ٹیبل کو بڑھانا ہے۔ جیسے جیسے سائز بڑھتا ہے، ایک سال سے زیادہ پرانی قطاریں S3 یا Azure Blob پر پہنچائیں اور چھوٹی گرم میزوں سے استفسار جاری رکھیں۔ زیادہ تر تعمیل کے تقاضوں کے لیے لاگز کو کم از کم 12 ماہ تک قابل رسائی ہونا ضروری ہے۔ میز پر ہی DELETE انجام نہ دیں۔

jwt.verify پھینکنا JsonWebTokenError: invalid signature

جس پاس ورڈ نے ٹوکن پر دستخط کیے وہ مماثل نہیں ہے۔ JWT_SECRET ماحول میں آپ اسے چیک کریں۔ یہ اکثر اس وقت ہوتا ہے جب ماحول کو تبدیل کیا جاتا ہے یا جب دوسری سروس کی قدریں مختلف ہوتی ہیں۔ .env. کوئی بھی سروس جسے آپ کال کریں۔ jwt.verify ہمیں اسی راز کی ضرورت ہے۔ کاپی اور دوبارہ ٹائپ نہ کریں۔

ختم

ہم نے جو نظام بنایا ہے: سٹوریج کی قطار کی سطح پر تنہائی، ہینڈلرز کے چلنے سے پہلے کردار کی جانچ، آڈٹ ٹیبلز جنہیں ایپس چھو نہیں سکتی، اور فی کرایہ دار کی شرح کو محدود کرنا۔ بس۔

ٹیسٹ وہ ہے جسے میں اکثر توڑتا ہوں۔ ٹیم آئسولیشن بناتی ہے اور بھیجتی ہے اور حقیقت میں یہ ثابت کرنے کے لیے کچھ نہیں لکھتی ہے کہ کرایہ داروں کے درمیان ڈیٹا لیک نہیں ہو سکتا۔ اس کے بعد استفسار 6 ماہ بعد دوبارہ کیا جاتا ہے اور tenant_id فلٹر خاموشی سے غائب ہو جاتا ہے۔ CI نے اسے پکڑ لیا۔ دستی کوڈ کا جائزہ شاذ و نادر ہی انجام دیا جاتا ہے۔

ایک بار جب آپ کا پروڈکٹ کافی بڑا ہو جاتا ہے، کرایہ دار کے لیے مخصوص اسکیمے آخر کار سامنے آئیں گے۔ لیکن پہلے ایسا نہیں تھا۔ قطار کی سطح کی تنہائی زیادہ تر ٹیموں سے زیادہ پیمانے پر ہینڈل کرتی ہے اور آپریشنل اوور ہیڈ کا صرف ایک حصہ لیتی ہے۔

مکمل ورکنگ کوڈ GitHub پر پایا جا سکتا ہے: nodejs-multitenant-saas-api.

Scroll to Top