526 lines
15 KiB
JavaScript
526 lines
15 KiB
JavaScript
|
|
const logger = require('../utils/logger');
|
||
|
|
const { db } = require('../database/db');
|
||
|
|
|
||
|
|
/**
|
||
|
|
* RBAC (Role-Based Access Control) Middleware
|
||
|
|
*
|
||
|
|
* Implements granular permission checking following the principle of least privilege.
|
||
|
|
* Each user has roles, and each role has specific permissions.
|
||
|
|
* This prevents over-privileged accounts and limits attack surface.
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Define all available permissions in the system
|
||
|
|
const PERMISSIONS = {
|
||
|
|
// User Management
|
||
|
|
'users.view': 'View user list and details',
|
||
|
|
'users.create': 'Create new users',
|
||
|
|
'users.edit': 'Edit existing users',
|
||
|
|
'users.delete': 'Delete users',
|
||
|
|
'users.manage_roles': 'Assign and modify user roles',
|
||
|
|
'users.unlock': 'Unlock locked user accounts',
|
||
|
|
'users.reset_password': 'Reset user passwords',
|
||
|
|
|
||
|
|
// Session Management
|
||
|
|
'sessions.view_own': 'View own active sessions',
|
||
|
|
'sessions.view_all': 'View all user sessions',
|
||
|
|
'sessions.terminate_own': 'Terminate own sessions',
|
||
|
|
'sessions.terminate_any': 'Terminate any user session',
|
||
|
|
'sessions.view_stats': 'View session statistics',
|
||
|
|
|
||
|
|
// Content Management
|
||
|
|
'playlists.view': 'View playlists',
|
||
|
|
'playlists.create': 'Create playlists',
|
||
|
|
'playlists.edit': 'Edit playlists',
|
||
|
|
'playlists.delete': 'Delete playlists',
|
||
|
|
'playlists.import': 'Import M3U files',
|
||
|
|
|
||
|
|
'channels.view': 'View channels',
|
||
|
|
'channels.edit': 'Edit channel details',
|
||
|
|
'channels.upload_logo': 'Upload custom channel logos',
|
||
|
|
'channels.delete_logo': 'Delete custom channel logos',
|
||
|
|
|
||
|
|
'favorites.view': 'View favorites',
|
||
|
|
'favorites.manage': 'Add/remove favorites',
|
||
|
|
|
||
|
|
'history.view_own': 'View own watch history',
|
||
|
|
'history.view_all': 'View all user watch history',
|
||
|
|
'history.delete_own': 'Delete own watch history',
|
||
|
|
'history.delete_any': 'Delete any user watch history',
|
||
|
|
|
||
|
|
// System & Settings
|
||
|
|
'settings.view': 'View application settings',
|
||
|
|
'settings.edit': 'Modify application settings',
|
||
|
|
|
||
|
|
'stats.view': 'View analytics and statistics',
|
||
|
|
'stats.view_detailed': 'View detailed analytics (user activity, etc.)',
|
||
|
|
|
||
|
|
'backup.view': 'View available backups',
|
||
|
|
'backup.create': 'Create backups',
|
||
|
|
'backup.restore': 'Restore from backups',
|
||
|
|
'backup.delete': 'Delete backups',
|
||
|
|
'backup.download': 'Download backups',
|
||
|
|
|
||
|
|
// Security Management
|
||
|
|
'security.view_sessions': 'View security session dashboard',
|
||
|
|
'security.view_csp': 'View CSP violation dashboard',
|
||
|
|
'security.manage_2fa': 'Manage two-factor authentication',
|
||
|
|
'security.view_audit': 'View audit logs',
|
||
|
|
|
||
|
|
// Search & Discovery
|
||
|
|
'search.use': 'Use search functionality',
|
||
|
|
'search.admin': 'Search users and system settings',
|
||
|
|
|
||
|
|
// VPN & Network
|
||
|
|
'vpn.view': 'View VPN settings',
|
||
|
|
'vpn.configure': 'Configure VPN settings',
|
||
|
|
'vpn.connect': 'Connect/disconnect VPN'
|
||
|
|
};
|
||
|
|
|
||
|
|
// Default role definitions with their permissions
|
||
|
|
const DEFAULT_ROLES = {
|
||
|
|
'admin': {
|
||
|
|
name: 'Administrator',
|
||
|
|
description: 'Full system access',
|
||
|
|
permissions: Object.keys(PERMISSIONS), // Admins have all permissions
|
||
|
|
is_system_role: true
|
||
|
|
},
|
||
|
|
'moderator': {
|
||
|
|
name: 'Moderator',
|
||
|
|
description: 'Content management and user support',
|
||
|
|
permissions: [
|
||
|
|
// User viewing (but not management)
|
||
|
|
'users.view',
|
||
|
|
|
||
|
|
// Content management
|
||
|
|
'playlists.view',
|
||
|
|
'playlists.create',
|
||
|
|
'playlists.edit',
|
||
|
|
'playlists.delete',
|
||
|
|
'playlists.import',
|
||
|
|
'channels.view',
|
||
|
|
'channels.edit',
|
||
|
|
'channels.upload_logo',
|
||
|
|
'channels.delete_logo',
|
||
|
|
|
||
|
|
// History management
|
||
|
|
'history.view_all',
|
||
|
|
'history.delete_any',
|
||
|
|
|
||
|
|
// Settings (view only)
|
||
|
|
'settings.view',
|
||
|
|
|
||
|
|
// Statistics
|
||
|
|
'stats.view',
|
||
|
|
'stats.view_detailed',
|
||
|
|
|
||
|
|
// Own sessions
|
||
|
|
'sessions.view_own',
|
||
|
|
'sessions.terminate_own',
|
||
|
|
|
||
|
|
// Own favorites
|
||
|
|
'favorites.view',
|
||
|
|
'favorites.manage',
|
||
|
|
|
||
|
|
// Search
|
||
|
|
'search.use',
|
||
|
|
|
||
|
|
// Own security
|
||
|
|
'security.manage_2fa'
|
||
|
|
],
|
||
|
|
is_system_role: true
|
||
|
|
},
|
||
|
|
'user': {
|
||
|
|
name: 'Regular User',
|
||
|
|
description: 'Standard user with content access',
|
||
|
|
permissions: [
|
||
|
|
// Own playlists
|
||
|
|
'playlists.view',
|
||
|
|
'playlists.create',
|
||
|
|
'playlists.edit',
|
||
|
|
'playlists.delete',
|
||
|
|
'playlists.import',
|
||
|
|
|
||
|
|
// Channels (view and customize)
|
||
|
|
'channels.view',
|
||
|
|
'channels.upload_logo',
|
||
|
|
'channels.delete_logo',
|
||
|
|
|
||
|
|
// Own favorites
|
||
|
|
'favorites.view',
|
||
|
|
'favorites.manage',
|
||
|
|
|
||
|
|
// Own history
|
||
|
|
'history.view_own',
|
||
|
|
'history.delete_own',
|
||
|
|
|
||
|
|
// Own settings
|
||
|
|
'settings.view',
|
||
|
|
'settings.edit',
|
||
|
|
|
||
|
|
// Own sessions
|
||
|
|
'sessions.view_own',
|
||
|
|
'sessions.terminate_own',
|
||
|
|
|
||
|
|
// Search (basic)
|
||
|
|
'search.use',
|
||
|
|
|
||
|
|
// Own security
|
||
|
|
'security.manage_2fa',
|
||
|
|
|
||
|
|
// VPN (if enabled)
|
||
|
|
'vpn.view',
|
||
|
|
'vpn.configure',
|
||
|
|
'vpn.connect'
|
||
|
|
],
|
||
|
|
is_system_role: true
|
||
|
|
},
|
||
|
|
'viewer': {
|
||
|
|
name: 'Viewer',
|
||
|
|
description: 'Read-only access for content viewing',
|
||
|
|
permissions: [
|
||
|
|
// View only
|
||
|
|
'playlists.view',
|
||
|
|
'channels.view',
|
||
|
|
'favorites.view',
|
||
|
|
'favorites.manage',
|
||
|
|
'history.view_own',
|
||
|
|
'history.delete_own',
|
||
|
|
'settings.view',
|
||
|
|
'sessions.view_own',
|
||
|
|
'sessions.terminate_own',
|
||
|
|
'search.use',
|
||
|
|
'security.manage_2fa'
|
||
|
|
],
|
||
|
|
is_system_role: true
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cache for user permissions to reduce database queries
|
||
|
|
* Format: { userId: { permissions: [...], expires: timestamp } }
|
||
|
|
*/
|
||
|
|
const permissionCache = new Map();
|
||
|
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clear permission cache for a specific user
|
||
|
|
*/
|
||
|
|
const clearUserPermissionCache = (userId) => {
|
||
|
|
permissionCache.delete(userId);
|
||
|
|
logger.info(`Permission cache cleared for user ${userId}`);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clear entire permission cache (call after role/permission changes)
|
||
|
|
*/
|
||
|
|
const clearAllPermissionCache = () => {
|
||
|
|
permissionCache.clear();
|
||
|
|
logger.info('All permission cache cleared');
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get user permissions from database with caching
|
||
|
|
*/
|
||
|
|
const getUserPermissions = (userId) => {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
// Check cache first
|
||
|
|
const cached = permissionCache.get(userId);
|
||
|
|
if (cached && cached.expires > Date.now()) {
|
||
|
|
return resolve(cached.permissions);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Query database
|
||
|
|
db.get(
|
||
|
|
`SELECT u.role, r.permissions
|
||
|
|
FROM users u
|
||
|
|
LEFT JOIN roles r ON u.role = r.role_key
|
||
|
|
WHERE u.id = ? AND u.is_active = 1`,
|
||
|
|
[userId],
|
||
|
|
(err, result) => {
|
||
|
|
if (err) {
|
||
|
|
logger.error('Error fetching user permissions:', err);
|
||
|
|
return reject(err);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!result) {
|
||
|
|
return reject(new Error('User not found or inactive'));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse permissions (stored as JSON string)
|
||
|
|
let permissions = [];
|
||
|
|
try {
|
||
|
|
if (result.permissions) {
|
||
|
|
permissions = JSON.parse(result.permissions);
|
||
|
|
} else {
|
||
|
|
// Fallback to default role permissions if not in database
|
||
|
|
const defaultRole = DEFAULT_ROLES[result.role];
|
||
|
|
permissions = defaultRole ? defaultRole.permissions : [];
|
||
|
|
}
|
||
|
|
} catch (parseErr) {
|
||
|
|
logger.error('Error parsing permissions:', parseErr);
|
||
|
|
permissions = [];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cache the result
|
||
|
|
permissionCache.set(userId, {
|
||
|
|
permissions,
|
||
|
|
expires: Date.now() + CACHE_TTL
|
||
|
|
});
|
||
|
|
|
||
|
|
resolve(permissions);
|
||
|
|
}
|
||
|
|
);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if user has a specific permission
|
||
|
|
*/
|
||
|
|
const hasPermission = async (userId, permission) => {
|
||
|
|
try {
|
||
|
|
const permissions = await getUserPermissions(userId);
|
||
|
|
return permissions.includes(permission);
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('Permission check failed:', error);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if user has ANY of the specified permissions
|
||
|
|
*/
|
||
|
|
const hasAnyPermission = async (userId, permissionList) => {
|
||
|
|
try {
|
||
|
|
const permissions = await getUserPermissions(userId);
|
||
|
|
return permissionList.some(p => permissions.includes(p));
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('Permission check failed:', error);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if user has ALL of the specified permissions
|
||
|
|
*/
|
||
|
|
const hasAllPermissions = async (userId, permissionList) => {
|
||
|
|
try {
|
||
|
|
const permissions = await getUserPermissions(userId);
|
||
|
|
return permissionList.every(p => permissions.includes(p));
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('Permission check failed:', error);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Middleware: Require specific permission(s)
|
||
|
|
* Usage: requirePermission('users.view')
|
||
|
|
* Usage: requirePermission(['users.view', 'users.edit']) - requires ANY
|
||
|
|
*/
|
||
|
|
const requirePermission = (requiredPermissions) => {
|
||
|
|
// Normalize to array
|
||
|
|
const permissionList = Array.isArray(requiredPermissions)
|
||
|
|
? requiredPermissions
|
||
|
|
: [requiredPermissions];
|
||
|
|
|
||
|
|
return async (req, res, next) => {
|
||
|
|
if (!req.user || !req.user.userId) {
|
||
|
|
return res.status(401).json({
|
||
|
|
error: 'Authentication required',
|
||
|
|
code: 'AUTH_REQUIRED'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Bypass permission check for user ID 1 (first admin) or users with role 'admin'
|
||
|
|
if (req.user.userId === 1 || req.user.role === 'admin') {
|
||
|
|
req.userPermissions = Object.keys(PERMISSIONS); // Grant all permissions
|
||
|
|
return next();
|
||
|
|
}
|
||
|
|
|
||
|
|
const userPermissions = await getUserPermissions(req.user.userId);
|
||
|
|
|
||
|
|
// Check if user has any of the required permissions
|
||
|
|
const hasAccess = permissionList.some(p => userPermissions.includes(p));
|
||
|
|
|
||
|
|
if (!hasAccess) {
|
||
|
|
logger.warn(`Access denied: User ${req.user.userId} lacks permission(s): ${permissionList.join(', ')}`);
|
||
|
|
return res.status(403).json({
|
||
|
|
error: 'Insufficient permissions',
|
||
|
|
code: 'INSUFFICIENT_PERMISSIONS',
|
||
|
|
required: permissionList,
|
||
|
|
details: 'You do not have the required permissions to perform this action'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Attach user permissions to request for further checks
|
||
|
|
req.userPermissions = userPermissions;
|
||
|
|
next();
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('Permission check error:', error);
|
||
|
|
res.status(500).json({
|
||
|
|
error: 'Permission validation failed',
|
||
|
|
code: 'PERMISSION_CHECK_FAILED'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Middleware: Require ALL specified permissions
|
||
|
|
* Usage: requireAllPermissions(['users.view', 'users.edit'])
|
||
|
|
*/
|
||
|
|
const requireAllPermissions = (permissionList) => {
|
||
|
|
return async (req, res, next) => {
|
||
|
|
if (!req.user || !req.user.userId) {
|
||
|
|
return res.status(401).json({
|
||
|
|
error: 'Authentication required',
|
||
|
|
code: 'AUTH_REQUIRED'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Bypass permission check for user ID 1 (first admin) or users with role 'admin'
|
||
|
|
if (req.user.userId === 1 || req.user.role === 'admin') {
|
||
|
|
req.userPermissions = Object.keys(PERMISSIONS); // Grant all permissions
|
||
|
|
return next();
|
||
|
|
}
|
||
|
|
|
||
|
|
const userPermissions = await getUserPermissions(req.user.userId);
|
||
|
|
|
||
|
|
// Check if user has ALL required permissions
|
||
|
|
const hasAccess = permissionList.every(p => userPermissions.includes(p));
|
||
|
|
|
||
|
|
if (!hasAccess) {
|
||
|
|
logger.warn(`Access denied: User ${req.user.userId} lacks all permissions: ${permissionList.join(', ')}`);
|
||
|
|
return res.status(403).json({
|
||
|
|
error: 'Insufficient permissions',
|
||
|
|
code: 'INSUFFICIENT_PERMISSIONS',
|
||
|
|
required: permissionList,
|
||
|
|
details: 'You do not have all the required permissions to perform this action'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
req.userPermissions = userPermissions;
|
||
|
|
next();
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('Permission check error:', error);
|
||
|
|
res.status(500).json({
|
||
|
|
error: 'Permission validation failed',
|
||
|
|
code: 'PERMISSION_CHECK_FAILED'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initialize roles table and seed default roles
|
||
|
|
*/
|
||
|
|
const initializeRoles = () => {
|
||
|
|
db.serialize(() => {
|
||
|
|
// Create roles table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS roles (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
role_key TEXT UNIQUE NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
description TEXT,
|
||
|
|
permissions TEXT NOT NULL,
|
||
|
|
is_system_role BOOLEAN DEFAULT 0,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
|
|
)`, (err) => {
|
||
|
|
if (err) {
|
||
|
|
logger.error('Failed to create roles table:', err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Seed default roles
|
||
|
|
Object.entries(DEFAULT_ROLES).forEach(([roleKey, roleData]) => {
|
||
|
|
db.run(
|
||
|
|
`INSERT OR IGNORE INTO roles (role_key, name, description, permissions, is_system_role)
|
||
|
|
VALUES (?, ?, ?, ?, ?)`,
|
||
|
|
[
|
||
|
|
roleKey,
|
||
|
|
roleData.name,
|
||
|
|
roleData.description,
|
||
|
|
JSON.stringify(roleData.permissions),
|
||
|
|
roleData.is_system_role ? 1 : 0
|
||
|
|
],
|
||
|
|
(err) => {
|
||
|
|
if (err) {
|
||
|
|
logger.error(`Failed to seed role ${roleKey}:`, err);
|
||
|
|
} else {
|
||
|
|
logger.info(`✓ Role seeded: ${roleKey}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create permission audit log table
|
||
|
|
db.run(`CREATE TABLE IF NOT EXISTS permission_audit_log (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
user_id INTEGER NOT NULL,
|
||
|
|
action TEXT NOT NULL,
|
||
|
|
target_type TEXT NOT NULL,
|
||
|
|
target_id INTEGER,
|
||
|
|
old_value TEXT,
|
||
|
|
new_value TEXT,
|
||
|
|
ip_address TEXT,
|
||
|
|
user_agent TEXT,
|
||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
|
|
)`, (err) => {
|
||
|
|
if (err) {
|
||
|
|
logger.error('Failed to create permission_audit_log table:', err);
|
||
|
|
} else {
|
||
|
|
logger.info('✓ Permission audit log table created');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Log permission-related actions for audit trail
|
||
|
|
*/
|
||
|
|
const logPermissionAction = (userId, action, targetType, targetId, oldValue, newValue, req) => {
|
||
|
|
const ipAddress = req?.ip || req?.connection?.remoteAddress || 'unknown';
|
||
|
|
const userAgent = req?.headers['user-agent'] || 'unknown';
|
||
|
|
|
||
|
|
db.run(
|
||
|
|
`INSERT INTO permission_audit_log
|
||
|
|
(user_id, action, target_type, target_id, old_value, new_value, ip_address, user_agent)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
|
|
[
|
||
|
|
userId,
|
||
|
|
action,
|
||
|
|
targetType,
|
||
|
|
targetId || null,
|
||
|
|
oldValue ? JSON.stringify(oldValue) : null,
|
||
|
|
newValue ? JSON.stringify(newValue) : null,
|
||
|
|
ipAddress,
|
||
|
|
userAgent
|
||
|
|
],
|
||
|
|
(err) => {
|
||
|
|
if (err) {
|
||
|
|
logger.error('Failed to log permission action:', err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
PERMISSIONS,
|
||
|
|
DEFAULT_ROLES,
|
||
|
|
requirePermission,
|
||
|
|
requireAllPermissions,
|
||
|
|
hasPermission,
|
||
|
|
hasAnyPermission,
|
||
|
|
hasAllPermissions,
|
||
|
|
getUserPermissions,
|
||
|
|
clearUserPermissionCache,
|
||
|
|
clearAllPermissionCache,
|
||
|
|
initializeRoles,
|
||
|
|
logPermissionAction
|
||
|
|
};
|