From ee4d3503e55ea1bdf36c70bb76f915976dadcba0 Mon Sep 17 00:00:00 2001 From: Alexander Borg Date: Tue, 23 Sep 2025 13:12:17 +0200 Subject: [PATCH] Fix jwt-token --- server/index.js | 16 +- server/middleware/tenant-limits.js | 354 +++++++++++++++++++++++++++++ server/routes/device.js | 3 +- server/routes/tenant.js | 42 +++- server/services/data-retention.js | 282 +++++++++++++++++++++++ 5 files changed, 694 insertions(+), 3 deletions(-) create mode 100644 server/middleware/tenant-limits.js create mode 100644 server/services/data-retention.js diff --git a/server/index.js b/server/index.js index e830017..51f72b5 100644 --- a/server/index.js +++ b/server/index.js @@ -90,6 +90,16 @@ app.use('/api/', limiter); const ipRestriction = new IPRestrictionMiddleware(); app.use((req, res, next) => ipRestriction.checkIPRestriction(req, res, next)); +// Tenant-specific API rate limiting (for authenticated endpoints) +const { enforceApiRateLimit } = require('./middleware/tenant-limits'); +app.use('/api', (req, res, next) => { + // Apply tenant rate limiting only to authenticated API endpoints + if (req.headers.authorization) { + return enforceApiRateLimit()(req, res, next); + } + next(); +}); + // Make io available to routes app.use((req, res, next) => { req.io = io; @@ -125,7 +135,11 @@ app.use(errorHandler); // Socket.IO initialization initializeSocketHandlers(io); -const PORT = process.env.PORT || 3001; +// Initialize services +const dataRetentionService = require('./services/data-retention'); +console.log('โœ… Data retention service initialized'); + +const PORT = process.env.PORT || 5000; // Migration runner const runMigrations = async () => { diff --git a/server/middleware/tenant-limits.js b/server/middleware/tenant-limits.js new file mode 100644 index 0000000..462d4d4 --- /dev/null +++ b/server/middleware/tenant-limits.js @@ -0,0 +1,354 @@ +/** + * Tenant Limits Middleware + * Enforces tenant subscription limits for users, devices, API rate limits, etc. + */ + +const MultiTenantAuth = require('./multi-tenant-auth'); +const { securityLogger } = require('./logger'); + +// Initialize multi-tenant auth +const multiAuth = new MultiTenantAuth(); + +/** + * Redis-like in-memory store for rate limiting (replace with Redis in production) + */ +class RateLimitStore { + constructor() { + this.store = new Map(); + this.cleanup(); + } + + get(key) { + const data = this.store.get(key); + if (!data) return null; + + // Check if expired + if (Date.now() > data.expires) { + this.store.delete(key); + return null; + } + + return data; + } + + set(key, value, ttlMs) { + this.store.set(key, { + ...value, + expires: Date.now() + ttlMs + }); + } + + delete(key) { + this.store.delete(key); + } + + // Clean up expired entries every minute + cleanup() { + setInterval(() => { + const now = Date.now(); + for (const [key, data] of this.store.entries()) { + if (now > data.expires) { + this.store.delete(key); + } + } + }, 60000); + } +} + +const rateLimitStore = new RateLimitStore(); + +/** + * Get tenant and validate access + */ +async function getTenantFromRequest(req) { + const tenantId = await multiAuth.determineTenant(req); + if (!tenantId) { + throw new Error('Unable to determine tenant'); + } + + const { Tenant } = require('../models'); + const tenant = await Tenant.findOne({ where: { slug: tenantId } }); + if (!tenant) { + throw new Error('Tenant not found'); + } + + return tenant; +} + +/** + * Check if tenant has reached user limit + */ +async function checkUserLimit(tenantId, excludeUserId = null) { + const { User } = require('../models'); + + const whereClause = { tenant_id: tenantId }; + if (excludeUserId) { + whereClause.id = { [require('sequelize').Op.ne]: excludeUserId }; + } + + const userCount = await User.count({ where: whereClause }); + return userCount; +} + +/** + * Check if tenant has reached device limit + */ +async function checkDeviceLimit(tenantId, excludeDeviceId = null) { + const { Device } = require('../models'); + + const whereClause = { tenant_id: tenantId }; + if (excludeDeviceId) { + whereClause.id = { [require('sequelize').Op.ne]: excludeDeviceId }; + } + + const deviceCount = await Device.count({ where: whereClause }); + return deviceCount; +} + +/** + * Middleware to enforce user creation limits + */ +function enforceUserLimit() { + return async (req, res, next) => { + try { + const tenant = await getTenantFromRequest(req); + const maxUsers = tenant.features?.max_users; + + // -1 means unlimited + if (maxUsers === -1) { + return next(); + } + + const currentUserCount = await checkUserLimit(tenant.id); + + if (currentUserCount >= maxUsers) { + securityLogger.logSecurityEvent('warning', 'User creation blocked due to tenant limit', { + action: 'user_creation_limit_exceeded', + tenantId: tenant.id, + tenantSlug: tenant.slug, + currentUserCount, + maxUsers, + userId: req.user?.id, + username: req.user?.username, + ip: req.ip, + userAgent: req.get('User-Agent') + }); + + return res.status(403).json({ + success: false, + message: `Tenant has reached the maximum number of users (${maxUsers}). Please upgrade your subscription or remove existing users.`, + error_code: 'TENANT_USER_LIMIT_EXCEEDED', + current_count: currentUserCount, + max_allowed: maxUsers + }); + } + + next(); + } catch (error) { + console.error('Error checking user limit:', error); + res.status(500).json({ + success: false, + message: 'Failed to validate user limit' + }); + } + }; +} + +/** + * Middleware to enforce device creation limits + */ +function enforceDeviceLimit() { + return async (req, res, next) => { + try { + const tenant = await getTenantFromRequest(req); + const maxDevices = tenant.features?.max_devices; + + // -1 means unlimited + if (maxDevices === -1) { + return next(); + } + + const currentDeviceCount = await checkDeviceLimit(tenant.id); + + if (currentDeviceCount >= maxDevices) { + securityLogger.logSecurityEvent('warning', 'Device creation blocked due to tenant limit', { + action: 'device_creation_limit_exceeded', + tenantId: tenant.id, + tenantSlug: tenant.slug, + currentDeviceCount, + maxDevices, + userId: req.user?.id, + username: req.user?.username, + ip: req.ip, + userAgent: req.get('User-Agent') + }); + + return res.status(403).json({ + success: false, + message: `Tenant has reached the maximum number of devices (${maxDevices}). Please upgrade your subscription or remove existing devices.`, + error_code: 'TENANT_DEVICE_LIMIT_EXCEEDED', + current_count: currentDeviceCount, + max_allowed: maxDevices + }); + } + + next(); + } catch (error) { + console.error('Error checking device limit:', error); + res.status(500).json({ + success: false, + message: 'Failed to validate device limit' + }); + } + }; +} + +/** + * Middleware to enforce API rate limits per tenant + * Tracks actual API requests (not page views) shared among all tenant users + */ +function enforceApiRateLimit(windowMs = 60000) { // Default 1 minute window + return async (req, res, next) => { + try { + const tenant = await getTenantFromRequest(req); + const maxRequests = tenant.features?.api_rate_limit; + + // -1 means unlimited + if (maxRequests === -1) { + return next(); + } + + const key = `api_rate_limit:${tenant.id}`; + const now = Date.now(); + const windowStart = now - windowMs; + + // Get current rate limit data + let rateLimitData = rateLimitStore.get(key); + + if (!rateLimitData) { + rateLimitData = { + requests: [], + totalRequests: 0 + }; + } + + // Remove old requests outside the window + rateLimitData.requests = rateLimitData.requests.filter(timestamp => timestamp > windowStart); + + // Check if limit exceeded + if (rateLimitData.requests.length >= maxRequests) { + const resetTime = rateLimitData.requests[0] + windowMs; + const retryAfter = Math.ceil((resetTime - now) / 1000); + + securityLogger.logSecurityEvent('warning', 'API rate limit exceeded for tenant', { + action: 'api_rate_limit_exceeded', + tenantId: tenant.id, + tenantSlug: tenant.slug, + currentRequests: rateLimitData.requests.length, + maxRequests, + windowMs, + userId: req.user?.id, + username: req.user?.username, + endpoint: req.path, + method: req.method, + ip: req.ip, + userAgent: req.get('User-Agent') + }); + + res.set({ + 'X-RateLimit-Limit': maxRequests, + 'X-RateLimit-Remaining': 0, + 'X-RateLimit-Reset': Math.ceil(resetTime / 1000), + 'Retry-After': retryAfter + }); + + return res.status(429).json({ + success: false, + message: `API rate limit exceeded. Maximum ${maxRequests} requests per ${windowMs/1000} seconds for your tenant.`, + error_code: 'TENANT_API_RATE_LIMIT_EXCEEDED', + max_requests: maxRequests, + window_seconds: windowMs / 1000, + retry_after_seconds: retryAfter + }); + } + + // Add current request + rateLimitData.requests.push(now); + rateLimitData.totalRequests++; + + // Store updated data + rateLimitStore.set(key, rateLimitData, windowMs); + + // Set rate limit headers + res.set({ + 'X-RateLimit-Limit': maxRequests, + 'X-RateLimit-Remaining': Math.max(0, maxRequests - rateLimitData.requests.length), + 'X-RateLimit-Reset': Math.ceil((now + windowMs) / 1000) + }); + + next(); + } catch (error) { + console.error('Error checking API rate limit:', error); + // Don't block on rate limit errors, but log them + next(); + } + }; +} + +/** + * Get tenant limits status + */ +async function getTenantLimitsStatus(tenantId) { + try { + const { Tenant } = require('../models'); + const tenant = await Tenant.findByPk(tenantId); + + if (!tenant) { + throw new Error('Tenant not found'); + } + + const [userCount, deviceCount] = await Promise.all([ + checkUserLimit(tenantId), + checkDeviceLimit(tenantId) + ]); + + const rateLimitKey = `api_rate_limit:${tenantId}`; + const rateLimitData = rateLimitStore.get(rateLimitKey); + const currentApiRequests = rateLimitData ? rateLimitData.requests.length : 0; + + return { + users: { + current: userCount, + limit: tenant.features?.max_users || 0, + unlimited: tenant.features?.max_users === -1 + }, + devices: { + current: deviceCount, + limit: tenant.features?.max_devices || 0, + unlimited: tenant.features?.max_devices === -1 + }, + api_requests: { + current_minute: currentApiRequests, + limit_per_minute: tenant.features?.api_rate_limit || 0, + unlimited: tenant.features?.api_rate_limit === -1 + }, + data_retention: { + days: tenant.features?.data_retention_days || 90, + unlimited: tenant.features?.data_retention_days === -1 + } + }; + } catch (error) { + console.error('Error getting tenant limits status:', error); + throw error; + } +} + +module.exports = { + enforceUserLimit, + enforceDeviceLimit, + enforceApiRateLimit, + getTenantLimitsStatus, + checkUserLimit, + checkDeviceLimit, + rateLimitStore +}; \ No newline at end of file diff --git a/server/routes/device.js b/server/routes/device.js index 80ed114..af5ad5a 100644 --- a/server/routes/device.js +++ b/server/routes/device.js @@ -4,6 +4,7 @@ const Joi = require('joi'); const { validateRequest } = require('../middleware/validation'); const { authenticateToken } = require('../middleware/auth'); const MultiTenantAuth = require('../middleware/multi-tenant-auth'); +const { enforceDeviceLimit } = require('../middleware/tenant-limits'); const { Op } = require('sequelize'); // Dynamic model injection for testing @@ -315,7 +316,7 @@ router.get('/:id', authenticateToken, async (req, res) => { }); // POST /api/devices - Create new device (admin only) -router.post('/', authenticateToken, validateRequest(deviceSchema), async (req, res) => { +router.post('/', authenticateToken, enforceDeviceLimit(), validateRequest(deviceSchema), async (req, res) => { try { const { Device, DroneDetection, Heartbeat, Tenant } = getModels(); diff --git a/server/routes/tenant.js b/server/routes/tenant.js index 96d1f55..0350899 100644 --- a/server/routes/tenant.js +++ b/server/routes/tenant.js @@ -13,6 +13,7 @@ const { authenticateToken } = require('../middleware/auth'); const { requirePermissions, requireAnyPermission, hasPermission } = require('../middleware/rbac'); const MultiTenantAuth = require('../middleware/multi-tenant-auth'); const { securityLogger } = require('../middleware/logger'); +const { enforceUserLimit, enforceDeviceLimit, enforceApiRateLimit, getTenantLimitsStatus } = require('../middleware/tenant-limits'); // Initialize multi-tenant auth const multiAuth = new MultiTenantAuth(); @@ -55,6 +56,45 @@ const upload = multer({ } }); +/** + * GET /tenant/limits + * Get current tenant limits and usage + */ +router.get('/limits', authenticateToken, requirePermissions(['tenant.view']), async (req, res) => { + try { + // Determine tenant from request + const tenantId = await multiAuth.determineTenant(req); + if (!tenantId) { + return res.status(400).json({ + success: false, + message: 'Unable to determine tenant' + }); + } + + const tenant = await Tenant.findOne({ where: { slug: tenantId } }); + if (!tenant) { + return res.status(404).json({ + success: false, + message: 'Tenant not found' + }); + } + + const limitsStatus = await getTenantLimitsStatus(tenant.id); + + res.json({ + success: true, + data: limitsStatus + }); + + } catch (error) { + console.error('Error fetching tenant limits:', error); + res.status(500).json({ + success: false, + message: 'Failed to fetch tenant limits' + }); + } +}); + /** * GET /tenant/info * Get current tenant information @@ -624,7 +664,7 @@ router.get('/users', authenticateToken, requirePermissions(['users.view']), asyn * POST /tenant/users * Create a new user in current tenant (user admin or higher, local auth only) */ -router.post('/users', authenticateToken, requirePermissions(['users.create']), async (req, res) => { +router.post('/users', authenticateToken, requirePermissions(['users.create']), enforceUserLimit(), async (req, res) => { try { // Determine tenant from request const tenantId = await multiAuth.determineTenant(req); diff --git a/server/services/data-retention.js b/server/services/data-retention.js new file mode 100644 index 0000000..5f7f9fa --- /dev/null +++ b/server/services/data-retention.js @@ -0,0 +1,282 @@ +/** + * Data Retention Cleanup Service + * Automatically removes old data based on tenant retention policies + */ + +const cron = require('node-cron'); +const { Op } = require('sequelize'); + +class DataRetentionService { + constructor() { + this.isRunning = false; + this.lastCleanup = null; + + // Run cleanup daily at 2 AM + this.scheduleCleanup(); + } + + /** + * Schedule automatic cleanup + */ + scheduleCleanup() { + // Run at 2:00 AM every day + cron.schedule('0 2 * * *', async () => { + console.log('๐Ÿ—‘๏ธ Starting scheduled data retention cleanup...'); + await this.runCleanup(); + }); + + console.log('๐Ÿ“… Data retention cleanup scheduled for 2:00 AM daily'); + } + + /** + * Run cleanup for all tenants + */ + async runCleanup() { + if (this.isRunning) { + console.log('โš ๏ธ Data retention cleanup already running, skipping...'); + return; + } + + try { + this.isRunning = true; + const startTime = Date.now(); + console.log('๐Ÿ—‘๏ธ Starting data retention cleanup...'); + + const { Tenant } = require('../models'); + const tenants = await Tenant.findAll({ + where: { + is_active: true + } + }); + + let totalCleaned = { + detections: 0, + heartbeats: 0, + logs: 0, + sessions: 0 + }; + + for (const tenant of tenants) { + const retentionDays = tenant.features?.data_retention_days; + + // Skip tenants with unlimited retention (-1) + if (retentionDays === -1) { + console.log(`โญ๏ธ Skipping tenant ${tenant.slug} - unlimited retention`); + continue; + } + + console.log(`๐Ÿงน Cleaning data for tenant ${tenant.slug} (${retentionDays} days retention)`); + + const cleanupResult = await this.cleanupTenantData(tenant.id, retentionDays); + + totalCleaned.detections += cleanupResult.detections; + totalCleaned.heartbeats += cleanupResult.heartbeats; + totalCleaned.logs += cleanupResult.logs; + totalCleaned.sessions += cleanupResult.sessions; + } + + const duration = Date.now() - startTime; + this.lastCleanup = new Date(); + + console.log(`โœ… Data retention cleanup completed in ${duration}ms`); + console.log(`๐Ÿ“Š Cleaned up:`, totalCleaned); + + } catch (error) { + console.error('โŒ Error during data retention cleanup:', error); + } finally { + this.isRunning = false; + } + } + + /** + * Clean up data for a specific tenant + */ + async cleanupTenantData(tenantId, retentionDays) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + + console.log(`๐Ÿ—‘๏ธ Cleaning data older than ${cutoffDate.toISOString()} for tenant ${tenantId}`); + + const { DroneDetection, Heartbeat, ApiLog, Session } = require('../models'); + + const cleanupResults = { + detections: 0, + heartbeats: 0, + logs: 0, + sessions: 0 + }; + + try { + // Clean up drone detections + const deletedDetections = await DroneDetection.destroy({ + where: { + tenant_id: tenantId, + timestamp: { + [Op.lt]: cutoffDate + } + } + }); + cleanupResults.detections = deletedDetections; + + // Clean up heartbeats + const deletedHeartbeats = await Heartbeat.destroy({ + where: { + tenant_id: tenantId, + timestamp: { + [Op.lt]: cutoffDate + } + } + }); + cleanupResults.heartbeats = deletedHeartbeats; + + // Clean up API logs (if exists) + try { + const deletedLogs = await ApiLog.destroy({ + where: { + tenant_id: tenantId, + created_at: { + [Op.lt]: cutoffDate + } + } + }); + cleanupResults.logs = deletedLogs; + } catch (error) { + // ApiLog table might not exist, skip silently + console.log(`โญ๏ธ Skipping API logs cleanup for tenant ${tenantId} (table might not exist)`); + } + + // Clean up old sessions + try { + const deletedSessions = await Session.destroy({ + where: { + tenant_id: tenantId, + updated_at: { + [Op.lt]: cutoffDate + } + } + }); + cleanupResults.sessions = deletedSessions; + } catch (error) { + // Session table might not exist, skip silently + console.log(`โญ๏ธ Skipping sessions cleanup for tenant ${tenantId} (table might not exist)`); + } + + console.log(`โœ… Tenant ${tenantId} cleanup:`, cleanupResults); + + } catch (error) { + console.error(`โŒ Error cleaning data for tenant ${tenantId}:`, error); + } + + return cleanupResults; + } + + /** + * Manual cleanup for a specific tenant + */ + async manualCleanup(tenantId, retentionDays = null) { + const { Tenant } = require('../models'); + const tenant = await Tenant.findByPk(tenantId); + + if (!tenant) { + throw new Error('Tenant not found'); + } + + const days = retentionDays || tenant.features?.data_retention_days; + + if (days === -1) { + throw new Error('Tenant has unlimited retention, manual cleanup requires explicit retention days'); + } + + console.log(`๐Ÿงน Manual cleanup for tenant ${tenant.slug} (${days} days retention)`); + + return await this.cleanupTenantData(tenantId, days); + } + + /** + * Get cleanup statistics + */ + async getCleanupStats() { + const { Tenant, DroneDetection, Heartbeat } = require('../models'); + + const tenants = await Tenant.findAll({ + where: { is_active: true } + }); + + const stats = []; + + for (const tenant of tenants) { + const retentionDays = tenant.features?.data_retention_days; + + if (retentionDays === -1) { + stats.push({ + tenant_id: tenant.id, + tenant_slug: tenant.slug, + retention_days: 'unlimited', + old_detections: 0, + old_heartbeats: 0, + next_cleanup: 'never' + }); + continue; + } + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + + const [oldDetections, oldHeartbeats] = await Promise.all([ + DroneDetection.count({ + where: { + tenant_id: tenant.id, + timestamp: { [Op.lt]: cutoffDate } + } + }), + Heartbeat.count({ + where: { + tenant_id: tenant.id, + timestamp: { [Op.lt]: cutoffDate } + } + }) + ]); + + stats.push({ + tenant_id: tenant.id, + tenant_slug: tenant.slug, + retention_days: retentionDays, + old_detections: oldDetections, + old_heartbeats: oldHeartbeats, + next_cleanup: this.getNextCleanupTime() + }); + } + + return { + last_cleanup: this.lastCleanup, + next_cleanup: this.getNextCleanupTime(), + is_running: this.isRunning, + tenant_stats: stats + }; + } + + /** + * Get next scheduled cleanup time + */ + getNextCleanupTime() { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(2, 0, 0, 0); + + return tomorrow; + } + + /** + * Force immediate cleanup (for testing/admin use) + */ + async forceCleanup() { + console.log('๐Ÿšจ Force cleanup initiated'); + await this.runCleanup(); + } +} + +// Create singleton instance +const dataRetentionService = new DataRetentionService(); + +module.exports = dataRetentionService; \ No newline at end of file