chore: cleanup files (possible RC)

This commit is contained in:
LukeGus
2025-12-29 02:46:52 -06:00
parent 7c850c1072
commit dcbc9454ab
123 changed files with 3521 additions and 4844 deletions

View File

@@ -578,7 +578,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "notes", "TEXT");
// SOCKS5 Proxy columns
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
addColumnIfNotExists("ssh_data", "socks5_host", "TEXT");
addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER");
@@ -590,7 +589,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
// System-encrypted fields for offline credential sharing
addColumnIfNotExists("ssh_credentials", "system_password", "TEXT");
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
@@ -655,7 +653,6 @@ const migrateSchema = () => {
}
}
// RBAC Phase 1: Host Access table
try {
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
} catch {
@@ -678,9 +675,6 @@ const migrateSchema = () => {
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",
@@ -689,15 +683,11 @@ const migrateSchema = () => {
}
}
// 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",
@@ -706,15 +696,11 @@ const migrateSchema = () => {
}
}
// Migration: Add sudo_password column to ssh_data table
try {
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
} catch {
try {
sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sudo_password TEXT");
databaseLogger.info("Added sudo_password column to ssh_data table", {
operation: "schema_migration",
});
} catch (alterError) {
databaseLogger.warn("Failed to add sudo_password column", {
operation: "schema_migration",
@@ -723,7 +709,6 @@ const migrateSchema = () => {
}
}
// RBAC Phase 2: Roles tables
try {
sqlite.prepare("SELECT id FROM roles LIMIT 1").get();
} catch {
@@ -740,9 +725,6 @@ const migrateSchema = () => {
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",
@@ -768,9 +750,6 @@ const migrateSchema = () => {
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",
@@ -779,7 +758,6 @@ const migrateSchema = () => {
}
}
// RBAC Phase 3: Audit logging tables
try {
sqlite.prepare("SELECT id FROM audit_logs LIMIT 1").get();
} catch {
@@ -802,9 +780,6 @@ const migrateSchema = () => {
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",
@@ -836,9 +811,6 @@ const migrateSchema = () => {
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",
@@ -847,7 +819,6 @@ const migrateSchema = () => {
}
}
// RBAC: Shared Credentials table
try {
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
} catch {
@@ -872,9 +843,6 @@ const migrateSchema = () => {
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
databaseLogger.info("Created shared_credentials table", {
operation: "schema_migration",
});
} catch (createError) {
databaseLogger.warn("Failed to create shared_credentials table", {
operation: "schema_migration",
@@ -883,51 +851,31 @@ const migrateSchema = () => {
}
}
// 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",
@@ -935,7 +883,6 @@ const migrateSchema = () => {
});
}
// Ensure only admin and user system roles exist
const systemRoles = [
{
name: "admin",
@@ -954,7 +901,6 @@ const migrateSchema = () => {
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)
@@ -969,11 +915,6 @@ const migrateSchema = () => {
}
}
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 }[];
@@ -994,11 +935,6 @@ const migrateSchema = () => {
// Ignore duplicate errors
}
}
databaseLogger.info("Migrated admin users to admin role", {
operation: "schema_migration",
count: adminUsers.length,
});
}
if (userRole) {
@@ -1014,11 +950,6 @@ const migrateSchema = () => {
// 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", {

View File

@@ -101,7 +101,7 @@ export const sshData = sqliteTable("ssh_data", {
socks5Port: integer("socks5_port"),
socks5Username: text("socks5_username"),
socks5Password: text("socks5_password"),
socks5ProxyChain: text("socks5_proxy_chain"), // JSON array for proxy chains
socks5ProxyChain: text("socks5_proxy_chain"),
createdAt: text("created_at")
.notNull()
@@ -186,7 +186,6 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
keyType: text("key_type"),
detectedKeyType: text("detected_key_type"),
// System-encrypted fields for offline credential sharing
systemPassword: text("system_password"),
systemKey: text("system_key", { length: 16384 }),
systemKeyPassword: text("system_key_password"),
@@ -296,32 +295,27 @@ export const commandHistory = sqliteTable("command_history", {
.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
.references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id")
.references(() => roles.id, { onDelete: "cascade" }), // Optional
.references(() => roles.id, { onDelete: "cascade" }),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Permission level (view-only)
permissionLevel: text("permission_level")
.notNull()
.default("view"), // Only "view" is supported
.default("view"),
// Time-based access
expiresAt: text("expires_at"), // NULL = never expires
expiresAt: text("expires_at"),
// Metadata
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -329,26 +323,21 @@ export const hostAccess = sqliteTable("host_access", {
accessCount: integer("access_count").notNull().default(0),
});
// RBAC: Shared Credentials (per-user encrypted credential copies)
export const sharedCredentials = sqliteTable("shared_credentials", {
id: integer("id").primaryKey({ autoIncrement: true }),
// Link to the host access grant (CASCADE delete when share revoked)
hostAccessId: integer("host_access_id")
.notNull()
.references(() => hostAccess.id, { onDelete: "cascade" }),
// Link to the original credential (for tracking updates/CASCADE delete)
originalCredentialId: integer("original_credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
// Target user (recipient of the share) - CASCADE delete when user deleted
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Encrypted credential data (encrypted with targetUserId's DEK)
encryptedUsername: text("encrypted_username").notNull(),
encryptedAuthType: text("encrypted_auth_type").notNull(),
encryptedPassword: text("encrypted_password"),
@@ -356,7 +345,6 @@ export const sharedCredentials = sqliteTable("shared_credentials", {
encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"),
// Metadata
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -364,26 +352,22 @@ export const sharedCredentials = sqliteTable("shared_credentials", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
// Track if needs re-encryption (when original credential updated but target user offline)
needsReEncryption: integer("needs_re_encryption", { mode: "boolean" })
.notNull()
.default(false),
});
// 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
displayName: text("display_name").notNull(),
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
permissions: text("permissions"),
createdAt: text("created_at")
.notNull()
@@ -410,32 +394,26 @@ export const userRoles = sqliteTable("user_roles", {
.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
username: text("username").notNull(),
// 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
action: text("action").notNull(),
resourceType: text("resource_type").notNull(),
resourceId: text("resource_id"),
resourceName: text("resource_name"),
// Context
details: text("details"), // JSON: { oldValue, newValue, reason, ... }
details: text("details"),
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`),
@@ -454,21 +432,17 @@ export const sessionRecordings = sqliteTable("session_recordings", {
onDelete: "set null",
}),
// Session info
startedAt: text("started_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
duration: integer("duration"), // seconds
duration: integer("duration"),
// Command log (lightweight)
commands: text("commands"), // JSON: [{ts, cmd, exitCode, blocked}]
dangerousActions: text("dangerous_actions"), // JSON: blocked commands
commands: text("commands"),
dangerousActions: text("dangerous_actions"),
// Full recording (optional, heavy)
recordingPath: text("recording_path"), // Path to .cast file
recordingPath: text("recording_path"),
// Metadata
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
.default(false),
terminationReason: text("termination_reason"),

View File

@@ -478,7 +478,6 @@ router.put(
userId,
);
// Update shared credentials if this credential is shared
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
@@ -541,8 +540,6 @@ router.delete(
return res.status(404).json({ error: "Credential not found" });
}
// Update hosts using this credential to set credentialId to null
// This prevents orphaned references before deletion
const hostsUsingCredential = await db
.select()
.from(sshData)
@@ -570,7 +567,6 @@ router.delete(
),
);
// Revoke all shares for hosts that used this credential
for (const host of hostsUsingCredential) {
const revokedShares = await db
.delete(hostAccess)
@@ -592,16 +588,11 @@ router.delete(
}
}
// Delete shared credentials for this original credential
// Note: This will also be handled by CASCADE, but we do it explicitly for logging
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
// sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
// No need for manual deletion
await db
.delete(sshCredentials)
.where(

View File

@@ -27,10 +27,8 @@ 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
*/
//Share a host with a user or role
//POST /rbac/host/:id/share
router.post(
"/host/:id/share",
authenticateJWT,
@@ -44,21 +42,19 @@ router.post(
try {
const {
targetType = "user", // "user" or "role"
targetType = "user",
targetUserId,
targetRoleId,
durationHours,
permissionLevel = "view", // Only "view" is supported
permissionLevel = "view",
} = 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)
@@ -70,7 +66,6 @@ router.post(
.json({ error: "Target role ID is required when sharing with role" });
}
// Verify user owns the host
const host = await db
.select()
.from(sshData)
@@ -86,7 +81,6 @@ router.post(
return res.status(403).json({ error: "Not host owner" });
}
// Check if host uses credentials (required for sharing)
if (!host[0].credentialId) {
return res.status(400).json({
error:
@@ -95,7 +89,6 @@ router.post(
});
}
// Verify target exists (user or role)
if (targetType === "user") {
const targetUser = await db
.select({ id: users.id, username: users.username })
@@ -118,7 +111,6 @@ router.post(
}
}
// Calculate expiry time
let expiresAt: string | null = null;
if (
durationHours &&
@@ -130,7 +122,6 @@ router.post(
expiresAt = expiryDate.toISOString();
}
// Validate permission level (only "view" is supported)
const validLevels = ["view"];
if (!validLevels.includes(permissionLevel)) {
return res.status(400).json({
@@ -139,7 +130,6 @@ router.post(
});
}
// Check if access already exists
const whereConditions = [eq(hostAccess.hostId, hostId)];
if (targetType === "user") {
whereConditions.push(eq(hostAccess.userId, targetUserId));
@@ -154,7 +144,6 @@ router.post(
.limit(1);
if (existing.length > 0) {
// Update existing access
await db
.update(hostAccess)
.set({
@@ -163,7 +152,6 @@ router.post(
})
.where(eq(hostAccess.id, existing[0].id));
// Re-create shared credential (delete old, create new)
await db
.delete(sharedCredentials)
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
@@ -187,16 +175,6 @@ router.post(
);
}
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",
@@ -204,7 +182,6 @@ router.post(
});
}
// Create new access
const result = await db.insert(hostAccess).values({
hostId,
userId: targetType === "user" ? targetUserId : null,
@@ -214,7 +191,6 @@ router.post(
expiresAt,
});
// Create shared credential for the target
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
@@ -235,17 +211,6 @@ router.post(
);
}
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}`,
@@ -262,10 +227,8 @@ router.post(
},
);
/**
* Revoke host access
* DELETE /rbac/host/:id/access/:accessId
*/
// Revoke host access
// DELETE /rbac/host/:id/access/:accessId
router.delete(
"/host/:id/access/:accessId",
authenticateJWT,
@@ -279,7 +242,6 @@ router.delete(
}
try {
// Verify user owns the host
const host = await db
.select()
.from(sshData)
@@ -290,16 +252,8 @@ router.delete(
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, {
@@ -313,10 +267,8 @@ router.delete(
},
);
/**
* Get host access list
* GET /rbac/host/:id/access
*/
// Get host access list
// GET /rbac/host/:id/access
router.get(
"/host/:id/access",
authenticateJWT,
@@ -329,7 +281,6 @@ router.get(
}
try {
// Verify user owns the host
const host = await db
.select()
.from(sshData)
@@ -340,7 +291,6 @@ router.get(
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,
@@ -361,7 +311,6 @@ router.get(
.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",
@@ -389,10 +338,8 @@ router.get(
},
);
/**
* Get user's shared hosts (hosts shared WITH this user)
* GET /rbac/shared-hosts
*/
// Get user's shared hosts (hosts shared WITH this user)
// GET /rbac/shared-hosts
router.get(
"/shared-hosts",
authenticateJWT,
@@ -438,10 +385,8 @@ router.get(
},
);
/**
* Get all roles
* GET /rbac/roles
*/
// Get all roles
// GET /rbac/roles
router.get(
"/roles",
authenticateJWT,
@@ -468,14 +413,8 @@ router.get(
},
);
// ============================================================================
// Role Management (CRUD)
// ============================================================================
/**
* Get all roles
* GET /rbac/roles
*/
// Get all roles
// GET /rbac/roles
router.get(
"/roles",
authenticateJWT,
@@ -504,10 +443,8 @@ router.get(
},
);
/**
* Create new role
* POST /rbac/roles
*/
// Create new role
// POST /rbac/roles
router.post(
"/roles",
authenticateJWT,
@@ -515,14 +452,12 @@ router.post(
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:
@@ -531,7 +466,6 @@ router.post(
}
try {
// Check if role name already exists
const existing = await db
.select({ id: roles.id })
.from(roles)
@@ -544,23 +478,16 @@ router.post(
});
}
// Create new role
const result = await db.insert(roles).values({
name,
displayName,
description: description || null,
isSystem: false,
permissions: null, // Roles are for grouping only
permissions: null,
});
const newRoleId = result.lastInsertRowid;
databaseLogger.info("Created new role", {
operation: "create_role",
roleId: newRoleId,
roleName: name,
});
res.status(201).json({
success: true,
roleId: newRoleId,
@@ -576,10 +503,8 @@ router.post(
},
);
/**
* Update role
* PUT /rbac/roles/:id
*/
// Update role
// PUT /rbac/roles/:id
router.put(
"/roles/:id",
authenticateJWT,
@@ -592,7 +517,6 @@ router.put(
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",
@@ -600,7 +524,6 @@ router.put(
}
try {
// Get existing role
const existingRole = await db
.select({
id: roles.id,
@@ -615,7 +538,6 @@ router.put(
return res.status(404).json({ error: "Role not found" });
}
// Build update object
const updates: {
displayName?: string;
description?: string | null;
@@ -632,15 +554,8 @@ router.put(
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",
@@ -655,10 +570,8 @@ router.put(
},
);
/**
* Delete role
* DELETE /rbac/roles/:id
*/
// Delete role
// DELETE /rbac/roles/:id
router.delete(
"/roles/:id",
authenticateJWT,
@@ -671,7 +584,6 @@ router.delete(
}
try {
// Get role details
const role = await db
.select({
id: roles.id,
@@ -686,41 +598,28 @@ router.delete(
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",
});
}
// Delete user-role assignments first
const deletedUserRoles = await db
.delete(userRoles)
.where(eq(userRoles.roleId, roleId))
.returning({ userId: userRoles.userId });
// Invalidate permission cache for affected users
for (const { userId } of deletedUserRoles) {
permissionManager.invalidateUserPermissionCache(userId);
}
// Delete host_access entries for this role
const deletedHostAccess = await db
.delete(hostAccess)
.where(eq(hostAccess.roleId, roleId))
.returning({ id: hostAccess.id });
// Note: sharedCredentials will be auto-deleted by CASCADE
// 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",
@@ -735,14 +634,8 @@ router.delete(
},
);
// ============================================================================
// User-Role Assignment
// ============================================================================
/**
* Assign role to user
* POST /rbac/users/:userId/roles
*/
// Assign role to user
// POST /rbac/users/:userId/roles
router.post(
"/users/:userId/roles",
authenticateJWT,
@@ -758,7 +651,6 @@ router.post(
return res.status(400).json({ error: "Role ID is required" });
}
// Verify target user exists
const targetUser = await db
.select()
.from(users)
@@ -769,7 +661,6 @@ router.post(
return res.status(404).json({ error: "User not found" });
}
// Verify role exists
const role = await db
.select()
.from(roles)
@@ -780,7 +671,6 @@ router.post(
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:
@@ -788,7 +678,6 @@ router.post(
});
}
// Check if already assigned
const existing = await db
.select()
.from(userRoles)
@@ -801,14 +690,12 @@ router.post(
return res.status(409).json({ error: "Role already assigned" });
}
// Assign role
await db.insert(userRoles).values({
userId: targetUserId,
roleId,
grantedBy: currentUserId,
});
// Create shared credentials for all hosts shared with this role
const hostsSharedWithRole = await db
.select()
.from(hostAccess)
@@ -839,31 +726,12 @@ router.post(
hostId: ssh_data.id,
},
);
// Continue with other hosts even if one fails
}
}
}
if (hostsSharedWithRole.length > 0) {
databaseLogger.info("Created shared credentials for new role member", {
operation: "assign_role_create_credentials",
targetUserId,
roleId,
hostCount: hostsSharedWithRole.length,
});
}
// 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",
@@ -878,10 +746,8 @@ router.post(
},
);
/**
* Remove role from user
* DELETE /rbac/users/:userId/roles/:roleId
*/
// Remove role from user
// DELETE /rbac/users/:userId/roles/:roleId
router.delete(
"/users/:userId/roles/:roleId",
authenticateJWT,
@@ -895,7 +761,6 @@ router.delete(
}
try {
// Verify role exists and get its details
const role = await db
.select({
id: roles.id,
@@ -910,7 +775,6 @@ router.delete(
return res.status(404).json({ error: "Role not found" });
}
// Prevent removal of system roles
if (role[0].isSystem) {
return res.status(403).json({
error:
@@ -918,22 +782,14 @@ router.delete(
});
}
// 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",
@@ -949,10 +805,8 @@ router.delete(
},
);
/**
* Get user's roles
* GET /rbac/users/:userId/roles
*/
// Get user's roles
// GET /rbac/users/:userId/roles
router.get(
"/users/:userId/roles",
authenticateJWT,
@@ -960,7 +814,6 @@ router.get(
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))

View File

@@ -604,7 +604,6 @@ router.put(
}
try {
// Check if user can update this host (owner or manage permission)
const accessInfo = await permissionManager.canAccessHost(
userId,
Number(hostId),
@@ -620,7 +619,6 @@ router.put(
return res.status(403).json({ error: "Access denied" });
}
// Shared users cannot edit hosts (view-only)
if (!accessInfo.isOwner) {
sshLogger.warn("Shared user attempted to update host (view-only)", {
operation: "host_update",
@@ -632,7 +630,6 @@ router.put(
});
}
// Get the actual owner ID for the update
const hostRecord = await db
.select({
userId: sshData.userId,
@@ -654,7 +651,6 @@ router.put(
const ownerId = hostRecord[0].userId;
// Only owner can change credentialId
if (
!accessInfo.isOwner &&
sshDataObj.credentialId !== undefined &&
@@ -665,7 +661,6 @@ router.put(
});
}
// Only owner can change authType
if (
!accessInfo.isOwner &&
sshDataObj.authType !== undefined &&
@@ -676,31 +671,15 @@ router.put(
});
}
// Check if credentialId is changing from non-null to null
// This happens when switching from "credential" auth to "password"/"key"/"none"
if (sshDataObj.credentialId !== undefined) {
if (
hostRecord[0].credentialId !== null &&
sshDataObj.credentialId === null
) {
// Auth type changed away from credential - revoke all shares
const revokedShares = await db
.delete(hostAccess)
.where(eq(hostAccess.hostId, Number(hostId)))
.returning({ id: hostAccess.id, userId: hostAccess.userId });
if (revokedShares.length > 0) {
sshLogger.info(
"Auto-revoked host shares due to auth type change from credential",
{
operation: "auto_revoke_shares",
hostId: Number(hostId),
revokedCount: revokedShares.length,
reason: "auth_type_changed_from_credential",
},
);
// Note: sharedCredentials will be auto-deleted by CASCADE
}
}
}
@@ -830,17 +809,14 @@ router.get(
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,
@@ -881,7 +857,6 @@ router.get(
socks5Password: sshData.socks5Password,
socks5ProxyChain: sshData.socks5ProxyChain,
// Shared access info
ownerId: sshData.userId,
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`,
permissionLevel: hostAccess.permissionLevel,
@@ -903,15 +878,13 @@ router.get(
)
.where(
or(
eq(sshData.userId, userId), // Own hosts
eq(sshData.userId, userId),
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),
@@ -922,11 +895,9 @@ router.get(
),
);
// Separate own hosts from shared hosts for proper decryption
const ownHosts = rawData.filter((row) => row.userId === userId);
const sharedHosts = rawData.filter((row) => row.userId !== userId);
// Decrypt own hosts with user's DEK
let decryptedOwnHosts: any[] = [];
try {
decryptedOwnHosts = await SimpleDBOps.select(
@@ -934,38 +905,16 @@ router.get(
"ssh_data",
userId,
);
sshLogger.debug("Own hosts decrypted successfully", {
operation: "host_fetch_own_decrypted",
userId,
count: decryptedOwnHosts.length,
});
} catch (decryptError) {
sshLogger.error("Failed to decrypt own hosts", decryptError, {
operation: "host_fetch_own_decrypt_failed",
userId,
});
// Return empty array if decryption fails
decryptedOwnHosts = [];
}
// For shared hosts, DON'T try to decrypt them with user's DEK
// Just pass them through as plain objects without encrypted credential fields
// The credentials will be resolved via SharedCredentialManager later when resolveHostCredentials is called
sshLogger.info("Processing shared hosts", {
operation: "host_fetch_shared_process",
userId,
count: sharedHosts.length,
});
const sanitizedSharedHosts = sharedHosts;
sshLogger.info("Combining hosts", {
operation: "host_fetch_combine",
userId,
ownCount: decryptedOwnHosts.length,
sharedCount: sanitizedSharedHosts.length,
});
const data = [...decryptedOwnHosts, ...sanitizedSharedHosts];
const result = await Promise.all(
@@ -1001,7 +950,6 @@ router.get(
? JSON.parse(row.socks5ProxyChain as string)
: [],
// Add shared access metadata
isShared: !!row.isShared,
permissionLevel: row.permissionLevel || undefined,
sharedExpiresAt: row.expiresAt || undefined,
@@ -1013,12 +961,6 @@ router.get(
}),
);
sshLogger.info("Credential resolution complete, sending response", {
operation: "host_fetch_complete",
userId,
hostCount: result.length,
});
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch SSH hosts from database", err, {
@@ -1220,7 +1162,6 @@ router.delete(
const numericHostId = Number(hostId);
// Delete all related data in correct order (child tables first)
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.hostId, numericHostId));
@@ -1245,15 +1186,12 @@ router.delete(
.delete(recentActivity)
.where(eq(recentActivity.hostId, numericHostId));
// Delete RBAC host access entries
await db.delete(hostAccess).where(eq(hostAccess.hostId, numericHostId));
// Delete session recordings
await db
.delete(sessionRecordings)
.where(eq(sessionRecordings.hostId, numericHostId));
// Finally delete the host itself
await db
.delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
@@ -1762,21 +1700,11 @@ async function resolveHostCredentials(
requestingUserId?: string,
): Promise<Record<string, unknown>> {
try {
sshLogger.info("Resolving credentials for host", {
operation: "resolve_credentials_start",
hostId: host.id as number,
hasCredentialId: !!host.credentialId,
requestingUserId,
ownerId: (host.ownerId || host.userId) as string,
});
if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number;
const ownerId = (host.ownerId || host.userId) as string;
// Check if this is a shared host access
if (requestingUserId && requestingUserId !== ownerId) {
// User is accessing a shared host - use shared credential
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
@@ -1796,7 +1724,6 @@ async function resolveHostCredentials(
keyType: sharedCred.keyType,
};
// Only override username if overrideCredentialUsername is not enabled
if (!host.overrideCredentialUsername) {
resolvedHost.username = sharedCred.username;
}
@@ -1816,11 +1743,9 @@ async function resolveHostCredentials(
: "Unknown error",
},
);
// Fall through to try owner's credential
}
}
// Original owner access - use original credential
const credentials = await SimpleDBOps.select(
db
.select()
@@ -1846,7 +1771,6 @@ async function resolveHostCredentials(
keyType: credential.key_type || credential.keyType,
};
// Only override username if overrideCredentialUsername is not enabled
if (!host.overrideCredentialUsername) {
resolvedHost.username = credential.username;
}
@@ -2053,7 +1977,6 @@ router.delete(
const hostIds = hostsToDelete.map((host) => host.id);
// Delete all related data for all hosts in the folder (child tables first)
if (hostIds.length > 0) {
await db
.delete(fileManagerRecent)
@@ -2079,21 +2002,17 @@ router.delete(
.delete(recentActivity)
.where(inArray(recentActivity.hostId, hostIds));
// Delete RBAC host access entries
await db.delete(hostAccess).where(inArray(hostAccess.hostId, hostIds));
// Delete session recordings
await db
.delete(sessionRecordings)
.where(inArray(sessionRecordings.hostId, hostIds));
}
// Now delete the hosts themselves
await db
.delete(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
// Finally delete the folder metadata
await db
.delete(sshFolders)
.where(

View File

@@ -139,33 +139,12 @@ function isNonEmptyString(val: unknown): val is string {
const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
/**
* Comprehensive user deletion utility that ensures all related data is deleted
* in proper order to avoid foreign key constraint errors.
*
* This function explicitly deletes all user-related data before deleting the user record.
* It wraps everything in a transaction for atomicity.
*
* @param userId - The ID of the user to delete
* @returns Promise<void>
* @throws Error if deletion fails
*/
async function deleteUserAndRelatedData(userId: string): Promise<void> {
try {
authLogger.info("Starting comprehensive user data deletion", {
operation: "delete_user_and_related_data_start",
userId,
});
// Delete all related data in proper order to avoid FK constraint errors
// Order matters due to foreign key relationships
// 1. Delete credential usage logs
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId));
// 2. Delete file manager data
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
@@ -176,32 +155,23 @@ async function deleteUserAndRelatedData(userId: string): Promise<void> {
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
// 3. Delete activity and alerts
await db.delete(recentActivity).where(eq(recentActivity.userId, userId));
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
// 4. Delete snippets and snippet folders
await db.delete(snippets).where(eq(snippets.userId, userId));
await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId));
// 5. Delete SSH folders
await db.delete(sshFolders).where(eq(sshFolders.userId, userId));
// 6. Delete command history
await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
// 7. Delete SSH data and credentials
await db.delete(sshData).where(eq(sshData.userId, userId));
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
// 8. Delete user-specific settings (encryption keys, etc.)
db.$client
.prepare("DELETE FROM settings WHERE key LIKE ?")
.run(`user_%_${userId}`);
// 9. Finally, delete the user record
// Note: Sessions, user_roles, host_access, audit_logs, and session_recordings
// will be automatically deleted via CASCADE DELETE foreign key constraints
await db.delete(users).where(eq(users.id, userId));
authLogger.success("User and all related data deleted successfully", {
@@ -293,7 +263,6 @@ 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
@@ -306,12 +275,7 @@ router.post("/create", async (req, res) => {
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,
grantedBy: id,
});
} else {
authLogger.warn("Default role not found during user registration", {
@@ -325,7 +289,6 @@ router.post("/create", async (req, res) => {
operation: "assign_default_role",
userId: id,
});
// Don't fail user creation if role assignment fails
}
try {
@@ -934,7 +897,6 @@ 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
@@ -947,12 +909,7 @@ router.get("/oidc/callback", async (req, res) => {
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,
grantedBy: id,
});
} else {
authLogger.warn(
@@ -973,7 +930,6 @@ router.get("/oidc/callback", async (req, res) => {
userId: id,
},
);
// Don't fail user creation if role assignment fails
}
try {
@@ -1215,7 +1171,6 @@ router.post("/login", async (req, res) => {
return res.status(401).json({ error: "Incorrect password" });
}
// Re-encrypt any pending shared credentials for this user
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
@@ -1227,7 +1182,6 @@ router.post("/login", async (req, res) => {
userId: userRecord.id,
error,
});
// Continue with login even if re-encryption fails
}
if (userRecord.totp_enabled) {
@@ -1303,15 +1257,7 @@ router.post("/logout", authenticateJWT, async (req, res) => {
try {
const payload = await authManager.verifyJWTToken(token);
sessionId = payload?.sessionId;
} catch (error) {
authLogger.debug(
"Token verification failed during logout (expected if token expired)",
{
operation: "logout_token_verify_failed",
userId,
},
);
}
} catch (error) {}
}
await authManager.logoutUser(userId, sessionId);
@@ -2840,11 +2786,9 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
});
}
// Revoke all sessions and logout the OIDC user before deletion
await authManager.revokeAllUserSessions(oidcUserId);
authManager.logoutUser(oidcUserId);
// Use the comprehensive deletion utility to ensure all data is properly deleted
await deleteUserAndRelatedData(oidcUserId);
try {