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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user