mcp-product-manager
Version:
MCP Orchestrator for task and project management with web interface
349 lines • 14.9 kB
JavaScript
// rbac.js - Role-Based Access Control middleware for MCP tool access
// Role definitions with hierarchical permissions
const ROLES = {
ADMIN: {
level: 4,
description: 'Full system access - all tools and operations',
permissions: ['*'] // Wildcard permission
},
ORCHESTRATOR: {
level: 3,
description: 'Orchestration and management operations',
permissions: ['*'] // Temporary - give all permissions to test SPAPS wizard
},
AGENT: {
level: 2,
description: 'Task execution and basic operations',
permissions: [
'tasks:claim',
'tasks:complete',
'tasks:update-status',
'tasks:report-tokens',
'tasks:detail',
'tasks:list',
'projects:status',
'bundles:detail',
'bundles:claim',
'agents:status',
'mcp:execute',
'usage:current'
]
},
VIEWER: {
level: 1,
description: 'Read-only access to system state',
permissions: [
'tasks:list',
'tasks:detail',
'projects:list',
'projects:status',
'bundles:detail',
'agents:list',
'health:check',
'usage:current'
]
},
ANONYMOUS: {
level: 0,
description: 'No access',
permissions: ['health:check']
}
};
// Tool permission matrix mapping endpoints to required permissions
const TOOL_PERMISSIONS = {
// Task operations
'POST /api/tasks': 'tasks:create',
'GET /api/tasks/:project': 'tasks:list',
'GET /api/tasks/detail/:id': 'tasks:detail',
'PATCH /api/tasks/:id': 'tasks:update',
'POST /api/tasks/:id/claim': 'tasks:claim',
'POST /api/tasks/:id/complete': 'tasks:complete',
'POST /api/tasks/:id/review': 'tasks:review',
'PUT /api/tasks/:id/status': 'tasks:update-status',
'PATCH /api/tasks/:id/priority': 'tasks:priority',
'GET /api/tasks/blockers/:project': 'tasks:blockers',
'GET /api/tasks/performance/:project': 'tasks:performance',
'POST /api/tasks/cleanup': 'tasks:cleanup',
'POST /api/tasks/:id/tokens': 'tasks:report-tokens',
// Project operations
'POST /api/projects': 'projects:create',
'GET /api/projects': 'projects:list',
'GET /api/projects/summaries': 'projects:summaries',
'GET /api/projects/:name/status': 'projects:status',
'POST /api/projects/:name/merge': 'projects:merge',
'POST /api/projects/:name/recalibrate': 'projects:recalibrate',
'GET /api/projects/:project/purpose': 'projects:purpose',
'PUT /api/projects/:project/purpose': 'projects:purpose',
'GET /api/projects/:project/checklist': 'projects:checklist',
'PUT /api/projects/:project/checklist': 'projects:checklist',
// Bundle operations
'POST /api/bundles/analyze': 'bundles:analyze',
'POST /api/bundles': 'bundles:create',
'GET /api/bundles/:id': 'bundles:detail',
'POST /api/bundles/:id/claim': 'bundles:claim',
'GET /api/bundles/recommendations': 'bundles:recommendations',
'GET /api/bundles/recommendations/:project': 'bundles:recommendations',
// Orchestrator operations
'POST /api/orchestrator/next-action': 'orchestrator:next-action',
'POST /api/orchestrator/orchestrate': 'orchestrator:orchestrate',
// Agent operations
'POST /api/agents/spawn': 'agents:spawn',
'GET /api/agents': 'agents:list',
'GET /api/agents/:name/status': 'agents:status',
'GET /api/agents/:name/scorecard': 'agents:scorecard',
'GET /api/agents/instructions': 'agents:instructions',
'GET /api/agents/instructions/:category': 'agents:instructions',
// Database operations
'POST /api/database/execute': 'database:execute',
// MCP operations
'POST /api/mcp/execute': 'mcp:execute',
'GET /api/mcp/tools': 'mcp:tools',
// Usage operations
'GET /api/usage/current': 'usage:current',
'GET /api/usage/trends': 'usage:trends',
'GET /api/usage/snapshot': 'usage:snapshot',
'POST /api/usage/snapshot': 'usage:snapshot',
'GET /api/usage/budget': 'usage:budget',
// SPAPS Integration Wizard
'POST /api/spaps/wizard': 'spaps:wizard',
'POST /spaps/wizard': 'spaps:wizard',
// Health check
'GET /api/health': 'health:check',
'GET /api': 'health:check'
};
// Extract role from request (multiple strategies)
const extractRole = (req) => {
// Strategy 1: X-Role header (for testing/development)
if (req.headers['x-role']) {
const role = req.headers['x-role'].toUpperCase();
if (ROLES[role]) {
return role;
}
}
// Strategy 2: MCP tools get ORCHESTRATOR access
const userAgent = req.headers['user-agent'] || '';
if (userAgent.includes('mcp-terminal') || userAgent.includes('claude-mcp') || userAgent.includes('axios')) {
// Check if it's from the MCP orchestrator tools by path
const path = req.path.toLowerCase();
if (path.includes('/orchestrator') ||
path.includes('/bundles') ||
path.includes('/tasks') ||
path.includes('/projects') ||
path.includes('/agents')) {
return 'ORCHESTRATOR';
}
}
// Strategy 2: Authorization header parsing
if (req.headers.authorization) {
const auth = req.headers.authorization;
if (auth.startsWith('Bearer ') && auth.includes('role=')) {
const roleMatch = auth.match(/role=([^&]+)/);
if (roleMatch) {
const role = roleMatch[1].toUpperCase();
if (ROLES[role]) {
return role;
}
}
}
}
// Strategy 3: Query parameter (lowest priority)
if (req.query.role) {
const role = req.query.role.toUpperCase();
if (ROLES[role]) {
return role;
}
}
// Strategy 4: Default based on endpoint patterns
const path = req.path;
const agent = req.headers['x-agent'] || req.body?.agent;
if (agent && agent.includes('orchestrator')) {
return 'ORCHESTRATOR';
}
if (path.includes('/orchestrator/')) {
return 'ORCHESTRATOR';
}
// Only infer AGENT role if there's an agent header/body AND specific agent paths
if (agent && (path.includes('/agents/') || path.includes('/tasks/'))) {
return 'AGENT';
}
// Default to VIEWER for read operations, ANONYMOUS for others
if (req.method === 'GET') {
return 'VIEWER';
}
return 'ANONYMOUS';
};
// Check if role has permission
const hasPermission = (role, requiredPermission) => {
const roleConfig = ROLES[role];
if (!roleConfig) {
return false;
}
// Admin has all permissions
if (roleConfig.permissions.includes('*')) {
return true;
}
// Check for exact permission match
if (roleConfig.permissions.includes(requiredPermission)) {
return true;
}
// Check for wildcard permissions (e.g., "tasks:*" matches "tasks:create")
const [category] = requiredPermission.split(':');
if (roleConfig.permissions.includes(`${category}:*`)) {
return true;
}
return false;
};
// Generate route key from request
const getRouteKey = (req) => {
const method = req.method;
let path = req.path;
// Normalize path to always start with /api for consistency
if (!path.startsWith('/api')) {
path = `/api${path}`;
}
// Known route patterns to match against
const routePatterns = [
// Task routes
{ pattern: /^\/api\/tasks\/([^\/]+)$/, replacement: '/api/tasks/:project' },
{ pattern: /^\/api\/tasks\/detail\/([^\/]+)$/, replacement: '/api/tasks/detail/:id' },
{ pattern: /^\/api\/tasks\/([^\/]+)\/claim$/, replacement: '/api/tasks/:id/claim' },
{ pattern: /^\/api\/tasks\/([^\/]+)\/complete$/, replacement: '/api/tasks/:id/complete' },
{ pattern: /^\/api\/tasks\/([^\/]+)\/review$/, replacement: '/api/tasks/:id/review' },
{ pattern: /^\/api\/tasks\/([^\/]+)\/status$/, replacement: '/api/tasks/:id/status' },
{ pattern: /^\/api\/tasks\/([^\/]+)\/priority$/, replacement: '/api/tasks/:id/priority' },
{ pattern: /^\/api\/tasks\/([^\/]+)\/tokens$/, replacement: '/api/tasks/:id/tokens' },
{ pattern: /^\/api\/tasks\/blockers\/([^\/]+)$/, replacement: '/api/tasks/blockers/:project' },
{ pattern: /^\/api\/tasks\/performance\/([^\/]+)$/, replacement: '/api/tasks/performance/:project' },
// Project routes
{ pattern: /^\/api\/projects\/([^\/]+)\/status$/, replacement: '/api/projects/:name/status' },
{ pattern: /^\/api\/projects\/([^\/]+)\/merge$/, replacement: '/api/projects/:name/merge' },
{ pattern: /^\/api\/projects\/([^\/]+)\/recalibrate$/, replacement: '/api/projects/:name/recalibrate' },
{ pattern: /^\/api\/projects\/([^\/]+)\/purpose$/, replacement: '/api/projects/:project/purpose' },
{ pattern: /^\/api\/projects\/([^\/]+)\/checklist$/, replacement: '/api/projects/:project/checklist' },
// Bundle routes - specific routes first
{ pattern: /^\/api\/bundles\/analyze$/, replacement: '/api/bundles/analyze' },
{ pattern: /^\/api\/bundles\/create$/, replacement: '/api/bundles/create' },
{ pattern: /^\/api\/bundles\/recommendations$/, replacement: '/api/bundles/recommendations' },
{ pattern: /^\/api\/bundles\/recommendations\/([^\/]+)$/, replacement: '/api/bundles/recommendations/:project' },
{ pattern: /^\/api\/bundles\/([^\/]+)\/claim$/, replacement: '/api/bundles/:id/claim' },
{ pattern: /^\/api\/bundles\/([^\/]+)$/, replacement: '/api/bundles/:id' },
// Agent routes
{ pattern: /^\/api\/agents\/([^\/]+)\/status$/, replacement: '/api/agents/:name/status' },
{ pattern: /^\/api\/agents\/([^\/]+)\/scorecard$/, replacement: '/api/agents/:name/scorecard' },
{ pattern: /^\/api\/agents\/instructions\/([^\/]+)$/, replacement: '/api/agents/instructions/:category' },
// RBAC routes
{ pattern: /^\/api\/rbac\/roles\/([^\/]+)$/, replacement: '/api/rbac/roles/:roleName' }
];
// Try to match against known patterns
for (const { pattern, replacement } of routePatterns) {
if (pattern.test(path)) {
return `${method} ${replacement}`;
}
}
// Return normalized path as-is if no pattern matches
return `${method} ${path}`;
};
// Main RBAC middleware
export const rbacMiddleware = (options = {}) => {
const { enforceLogging = true, allowDevelopmentBypass = process.env.NODE_ENV === 'development', defaultRole = 'ANONYMOUS' } = options;
return (req, res, next) => {
// Development bypass
if (allowDevelopmentBypass && req.headers['x-rbac-bypass'] === 'true') {
req.rbac = {
role: 'ADMIN',
permissions: ['*'],
bypassed: true
};
return next();
}
// Extract user role
const role = extractRole(req) || defaultRole;
const roleConfig = ROLES[role];
// Get required permission for this endpoint
const routeKey = getRouteKey(req);
const requiredPermission = TOOL_PERMISSIONS[routeKey];
// Debug logging for SPAPS wizard
if (req.path.includes('/spaps/wizard')) {
console.error(`🔍 SPAPS DEBUG: Route key: "${routeKey}"`);
console.error(`🔍 SPAPS DEBUG: Required permission: "${requiredPermission}"`);
console.error(`🔍 SPAPS DEBUG: Available SPAPS keys:`, Object.keys(TOOL_PERMISSIONS).filter(k => k.includes('spaps')));
}
// Debug logging for route key issues
if (!requiredPermission && req.path.includes('/api/')) {
console.log(`🔍 DEBUG: No permission found for route key: "${routeKey}"`);
console.log(`🔍 Available keys:`, Object.keys(TOOL_PERMISSIONS).filter(k => k.includes(req.path.split('/')[2] || '')));
}
// If no specific permission defined, default based on method
const permission = requiredPermission || (req.method === 'GET' ? 'read' : 'write');
// Check permission
const authorized = hasPermission(role, permission);
// Attach RBAC info to request
req.rbac = {
role,
roleConfig,
requiredPermission: permission,
authorized,
routeKey
};
// Log access attempt if enabled
if (enforceLogging) {
const logData = {
timestamp: new Date().toISOString(),
method: req.method,
path: req.path,
role,
permission,
authorized,
ip: req.ip,
userAgent: req.headers['user-agent'],
agent: req.headers['x-agent'] || req.body?.agent
};
// Store in req for downstream logging middleware
req.rbacLog = logData;
console.log(`🔐 RBAC [${authorized ? '✅' : '❌'}] ${role} → ${req.method} ${req.path} (${permission})`);
}
// Block unauthorized requests
if (!authorized) {
return res.status(403).json({
success: false,
error: 'Access denied',
details: {
message: `Role '${role}' lacks permission '${permission}' for ${req.method} ${req.path}`,
rbac: {
role,
role_level: roleConfig.level,
required_permission: permission,
available_permissions: roleConfig.permissions,
route_key: routeKey
},
suggestion: role === 'ANONYMOUS'
? 'Please provide authentication headers (X-Role, Authorization, or query parameter)'
: `This operation requires elevated permissions. Current role: ${role}`,
help: 'Contact administrator for role assignment or use X-Role header for testing'
}
});
}
next();
};
};
// Utility function to check permissions programmatically
export const checkPermission = (role, permission) => {
return hasPermission(role, permission);
};
// Get role information
export const getRoleInfo = (role) => {
return ROLES[role] || null;
};
// List all available roles
export const listRoles = () => {
return Object.entries(ROLES).map(([name, config]) => ({
name,
level: config.level,
description: config.description,
permissions: config.permissions
}));
};
// Export role constants
export { ROLES, TOOL_PERMISSIONS };
export default rbacMiddleware;
//# sourceMappingURL=rbac.js.map