fix: rbac implementation general issues (local squash)
This commit is contained in:
@@ -590,6 +590,11 @@ 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");
|
||||
|
||||
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
@@ -842,6 +847,42 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// RBAC: Shared Credentials table
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS shared_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
original_credential_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
encrypted_username TEXT NOT NULL,
|
||||
encrypted_auth_type TEXT NOT NULL,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
needs_re_encryption INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||
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",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old system roles and seed correct ones
|
||||
try {
|
||||
// First, check what roles exist
|
||||
|
||||
@@ -185,6 +185,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
key_password: text("key_password"),
|
||||
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"),
|
||||
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
createdAt: text("created_at")
|
||||
@@ -307,10 +313,10 @@ export const hostAccess = sqliteTable("host_access", {
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Permission level
|
||||
// Permission level (view-only)
|
||||
permissionLevel: text("permission_level")
|
||||
.notNull()
|
||||
.default("use"), // "view" | "use" | "manage"
|
||||
.default("view"), // Only "view" is supported
|
||||
|
||||
// Time-based access
|
||||
expiresAt: text("expires_at"), // NULL = never expires
|
||||
@@ -323,6 +329,47 @@ 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"),
|
||||
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||
encryptedKeyPassword: text("encrypted_key_password"),
|
||||
encryptedKeyType: text("encrypted_key_type"),
|
||||
|
||||
// Metadata
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.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 }),
|
||||
|
||||
@@ -4,7 +4,12 @@ import type {
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
|
||||
import {
|
||||
sshCredentials,
|
||||
sshCredentialUsage,
|
||||
sshData,
|
||||
hostAccess,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
@@ -473,6 +478,15 @@ router.put(
|
||||
userId,
|
||||
);
|
||||
|
||||
// Update shared credentials if this credential is shared
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.updateSharedCredentialsForOriginal(
|
||||
parseInt(id),
|
||||
userId,
|
||||
);
|
||||
|
||||
const credential = updated[0];
|
||||
authLogger.success(
|
||||
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
|
||||
@@ -555,8 +569,36 @@ router.delete(
|
||||
eq(sshData.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
// Revoke all shares for hosts that used this credential
|
||||
for (const host of hostsUsingCredential) {
|
||||
const revokedShares = await db
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, host.id))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (revokedShares.length > 0) {
|
||||
authLogger.info(
|
||||
"Auto-revoked host shares due to credential deletion",
|
||||
{
|
||||
operation: "auto_revoke_shares",
|
||||
hostId: host.id,
|
||||
credentialId: parseInt(id),
|
||||
revokedCount: revokedShares.length,
|
||||
reason: "credential_deleted",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1601,10 +1643,7 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
const deployResult = await deploySSHKeyToHost(
|
||||
hostConfig,
|
||||
credData,
|
||||
);
|
||||
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
|
||||
|
||||
if (deployResult.success) {
|
||||
res.json({
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
roles,
|
||||
userRoles,
|
||||
auditLogs,
|
||||
sharedCredentials,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -47,7 +48,7 @@ router.post(
|
||||
targetUserId,
|
||||
targetRoleId,
|
||||
durationHours,
|
||||
permissionLevel = "use",
|
||||
permissionLevel = "view", // Only "view" is supported
|
||||
} = req.body;
|
||||
|
||||
// Validate target type
|
||||
@@ -129,11 +130,11 @@ router.post(
|
||||
expiresAt = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
// Validate permission level
|
||||
const validLevels = ["view", "use", "manage"];
|
||||
// Validate permission level (only "view" is supported)
|
||||
const validLevels = ["view"];
|
||||
if (!validLevels.includes(permissionLevel)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid permission level",
|
||||
error: "Invalid permission level. Only 'view' is supported.",
|
||||
validLevels,
|
||||
});
|
||||
}
|
||||
@@ -162,6 +163,30 @@ 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));
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.info("Updated existing host access", {
|
||||
operation: "share_host",
|
||||
hostId,
|
||||
@@ -189,6 +214,27 @@ router.post(
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Create shared credential for the target
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.info("Created host access", {
|
||||
operation: "share_host",
|
||||
hostId,
|
||||
@@ -308,8 +354,6 @@ router.get(
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
createdAt: hostAccess.createdAt,
|
||||
lastAccessedAt: hostAccess.lastAccessedAt,
|
||||
accessCount: hostAccess.accessCount,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.leftJoin(users, eq(hostAccess.userId, users.id))
|
||||
@@ -331,8 +375,6 @@ router.get(
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
createdAt: access.createdAt,
|
||||
lastAccessedAt: access.lastAccessedAt,
|
||||
accessCount: access.accessCount,
|
||||
}));
|
||||
|
||||
res.json({ accessList });
|
||||
@@ -651,31 +693,24 @@ router.delete(
|
||||
});
|
||||
}
|
||||
|
||||
// Check if role is in use
|
||||
const usageCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId));
|
||||
// Delete user-role assignments first
|
||||
const deletedUserRoles = await db
|
||||
.delete(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId))
|
||||
.returning({ userId: userRoles.userId });
|
||||
|
||||
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,
|
||||
});
|
||||
// Invalidate permission cache for affected users
|
||||
for (const { userId } of deletedUserRoles) {
|
||||
permissionManager.invalidateUserPermissionCache(userId);
|
||||
}
|
||||
|
||||
// Check if role is used in host_access
|
||||
const hostAccessCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(hostAccess)
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
// Delete host_access entries for this role
|
||||
const deletedHostAccess = await db
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.roleId, roleId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
// Note: sharedCredentials will be auto-deleted by CASCADE
|
||||
|
||||
// Delete role
|
||||
await db.delete(roles).where(eq(roles.id, roleId));
|
||||
@@ -773,6 +808,51 @@ router.post(
|
||||
grantedBy: currentUserId,
|
||||
});
|
||||
|
||||
// Create shared credentials for all hosts shared with this role
|
||||
const hostsSharedWithRole = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
for (const { host_access, ssh_data } of hostsSharedWithRole) {
|
||||
if (ssh_data.credentialId) {
|
||||
try {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
host_access.id,
|
||||
ssh_data.credentialId,
|
||||
targetUserId,
|
||||
ssh_data.userId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credential for new role member",
|
||||
error,
|
||||
{
|
||||
operation: "assign_role_create_credentials",
|
||||
targetUserId,
|
||||
roleId,
|
||||
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);
|
||||
|
||||
|
||||
@@ -391,7 +391,8 @@ router.post(
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
const resolvedHost =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
|
||||
sshLogger.success(
|
||||
`SSH host created: ${name} (${ip}:${port}) by user ${userId}`,
|
||||
@@ -619,9 +620,25 @@ 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",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can modify host configuration",
|
||||
});
|
||||
}
|
||||
|
||||
// Get the actual owner ID for the update
|
||||
const hostRecord = await db
|
||||
.select({ userId: sshData.userId })
|
||||
.select({
|
||||
userId: sshData.userId,
|
||||
credentialId: sshData.credentialId,
|
||||
authType: sshData.authType,
|
||||
})
|
||||
.from(sshData)
|
||||
.where(eq(sshData.id, Number(hostId)))
|
||||
.limit(1);
|
||||
@@ -637,6 +654,56 @@ router.put(
|
||||
|
||||
const ownerId = hostRecord[0].userId;
|
||||
|
||||
// Only owner can change credentialId
|
||||
if (
|
||||
!accessInfo.isOwner &&
|
||||
sshDataObj.credentialId !== undefined &&
|
||||
sshDataObj.credentialId !== hostRecord[0].credentialId
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can change the credential",
|
||||
});
|
||||
}
|
||||
|
||||
// Only owner can change authType
|
||||
if (
|
||||
!accessInfo.isOwner &&
|
||||
sshDataObj.authType !== undefined &&
|
||||
sshDataObj.authType !== hostRecord[0].authType
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can change the authentication type",
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await SimpleDBOps.update(
|
||||
sshData,
|
||||
"ssh_data",
|
||||
@@ -691,7 +758,8 @@ router.put(
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
const resolvedHost =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
|
||||
sshLogger.success(
|
||||
`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`,
|
||||
@@ -854,12 +922,51 @@ router.get(
|
||||
),
|
||||
);
|
||||
|
||||
// Decrypt and format the data
|
||||
const data = await SimpleDBOps.select(
|
||||
Promise.resolve(rawData),
|
||||
"ssh_data",
|
||||
// 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(
|
||||
Promise.resolve(ownHosts),
|
||||
"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(
|
||||
data.map(async (row: Record<string, unknown>) => {
|
||||
@@ -900,10 +1007,18 @@ router.get(
|
||||
sharedExpiresAt: row.expiresAt || undefined,
|
||||
};
|
||||
|
||||
return (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
const resolved =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
return resolved;
|
||||
}),
|
||||
);
|
||||
|
||||
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, {
|
||||
@@ -978,7 +1093,7 @@ router.get(
|
||||
: [],
|
||||
};
|
||||
|
||||
res.json((await resolveHostCredentials(result)) || result);
|
||||
res.json((await resolveHostCredentials(result, userId)) || result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
|
||||
operation: "host_fetch_by_id",
|
||||
@@ -1022,7 +1137,7 @@ router.get(
|
||||
|
||||
const host = hosts[0];
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(host)) || host;
|
||||
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
|
||||
|
||||
const exportData = {
|
||||
name: resolvedHost.name,
|
||||
@@ -1644,12 +1759,68 @@ router.delete(
|
||||
|
||||
async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
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");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
host.id as number,
|
||||
requestingUserId,
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
const resolvedHost: Record<string, unknown> = {
|
||||
...host,
|
||||
authType: sharedCred.authType,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
};
|
||||
|
||||
// Only override username if overrideCredentialUsername is not enabled
|
||||
if (!host.overrideCredentialUsername) {
|
||||
resolvedHost.username = sharedCred.username;
|
||||
}
|
||||
|
||||
return resolvedHost;
|
||||
}
|
||||
} catch (sharedCredError) {
|
||||
sshLogger.warn(
|
||||
"Failed to get shared credential, falling back to owner credential",
|
||||
{
|
||||
operation: "resolve_shared_credential_fallback",
|
||||
hostId: host.id as number,
|
||||
requestingUserId,
|
||||
error:
|
||||
sharedCredError instanceof Error
|
||||
? sharedCredError.message
|
||||
: "Unknown error",
|
||||
},
|
||||
);
|
||||
// Fall through to try owner's credential
|
||||
}
|
||||
}
|
||||
|
||||
// Original owner access - use original credential
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
|
||||
@@ -1215,6 +1215,21 @@ 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");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id);
|
||||
} catch (error) {
|
||||
authLogger.warn("Failed to re-encrypt pending shared credentials", {
|
||||
operation: "reencrypt_pending_credentials",
|
||||
userId: userRecord.id,
|
||||
error,
|
||||
});
|
||||
// Continue with login even if re-encryption fails
|
||||
}
|
||||
|
||||
if (userRecord.totp_enabled) {
|
||||
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
||||
pendingTOTP: true,
|
||||
|
||||
Reference in New Issue
Block a user