diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 1eca73d9..744f2889 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; import snippetsRoutes from "./routes/snippets.js"; import terminalRoutes from "./routes/terminal.js"; +import rbacRoutes from "./routes/rbac.js"; import cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; @@ -1436,6 +1437,7 @@ app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); app.use("/terminal", terminalRoutes); +app.use("/rbac", rbacRoutes); app.use( ( diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index f9e1017f..5ab7755e 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -328,6 +328,81 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'use', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + permissions TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS user_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + role_id INTEGER NOT NULL, + granted_by TEXT, + granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, role_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + resource_name TEXT, + details TEXT, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + error_message TEXT, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS session_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + access_id INTEGER, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at TEXT, + duration INTEGER, + commands TEXT, + dangerous_actions TEXT, + recording_path TEXT, + terminated_by_owner INTEGER DEFAULT 0, + termination_reason TEXT, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL + ); + `); try { @@ -551,6 +626,331 @@ const migrateSchema = () => { } } + // RBAC Phase 1: Host Access table + try { + sqlite.prepare("SELECT id FROM host_access LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'use', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE + ); + `); + databaseLogger.info("Created host_access table", { + operation: "schema_migration", + }); + } catch (createError) { + databaseLogger.warn("Failed to create host_access table", { + operation: "schema_migration", + error: createError, + }); + } + } + + // Migration: Add role_id column to existing host_access table + try { + sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get(); + } catch { + try { + sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE"); + databaseLogger.info("Added role_id column to host_access table", { + operation: "schema_migration", + }); + } catch (alterError) { + databaseLogger.warn("Failed to add role_id column", { + operation: "schema_migration", + error: alterError, + }); + } + } + + // RBAC Phase 2: Roles tables + try { + sqlite.prepare("SELECT id FROM roles LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + permissions TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + databaseLogger.info("Created roles table", { + operation: "schema_migration", + }); + } catch (createError) { + databaseLogger.warn("Failed to create roles table", { + operation: "schema_migration", + error: createError, + }); + } + } + + try { + sqlite.prepare("SELECT id FROM user_roles LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS user_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + role_id INTEGER NOT NULL, + granted_by TEXT, + granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, role_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL + ); + `); + databaseLogger.info("Created user_roles table", { + operation: "schema_migration", + }); + } catch (createError) { + databaseLogger.warn("Failed to create user_roles table", { + operation: "schema_migration", + error: createError, + }); + } + } + + // RBAC Phase 3: Audit logging tables + try { + sqlite.prepare("SELECT id FROM audit_logs LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + resource_name TEXT, + details TEXT, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + error_message TEXT, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + databaseLogger.info("Created audit_logs table", { + operation: "schema_migration", + }); + } catch (createError) { + databaseLogger.warn("Failed to create audit_logs table", { + operation: "schema_migration", + error: createError, + }); + } + } + + try { + sqlite.prepare("SELECT id FROM session_recordings LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS session_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + access_id INTEGER, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at TEXT, + duration INTEGER, + commands TEXT, + dangerous_actions TEXT, + recording_path TEXT, + terminated_by_owner INTEGER DEFAULT 0, + termination_reason TEXT, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL + ); + `); + databaseLogger.info("Created session_recordings table", { + operation: "schema_migration", + }); + } catch (createError) { + databaseLogger.warn("Failed to create session_recordings table", { + operation: "schema_migration", + error: createError, + }); + } + } + + // Clean up old system roles and seed correct ones + try { + // First, check what roles exist + const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>; + databaseLogger.info("Current roles in database", { + operation: "schema_migration", + roles: existingRoles, + }); + + // Migration: Remove ALL old unwanted roles (system or not) and keep only admin and user + try { + const validSystemRoles = ['admin', 'user']; + const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member']; + let deletedCount = 0; + + // First delete known unwanted role names + const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?"); + for (const roleName of unwantedRoleNames) { + const result = deleteByName.run(roleName); + if (result.changes > 0) { + deletedCount += result.changes; + databaseLogger.info(`Deleted role by name: ${roleName}`, { + operation: "schema_migration", + }); + } + } + + // Then delete any system roles that are not admin or user + const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1"); + for (const role of existingRoles) { + if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) { + const result = deleteOldSystemRole.run(role.name); + if (result.changes > 0) { + deletedCount += result.changes; + databaseLogger.info(`Deleted system role: ${role.name}`, { + operation: "schema_migration", + }); + } + } + } + + databaseLogger.info("Cleanup completed", { + operation: "schema_migration", + deletedCount, + }); + } catch (cleanupError) { + databaseLogger.warn("Failed to clean up old system roles", { + operation: "schema_migration", + error: cleanupError, + }); + } + + // Ensure only admin and user system roles exist + const systemRoles = [ + { + name: "admin", + displayName: "rbac.roles.admin", + description: "Administrator with full access", + permissions: null, + }, + { + name: "user", + displayName: "rbac.roles.user", + description: "Regular user", + permissions: null, + }, + ]; + + for (const role of systemRoles) { + const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name); + if (!existingRole) { + // Create if doesn't exist + try { + sqlite.prepare(` + INSERT INTO roles (name, display_name, description, is_system, permissions) + VALUES (?, ?, ?, 1, ?) + `).run(role.name, role.displayName, role.description, role.permissions); + } catch (insertError) { + databaseLogger.warn(`Failed to create system role: ${role.name}`, { + operation: "schema_migration", + error: insertError, + }); + } + } + } + + databaseLogger.info("System roles migration completed", { + operation: "schema_migration", + }); + + // Migrate existing is_admin users to roles + try { + const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[]; + const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[]; + + const adminRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'admin'").get() as { id: number } | undefined; + const userRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'user'").get() as { id: number } | undefined; + + if (adminRole) { + const insertUserRole = sqlite.prepare(` + INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + `); + + for (const admin of adminUsers) { + try { + insertUserRole.run(admin.id, adminRole.id); + } catch (error) { + // Ignore duplicate errors + } + } + + databaseLogger.info("Migrated admin users to admin role", { + operation: "schema_migration", + count: adminUsers.length, + }); + } + + if (userRole) { + const insertUserRole = sqlite.prepare(` + INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + `); + + for (const user of normalUsers) { + try { + insertUserRole.run(user.id, userRole.id); + } catch (error) { + // Ignore duplicate errors + } + } + + databaseLogger.info("Migrated normal users to user role", { + operation: "schema_migration", + count: normalUsers.length, + }); + } + } catch (migrationError) { + databaseLogger.warn("Failed to migrate existing users to roles", { + operation: "schema_migration", + error: migrationError, + }); + } + } catch (seedError) { + databaseLogger.warn("Failed to seed system roles", { + operation: "schema_migration", + error: seedError, + }); + } + databaseLogger.success("Schema migration completed", { operation: "schema_migration", }); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 074b4103..4fe67aba 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -276,3 +276,140 @@ export const commandHistory = sqliteTable("command_history", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); + +// RBAC Phase 1: Host Sharing +export const hostAccess = sqliteTable("host_access", { + id: integer("id").primaryKey({ autoIncrement: true }), + hostId: integer("host_id") + .notNull() + .references(() => sshData.id, { onDelete: "cascade" }), + + // Share target: either userId OR roleId (at least one must be set) + userId: text("user_id") + .references(() => users.id, { onDelete: "cascade" }), // Optional + roleId: integer("role_id") + .references(() => roles.id, { onDelete: "cascade" }), // Optional + + grantedBy: text("granted_by") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + // Permission level + permissionLevel: text("permission_level") + .notNull() + .default("use"), // "view" | "use" | "manage" + + // Time-based access + expiresAt: text("expires_at"), // NULL = never expires + + // Metadata + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + lastAccessedAt: text("last_accessed_at"), + accessCount: integer("access_count").notNull().default(0), +}); + +// RBAC Phase 2: Roles +export const roles = sqliteTable("roles", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull().unique(), + displayName: text("display_name").notNull(), // For i18n + description: text("description"), + + // System roles cannot be deleted + isSystem: integer("is_system", { mode: "boolean" }) + .notNull() + .default(false), + + // Permissions stored as JSON array (optional - used for grouping only in current phase) + permissions: text("permissions"), // ["hosts.*", "credentials.read", ...] - optional + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const userRoles = sqliteTable("user_roles", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id") + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: text("granted_by").references(() => users.id, { + onDelete: "set null", + }), + grantedAt: text("granted_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +// RBAC Phase 3: Audit Logging +export const auditLogs = sqliteTable("audit_logs", { + id: integer("id").primaryKey({ autoIncrement: true }), + + // Who + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + username: text("username").notNull(), // Snapshot in case user deleted + + // What + action: text("action").notNull(), // "create", "read", "update", "delete", "share" + resourceType: text("resource_type").notNull(), // "host", "credential", "user", "session" + resourceId: text("resource_id"), // Can be text or number, store as text + resourceName: text("resource_name"), // Human-readable identifier + + // Context + details: text("details"), // JSON: { oldValue, newValue, reason, ... } + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + + // Result + success: integer("success", { mode: "boolean" }).notNull(), + errorMessage: text("error_message"), + + // When + timestamp: text("timestamp") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sessionRecordings = sqliteTable("session_recordings", { + id: integer("id").primaryKey({ autoIncrement: true }), + + hostId: integer("host_id") + .notNull() + .references(() => sshData.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + accessId: integer("access_id").references(() => hostAccess.id, { + onDelete: "set null", + }), + + // Session info + startedAt: text("started_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + endedAt: text("ended_at"), + duration: integer("duration"), // seconds + + // Command log (lightweight) + commands: text("commands"), // JSON: [{ts, cmd, exitCode, blocked}] + dangerousActions: text("dangerous_actions"), // JSON: blocked commands + + // Full recording (optional, heavy) + recordingPath: text("recording_path"), // Path to .cast file + + // Metadata + terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" }) + .default(false), + terminationReason: text("termination_reason"), +}); diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts new file mode 100644 index 00000000..80e6efed --- /dev/null +++ b/src/backend/database/routes/rbac.ts @@ -0,0 +1,876 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import express from "express"; +import { db } from "../db/index.js"; +import { + hostAccess, + sshData, + users, + roles, + userRoles, + auditLogs, +} from "../db/schema.js"; +import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm"; +import type { Request, Response } from "express"; +import { databaseLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; + +const router = express.Router(); + +const authManager = AuthManager.getInstance(); +const permissionManager = PermissionManager.getInstance(); + +const authenticateJWT = authManager.createAuthMiddleware(); + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +/** + * Share a host with a user or role + * POST /rbac/host/:id/share + */ +router.post( + "/host/:id/share", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const hostId = parseInt(req.params.id, 10); + const userId = req.userId!; + + if (isNaN(hostId)) { + return res.status(400).json({ error: "Invalid host ID" }); + } + + try { + const { + targetType = "user", // "user" or "role" + targetUserId, + targetRoleId, + durationHours, + permissionLevel = "use", + } = req.body; + + // Validate target type + if (!["user", "role"].includes(targetType)) { + return res.status(400).json({ error: "Invalid target type. Must be 'user' or 'role'" }); + } + + // Validate required fields based on target type + if (targetType === "user" && !isNonEmptyString(targetUserId)) { + return res.status(400).json({ error: "Target user ID is required when sharing with user" }); + } + if (targetType === "role" && !targetRoleId) { + return res.status(400).json({ error: "Target role ID is required when sharing with role" }); + } + + // Verify user owns the host + const host = await db + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))) + .limit(1); + + if (host.length === 0) { + databaseLogger.warn("Attempt to share host not owned by user", { + operation: "share_host", + userId, + hostId, + }); + return res.status(403).json({ error: "Not host owner" }); + } + + // Verify target exists (user or role) + if (targetType === "user") { + const targetUser = await db + .select({ id: users.id, username: users.username }) + .from(users) + .where(eq(users.id, targetUserId)) + .limit(1); + + if (targetUser.length === 0) { + return res.status(404).json({ error: "Target user not found" }); + } + } else { + const targetRole = await db + .select({ id: roles.id, name: roles.name }) + .from(roles) + .where(eq(roles.id, targetRoleId)) + .limit(1); + + if (targetRole.length === 0) { + return res.status(404).json({ error: "Target role not found" }); + } + } + + // Calculate expiry time + let expiresAt: string | null = null; + if (durationHours && typeof durationHours === "number" && durationHours > 0) { + const expiryDate = new Date(); + expiryDate.setHours(expiryDate.getHours() + durationHours); + expiresAt = expiryDate.toISOString(); + } + + // Validate permission level + const validLevels = ["view", "use", "manage"]; + if (!validLevels.includes(permissionLevel)) { + return res.status(400).json({ + error: "Invalid permission level", + validLevels, + }); + } + + // Check if access already exists + const whereConditions = [eq(hostAccess.hostId, hostId)]; + if (targetType === "user") { + whereConditions.push(eq(hostAccess.userId, targetUserId)); + } else { + whereConditions.push(eq(hostAccess.roleId, targetRoleId)); + } + + const existing = await db + .select() + .from(hostAccess) + .where(and(...whereConditions)) + .limit(1); + + if (existing.length > 0) { + // Update existing access + await db + .update(hostAccess) + .set({ + permissionLevel, + expiresAt, + }) + .where(eq(hostAccess.id, existing[0].id)); + + databaseLogger.info("Updated existing host access", { + operation: "share_host", + hostId, + targetType, + targetUserId: targetType === "user" ? targetUserId : undefined, + targetRoleId: targetType === "role" ? targetRoleId : undefined, + permissionLevel, + expiresAt, + }); + + return res.json({ + success: true, + message: "Host access updated", + expiresAt, + }); + } + + // Create new access + const result = await db.insert(hostAccess).values({ + hostId, + userId: targetType === "user" ? targetUserId : null, + roleId: targetType === "role" ? targetRoleId : null, + grantedBy: userId, + permissionLevel, + expiresAt, + }); + + databaseLogger.info("Created host access", { + operation: "share_host", + hostId, + hostName: host[0].name, + targetType, + targetUserId: targetType === "user" ? targetUserId : undefined, + targetRoleId: targetType === "role" ? targetRoleId : undefined, + permissionLevel, + expiresAt, + }); + + res.json({ + success: true, + message: `Host shared successfully with ${targetType}`, + expiresAt, + }); + } catch (error) { + databaseLogger.error("Failed to share host", error, { + operation: "share_host", + hostId, + userId, + }); + res.status(500).json({ error: "Failed to share host" }); + } + }, +); + +/** + * Revoke host access + * DELETE /rbac/host/:id/access/:accessId + */ +router.delete( + "/host/:id/access/:accessId", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const hostId = parseInt(req.params.id, 10); + const accessId = parseInt(req.params.accessId, 10); + const userId = req.userId!; + + if (isNaN(hostId) || isNaN(accessId)) { + return res.status(400).json({ error: "Invalid ID" }); + } + + try { + // Verify user owns the host + const host = await db + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))) + .limit(1); + + if (host.length === 0) { + return res.status(403).json({ error: "Not host owner" }); + } + + // Delete the access + await db.delete(hostAccess).where(eq(hostAccess.id, accessId)); + + databaseLogger.info("Revoked host access", { + operation: "revoke_host_access", + hostId, + accessId, + userId, + }); + + res.json({ success: true, message: "Access revoked" }); + } catch (error) { + databaseLogger.error("Failed to revoke host access", error, { + operation: "revoke_host_access", + hostId, + accessId, + userId, + }); + res.status(500).json({ error: "Failed to revoke access" }); + } + }, +); + +/** + * Get host access list + * GET /rbac/host/:id/access + */ +router.get( + "/host/:id/access", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const hostId = parseInt(req.params.id, 10); + const userId = req.userId!; + + if (isNaN(hostId)) { + return res.status(400).json({ error: "Invalid host ID" }); + } + + try { + // Verify user owns the host + const host = await db + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))) + .limit(1); + + if (host.length === 0) { + return res.status(403).json({ error: "Not host owner" }); + } + + // Get all access records (both user and role based) + const rawAccessList = await db + .select({ + id: hostAccess.id, + userId: hostAccess.userId, + roleId: hostAccess.roleId, + username: users.username, + roleName: roles.name, + roleDisplayName: roles.displayName, + grantedBy: hostAccess.grantedBy, + grantedByUsername: sql`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`, + permissionLevel: hostAccess.permissionLevel, + expiresAt: hostAccess.expiresAt, + createdAt: hostAccess.createdAt, + lastAccessedAt: hostAccess.lastAccessedAt, + accessCount: hostAccess.accessCount, + }) + .from(hostAccess) + .leftJoin(users, eq(hostAccess.userId, users.id)) + .leftJoin(roles, eq(hostAccess.roleId, roles.id)) + .where(eq(hostAccess.hostId, hostId)) + .orderBy(desc(hostAccess.createdAt)); + + // Format access list with type information + const accessList = rawAccessList.map(access => ({ + id: access.id, + targetType: access.userId ? "user" : "role", + userId: access.userId, + roleId: access.roleId, + username: access.username, + roleName: access.roleName, + roleDisplayName: access.roleDisplayName, + grantedBy: access.grantedBy, + grantedByUsername: access.grantedByUsername, + permissionLevel: access.permissionLevel, + expiresAt: access.expiresAt, + createdAt: access.createdAt, + lastAccessedAt: access.lastAccessedAt, + accessCount: access.accessCount, + })); + + res.json({ accessList }); + } catch (error) { + databaseLogger.error("Failed to get host access list", error, { + operation: "get_host_access_list", + hostId, + userId, + }); + res.status(500).json({ error: "Failed to get access list" }); + } + }, +); + +/** + * Get user's shared hosts (hosts shared WITH this user) + * GET /rbac/shared-hosts + */ +router.get( + "/shared-hosts", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const userId = req.userId!; + + try { + const now = new Date().toISOString(); + + const sharedHosts = await db + .select({ + id: sshData.id, + name: sshData.name, + ip: sshData.ip, + port: sshData.port, + username: sshData.username, + folder: sshData.folder, + tags: sshData.tags, + permissionLevel: hostAccess.permissionLevel, + expiresAt: hostAccess.expiresAt, + grantedBy: hostAccess.grantedBy, + ownerUsername: users.username, + }) + .from(hostAccess) + .innerJoin(sshData, eq(hostAccess.hostId, sshData.id)) + .innerJoin(users, eq(sshData.userId, users.id)) + .where( + and( + eq(hostAccess.userId, userId), + or( + isNull(hostAccess.expiresAt), + gte(hostAccess.expiresAt, now), + ), + ), + ) + .orderBy(desc(hostAccess.createdAt)); + + res.json({ sharedHosts }); + } catch (error) { + databaseLogger.error("Failed to get shared hosts", error, { + operation: "get_shared_hosts", + userId, + }); + res.status(500).json({ error: "Failed to get shared hosts" }); + } + }, +); + +/** + * Get all roles + * GET /rbac/roles + */ +router.get( + "/roles", + authenticateJWT, + permissionManager.requireAdmin(), + async (req: AuthenticatedRequest, res: Response) => { + try { + const allRoles = await db + .select() + .from(roles) + .orderBy(roles.isSystem, roles.name); + + const rolesWithParsedPermissions = allRoles.map((role) => ({ + ...role, + permissions: JSON.parse(role.permissions), + })); + + res.json({ roles: rolesWithParsedPermissions }); + } catch (error) { + databaseLogger.error("Failed to get roles", error, { + operation: "get_roles", + }); + res.status(500).json({ error: "Failed to get roles" }); + } + }, +); + +// ============================================================================ +// Role Management (CRUD) +// ============================================================================ + +/** + * Get all roles + * GET /rbac/roles + */ +router.get( + "/roles", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + try { + const rolesList = await db + .select({ + id: roles.id, + name: roles.name, + displayName: roles.displayName, + description: roles.description, + isSystem: roles.isSystem, + createdAt: roles.createdAt, + updatedAt: roles.updatedAt, + }) + .from(roles) + .orderBy(roles.isSystem, roles.name); + + res.json({ roles: rolesList }); + } catch (error) { + databaseLogger.error("Failed to get roles", error, { + operation: "get_roles", + }); + res.status(500).json({ error: "Failed to get roles" }); + } + }, +); + +/** + * Create new role + * POST /rbac/roles + */ +router.post( + "/roles", + authenticateJWT, + permissionManager.requireAdmin(), + async (req: AuthenticatedRequest, res: Response) => { + const { name, displayName, description } = req.body; + + // Validate required fields + if (!isNonEmptyString(name) || !isNonEmptyString(displayName)) { + return res.status(400).json({ + error: "Role name and display name are required", + }); + } + + // Validate name format (alphanumeric, underscore, hyphen only) + if (!/^[a-z0-9_-]+$/.test(name)) { + return res.status(400).json({ + error: + "Role name must contain only lowercase letters, numbers, underscores, and hyphens", + }); + } + + try { + // Check if role name already exists + const existing = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, name)) + .limit(1); + + if (existing.length > 0) { + return res.status(409).json({ + error: "A role with this name already exists", + }); + } + + // Create new role + const result = await db.insert(roles).values({ + name, + displayName, + description: description || null, + isSystem: false, + permissions: null, // Roles are for grouping only + }); + + const newRoleId = result.lastInsertRowid; + + databaseLogger.info("Created new role", { + operation: "create_role", + roleId: newRoleId, + roleName: name, + }); + + res.status(201).json({ + success: true, + roleId: newRoleId, + message: "Role created successfully", + }); + } catch (error) { + databaseLogger.error("Failed to create role", error, { + operation: "create_role", + roleName: name, + }); + res.status(500).json({ error: "Failed to create role" }); + } + }, +); + +/** + * Update role + * PUT /rbac/roles/:id + */ +router.put( + "/roles/:id", + authenticateJWT, + permissionManager.requireAdmin(), + async (req: AuthenticatedRequest, res: Response) => { + const roleId = parseInt(req.params.id, 10); + const { displayName, description } = req.body; + + if (isNaN(roleId)) { + return res.status(400).json({ error: "Invalid role ID" }); + } + + // Validate at least one field to update + if (!displayName && description === undefined) { + return res.status(400).json({ + error: "At least one field (displayName or description) is required", + }); + } + + try { + // Get existing role + const existingRole = await db + .select({ + id: roles.id, + name: roles.name, + isSystem: roles.isSystem, + }) + .from(roles) + .where(eq(roles.id, roleId)) + .limit(1); + + if (existingRole.length === 0) { + return res.status(404).json({ error: "Role not found" }); + } + + // Build update object + const updates: { + displayName?: string; + description?: string | null; + updatedAt: string; + } = { + updatedAt: new Date().toISOString(), + }; + + if (displayName) { + updates.displayName = displayName; + } + + if (description !== undefined) { + updates.description = description || null; + } + + // Update role + await db.update(roles).set(updates).where(eq(roles.id, roleId)); + + databaseLogger.info("Updated role", { + operation: "update_role", + roleId, + roleName: existingRole[0].name, + }); + + res.json({ + success: true, + message: "Role updated successfully", + }); + } catch (error) { + databaseLogger.error("Failed to update role", error, { + operation: "update_role", + roleId, + }); + res.status(500).json({ error: "Failed to update role" }); + } + }, +); + +/** + * Delete role + * DELETE /rbac/roles/:id + */ +router.delete( + "/roles/:id", + authenticateJWT, + permissionManager.requireAdmin(), + async (req: AuthenticatedRequest, res: Response) => { + const roleId = parseInt(req.params.id, 10); + + if (isNaN(roleId)) { + return res.status(400).json({ error: "Invalid role ID" }); + } + + try { + // Get role details + const role = await db + .select({ + id: roles.id, + name: roles.name, + isSystem: roles.isSystem, + }) + .from(roles) + .where(eq(roles.id, roleId)) + .limit(1); + + if (role.length === 0) { + return res.status(404).json({ error: "Role not found" }); + } + + // Cannot delete system roles + if (role[0].isSystem) { + return res.status(403).json({ + error: "Cannot delete system roles", + }); + } + + // Check if role is in use + const usageCount = await db + .select({ count: sql`count(*)` }) + .from(userRoles) + .where(eq(userRoles.roleId, roleId)); + + if (usageCount[0].count > 0) { + return res.status(409).json({ + error: `Cannot delete role: ${usageCount[0].count} user(s) are assigned to this role`, + usageCount: usageCount[0].count, + }); + } + + // Check if role is used in host_access + const hostAccessCount = await db + .select({ count: sql`count(*)` }) + .from(hostAccess) + .where(eq(hostAccess.roleId, roleId)); + + if (hostAccessCount[0].count > 0) { + return res.status(409).json({ + error: `Cannot delete role: ${hostAccessCount[0].count} host(s) are shared with this role`, + hostAccessCount: hostAccessCount[0].count, + }); + } + + // Delete role + await db.delete(roles).where(eq(roles.id, roleId)); + + databaseLogger.info("Deleted role", { + operation: "delete_role", + roleId, + roleName: role[0].name, + }); + + res.json({ + success: true, + message: "Role deleted successfully", + }); + } catch (error) { + databaseLogger.error("Failed to delete role", error, { + operation: "delete_role", + roleId, + }); + res.status(500).json({ error: "Failed to delete role" }); + } + }, +); + +// ============================================================================ +// User-Role Assignment +// ============================================================================ + +/** + * Assign role to user + * POST /rbac/users/:userId/roles + */ +router.post( + "/users/:userId/roles", + authenticateJWT, + permissionManager.requireAdmin(), + async (req: AuthenticatedRequest, res: Response) => { + const targetUserId = req.params.userId; + const currentUserId = req.userId!; + + try { + const { roleId } = req.body; + + if (typeof roleId !== "number") { + return res.status(400).json({ error: "Role ID is required" }); + } + + // Verify target user exists + const targetUser = await db + .select() + .from(users) + .where(eq(users.id, targetUserId)) + .limit(1); + + if (targetUser.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + // Verify role exists + const role = await db + .select() + .from(roles) + .where(eq(roles.id, roleId)) + .limit(1); + + if (role.length === 0) { + return res.status(404).json({ error: "Role not found" }); + } + + // Check if already assigned + const existing = await db + .select() + .from(userRoles) + .where( + and( + eq(userRoles.userId, targetUserId), + eq(userRoles.roleId, roleId), + ), + ) + .limit(1); + + if (existing.length > 0) { + return res.status(409).json({ error: "Role already assigned" }); + } + + // Assign role + await db.insert(userRoles).values({ + userId: targetUserId, + roleId, + grantedBy: currentUserId, + }); + + // Invalidate permission cache + permissionManager.invalidateUserPermissionCache(targetUserId); + + databaseLogger.info("Assigned role to user", { + operation: "assign_role", + targetUserId, + roleId, + roleName: role[0].name, + grantedBy: currentUserId, + }); + + res.json({ + success: true, + message: "Role assigned successfully", + }); + } catch (error) { + databaseLogger.error("Failed to assign role", error, { + operation: "assign_role", + targetUserId, + }); + res.status(500).json({ error: "Failed to assign role" }); + } + }, +); + +/** + * Remove role from user + * DELETE /rbac/users/:userId/roles/:roleId + */ +router.delete( + "/users/:userId/roles/:roleId", + authenticateJWT, + permissionManager.requireAdmin(), + async (req: AuthenticatedRequest, res: Response) => { + const targetUserId = req.params.userId; + const roleId = parseInt(req.params.roleId, 10); + + if (isNaN(roleId)) { + return res.status(400).json({ error: "Invalid role ID" }); + } + + try { + // Delete the user-role assignment + await db + .delete(userRoles) + .where( + and( + eq(userRoles.userId, targetUserId), + eq(userRoles.roleId, roleId), + ), + ); + + // Invalidate permission cache + permissionManager.invalidateUserPermissionCache(targetUserId); + + databaseLogger.info("Removed role from user", { + operation: "remove_role", + targetUserId, + roleId, + }); + + res.json({ + success: true, + message: "Role removed successfully", + }); + } catch (error) { + databaseLogger.error("Failed to remove role", error, { + operation: "remove_role", + targetUserId, + roleId, + }); + res.status(500).json({ error: "Failed to remove role" }); + } + }, +); + +/** + * Get user's roles + * GET /rbac/users/:userId/roles + */ +router.get( + "/users/:userId/roles", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const targetUserId = req.params.userId; + const currentUserId = req.userId!; + + // Users can only see their own roles unless they're admin + if ( + targetUserId !== currentUserId && + !(await permissionManager.isAdmin(currentUserId)) + ) { + return res.status(403).json({ error: "Access denied" }); + } + + try { + const userRolesList = await db + .select({ + id: userRoles.id, + roleId: roles.id, + roleName: roles.name, + roleDisplayName: roles.displayName, + description: roles.description, + isSystem: roles.isSystem, + grantedAt: userRoles.grantedAt, + }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, targetUserId)); + + res.json({ roles: userRolesList }); + } catch (error) { + databaseLogger.error("Failed to get user roles", error, { + operation: "get_user_roles", + targetUserId, + }); + res.status(500).json({ error: "Failed to get user roles" }); + } + }, +); + +export default router; diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 955135a4..fbddf6ad 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -11,8 +11,10 @@ import { sshFolders, commandHistory, recentActivity, + hostAccess, + userRoles, } from "../db/schema.js"; -import { eq, and, desc, isNotNull, or } from "drizzle-orm"; +import { eq, and, desc, isNotNull, or, isNull, gte, sql, inArray } from "drizzle-orm"; import type { Request, Response } from "express"; import multer from "multer"; import { sshLogger } from "../../utils/logger.js"; @@ -656,8 +658,98 @@ router.get( return res.status(400).json({ error: "Invalid userId" }); } try { + const now = new Date().toISOString(); + + // Get user's role IDs + const userRoleIds = await db + .select({ roleId: userRoles.roleId }) + .from(userRoles) + .where(eq(userRoles.userId, userId)); + const roleIds = userRoleIds.map((r) => r.roleId); + + // Query own hosts + shared hosts with access check + const rawData = await db + .select({ + // All ssh_data fields + id: sshData.id, + userId: sshData.userId, + name: sshData.name, + ip: sshData.ip, + port: sshData.port, + username: sshData.username, + folder: sshData.folder, + tags: sshData.tags, + pin: sshData.pin, + authType: sshData.authType, + password: sshData.password, + key: sshData.key, + keyPassword: sshData.key_password, + keyType: sshData.keyType, + enableTerminal: sshData.enableTerminal, + enableTunnel: sshData.enableTunnel, + tunnelConnections: sshData.tunnelConnections, + jumpHosts: sshData.jumpHosts, + enableFileManager: sshData.enableFileManager, + defaultPath: sshData.defaultPath, + autostartPassword: sshData.autostartPassword, + autostartKey: sshData.autostartKey, + autostartKeyPassword: sshData.autostartKeyPassword, + forceKeyboardInteractive: sshData.forceKeyboardInteractive, + statsConfig: sshData.statsConfig, + terminalConfig: sshData.terminalConfig, + createdAt: sshData.createdAt, + updatedAt: sshData.updatedAt, + credentialId: sshData.credentialId, + overrideCredentialUsername: sshData.overrideCredentialUsername, + quickActions: sshData.quickActions, + + // Shared access info + isShared: sql`${hostAccess.id} IS NOT NULL`, + permissionLevel: hostAccess.permissionLevel, + expiresAt: hostAccess.expiresAt, + }) + .from(sshData) + .leftJoin( + hostAccess, + and( + eq(hostAccess.hostId, sshData.id), + or( + eq(hostAccess.userId, userId), + roleIds.length > 0 ? inArray(hostAccess.roleId, roleIds) : sql`false`, + ), + or( + isNull(hostAccess.expiresAt), + gte(hostAccess.expiresAt, now), + ), + ), + ) + .where( + or( + eq(sshData.userId, userId), // Own hosts + and( + // Shared to user directly (not expired) + eq(hostAccess.userId, userId), + or( + isNull(hostAccess.expiresAt), + gte(hostAccess.expiresAt, now), + ), + ), + roleIds.length > 0 + ? and( + // Shared to user's role (not expired) + inArray(hostAccess.roleId, roleIds), + or( + isNull(hostAccess.expiresAt), + gte(hostAccess.expiresAt, now), + ), + ) + : sql`false`, + ), + ); + + // Decrypt and format the data const data = await SimpleDBOps.select( - db.select().from(sshData).where(eq(sshData.userId, userId)), + Promise.resolve(rawData), "ssh_data", userId, ); @@ -690,6 +782,11 @@ router.get( ? JSON.parse(row.terminalConfig as string) : undefined, forceKeyboardInteractive: row.forceKeyboardInteractive === "true", + + // Add shared access metadata + isShared: !!row.isShared, + permissionLevel: row.permissionLevel || undefined, + sharedExpiresAt: row.expiresAt || undefined, }; return (await resolveHostCredentials(baseHost)) || baseHost; diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 473654d9..814f06bc 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -15,6 +15,8 @@ import { sshCredentialUsage, recentActivity, snippets, + roles, + userRoles, } from "../db/schema.js"; import { eq, and } from "drizzle-orm"; import bcrypt from "bcryptjs"; @@ -210,6 +212,41 @@ router.post("/create", async (req, res) => { totp_backup_codes: null, }); + // Assign default role to new user + try { + const defaultRoleName = isFirstUser ? "admin" : "user"; + const defaultRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, defaultRoleName)) + .limit(1); + + if (defaultRole.length > 0) { + await db.insert(userRoles).values({ + userId: id, + roleId: defaultRole[0].id, + grantedBy: id, // Self-assigned during registration + }); + authLogger.info("Assigned default role to new user", { + operation: "assign_default_role", + userId: id, + roleName: defaultRoleName, + }); + } else { + authLogger.warn("Default role not found during user registration", { + operation: "assign_default_role", + userId: id, + roleName: defaultRoleName, + }); + } + } catch (roleError) { + authLogger.error("Failed to assign default role", roleError, { + operation: "assign_default_role", + userId: id, + }); + // Don't fail user creation if role assignment fails + } + try { await authManager.registerUser(id, password); } catch (encryptionError) { @@ -816,6 +853,41 @@ router.get("/oidc/callback", async (req, res) => { scopes: String(config.scopes), }); + // Assign default role to new OIDC user + try { + const defaultRoleName = isFirstUser ? "admin" : "user"; + const defaultRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, defaultRoleName)) + .limit(1); + + if (defaultRole.length > 0) { + await db.insert(userRoles).values({ + userId: id, + roleId: defaultRole[0].id, + grantedBy: id, // Self-assigned during registration + }); + authLogger.info("Assigned default role to new OIDC user", { + operation: "assign_default_role_oidc", + userId: id, + roleName: defaultRoleName, + }); + } else { + authLogger.warn("Default role not found during OIDC user registration", { + operation: "assign_default_role_oidc", + userId: id, + roleName: defaultRoleName, + }); + } + } catch (roleError) { + authLogger.error("Failed to assign default role to OIDC user", roleError, { + operation: "assign_default_role_oidc", + userId: id, + }); + // Don't fail user creation if role assignment fails + } + try { const sessionDurationMs = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts new file mode 100644 index 00000000..3bf470da --- /dev/null +++ b/src/backend/utils/permission-manager.ts @@ -0,0 +1,456 @@ +import type { Request, Response, NextFunction } from "express"; +import { db } from "../database/db/index.js"; +import { + hostAccess, + roles, + userRoles, + sshData, + users, +} from "../database/db/schema.js"; +import { eq, and, or, isNull, gte, sql } from "drizzle-orm"; +import { databaseLogger } from "./logger.js"; + +interface AuthenticatedRequest extends Request { + userId?: string; + dataKey?: Buffer; +} + +interface HostAccessInfo { + hasAccess: boolean; + isOwner: boolean; + isShared: boolean; + permissionLevel?: string; + expiresAt?: string | null; +} + +interface PermissionCheckResult { + allowed: boolean; + reason?: string; +} + +class PermissionManager { + private static instance: PermissionManager; + private permissionCache: Map; + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + private constructor() { + this.permissionCache = new Map(); + + // Auto-cleanup expired host access every 1 minute + setInterval( + () => { + this.cleanupExpiredAccess().catch((error) => { + databaseLogger.error( + "Failed to run periodic host access cleanup", + error, + { + operation: "host_access_cleanup_periodic", + }, + ); + }); + }, + 60 * 1000, + ); + + // Clear permission cache every 5 minutes + setInterval( + () => { + this.clearPermissionCache(); + }, + this.CACHE_TTL, + ); + } + + static getInstance(): PermissionManager { + if (!this.instance) { + this.instance = new PermissionManager(); + } + return this.instance; + } + + /** + * Clean up expired host access entries + */ + private async cleanupExpiredAccess(): Promise { + try { + const now = new Date().toISOString(); + const result = await db + .delete(hostAccess) + .where( + and( + sql`${hostAccess.expiresAt} IS NOT NULL`, + sql`${hostAccess.expiresAt} <= ${now}`, + ), + ) + .returning({ id: hostAccess.id }); + + if (result.length > 0) { + databaseLogger.info("Cleaned up expired host access", { + operation: "host_access_cleanup", + count: result.length, + }); + } + } catch (error) { + databaseLogger.error("Failed to cleanup expired host access", error, { + operation: "host_access_cleanup_failed", + }); + } + } + + /** + * Clear permission cache + */ + private clearPermissionCache(): void { + this.permissionCache.clear(); + } + + /** + * Invalidate permission cache for a specific user + */ + invalidateUserPermissionCache(userId: string): void { + this.permissionCache.delete(userId); + } + + /** + * Get user permissions from roles + */ + async getUserPermissions(userId: string): Promise { + // Check cache first + const cached = this.permissionCache.get(userId); + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + return cached.permissions; + } + + try { + const userRoleRecords = await db + .select({ + permissions: roles.permissions, + }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, userId)); + + const allPermissions = new Set(); + for (const record of userRoleRecords) { + try { + const permissions = JSON.parse(record.permissions) as string[]; + for (const perm of permissions) { + allPermissions.add(perm); + } + } catch (parseError) { + databaseLogger.warn("Failed to parse role permissions", { + operation: "get_user_permissions", + userId, + error: parseError, + }); + } + } + + const permissionsArray = Array.from(allPermissions); + + // Cache the result + this.permissionCache.set(userId, { + permissions: permissionsArray, + timestamp: Date.now(), + }); + + return permissionsArray; + } catch (error) { + databaseLogger.error("Failed to get user permissions", error, { + operation: "get_user_permissions", + userId, + }); + return []; + } + } + + /** + * Check if user has a specific permission + * Supports wildcards: "hosts.*", "*" + */ + async hasPermission( + userId: string, + permission: string, + ): Promise { + const userPermissions = await this.getUserPermissions(userId); + + // Check for wildcard "*" (god mode) + if (userPermissions.includes("*")) { + return true; + } + + // Check exact match + if (userPermissions.includes(permission)) { + return true; + } + + // Check wildcard matches + const parts = permission.split("."); + for (let i = parts.length; i > 0; i--) { + const wildcardPermission = parts.slice(0, i).join(".") + ".*"; + if (userPermissions.includes(wildcardPermission)) { + return true; + } + } + + return false; + } + + /** + * Check if user can access a specific host + */ + async canAccessHost( + userId: string, + hostId: number, + action: "read" | "write" | "execute" | "delete" | "share" = "read", + ): Promise { + try { + // Check if user is the owner + const host = await db + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))) + .limit(1); + + if (host.length > 0) { + return { + hasAccess: true, + isOwner: true, + isShared: false, + }; + } + + // Check if host is shared with user + const now = new Date().toISOString(); + const sharedAccess = await db + .select() + .from(hostAccess) + .where( + and( + eq(hostAccess.hostId, hostId), + eq(hostAccess.userId, userId), + or( + isNull(hostAccess.expiresAt), + gte(hostAccess.expiresAt, now), + ), + ), + ) + .limit(1); + + if (sharedAccess.length > 0) { + const access = sharedAccess[0]; + + // Check permission level for write/delete actions + if (action === "write" || action === "delete") { + const level = access.permissionLevel; + if (level === "readonly") { + return { + hasAccess: false, + isOwner: false, + isShared: true, + permissionLevel: level, + expiresAt: access.expiresAt, + }; + } + } + + // Update last accessed time + try { + db.update(hostAccess) + .set({ + lastAccessedAt: now, + accessCount: sql`${hostAccess.accessCount} + 1`, + }) + .where(eq(hostAccess.id, access.id)) + .run(); + } catch (error) { + databaseLogger.warn("Failed to update host access stats", { + operation: "update_host_access_stats", + error, + }); + } + + return { + hasAccess: true, + isOwner: false, + isShared: true, + permissionLevel: access.permissionLevel, + expiresAt: access.expiresAt, + }; + } + + return { + hasAccess: false, + isOwner: false, + isShared: false, + }; + } catch (error) { + databaseLogger.error("Failed to check host access", error, { + operation: "can_access_host", + userId, + hostId, + action, + }); + return { + hasAccess: false, + isOwner: false, + isShared: false, + }; + } + } + + /** + * Check if user is admin (backward compatibility) + */ + async isAdmin(userId: string): Promise { + try { + // Check old is_admin field + const user = await db + .select({ isAdmin: users.is_admin }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (user.length > 0 && user[0].isAdmin) { + return true; + } + + // Check if user has admin or super_admin role + const adminRoles = await db + .select({ roleName: roles.name }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where( + and( + eq(userRoles.userId, userId), + or(eq(roles.name, "admin"), eq(roles.name, "super_admin")), + ), + ); + + return adminRoles.length > 0; + } catch (error) { + databaseLogger.error("Failed to check admin status", error, { + operation: "is_admin", + userId, + }); + return false; + } + } + + /** + * Middleware: Require specific permission + */ + requirePermission(permission: string) { + return async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, + ) => { + const userId = req.userId; + + if (!userId) { + return res.status(401).json({ error: "Not authenticated" }); + } + + const hasPermission = await this.hasPermission(userId, permission); + + if (!hasPermission) { + databaseLogger.warn("Permission denied", { + operation: "permission_check", + userId, + permission, + path: req.path, + }); + + return res.status(403).json({ + error: "Insufficient permissions", + required: permission, + }); + } + + next(); + }; + } + + /** + * Middleware: Require host access + */ + requireHostAccess( + hostIdParam: string = "id", + action: "read" | "write" | "execute" | "delete" | "share" = "read", + ) { + return async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, + ) => { + const userId = req.userId; + + if (!userId) { + return res.status(401).json({ error: "Not authenticated" }); + } + + const hostId = parseInt(req.params[hostIdParam], 10); + + if (isNaN(hostId)) { + return res.status(400).json({ error: "Invalid host ID" }); + } + + const accessInfo = await this.canAccessHost(userId, hostId, action); + + if (!accessInfo.hasAccess) { + databaseLogger.warn("Host access denied", { + operation: "host_access_check", + userId, + hostId, + action, + }); + + return res.status(403).json({ + error: "Access denied to host", + hostId, + action, + }); + } + + // Attach access info to request for use in route handlers + (req as any).hostAccessInfo = accessInfo; + + next(); + }; + } + + /** + * Middleware: Require admin role (backward compatible) + */ + requireAdmin() { + return async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, + ) => { + const userId = req.userId; + + if (!userId) { + return res.status(401).json({ error: "Not authenticated" }); + } + + const isAdmin = await this.isAdmin(userId); + + if (!isAdmin) { + databaseLogger.warn("Admin access denied", { + operation: "admin_check", + userId, + path: req.path, + }); + + return res.status(403).json({ error: "Admin access required" }); + } + + next(); + }; + } +} + +export { PermissionManager }; +export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult }; diff --git a/src/hooks/use-confirmation.ts b/src/hooks/use-confirmation.ts index 18037d8f..c8b7fe73 100644 --- a/src/hooks/use-confirmation.ts +++ b/src/hooks/use-confirmation.ts @@ -36,24 +36,50 @@ export function useConfirmation() { }; const confirmWithToast = ( - message: string, - callback: () => void, - variant: "default" | "destructive" = "default", - ) => { - const actionText = variant === "destructive" ? "Delete" : "Confirm"; - const cancelText = "Cancel"; + opts: ConfirmationOptions | string, + callback?: () => void, + variant?: "default" | "destructive", + ): Promise => { + // Legacy signature support + if (typeof opts === "string" && callback) { + const actionText = variant === "destructive" ? "Delete" : "Confirm"; + const cancelText = "Cancel"; - toast(message, { - action: { - label: actionText, - onClick: callback, - }, - cancel: { - label: cancelText, - onClick: () => {}, - }, - duration: 10000, - className: variant === "destructive" ? "border-red-500" : "", + toast(opts, { + action: { + label: actionText, + onClick: callback, + }, + cancel: { + label: cancelText, + onClick: () => {}, + }, + duration: 10000, + className: variant === "destructive" ? "border-red-500" : "", + }); + return Promise.resolve(true); + } + + // New Promise-based signature + return new Promise((resolve) => { + const options = opts as ConfirmationOptions; + const actionText = options.confirmText || "Confirm"; + const cancelText = options.cancelText || "Cancel"; + const variantClass = options.variant === "destructive" ? "border-red-500" : ""; + + toast(options.title, { + description: options.description, + action: { + label: actionText, + onClick: () => resolve(true), + }, + cancel: { + label: cancelText, + onClick: () => resolve(false), + }, + duration: 10000, + className: variantClass, + }); }); }; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c21d4a16..cfb7e3eb 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -402,7 +402,10 @@ "documentation": "Documentation", "retry": "Retry", "checking": "Checking...", - "checkingDatabase": "Checking database connection..." + "checkingDatabase": "Checking database connection...", + "actions": "Actions", + "remove": "Remove", + "revoke": "Revoke" }, "nav": { "home": "Home", @@ -1808,6 +1811,162 @@ "ram": "RAM", "notAvailable": "N/A" }, + "rbac": { + "shareHost": "Share Host", + "shareHostTitle": "Share Host Access", + "shareHostDescription": "Grant temporary or permanent access to this host", + "targetUser": "Target User", + "selectUser": "Select a user to share with", + "duration": "Duration", + "durationHours": "Duration (hours)", + "neverExpires": "Never expires", + "permissionLevel": "Permission Level", + "permissionLevels": { + "readonly": "Read-Only", + "readonlyDesc": "Can view only, no command input", + "restricted": "Restricted", + "restrictedDesc": "Blocks dangerous commands (passwd, rm -rf, etc.)", + "monitored": "Monitored", + "monitoredDesc": "Records all commands but doesn't block (Recommended)", + "full": "Full Access", + "fullDesc": "No restrictions (Not recommended)" + }, + "blockedCommands": "Blocked Commands", + "blockedCommandsPlaceholder": "Enter commands to block, e.g., passwd, rm, dd", + "maxSessionDuration": "Max Session Duration (minutes)", + "createTempUser": "Create Temporary User", + "createTempUserDesc": "Creates a restricted user on the server instead of sharing your credentials. Requires sudo access. Most secure option.", + "expiresAt": "Expires At", + "expiresIn": "Expires in {{hours}} hours", + "expired": "Expired", + "grantedBy": "Granted By", + "accessLevel": "Access Level", + "lastAccessed": "Last Accessed", + "accessCount": "Access Count", + "revokeAccess": "Revoke Access", + "confirmRevokeAccess": "Are you sure you want to revoke access for {{username}}?", + "hostSharedSuccessfully": "Host shared successfully with {{username}}", + "hostAccessUpdated": "Host access updated", + "failedToShareHost": "Failed to share host", + "accessRevokedSuccessfully": "Access revoked successfully", + "failedToRevokeAccess": "Failed to revoke access", + "shared": "Shared", + "sharedHosts": "Shared Hosts", + "sharedWithMe": "Shared With Me", + "noSharedHosts": "No hosts shared with you", + "owner": "Owner", + "viewAccessList": "View Access List", + "accessList": "Access List", + "noAccessGranted": "No access has been granted for this host", + "noAccessGrantedMessage": "No users have been granted access to this host yet", + "manageAccessFor": "Manage access for", + "totalAccessRecords": "{{count}} access record(s)", + "neverAccessed": "Never", + "timesAccessed": "{{count}} time(s)", + "daysRemaining": "{{days}} day(s)", + "hoursRemaining": "{{hours}} hour(s)", + "expired": "Expired", + "failedToFetchAccessList": "Failed to fetch access list", + "currentAccess": "Current Access", + "securityWarning": "Security Warning", + "securityWarningMessage": "Sharing credentials gives the user full access to perform any operations on the server, including changing passwords and deleting files. Only share with trusted users.", + "tempUserRecommended": "We recommend enabling 'Create Temporary User' for better security.", + "roleManagement": "Role Management", + "assignRole": "Assign Role", + "removeRole": "Remove Role", + "userRoles": "User Roles", + "permissions": "Permissions", + "systemRole": "System Role", + "customRole": "Custom Role", + "roleAssignedSuccessfully": "Role assigned to {{username}} successfully", + "failedToAssignRole": "Failed to assign role", + "roleRemovedSuccessfully": "Role removed from {{username}} successfully", + "failedToRemoveRole": "Failed to remove role", + "cannotRemoveSystemRole": "Cannot remove system role", + "auditLogs": "Audit Logs", + "viewAuditLogs": "View Audit Logs", + "action": "Action", + "resourceType": "Resource Type", + "resourceName": "Resource Name", + "timestamp": "Timestamp", + "ipAddress": "IP Address", + "userAgent": "User Agent", + "success": "Success", + "failed": "Failed", + "details": "Details", + "noAuditLogs": "No audit logs available", + "sessionRecordings": "Session Recordings", + "viewRecording": "View Recording", + "downloadRecording": "Download Recording", + "dangerousCommand": "Dangerous Command Detected", + "commandBlocked": "Command Blocked", + "terminateSession": "Terminate Session", + "sessionTerminated": "Session terminated by host owner", + "sharedAccessExpired": "Your shared access to this host has expired", + "sharedAccessExpiresIn": "Shared access expires in {{hours}} hours", + "roles": { + "label": "Roles", + "admin": "Administrator", + "user": "User" + }, + "createRole": "Create Role", + "editRole": "Edit Role", + "roleName": "Role Name", + "displayName": "Display Name", + "description": "Description", + "assignRoles": "Assign Roles", + "userRoleAssignment": "User-Role Assignment", + "selectUserPlaceholder": "Select a user", + "currentRoles": "Current Roles", + "noRolesAssigned": "No roles assigned", + "assignNewRole": "Assign New Role", + "selectRolePlaceholder": "Select a role", + "assign": "Assign", + "roleCreatedSuccessfully": "Role created successfully", + "roleUpdatedSuccessfully": "Role updated successfully", + "roleDeletedSuccessfully": "Role deleted successfully", + "failedToLoadRoles": "Failed to load roles", + "failedToSaveRole": "Failed to save role", + "failedToDeleteRole": "Failed to delete role", + "roleDisplayNameRequired": "Role display name is required", + "roleNameRequired": "Role name is required", + "roleNameHint": "Use lowercase letters, numbers, underscores, and hyphens only", + "displayNamePlaceholder": "Developer", + "descriptionPlaceholder": "Software developers and engineers", + "confirmDeleteRole": "Delete Role", + "confirmDeleteRoleDescription": "Are you sure you want to delete the role '{{name}}'? This action cannot be undone.", + "confirmRemoveRole": "Remove Role", + "confirmRemoveRoleDescription": "Are you sure you want to remove this role from the user?", + "editRoleDescription": "Update role information", + "createRoleDescription": "Create a new custom role for grouping users", + "assignRolesDescription": "Manage role assignments for users", + "noRoles": "No roles found", + "selectRole": "Select Role", + "type": "Type", + "user": "User", + "role": "Role", + "saveHostFirst": "Save Host First", + "saveHostFirstDescription": "Please save the host before configuring sharing settings.", + "shareWithUser": "Share with User", + "shareWithRole": "Share with Role", + "share": "Share", + "target": "Target", + "expires": "Expires", + "never": "Never", + "noAccessRecords": "No access records found", + "sharedSuccessfully": "Shared successfully", + "failedToShare": "Failed to share", + "confirmRevokeAccessDescription": "Are you sure you want to revoke this access?", + "hours": "hours", + "sharing": "Sharing", + "selectUserAndRole": "Please select both a user and a role", + "view": "View Only", + "viewDesc": "Can view and connect to the host in read-only mode", + "use": "Use", + "useDesc": "Can use the host normally but cannot modify host configuration", + "manage": "Manage", + "manageDesc": "Full control including modifying host configuration and sharing settings" + }, "commandPalette": { "searchPlaceholder": "Search for hosts or quick actions...", "recentActivity": "Recent Activity", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 73bb39e1..a9f6f84a 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -382,7 +382,10 @@ "documentation": "文档", "retry": "重试", "checking": "检查中...", - "checkingDatabase": "正在检查数据库连接..." + "checkingDatabase": "正在检查数据库连接...", + "actions": "操作", + "remove": "移除", + "revoke": "撤销" }, "nav": { "home": "首页", @@ -1669,6 +1672,162 @@ "ram": "内存", "notAvailable": "不可用" }, + "rbac": { + "shareHost": "分享主机", + "shareHostTitle": "分享主机访问权限", + "shareHostDescription": "授予临时或永久访问此主机的权限", + "targetUser": "目标用户", + "selectUser": "选择要分享的用户", + "duration": "时长", + "durationHours": "时长(小时)", + "neverExpires": "永不过期", + "permissionLevel": "权限级别", + "permissionLevels": { + "readonly": "只读", + "readonlyDesc": "仅可查看,无法输入命令", + "restricted": "受限", + "restrictedDesc": "阻止危险命令(passwd、rm -rf等)", + "monitored": "监控", + "monitoredDesc": "记录所有命令但不阻止(推荐)", + "full": "完全访问", + "fullDesc": "无任何限制(不推荐)" + }, + "blockedCommands": "阻止的命令", + "blockedCommandsPlaceholder": "输入要阻止的命令,如:passwd, rm, dd", + "maxSessionDuration": "最大会话时长(分钟)", + "createTempUser": "创建临时用户", + "createTempUserDesc": "在服务器上创建受限用户而不是共享您的凭据。需要sudo权限。最安全的选项。", + "expiresAt": "过期时间", + "expiresIn": "{{hours}}小时后过期", + "expired": "已过期", + "grantedBy": "授予者", + "accessLevel": "访问级别", + "lastAccessed": "最后访问", + "accessCount": "访问次数", + "revokeAccess": "撤销访问", + "confirmRevokeAccess": "确定要撤销{{username}}的访问权限吗?", + "hostSharedSuccessfully": "已成功与{{username}}分享主机", + "hostAccessUpdated": "主机访问已更新", + "failedToShareHost": "分享主机失败", + "accessRevokedSuccessfully": "访问权限已成功撤销", + "failedToRevokeAccess": "撤销访问失败", + "shared": "共享", + "sharedHosts": "共享主机", + "sharedWithMe": "与我共享", + "noSharedHosts": "没有与您共享的主机", + "owner": "所有者", + "viewAccessList": "查看访问列表", + "accessList": "访问列表", + "noAccessGranted": "此主机尚未授予任何访问权限", + "noAccessGrantedMessage": "还没有用户被授予此主机的访问权限", + "manageAccessFor": "管理访问权限", + "totalAccessRecords": "{{count}} 条访问记录", + "neverAccessed": "从未访问", + "timesAccessed": "{{count}} 次", + "daysRemaining": "{{days}} 天", + "hoursRemaining": "{{hours}} 小时", + "expired": "已过期", + "failedToFetchAccessList": "获取访问列表失败", + "currentAccess": "当前访问", + "securityWarning": "安全警告", + "securityWarningMessage": "分享凭据会让用户完全访问服务器并执行任何操作,包括更改密码和删除文件。仅与受信任的用户共享。", + "tempUserRecommended": "我们建议启用'创建临时用户'以获得更好的安全性。", + "roleManagement": "角色管理", + "assignRole": "分配角色", + "removeRole": "移除角色", + "userRoles": "用户角色", + "permissions": "权限", + "systemRole": "系统角色", + "customRole": "自定义角色", + "roleAssignedSuccessfully": "已成功为{{username}}分配角色", + "failedToAssignRole": "分配角色失败", + "roleRemovedSuccessfully": "已成功从{{username}}移除角色", + "failedToRemoveRole": "移除角色失败", + "cannotRemoveSystemRole": "无法移除系统角色", + "auditLogs": "审计日志", + "viewAuditLogs": "查看审计日志", + "action": "操作", + "resourceType": "资源类型", + "resourceName": "资源名称", + "timestamp": "时间戳", + "ipAddress": "IP地址", + "userAgent": "用户代理", + "success": "成功", + "failed": "失败", + "details": "详情", + "noAuditLogs": "无可用审计日志", + "sessionRecordings": "会话录制", + "viewRecording": "查看录制", + "downloadRecording": "下载录制", + "dangerousCommand": "检测到危险命令", + "commandBlocked": "命令已阻止", + "terminateSession": "终止会话", + "sessionTerminated": "会话已被主机所有者终止", + "sharedAccessExpired": "您对此主机的共享访问权限已过期", + "sharedAccessExpiresIn": "共享访问将在{{hours}}小时后过期", + "roles": { + "label": "角色", + "admin": "管理员", + "user": "用户" + }, + "createRole": "创建角色", + "editRole": "编辑角色", + "roleName": "角色名称", + "displayName": "显示名称", + "description": "描述", + "assignRoles": "分配角色", + "userRoleAssignment": "用户角色分配", + "selectUserPlaceholder": "选择用户", + "currentRoles": "当前角色", + "noRolesAssigned": "未分配角色", + "assignNewRole": "分配新角色", + "selectRolePlaceholder": "选择角色", + "assign": "分配", + "roleCreatedSuccessfully": "角色创建成功", + "roleUpdatedSuccessfully": "角色更新成功", + "roleDeletedSuccessfully": "角色删除成功", + "failedToLoadRoles": "加载角色失败", + "failedToSaveRole": "保存角色失败", + "failedToDeleteRole": "删除角色失败", + "roleDisplayNameRequired": "角色显示名称是必需的", + "roleNameRequired": "角色名称是必需的", + "roleNameHint": "仅使用小写字母、数字、下划线和连字符", + "displayNamePlaceholder": "开发者", + "descriptionPlaceholder": "软件开发人员和工程师", + "confirmDeleteRole": "删除角色", + "confirmDeleteRoleDescription": "确定要删除角色'{{name}}'吗?此操作无法撤销。", + "confirmRemoveRole": "移除角色", + "confirmRemoveRoleDescription": "确定要从用户中移除此角色吗?", + "editRoleDescription": "更新角色信息", + "createRoleDescription": "创建新的自定义角色以分组用户", + "assignRolesDescription": "管理用户的角色分配", + "noRoles": "未找到角色", + "selectRole": "选择角色", + "type": "类型", + "user": "用户", + "role": "角色", + "saveHostFirst": "请先保存主机", + "saveHostFirstDescription": "请先保存主机后再配置分享设置。", + "shareWithUser": "与用户分享", + "shareWithRole": "与角色分享", + "share": "分享", + "target": "目标", + "expires": "过期时间", + "never": "永不", + "noAccessRecords": "未找到访问记录", + "sharedSuccessfully": "分享成功", + "failedToShare": "分享失败", + "confirmRevokeAccessDescription": "确定要撤销此访问权限吗?", + "hours": "小时", + "sharing": "分享", + "selectUserAndRole": "请选择用户和角色", + "view": "仅查看", + "viewDesc": "可以查看和连接主机,但仅限只读模式", + "use": "使用", + "useDesc": "可以正常使用主机,但不能修改主机配置", + "manage": "管理", + "manageDesc": "完全控制,包括修改主机配置和分享设置" + }, "commandPalette": { "searchPlaceholder": "搜索主机或快速操作...", "recentActivity": "最近活动", diff --git a/src/ui/desktop/admin/AdminSettings.tsx b/src/ui/desktop/admin/AdminSettings.tsx index 9febd08a..30955663 100644 --- a/src/ui/desktop/admin/AdminSettings.tsx +++ b/src/ui/desktop/admin/AdminSettings.tsx @@ -67,6 +67,7 @@ import { linkOIDCToPasswordAccount, unlinkOIDCFromPasswordAccount, } from "@/ui/main-axios.ts"; +import { RoleManagement } from "./RoleManagement.tsx"; interface AdminSettingsProps { isTopbarOpen?: boolean; @@ -771,6 +772,10 @@ export function AdminSettings({ {t("admin.adminManagement")} + + + {t("rbac.roles.label")} + {t("admin.databaseSecurity")} @@ -1398,6 +1403,10 @@ export function AdminSettings({ + + + +
diff --git a/src/ui/desktop/admin/RoleManagement.tsx b/src/ui/desktop/admin/RoleManagement.tsx new file mode 100644 index 00000000..b129b418 --- /dev/null +++ b/src/ui/desktop/admin/RoleManagement.tsx @@ -0,0 +1,515 @@ +import React from "react"; +import { Button } from "@/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Label } from "@/components/ui/label.tsx"; +import { Textarea } from "@/components/ui/textarea.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.tsx"; +import { Badge } from "@/components/ui/badge.tsx"; +import { Shield, Plus, Edit, Trash2, Users } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useConfirmation } from "@/hooks/use-confirmation.ts"; +import { + getRoles, + createRole, + updateRole, + deleteRole, + getUserList, + getUserRoles, + assignRoleToUser, + removeRoleFromUser, + type Role, + type UserRole, +} from "@/ui/main-axios.ts"; + +interface User { + id: string; + username: string; + is_admin: boolean; +} + +export function RoleManagement(): React.ReactElement { + const { t } = useTranslation(); + const { confirmWithToast } = useConfirmation(); + + const [roles, setRoles] = React.useState([]); + const [users, setUsers] = React.useState([]); + const [loading, setLoading] = React.useState(false); + + // Create/Edit Role Dialog + const [roleDialogOpen, setRoleDialogOpen] = React.useState(false); + const [editingRole, setEditingRole] = React.useState(null); + const [roleName, setRoleName] = React.useState(""); + const [roleDisplayName, setRoleDisplayName] = React.useState(""); + const [roleDescription, setRoleDescription] = React.useState(""); + + // Assign Role Dialog + const [assignDialogOpen, setAssignDialogOpen] = React.useState(false); + const [selectedUserId, setSelectedUserId] = React.useState(""); + const [selectedRoleId, setSelectedRoleId] = React.useState( + null, + ); + const [userRoles, setUserRoles] = React.useState([]); + + // Load roles + const loadRoles = React.useCallback(async () => { + setLoading(true); + try { + const response = await getRoles(); + setRoles(response.roles || []); + } catch (error) { + toast.error(t("rbac.failedToLoadRoles")); + console.error("Failed to load roles:", error); + setRoles([]); + } finally { + setLoading(false); + } + }, [t]); + + // Load users + const loadUsers = React.useCallback(async () => { + try { + const response = await getUserList(); + // Map UserInfo to User format + const mappedUsers = (response.users || []).map((user) => ({ + id: user.id, + username: user.username, + is_admin: user.is_admin, + })); + setUsers(mappedUsers); + } catch (error) { + console.error("Failed to load users:", error); + setUsers([]); + } + }, []); + + React.useEffect(() => { + loadRoles(); + loadUsers(); + }, [loadRoles, loadUsers]); + + // Create role + const handleCreateRole = () => { + setEditingRole(null); + setRoleName(""); + setRoleDisplayName(""); + setRoleDescription(""); + setRoleDialogOpen(true); + }; + + // Edit role + const handleEditRole = (role: Role) => { + setEditingRole(role); + setRoleName(role.name); + setRoleDisplayName(role.displayName); + setRoleDescription(role.description || ""); + setRoleDialogOpen(true); + }; + + // Save role + const handleSaveRole = async () => { + if (!roleDisplayName.trim()) { + toast.error(t("rbac.roleDisplayNameRequired")); + return; + } + + if (!editingRole && !roleName.trim()) { + toast.error(t("rbac.roleNameRequired")); + return; + } + + try { + if (editingRole) { + // Update existing role + await updateRole(editingRole.id, { + displayName: roleDisplayName, + description: roleDescription || null, + }); + toast.success(t("rbac.roleUpdatedSuccessfully")); + } else { + // Create new role + await createRole({ + name: roleName, + displayName: roleDisplayName, + description: roleDescription || null, + }); + toast.success(t("rbac.roleCreatedSuccessfully")); + } + + setRoleDialogOpen(false); + loadRoles(); + } catch (error) { + toast.error(t("rbac.failedToSaveRole")); + } + }; + + // Delete role + const handleDeleteRole = async (role: Role) => { + const confirmed = await confirmWithToast({ + title: t("rbac.confirmDeleteRole"), + description: t("rbac.confirmDeleteRoleDescription", { + name: role.displayName, + }), + confirmText: t("common.delete"), + cancelText: t("common.cancel"), + }); + + if (!confirmed) return; + + try { + await deleteRole(role.id); + toast.success(t("rbac.roleDeletedSuccessfully")); + loadRoles(); + } catch (error) { + toast.error(t("rbac.failedToDeleteRole")); + } + }; + + // Open assign dialog + const handleOpenAssignDialog = async () => { + setSelectedUserId(""); + setSelectedRoleId(null); + setUserRoles([]); + setAssignDialogOpen(true); + }; + + // Load user roles when user is selected + const handleUserSelect = async (userId: string) => { + setSelectedUserId(userId); + setUserRoles([]); + + if (!userId) return; + + try { + const response = await getUserRoles(userId); + setUserRoles(response.roles || []); + } catch (error) { + console.error("Failed to load user roles:", error); + setUserRoles([]); + } + }; + + // Assign role to user + const handleAssignRole = async () => { + if (!selectedUserId || !selectedRoleId) { + toast.error(t("rbac.selectUserAndRole")); + return; + } + + try { + await assignRoleToUser(selectedUserId, selectedRoleId); + const selectedUser = users.find((u) => u.id === selectedUserId); + toast.success(t("rbac.roleAssignedSuccessfully", { username: selectedUser?.username || selectedUserId })); + setSelectedRoleId(null); + handleUserSelect(selectedUserId); + } catch (error) { + toast.error(t("rbac.failedToAssignRole")); + } + }; + + // Remove role from user + const handleRemoveUserRole = async (roleId: number) => { + if (!selectedUserId) return; + + try { + await removeRoleFromUser(selectedUserId, roleId); + const selectedUser = users.find((u) => u.id === selectedUserId); + toast.success(t("rbac.roleRemovedSuccessfully", { username: selectedUser?.username || selectedUserId })); + handleUserSelect(selectedUserId); + } catch (error) { + toast.error(t("rbac.failedToRemoveRole")); + } + }; + + return ( +
+ {/* Roles Section */} +
+
+

