UNPKG

@mamoorali295/rbac

Version:

Complete RBAC (Role-Based Access Control) system for Node.js with Express middleware, NestJS integration, GraphQL support, MongoDB & PostgreSQL support, modern admin dashboard, TypeScript support, and dynamic permission management

588 lines (587 loc) 23.5 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RBAC = void 0; const MongoAdapter_1 = require("./adapters/MongoAdapter"); const PostgresAdapter_1 = require("./adapters/PostgresAdapter"); const userrole_controller_1 = require("./mongo/controllers/userrole.controller"); const feature_controller_1 = require("./mongo/controllers/feature.controller"); /** * Role-Based Access Control (RBAC) system for Node.js applications. * Provides middleware functions for authentication, authorization, and user management. * * @class RBACSystem */ class RBACSystem { constructor() { this.config = null; this.initialized = false; this.dbAdapter = null; } /** * Initialize the RBAC system with the provided configuration. * Sets up database connection and creates standard permissions. * * @param {RBACConfig} config - Configuration object containing database connection and optional hooks * @returns {Promise<void>} Promise that resolves when initialization is complete * @throws {Error} If database connection fails or standard permissions cannot be created * * @example * ```typescript * // MongoDB configuration * await RBAC.init({ * database: { * type: 'mongodb', * connection: mongoose.connection * }, * authAdapter: async (req) => ({ user_id: req.user.id }), * defaultRole: 'user' * }); * * // PostgreSQL configuration * await RBAC.init({ * database: { * type: 'postgresql', * connection: pgPool * }, * authAdapter: async (req) => ({ user_id: req.user.id }), * defaultRole: 'user' * }); * * // Legacy MongoDB configuration (deprecated) * await RBAC.init({ * db: mongoose.connection, * authAdapter: async (req) => ({ user_id: req.user.id }), * defaultRole: 'user' * }); * ``` */ init(config) { return __awaiter(this, void 0, void 0, function* () { this.config = config; // Handle legacy configuration format if (config.db && !config.database) { config.database = { type: 'mongodb', connection: config.db }; } // Initialize database adapter based on configuration if (config.database) { switch (config.database.type) { case 'mongodb': this.dbAdapter = new MongoAdapter_1.MongoAdapter(config.database.connection); break; case 'postgresql': this.dbAdapter = new PostgresAdapter_1.PostgresAdapter(config.database.connection); break; default: throw new Error(`Unsupported database type: ${config.database.type}`); } yield this.dbAdapter.init(); } else { throw new Error("Database configuration is required. Please provide either 'database' or 'db' in config."); } this.initialized = true; }); } /** * Ensures that the RBAC system has been initialized before use. * * @private * @throws {Error} If the system has not been initialized */ ensureInitialized() { if (!this.initialized || !this.config || !this.dbAdapter) { throw new Error("RBAC system not initialized. Call RBAC.init(config) first."); } } /** * Automatically infers the feature and permission from the HTTP request. * Uses the first path segment as the feature and HTTP method/path patterns for permission. * * @private * @param {ExpressRequest} req - Express request object * @returns {{feature: string, permission: string}} Inferred feature and permission * * @example * GET /billing/invoices -> { feature: 'billing', permission: 'read' } * POST /billing/create -> { feature: 'billing', permission: 'create' } * DELETE /billing/remove -> { feature: 'billing', permission: 'delete' } */ inferFeatureAndPermission(req) { const pathSegments = req.path.split("/").filter(Boolean); const method = req.method.toLowerCase(); const feature = pathSegments[0] || "default"; let permission = "read"; if (req.path.includes("/sudo")) { permission = "sudo"; } else { switch (method) { case "get": permission = "read"; break; case "post": if (req.path.includes("/delete") || req.path.includes("/remove")) { permission = "delete"; } else if (req.path.includes("/update") || req.path.includes("/edit")) { permission = "update"; } else if (req.path.includes("/create") || req.path.includes("/add")) { permission = "create"; } else { permission = "create"; } break; case "put": case "patch": permission = "update"; break; case "delete": permission = "delete"; break; default: permission = "read"; } } return { feature, permission }; } /** * Extracts user identity from the request using authAdapter or fallback properties. * * @private * @param {ExpressRequest} req - Express request object * @returns {Promise<{user_id: string, email?: string}>} User identity object * @throws {Error} If user identity cannot be determined */ getUserIdentity(req) { return __awaiter(this, void 0, void 0, function* () { this.ensureInitialized(); if (this.config.authAdapter) { return yield this.config.authAdapter(req); } const user_id = req.user_id || req.userId; const email = req.email; if (!user_id) { throw new Error("Unable to determine user identity. Provide authAdapter or attach user_id to request."); } return { user_id, email }; }); } /** * Express middleware that checks if the current user has the required permissions. * Can auto-infer permissions from the route or use explicitly provided options. * * @param {PermissionCheckOptions} options - Optional feature and permission specification * @returns {Function} Express middleware function * * @example * // Auto-inferred permissions * app.get('/billing/invoices', RBAC.checkPermissions(), handler); * * // Explicit permissions * app.post('/admin/reset', RBAC.checkPermissions({ * feature: 'admin', * permission: 'sudo' * }), handler); */ checkPermissions(options = {}) { return (req, res, next) => __awaiter(this, void 0, void 0, function* () { try { this.ensureInitialized(); const { user_id } = yield this.getUserIdentity(req); const { feature, permission } = options.feature && options.permission ? { feature: options.feature, permission: options.permission } : this.inferFeatureAndPermission(req); const user = yield this.dbAdapter.findUserByUserIdWithRole(user_id); if (!user) { return res.status(401).json({ error: "User not found in RBAC system" }); } const role = user.role; if (!role || !role.features) { return res.status(403).json({ error: "No role or features assigned" }); } const userFeature = role.features.find((f) => f.feature.name === feature); if (!userFeature) { return res.status(403).json({ error: `Access denied to feature: ${feature}` }); } const hasPermission = userFeature.permissions.some((p) => p.name === permission); if (!hasPermission) { return res.status(403).json({ error: `Permission denied: ${permission} on ${feature}` }); } next(); } catch (error) { res.status(500).json({ error: "Internal server error" }); } }); } /** * Express middleware that registers a new user in the RBAC system. * Automatically assigns default role if configured and calls registration hooks. * * @param {RegisterUserOptions} options - Optional user data extractor function * @returns {Function} Express middleware function * * @example * // Default extraction from req.body * app.post('/signup', RBAC.registerUser(), handler); * * // Custom user data extraction * app.post('/signup', RBAC.registerUser({ * userExtractor: (req) => ({ * user_id: req.body.id, * name: req.body.fullName, * email: req.body.emailAddress * }) * }), handler); */ registerUser(options = {}) { return (req, res, next) => __awaiter(this, void 0, void 0, function* () { try { this.ensureInitialized(); const userData = options.userExtractor ? options.userExtractor(req) : { user_id: req.body.user_id || req.body.id, name: req.body.name, email: req.body.email, }; if (!userData.user_id) { return res.status(400).json({ error: "user_id is required" }); } const existingUser = yield this.dbAdapter.findUserByUserId(userData.user_id); if (existingUser) { return res.status(409).json({ error: "User already registered in RBAC system" }); } let defaultRoleId = undefined; if (this.config.defaultRole) { const role = yield this.dbAdapter.findRoleByName(this.config.defaultRole); if (role) { defaultRoleId = role.id; } } yield this.dbAdapter.createUser({ user_id: userData.user_id, name: userData.name || "", email: userData.email || "", role_id: defaultRoleId, }); if (this.config.onUserRegister) { yield this.config.onUserRegister(userData); } next(); } catch (error) { res.status(500).json({ error: "Internal server error" }); } }); } /** * Manually register a user in the RBAC system without using middleware. * Useful for programmatic user registration outside of HTTP requests. * * @param {string} user_id - Unique identifier for the user * @param {Object} userData - User data object * @param {string} [userData.name] - User's display name * @param {string} [userData.email] - User's email address * @returns {Promise<void>} Promise that resolves when user is registered * @throws {Error} If user already exists or registration fails * * @example * ```typescript * await RBAC.registerUserManual('user123', { * name: 'John Doe', * email: 'john@example.com' * }); * ``` */ registerUserManual(user_id, userData) { return __awaiter(this, void 0, void 0, function* () { this.ensureInitialized(); const existingUser = yield this.dbAdapter.findUserByUserId(user_id); if (existingUser) { throw new Error("User already exists"); } let defaultRoleId = undefined; if (this.config.defaultRole) { const role = yield this.dbAdapter.findRoleByName(this.config.defaultRole); if (role) { defaultRoleId = role.id; } } yield this.dbAdapter.createUser({ user_id, name: userData.name || "", email: userData.email || "", role_id: defaultRoleId, }); if (this.config.onUserRegister) { yield this.config.onUserRegister(Object.assign({ user_id }, userData)); } }); } /** * Update user information in the RBAC system. * * @param {string} user_id - Unique identifier for the user * @param {Object} userData - User data to update * @param {string} [userData.name] - New display name * @param {string} [userData.email] - New email address * @returns {Promise<void>} Promise that resolves when user is updated * @throws {Error} If user is not found * * @example * ```typescript * await RBAC.updateUser('user123', { * name: 'John Smith', * email: 'johnsmith@example.com' * }); * ``` */ updateUser(user_id, userData) { return __awaiter(this, void 0, void 0, function* () { this.ensureInitialized(); const user = yield this.dbAdapter.findUserByUserId(user_id); if (!user) { throw new Error("User not found"); } const updates = {}; if (userData.name !== undefined) updates.name = userData.name; if (userData.email !== undefined) updates.email = userData.email; yield this.dbAdapter.updateUser(user_id, updates); }); } /** * Assign a role to a user in the RBAC system. * * @param {string} user_id - Unique identifier for the user * @param {string} roleName - Name of the role to assign * @returns {Promise<void>} Promise that resolves when role is assigned * @throws {Error} If user or role is not found * * @example * ```typescript * await RBAC.assignRole('user123', 'admin'); * ``` */ assignRole(user_id, roleName) { return __awaiter(this, void 0, void 0, function* () { this.ensureInitialized(); const user = yield this.dbAdapter.findUserByUserId(user_id); if (!user) { throw new Error("User not found"); } const role = yield this.dbAdapter.findRoleByName(roleName); if (!role) { throw new Error("Role not found"); } yield this.dbAdapter.updateUser(user_id, { role_id: role.id }); if (this.config.onRoleUpdate) { yield this.config.onRoleUpdate({ user_id, role: roleName }); } }); } /** * Get the role name assigned to a user. * * @param {string} user_id - Unique identifier for the user * @returns {Promise<string | null>} Promise that resolves to the role name or null if no role assigned * * @example * ```typescript * const role = await RBAC.getUserRole('user123'); * console.log(role); // 'admin' or null * ``` */ getUserRole(user_id) { return __awaiter(this, void 0, void 0, function* () { this.ensureInitialized(); const user = yield this.dbAdapter.findUserByUserIdWithRole(user_id); if (!user || !user.role) { return null; } return user.role.name; }); } /** * Get all permissions a user has for a specific feature. * * @param {string} user_id - Unique identifier for the user * @param {string} featureName - Name of the feature to check permissions for * @returns {Promise<string[]>} Promise that resolves to an array of permission names * * @example * ```typescript * const permissions = await RBAC.getFeaturePermissions('user123', 'billing'); * console.log(permissions); // ['read', 'create', 'update'] * ``` */ getFeaturePermissions(user_id, featureName) { return __awaiter(this, void 0, void 0, function* () { this.ensureInitialized(); return yield this.dbAdapter.getUserFeaturePermissions(user_id, featureName); }); } /** * Creates an Express router for the RBAC admin dashboard. * Provides a complete web interface for managing users, roles, features, and permissions. * * @param {AdminDashboardOptions} options - Dashboard configuration options * @param {string} options.user - Admin username for authentication * @param {string} options.pass - Admin password for authentication * @param {string} [options.sessionSecret] - Secret key for session encryption * @param {string} [options.sessionName] - Custom session cookie name * @returns {express.Router} Express router instance for the admin dashboard * * @example * ```typescript * app.use('/rbac-admin', RBAC.adminDashboard({ * user: 'admin', * pass: 'secure-password', * sessionSecret: 'your-secret-key', * sessionName: 'rbac.admin.sid' * })); * ``` */ adminDashboard(options) { // Check if Express is available try { require.resolve('express'); require.resolve('express-session'); } catch (error) { throw new Error('Express dependencies not found. Please install express and express-session to use the admin dashboard:\n\n' + 'npm install express express-session\n\n' + 'Or if using yarn:\n' + 'yarn add express express-session'); } const express = require('express'); const session = require('express-session'); const { createAdminRouter } = require('./admin/router'); const { getLoginView } = require('./admin/views/login'); const dashboardRouter = express.Router(); dashboardRouter.use(session({ secret: options.sessionSecret || 'rbac-admin-secret-key', name: options.sessionName || 'rbac.admin.sid', resave: false, saveUninitialized: false, cookie: { secure: false, httpOnly: true, maxAge: 24 * 60 * 60 * 1000, } })); dashboardRouter.use(express.json()); dashboardRouter.use(express.urlencoded({ extended: true })); dashboardRouter.get('/login', (req, res) => { var _a; if ((_a = req.session) === null || _a === void 0 ? void 0 : _a.authenticated) { return res.redirect(req.baseUrl + '/'); } res.send(getLoginView(req.baseUrl)); }); dashboardRouter.post('/login', (req, res) => { const { username, password } = req.body; if (username === options.user && password === options.pass) { req.session.authenticated = true; req.session.username = username; req.session.loginTime = new Date().toISOString(); res.redirect(req.baseUrl + '/'); } else { res.redirect(req.baseUrl + '/login?error=1'); } }); dashboardRouter.post('/logout', (req, res) => { req.session.destroy((err) => { res.redirect(req.baseUrl + '/login'); }); }); dashboardRouter.use((req, res, next) => { var _a; if (req.path === '/login' || req.path.startsWith('/login')) { return next(); } // Check if RBAC is initialized only when accessing protected routes if (!this.initialized || !this.dbAdapter) { return res.status(500).send(` <html> <body style="font-family: Arial, sans-serif; padding: 40px; text-align: center;"> <h1 style="color: #e74c3c;">RBAC System Not Initialized</h1> <p>Please call <code>RBAC.init(config)</code> before accessing the admin dashboard.</p> <p style="color: #7f8c8d;">The dashboard will be available once RBAC is properly initialized.</p> </body> </html> `); } if (!((_a = req.session) === null || _a === void 0 ? void 0 : _a.authenticated)) { return res.redirect(req.baseUrl + '/login'); } next(); }); // Create a lazy-loaded admin router that gets the dbAdapter when needed dashboardRouter.use('/', (req, res, next) => { if (!this.dbAdapter) { return res.status(500).send('RBAC system not initialized'); } const adminRouter = createAdminRouter(this.dbAdapter); adminRouter(req, res, next); }); return dashboardRouter; } /** * Provides access to internal controllers for advanced database operations. * These controllers offer direct database access for complex RBAC operations. * * @returns {Object} Object containing controller instances * @returns {Object} controllers.userRole - User role management controller * @returns {Object} controllers.feature - Feature management controller * * @example * ```typescript * const { userRole, feature } = RBAC.controllers; * const allRoles = await userRole.getAllRoles(); * const allFeatures = await feature.getAllFeatures(); * ``` */ get controllers() { return { userRole: userrole_controller_1.userRoleController, feature: feature_controller_1.featureController, }; } } /** * Singleton instance of the RBACSystem class. * This is the main export that applications should use for all RBAC operations. * * @example * ```typescript * import { RBAC } from '@sheikh295/rbac'; * * // Initialize the system * await RBAC.init({ * db: mongoose.connection, * authAdapter: async (req) => ({ user_id: req.user.id }), * defaultRole: 'user' * }); * * // Use middleware * app.get('/protected', RBAC.checkPermissions(), handler); * app.use('/admin', RBAC.adminDashboard({ user: 'admin', pass: 'secret' })); * ``` */ exports.RBAC = new RBACSystem();