Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 149 additions & 2 deletions backend/src/middleware/tenant.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
const Tenant = require('../models/Tenant');
const TenantUser = require('../models/TenantUser');

/**
* Audit trail for cross-tenant access events
*/
const auditLog = [];

function addAuditEntry(entry) {
const logEntry = {
...entry,
timestamp: new Date(),
id: auditLog.length + 1
};
auditLog.push(logEntry);
console.warn('[TENANT-AUDIT]', JSON.stringify(logEntry));
return logEntry;
}

function getAuditLog() {
return auditLog;
}

/**
* Multi-tenant middleware to identify and validate tenant from request
* Sets req.tenant and req.tenantId on every authenticated request.
*/
const tenantMiddleware = async (req, res, next) => {
try {
Expand Down Expand Up @@ -161,9 +182,12 @@ const ensureTenantUser = async (req, res, next) => {
});
}

// JWT payload uses `userId` field (not `_id`) — see tenantService.generateTokens()
const userId = req.user.userId || req.user._id || req.user.sub;

// Check if user belongs to current tenant
const userTenant = await TenantUser.findOne({
_id: req.user._id,
_id: userId,
tenantId: req.tenantId
});

Expand Down Expand Up @@ -250,10 +274,133 @@ const tenantIsolation = (Model) => {
};
};

/**
* Verifies the URL route parameter :tenantId matches the resolved req.tenantId.
* Prevents cross-tenant access through URL manipulation.
*/
const verifyTenantAccess = (paramName = 'tenantId') => {
return (req, res, next) => {
const routeTenantId = req.params[paramName];

if (!routeTenantId) {
return next();
}

if (!req.tenantId) {
return res.status(400).json({
success: false,
message: 'Tenant context not established'
});
}

if (req.tenantId.toString() !== routeTenantId) {
addAuditEntry({
type: 'CROSS_TENANT_DENIED',
severity: 'WARN',
routeParamTenantId: routeTenantId,
resolvedTenantId: req.tenantId.toString(),
userId: req.user?.userId || req.user?._id || null,
path: req.originalUrl,
method: req.method
});

return res.status(403).json({
success: false,
message: 'Cross-tenant access denied',
code: 'CROSS_TENANT_ACCESS_DENIED'
});
}

next();
};
};

/**
* Admin scope override middleware.
* Allows super_admin users to explicitly access a different tenant scope.
* Requires an explicit X-Admin-Override header or query parameter.
* Logs all such access to the audit trail.
*/
const adminScopeOverride = (req, res, next) => {
// Only applies when a super_admin is performing the request
if (!req.user || !req.tenantUser) {
return next();
}

const isSuperAdmin = req.tenantUser.hasRole('super_admin');
if (!isSuperAdmin) {
return next();
}

const overrideTarget = req.headers['x-admin-override'] || req.query.adminTenantId;

if (!overrideTarget) {
return next();
}

// Resolve the target tenant
Tenant.findById(overrideTarget)
.then(targetTenant => {
if (!targetTenant) {
return res.status(404).json({
success: false,
message: 'Admin override target tenant not found'
});
}

// Record the override in audit log
addAuditEntry({
type: 'ADMIN_SCOPE_OVERRIDE',
severity: 'INFO',
adminUserId: req.user.userId || req.user._id,
originalTenantId: req.tenantId.toString(),
targetTenantId: targetTenant._id.toString(),
targetTenantName: targetTenant.name,
path: req.originalUrl,
method: req.method
});

// Switch tenant context to the target tenant
req.tenant = targetTenant;
req.tenantId = targetTenant._id;
req.adminOverrideActive = true;
req.adminOriginalTenantId = req.tenantId.toString();

res.set('X-Admin-Override', 'true');
res.set('X-Original-Tenant-ID', req.tenantId.toString());

next();
})
.catch(err => {
console.error('Admin scope override error:', err);
res.status(500).json({
success: false,
message: 'Failed to process admin override'
});
});
};

/**
* Wraps a MongoDB query filter object with a tenantId scope condition.
* Ensures queries never accidentally omit the tenant filter.
*/
function withTenantScope(queryFilter, tenantId) {
if (!tenantId) {
return queryFilter;
}
const filter = { ...queryFilter, tenantId };
return filter;
}

module.exports = {
tenantMiddleware,
checkResourceLimits,
ensureTenantUser,
requireTenantPermission,
tenantIsolation
tenantIsolation,
verifyTenantAccess,
adminScopeOverride,
withTenantScope,
getAuditLog,
addAuditEntry
};
24 changes: 16 additions & 8 deletions backend/src/services/tenantAnalyticsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class TenantAnalyticsService {
]);

// Growth metrics over time
const growthMetrics = await this.getGrowthMetrics(startDate);
const growthMetrics = await this.getGrowthMetrics(startDate, tenantId);

// Geographic distribution (anonymized sample data)
const geographicData = [
Expand Down Expand Up @@ -231,15 +231,20 @@ class TenantAnalyticsService {

/**
* Get growth metrics over time
* @param {Date} startDate - Start date for the filter range
* @param {string|null} tenantId - Optional tenant ID to scope metrics to a single tenant
*/
async getGrowthMetrics(startDate) {
async getGrowthMetrics(startDate, tenantId = null) {
try {
const tenantMatch = { createdAt: { $gte: startDate } };
if (tenantId) {
tenantMatch._id = new mongoose.Types.ObjectId(tenantId);
}

// Daily tenant growth
const dailyGrowth = await Tenant.aggregate([
{
$match: {
createdAt: { $gte: startDate }
}
$match: tenantMatch
},
{
$group: {
Expand All @@ -257,12 +262,15 @@ class TenantAnalyticsService {
}
]);

const userMatch = { createdAt: { $gte: startDate } };
if (tenantId) {
userMatch.tenantId = new mongoose.Types.ObjectId(tenantId);
}

// Daily user growth
const userGrowth = await TenantUser.aggregate([
{
$match: {
createdAt: { $gte: startDate }
}
$match: userMatch
},
{
$group: {
Expand Down
16 changes: 16 additions & 0 deletions backend/src/services/tenantService.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');

class TenantService {
/**
* Validates that a tenant exists and is accessible.
* Throws if not found.
*/
async validateTenantAccess(tenantId) {
const tenant = await Tenant.findById(tenantId);
if (!tenant) {
throw new Error('Tenant not found');
}
return tenant;
}

/**
* Create a new tenant
*/
Expand Down Expand Up @@ -205,6 +217,7 @@ class TenantService {
*/
async getTenantUsers(tenantId, options = {}) {
try {
await this.validateTenantAccess(tenantId);
const {
page = 1,
limit = 20,
Expand Down Expand Up @@ -264,6 +277,7 @@ class TenantService {
*/
async updateTenantSettings(tenantId, settings) {
try {
await this.validateTenantAccess(tenantId);
const tenant = await Tenant.findByIdAndUpdate(
tenantId,
{ $set: { settings: { ...settings } } },
Expand All @@ -285,6 +299,7 @@ class TenantService {
*/
async updateTenantBranding(tenantId, branding) {
try {
await this.validateTenantAccess(tenantId);
const tenant = await Tenant.findByIdAndUpdate(
tenantId,
{ $set: { branding: { ...branding } } },
Expand Down Expand Up @@ -360,6 +375,7 @@ class TenantService {
*/
async updateTenantStatus(tenantId, status) {
try {
await this.validateTenantAccess(tenantId);
const tenant = await Tenant.findByIdAndUpdate(
tenantId,
{ $set: { status } },
Expand Down
Loading
Loading