Fix RBAC role system bugs and improve UX (#446)
* Fix RBAC role system bugs and improve UX - Fix user list dropdown selection in host sharing - Fix role sharing permissions to include role-based access - Fix translation template interpolation for success messages - Standardize system roles to admin and user only - Auto-assign user role to new registrations - Remove blocking confirmation dialogs in modal contexts - Add missing i18n keys for common actions - Fix button type to prevent unintended form submissions * Enhance RBAC system with UI improvements and security fixes - Move role assignment to Users tab with per-user role management - Protect system roles (admin/user) from editing and manual assignment - Simplify permission system: remove Use level, keep View and Manage - Hide Update button and Sharing tab for view-only/shared hosts - Prevent users from sharing hosts with themselves - Unify table and modal styling across admin panels - Auto-assign system roles on user registration - Add permission metadata to host interface * Add empty state message for role assignment - Display helpful message when no custom roles available - Clarify that system roles are auto-assigned - Add noCustomRolesToAssign translation in English and Chinese * fix: Prevent credential sharing errors for shared hosts - Skip credential resolution for shared hosts with credential authentication to prevent decryption errors (credentials are encrypted per-user) - Add warning alert in sharing tab when host uses credential authentication - Inform users that shared users cannot connect to credential-based hosts - Add translations for credential sharing warning (EN/ZH) This prevents authentication failures when sharing hosts configured with credential authentication while maintaining security by keeping credentials isolated per user. * feat: Improve rbac UI and fixes some bugs --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com>
This commit was merged in pull request #446.
This commit is contained in:
@@ -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(
|
||||
(
|
||||
|
||||
@@ -330,6 +330,81 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
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 {
|
||||
@@ -559,6 +634,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",
|
||||
});
|
||||
|
||||
@@ -279,3 +279,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"),
|
||||
});
|
||||
|
||||
908
src/backend/database/routes/rbac.ts
Normal file
908
src/backend/database/routes/rbac.ts
Normal file
@@ -0,0 +1,908 @@
|
||||
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<string>`(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<number>`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<number>`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" });
|
||||
}
|
||||
|
||||
// Prevent manual assignment of system roles
|
||||
if (role[0].isSystem) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"System roles (admin, user) are automatically assigned and cannot be manually assigned",
|
||||
});
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Verify role exists and get its 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" });
|
||||
}
|
||||
|
||||
// Prevent removal of system roles
|
||||
if (role[0].isSystem) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"System roles (admin, user) are automatically assigned and cannot be removed",
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -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";
|
||||
@@ -665,8 +667,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<boolean>`${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,
|
||||
);
|
||||
@@ -700,6 +792,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;
|
||||
@@ -1462,6 +1559,29 @@ async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
// Skip credential resolution for shared hosts
|
||||
// Shared users cannot access the owner's encrypted credentials
|
||||
if (host.isShared && host.credentialId) {
|
||||
sshLogger.info(
|
||||
`Skipping credential resolution for shared host ${host.id} with credentialId ${host.credentialId}`,
|
||||
{
|
||||
operation: "resolve_host_credentials_shared",
|
||||
hostId: host.id as number,
|
||||
isShared: host.isShared,
|
||||
},
|
||||
);
|
||||
// Return host without resolving credentials
|
||||
// The frontend should handle credential auth for shared hosts differently
|
||||
const result = { ...host };
|
||||
if (host.key_password !== undefined) {
|
||||
if (result.keyPassword === undefined) {
|
||||
result.keyPassword = host.key_password;
|
||||
}
|
||||
delete result.key_password;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (host.credentialId && host.userId) {
|
||||
const credentialId = host.credentialId as number;
|
||||
const userId = host.userId as string;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user