+ + {t("rbac.roleManagement")} +

+ +
+ + + + + {t("rbac.roleName")} + {t("rbac.displayName")} + {t("rbac.description")} + {t("rbac.type")} + {t("common.actions")} + + + + {loading ? ( + + + {t("common.loading")} + + + ) : roles.length === 0 ? ( + + + {t("rbac.noRoles")} + + + ) : ( + roles.map((role) => ( + + {role.name} + {t(role.displayName)} + + {role.description || "-"} + + + {role.isSystem ? ( + {t("rbac.systemRole")} + ) : ( + {t("rbac.customRole")} + )} + + +
+ + {!role.isSystem && ( + + )} +
+
+
+ )) + )} +
+
+
+ + {/* User-Role Assignment Section */} +
+
+

+ + {t("rbac.userRoleAssignment")} +

+ +
+
+ + {/* Create/Edit Role Dialog */} + + + + + {editingRole ? t("rbac.editRole") : t("rbac.createRole")} + + + {editingRole + ? t("rbac.editRoleDescription") + : t("rbac.createRoleDescription")} + + + +
+ {!editingRole && ( +
+ + setRoleName(e.target.value.toLowerCase())} + placeholder="developer" + disabled={!!editingRole} + /> +

+ {t("rbac.roleNameHint")} +

+
+ )} + +
+ + setRoleDisplayName(e.target.value)} + placeholder={t("rbac.displayNamePlaceholder")} + /> +
+ +
+ +