mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
feat(rbac): add Deployer & Node Admin roles with scoped permissions (Team Pro) (#253)
* feat(rbac): add Deployer & Node Admin roles with scoped permissions (Team Pro) Add intermediate RBAC roles gated to Team Pro tier: - Deployer: can deploy/restart/stop/start stacks but cannot edit compose files, delete stacks, or access system settings - Node Admin: full stack and node management within scope, no system settings access - Scoped permissions: assign roles per-stack or per-node for fine-grained access control - Permission engine with checkPermission/requirePermission guards replacing requireAdmin on stack/node routes - Frontend can() function with /api/permissions/me endpoint for client-side permission checks - User management UI updated with 4-role selector and scoped permission editor - Documentation updated with permission matrix, scoped permission docs, and screenshots * fix(rbac): remove unused RoleAssignment import to fix lint error
This commit is contained in:
+248
-36
@@ -18,7 +18,7 @@ import httpProxy from 'http-proxy';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import path from 'path';
|
||||
import { HostTerminalService } from './services/HostTerminalService';
|
||||
import { DatabaseService, Node, AuthProvider, ScheduledTask } from './services/DatabaseService';
|
||||
import { DatabaseService, Node, AuthProvider, ScheduledTask, UserRole, ResourceType } from './services/DatabaseService';
|
||||
import { NotificationService } from './services/NotificationService';
|
||||
import { MonitorService } from './services/MonitorService';
|
||||
import { ImageUpdateService } from './services/ImageUpdateService';
|
||||
@@ -204,7 +204,7 @@ app.use(nodeContextMiddleware);
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: { username: string; role: 'admin' | 'viewer' };
|
||||
user?: { username: string; role: UserRole; userId: number };
|
||||
nodeId: number;
|
||||
apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin';
|
||||
}
|
||||
@@ -254,19 +254,20 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
}
|
||||
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
|
||||
const creator = DatabaseService.getInstance().getUserById(apiToken.user_id);
|
||||
const roleMap: Record<string, 'admin' | 'viewer'> = {
|
||||
const roleMap: Record<string, UserRole> = {
|
||||
'read-only': 'viewer',
|
||||
'deploy-only': 'admin',
|
||||
'full-admin': 'admin',
|
||||
};
|
||||
req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer' };
|
||||
req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer', userId: apiToken.user_id };
|
||||
req.apiTokenScope = apiToken.scope as 'read-only' | 'deploy-only' | 'full-admin';
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens.
|
||||
req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as 'admin' | 'viewer') || 'admin' };
|
||||
const dbUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
|
||||
req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as UserRole) || 'admin', userId: dbUser?.id ?? 0 };
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('[Auth] Token validation failed:', (err as Error).message);
|
||||
@@ -806,6 +807,80 @@ const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// --- Scoped RBAC Permission Engine (Team Pro) ---
|
||||
|
||||
type PermissionAction =
|
||||
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
|
||||
| 'node:read' | 'node:manage'
|
||||
| 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks'
|
||||
| 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries';
|
||||
|
||||
const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
|
||||
admin: [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
'system:settings', 'system:users', 'system:license', 'system:webhooks',
|
||||
'system:tokens', 'system:console', 'system:audit', 'system:registries',
|
||||
],
|
||||
'node-admin': [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
],
|
||||
deployer: [
|
||||
'stack:read', 'stack:deploy',
|
||||
],
|
||||
viewer: [
|
||||
'stack:read', 'node:read',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Core permission resolver. Checks if the current user can perform `action` on an optional resource.
|
||||
* 1. Admin → always true (backward compat)
|
||||
* 2. Check global role permissions
|
||||
* 3. If resource specified AND Team Pro → check scoped role_assignments
|
||||
*/
|
||||
function checkPermission(
|
||||
req: Request,
|
||||
action: PermissionAction,
|
||||
resourceType?: ResourceType,
|
||||
resourceId?: string,
|
||||
): boolean {
|
||||
if (!req.user) return false;
|
||||
|
||||
const globalRole = req.user.role;
|
||||
|
||||
// Admins always have full access
|
||||
if (globalRole === 'admin') return true;
|
||||
|
||||
// Check if the user's global role grants this action
|
||||
if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true;
|
||||
|
||||
// Scoped assignments only apply when a resource is specified and license is Team Pro
|
||||
if (!resourceType || !resourceId) return false;
|
||||
if (LicenseService.getInstance().getVariant() !== 'team') return false;
|
||||
|
||||
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
|
||||
for (const assignment of assignments) {
|
||||
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Generic permission guard — sends 403 if denied. */
|
||||
function requirePermission(
|
||||
req: Request,
|
||||
res: Response,
|
||||
action: PermissionAction,
|
||||
resourceType?: ResourceType,
|
||||
resourceId?: string,
|
||||
): boolean {
|
||||
if (checkPermission(req, action, resourceType, resourceId)) return true;
|
||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scope enforcement for API tokens - restricts which endpoints a token can reach.
|
||||
const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [
|
||||
/^\/api\/stacks\/[^/]+\/deploy$/,
|
||||
@@ -1690,10 +1765,12 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
|
||||
res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
return;
|
||||
}
|
||||
if (role !== 'admin' && role !== 'viewer') {
|
||||
res.status(400).json({ error: 'Role must be "admin" or "viewer"' });
|
||||
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
|
||||
if (!validRoles.includes(role)) {
|
||||
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' });
|
||||
return;
|
||||
}
|
||||
if ((role === 'deployer' || role === 'node-admin') && !requireTeamPro(req, res)) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getUserByUsername(username);
|
||||
@@ -1708,7 +1785,7 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
|
||||
res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxAdmins} admin account${seatLimits.maxAdmins === 1 ? '' : 's'}. Upgrade to Team Pro for unlimited accounts.` });
|
||||
return;
|
||||
}
|
||||
if (role === 'viewer' && seatLimits.maxViewers !== null && db.getViewerCount() >= seatLimits.maxViewers) {
|
||||
if (role !== 'admin' && seatLimits.maxViewers !== null && db.getNonAdminCount() >= seatLimits.maxViewers) {
|
||||
res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxViewers} viewer account${seatLimits.maxViewers === 1 ? '' : 's'}. Upgrade to Team Pro for unlimited accounts.` });
|
||||
return;
|
||||
}
|
||||
@@ -1755,17 +1832,19 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P
|
||||
}
|
||||
|
||||
if (role !== undefined) {
|
||||
if (role !== 'admin' && role !== 'viewer') {
|
||||
res.status(400).json({ error: 'Role must be "admin" or "viewer"' });
|
||||
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
|
||||
if (!validRoles.includes(role)) {
|
||||
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' });
|
||||
return;
|
||||
}
|
||||
if ((role === 'deployer' || role === 'node-admin') && !requireTeamPro(req, res)) return;
|
||||
// Prevent demoting yourself
|
||||
if (user.username === req.user!.username && role !== user.role) {
|
||||
res.status(400).json({ error: 'Cannot change your own role' });
|
||||
return;
|
||||
}
|
||||
// Prevent removing the last admin
|
||||
if (user.role === 'admin' && role === 'viewer' && db.getAdminCount() <= 1) {
|
||||
if (user.role === 'admin' && role !== 'admin' && db.getAdminCount() <= 1) {
|
||||
res.status(400).json({ error: 'Cannot demote the only admin user' });
|
||||
return;
|
||||
}
|
||||
@@ -1823,6 +1902,137 @@ app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response)
|
||||
}
|
||||
});
|
||||
|
||||
// --- Scoped Role Assignments (Team Pro) ---
|
||||
|
||||
app.get('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireTeamPro(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
if (!db.getUser(userId)) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
const assignments = db.getAllRoleAssignments(userId);
|
||||
res.json(assignments);
|
||||
} catch (error) {
|
||||
console.error('[Roles] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch role assignments' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireTeamPro(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const { role, resource_type, resource_id } = req.body;
|
||||
|
||||
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
|
||||
if (!validRoles.includes(role)) {
|
||||
res.status(400).json({ error: 'Invalid role' });
|
||||
return;
|
||||
}
|
||||
const validResourceTypes: ResourceType[] = ['stack', 'node'];
|
||||
if (!validResourceTypes.includes(resource_type)) {
|
||||
res.status(400).json({ error: 'Invalid resource type' });
|
||||
return;
|
||||
}
|
||||
if (!resource_id || typeof resource_id !== 'string') {
|
||||
res.status(400).json({ error: 'resource_id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
if (!db.getUser(userId)) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id });
|
||||
res.status(201).json({ id, user_id: userId, role, resource_type, resource_id });
|
||||
} catch (err: unknown) {
|
||||
if ((err as Error).message?.includes('UNIQUE constraint')) {
|
||||
res.status(409).json({ error: 'This role assignment already exists' });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Roles] Create error:', error);
|
||||
res.status(500).json({ error: 'Failed to add role assignment' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id/roles/:assignId', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireTeamPro(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const assignId = parseInt(req.params.assignId as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const assignment = db.getRoleAssignmentById(assignId);
|
||||
if (!assignment || assignment.user_id !== userId) {
|
||||
res.status(404).json({ error: 'Role assignment not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.deleteRoleAssignment(assignId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Roles] Delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete role assignment' });
|
||||
}
|
||||
});
|
||||
|
||||
// Return the current user's effective permissions (any authenticated user)
|
||||
app.get('/api/permissions/me', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const globalRole = req.user.role;
|
||||
const globalPermissions = ROLE_PERMISSIONS[globalRole] || [];
|
||||
const assignments = db.getAllRoleAssignments(req.user.userId);
|
||||
|
||||
const scopedPermissions: Record<string, PermissionAction[]> = {};
|
||||
for (const a of assignments) {
|
||||
const key = `${a.resource_type}:${a.resource_id}`;
|
||||
const perms = ROLE_PERMISSIONS[a.role] || [];
|
||||
const existing = scopedPermissions[key] || [];
|
||||
scopedPermissions[key] = [...new Set([...existing, ...perms])];
|
||||
}
|
||||
|
||||
res.json({
|
||||
globalRole,
|
||||
globalPermissions,
|
||||
scopedPermissions,
|
||||
isTeamPro: LicenseService.getInstance().getVariant() === 'team',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remote Node HTTP Proxy - single global instance.
|
||||
// Previously, createProxyMiddleware was called inside the request handler on every API
|
||||
// call, spawning a new proxy instance (and http-proxy server) each time. This caused:
|
||||
@@ -2231,9 +2441,9 @@ app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
@@ -2377,9 +2587,9 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
@@ -2411,7 +2621,7 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePermission(req, res, 'stack:create')) return;
|
||||
try {
|
||||
const { stackName } = req.body;
|
||||
if (!stackName || typeof stackName !== 'string') {
|
||||
@@ -2432,8 +2642,8 @@ app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.delete('/api/stacks/:name', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.name as string;
|
||||
if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return;
|
||||
try {
|
||||
// Stage 1: Tell Docker to clean up ghost networks/containers
|
||||
try {
|
||||
@@ -2511,9 +2721,9 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
|
||||
|
||||
// End of legacy container routes
|
||||
app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
|
||||
res.json({ message: 'Deployed successfully' });
|
||||
@@ -2525,9 +2735,9 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) =>
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined);
|
||||
res.json({ status: 'Command started' });
|
||||
} catch (error) {
|
||||
@@ -2536,9 +2746,9 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
@@ -2555,9 +2765,9 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) =
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
@@ -2574,9 +2784,9 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
@@ -2594,9 +2804,9 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) =>
|
||||
|
||||
// Update stack: pull images and recreate containers
|
||||
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic);
|
||||
res.json({ status: 'Update completed' });
|
||||
@@ -2608,10 +2818,10 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) =>
|
||||
|
||||
// Manual rollback endpoint (Pro + Admin)
|
||||
app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
||||
const backupInfo = await fsSvc.getBackupInfo(stackName);
|
||||
if (!backupInfo.exists) {
|
||||
@@ -4098,7 +4308,7 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
|
||||
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePermission(req, res, 'node:manage')) return;
|
||||
try {
|
||||
const { name, type, compose_dir, is_default, api_url, api_token } = req.body;
|
||||
|
||||
@@ -4143,9 +4353,10 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => {
|
||||
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const nodeId = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeId)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const id = parseInt(nodeId);
|
||||
const updates = req.body;
|
||||
|
||||
if (updates.api_url !== undefined && updates.api_url !== '') {
|
||||
@@ -4173,9 +4384,10 @@ app.delete('/api/nodes/:id', async (req: Request, res: Response) => {
|
||||
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const id = parseInt(nodeIdParam);
|
||||
DatabaseService.getInstance().deleteNode(id);
|
||||
NodeRegistry.getInstance().evictConnection(id);
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -62,11 +62,14 @@ export interface WebhookExecution {
|
||||
|
||||
export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta';
|
||||
|
||||
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin';
|
||||
export type ResourceType = 'stack' | 'node';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: 'admin' | 'viewer';
|
||||
role: UserRole;
|
||||
auth_provider: AuthProvider;
|
||||
provider_id: string | null;
|
||||
email: string | null;
|
||||
@@ -74,6 +77,15 @@ export interface User {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface RoleAssignment {
|
||||
id: number;
|
||||
user_id: number;
|
||||
role: UserRole;
|
||||
resource_type: ResourceType;
|
||||
resource_id: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface SSOConfig {
|
||||
id: number;
|
||||
provider: string;
|
||||
@@ -201,6 +213,7 @@ export class DatabaseService {
|
||||
this.migrateEncryptNodeTokens();
|
||||
this.migrateSSOColumns();
|
||||
this.migrateRegistries();
|
||||
this.migrateRoleAssignments();
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseService {
|
||||
@@ -529,6 +542,25 @@ export class DatabaseService {
|
||||
`);
|
||||
}
|
||||
|
||||
private migrateRoleAssignments(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS role_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id);
|
||||
`);
|
||||
try {
|
||||
this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_unique ON role_assignments(user_id, role, resource_type, resource_id)');
|
||||
} catch { /* already exists */ }
|
||||
}
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
public getAgents(): Agent[] {
|
||||
@@ -902,7 +934,7 @@ export class DatabaseService {
|
||||
return this.db.prepare('SELECT * FROM users WHERE auth_provider = ? AND provider_id = ?').get(authProvider, providerId) as User | undefined;
|
||||
}
|
||||
|
||||
public addUser(user: { username: string; password_hash: string; role: 'admin' | 'viewer'; auth_provider?: AuthProvider; provider_id?: string | null; email?: string | null }): number {
|
||||
public addUser(user: { username: string; password_hash: string; role: UserRole; auth_provider?: AuthProvider; provider_id?: string | null; email?: string | null }): number {
|
||||
const now = Date.now();
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO users (username, password_hash, role, auth_provider, provider_id, email, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
@@ -943,6 +975,44 @@ export class DatabaseService {
|
||||
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'viewer'").get() as { count: number })?.count || 0;
|
||||
}
|
||||
|
||||
public getNonAdminCount(): number {
|
||||
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role != 'admin'").get() as { count: number })?.count || 0;
|
||||
}
|
||||
|
||||
// --- Role Assignments ---
|
||||
|
||||
public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ?'
|
||||
).all(userId, resourceType, resourceId) as RoleAssignment[];
|
||||
}
|
||||
|
||||
public getAllRoleAssignments(userId: number): RoleAssignment[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id'
|
||||
).all(userId) as RoleAssignment[];
|
||||
}
|
||||
|
||||
public addRoleAssignment(assignment: { user_id: number; role: UserRole; resource_type: ResourceType; resource_id: string }): number {
|
||||
const now = Date.now();
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, now);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public getRoleAssignmentById(id: number): RoleAssignment | undefined {
|
||||
return this.db.prepare('SELECT * FROM role_assignments WHERE id = ?').get(id) as RoleAssignment | undefined;
|
||||
}
|
||||
|
||||
public deleteRoleAssignment(id: number): void {
|
||||
this.db.prepare('DELETE FROM role_assignments WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
public deleteRoleAssignmentsByUser(userId: number): void {
|
||||
this.db.prepare('DELETE FROM role_assignments WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
// --- SSO Config ---
|
||||
|
||||
public getSSOConfigs(): SSOConfig[] {
|
||||
|
||||
Reference in New Issue
Block a user