From 5c9d8bfa34a062ed315795684d606fcdf046e1cc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 13 May 2026 17:26:24 +0800 Subject: [PATCH] feat: support per-user credentials on shared hosts --- src/backend/database/db/index.ts | 13 +++++++ src/backend/database/db/schema.ts | 4 +++ src/backend/database/routes/rbac.ts | 55 +++++++++++++++++++++++++++++ src/backend/ssh/host-resolver.ts | 46 +++++++++++++++++++++++- 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index e158bc7a..aa6a13bf 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1007,6 +1007,19 @@ const migrateSchema = () => { } } + try { + sqlite.prepare("SELECT override_credential_id FROM host_access LIMIT 1").get(); + } catch { + try { + sqlite.exec("ALTER TABLE host_access ADD COLUMN override_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL"); + } catch (alterError) { + databaseLogger.warn("Failed to add override_credential_id column", { + operation: "schema_migration", + error: alterError, + }); + } + } + try { sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get(); } catch { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 072d9619..b2cb2fe7 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -461,6 +461,10 @@ export const hostAccess = sqliteTable("host_access", { .default(sql`CURRENT_TIMESTAMP`), lastAccessedAt: text("last_accessed_at"), accessCount: integer("access_count").notNull().default(0), + overrideCredentialId: integer("override_credential_id").references( + () => sshCredentials.id, + { onDelete: "set null" }, + ), }); export const sharedCredentials = sqliteTable("shared_credentials", { diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 4d4eda7b..083fb88c 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -10,6 +10,7 @@ import { sharedCredentials, snippets, snippetAccess, + sshCredentials, } from "../db/schema.js"; import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm"; import type { Response } from "express"; @@ -1520,4 +1521,58 @@ router.get( }, ); +router.put( + "/host-access/:hostId/credential", + async (req: express.Request, res: express.Response) => { + try { + const userId = (req as AuthenticatedRequest).userId!; + const hostId = Number.parseInt(String(req.params.hostId), 10); + const { credentialId } = req.body; + + if (!hostId || isNaN(hostId)) { + return res.status(400).json({ error: "Invalid host ID" }); + } + + const access = await db + .select() + .from(hostAccess) + .where( + and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), + ) + .limit(1); + + if (access.length === 0) { + return res.status(403).json({ error: "No access to this host" }); + } + + if (credentialId) { + const cred = await db + .select({ id: sshCredentials.id }) + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ) + .limit(1); + + if (cred.length === 0) { + return res.status(404).json({ error: "Credential not found" }); + } + } + + await db + .update(hostAccess) + .set({ overrideCredentialId: credentialId || null }) + .where(eq(hostAccess.id, access[0].id)); + + res.json({ success: true }); + } catch (error) { + databaseLogger.error("Failed to set override credential", error); + res.status(500).json({ error: "Failed to update credential" }); + } + }, +); + export default router; diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts index 94d2f793..844da398 100644 --- a/src/backend/ssh/host-resolver.ts +++ b/src/backend/ssh/host-resolver.ts @@ -75,8 +75,52 @@ export async function resolveHostById( if (host.credentialId) { const ownerId = (host.userId || userId) as string; try { - // Try shared credential first for non-owner users + // Try user's own override credential first if (userId !== ownerId) { + try { + const { hostAccess } = await import("../database/db/schema.js"); + const accessRecords = await db + .select() + .from(hostAccess) + .where( + and( + eq(hostAccess.hostId, hostId), + eq(hostAccess.userId, userId), + ), + ) + .limit(1); + const overrideCredId = accessRecords[0]?.overrideCredentialId as number | null; + if (overrideCredId) { + const userCreds = await SimpleDBOps.select( + db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, overrideCredId), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + if (userCreds.length > 0) { + const cred = userCreds[0] as Record; + host.password = cred.password; + host.key = cred.key; + host.keyPassword = cred.keyPassword; + host.keyType = cred.keyType; + if (!host.overrideCredentialUsername) { + host.username = cred.username; + } + host.authType = cred.key ? "key" : cred.password ? "password" : "none"; + return host as unknown as SSHHost; + } + } + } catch { + // fall through to shared credential + } + try { const { SharedCredentialManager } = await import("../utils/shared-credential-manager.js");