diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 9711a8f5..4231cbf8 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1008,6 +1008,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 96413a58..aea9bbdc 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -462,6 +462,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 7c11debf..c7a7ca8b 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"; @@ -1524,4 +1525,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");