diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 296a6f12..9e5a5700 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1585,36 +1585,54 @@ const migrateSchema = () => { } try { - sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get(); + sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get(); } catch { try { sqlite.exec(` - CREATE TABLE IF NOT EXISTS shared_credentials ( + CREATE TABLE IF NOT EXISTS shared_host_secrets ( 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, + protocol TEXT NOT NULL DEFAULT 'ssh', + source_type TEXT NOT NULL DEFAULT 'credential', + original_credential_id INTEGER, + encrypted_username TEXT, + encrypted_auth_type TEXT, encrypted_password TEXT, encrypted_key TEXT, encrypted_key_password TEXT, encrypted_key_type TEXT, + encrypted_domain TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(host_access_id, target_user_id, protocol), 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 ); `); } catch (createError) { - databaseLogger.warn("Failed to create shared_credentials table", { + databaseLogger.warn("Failed to create shared_host_secrets table", { operation: "schema_migration", error: createError, }); } } + try { + if (getRawSettingValue("rbac_permission_levels_v2") === null) { + sqlite.exec( + "UPDATE host_access SET permission_level = 'connect' WHERE permission_level = 'view'", + ); + setRawSettingValue("rbac_permission_levels_v2", "done"); + } + } catch (migrateError) { + databaseLogger.warn("Failed to migrate legacy view permission level", { + operation: "schema_migration", + error: migrateError, + }); + } + try { sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get(); } catch { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 6d758a60..c144fc76 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -523,7 +523,7 @@ export const hostAccess = sqliteTable("host_access", { permissionLevel: text("permission_level") .notNull() - .default("view"), + .default("connect"), expiresAt: text("expires_at"), @@ -538,27 +538,32 @@ export const hostAccess = sqliteTable("host_access", { ), }); -export const sharedCredentials = sqliteTable("shared_credentials", { +export const sharedHostSecrets = sqliteTable("shared_host_secrets", { id: integer("id").primaryKey({ autoIncrement: true }), hostAccessId: integer("host_access_id") .notNull() .references(() => hostAccess.id, { onDelete: "cascade" }), - originalCredentialId: integer("original_credential_id") - .notNull() - .references(() => sshCredentials.id, { onDelete: "cascade" }), - targetUserId: text("target_user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - encryptedUsername: text("encrypted_username").notNull(), - encryptedAuthType: text("encrypted_auth_type").notNull(), + protocol: text("protocol").notNull().default("ssh"), + sourceType: text("source_type").notNull().default("credential"), + + originalCredentialId: integer("original_credential_id").references( + () => sshCredentials.id, + { onDelete: "cascade" }, + ), + + encryptedUsername: text("encrypted_username"), + encryptedAuthType: text("encrypted_auth_type"), encryptedPassword: text("encrypted_password"), encryptedKey: text("encrypted_key", { length: 16384 }), encryptedKeyPassword: text("encrypted_key_password"), encryptedKeyType: text("encrypted_key_type"), + encryptedDomain: text("encrypted_domain"), createdAt: text("created_at") .notNull() diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index e74e92ee..0db7fea1 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -28,7 +28,7 @@ import { RoleRepository } from "./role-repository.js"; import { SessionRecordingRepository } from "./session-recording-repository.js"; import { SessionRepository } from "./session-repository.js"; import { SettingsRepository } from "./settings-repository.js"; -import { SharedCredentialRepository } from "./shared-credential-repository.js"; +import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SnippetRepository } from "./snippet-repository.js"; import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; import { SsoProviderRepository } from "./sso-provider-repository.js"; @@ -260,10 +260,10 @@ export function createCurrentSettingsRepository(): SettingsRepository { ); } -export function createCurrentSharedCredentialRepository(): SharedCredentialRepository { - return new SharedCredentialRepository( +export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRepository { + return new SharedHostSecretsRepository( createCurrentRepositoryContext(), - createCurrentRepositoryWriteHook("shared_credential_repository_write"), + createCurrentRepositoryWriteHook("shared_host_secrets_repository_write"), ); } diff --git a/src/backend/database/repositories/host-resolution-repository.ts b/src/backend/database/repositories/host-resolution-repository.ts index f97ee713..61288d58 100644 --- a/src/backend/database/repositories/host-resolution-repository.ts +++ b/src/backend/database/repositories/host-resolution-repository.ts @@ -18,6 +18,7 @@ export interface HostUpdateStateRecord { rdpCredentialId: number | null; vncCredentialId: number | null; telnetCredentialId: number | null; + vaultProfileId: number | null; authType: string; } export interface HostListAccessEntry { @@ -74,6 +75,7 @@ export class HostResolutionRepository { rdpCredentialId: hosts.rdpCredentialId, vncCredentialId: hosts.vncCredentialId, telnetCredentialId: hosts.telnetCredentialId, + vaultProfileId: hosts.vaultProfileId, authType: hosts.authType, }) .from(hosts) diff --git a/src/backend/database/repositories/rbac-access-repository.ts b/src/backend/database/repositories/rbac-access-repository.ts index 9a91f9ac..92a2463e 100644 --- a/src/backend/database/repositories/rbac-access-repository.ts +++ b/src/backend/database/repositories/rbac-access-repository.ts @@ -3,7 +3,7 @@ import { hostAccess, hosts, roles, - sharedCredentials, + sharedHostSecrets, snippetAccess, snippets, users, @@ -152,10 +152,6 @@ export class RbacAccessRepository { }) .where(eq(hostAccess.id, existing.id)); - await this.context.drizzle - .delete(sharedCredentials) - .where(eq(sharedCredentials.hostAccessId, existing.id)); - await this.afterWrite(); return { id: existing.id, created: false }; } @@ -583,25 +579,73 @@ export class RbacAccessRepository { .where(eq(hostAccess.roleId, roleId)); } - async findSharedCredentialForHostAndUser( + async findSharedSecretForHostUserProtocol( hostId: number, userId: string, - ): Promise { + protocol: string, + ): Promise { const rows = await this.context.drizzle .select({ - sharedCredential: sharedCredentials, + secret: sharedHostSecrets, }) - .from(sharedCredentials) - .innerJoin(hostAccess, eq(sharedCredentials.hostAccessId, hostAccess.id)) + .from(sharedHostSecrets) + .innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id)) .where( and( eq(hostAccess.hostId, hostId), - eq(sharedCredentials.targetUserId, userId), + eq(sharedHostSecrets.targetUserId, userId), + eq(sharedHostSecrets.protocol, protocol), ), ) .limit(1); - return rows[0]?.sharedCredential ?? null; + return rows[0]?.secret ?? null; + } + + async listActiveHostAccessGrants( + hostId: number, + now = new Date().toISOString(), + ): Promise<(typeof hostAccess.$inferSelect)[]> { + return this.context.drizzle + .select() + .from(hostAccess) + .where( + and( + eq(hostAccess.hostId, hostId), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), + ), + ); + } + + async findHostAccessById( + accessId: number, + hostId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hostAccess) + .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))) + .limit(1); + + return rows[0] ?? null; + } + + async updateHostAccessGrant( + accessId: number, + hostId: number, + update: { permissionLevel?: string; expiresAt?: string | null }, + ): Promise { + const rows = await this.context.drizzle + .update(hostAccess) + .set(update) + .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))) + .returning({ id: hostAccess.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; } async findHostAccessOwnerId(hostAccessId: number): Promise { diff --git a/src/backend/database/repositories/session-recording-repository.ts b/src/backend/database/repositories/session-recording-repository.ts index 8fb10b34..678a24a1 100644 --- a/src/backend/database/repositories/session-recording-repository.ts +++ b/src/backend/database/repositories/session-recording-repository.ts @@ -27,6 +27,8 @@ export interface SessionRecordingListRecord { endedAt: string | null; duration: number | null; recordingPath: string | null; + protocol: string; + format: string | null; hostName: string | null; hostIp: string | null; } @@ -106,6 +108,8 @@ export class SessionRecordingRepository { endedAt: sessionRecordings.endedAt, duration: sessionRecordings.duration, recordingPath: sessionRecordings.recordingPath, + protocol: sessionRecordings.protocol, + format: sessionRecordings.format, hostName: hosts.name, hostIp: hosts.ip, }) diff --git a/src/backend/database/repositories/shared-credential-repository.ts b/src/backend/database/repositories/shared-credential-repository.ts deleted file mode 100644 index 462b72f7..00000000 --- a/src/backend/database/repositories/shared-credential-repository.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { sharedCredentials } from "../db/schema.js"; -import type { DatabaseContext } from "./database-context.js"; - -export type SharedCredentialRecord = typeof sharedCredentials.$inferSelect; -export type NewSharedCredentialRecord = typeof sharedCredentials.$inferInsert; -export type SharedCredentialUpdate = Partial< - Omit ->; - -export class SharedCredentialRepository { - constructor( - private readonly context: DatabaseContext, - private readonly onWrite?: () => void | Promise, - ) {} - - async existsForHostAccessAndTargetUser( - hostAccessId: number, - targetUserId: string, - ): Promise { - const rows = await this.context.drizzle - .select({ id: sharedCredentials.id }) - .from(sharedCredentials) - .where( - and( - eq(sharedCredentials.hostAccessId, hostAccessId), - eq(sharedCredentials.targetUserId, targetUserId), - ), - ) - .limit(1); - - return rows.length > 0; - } - - async create( - sharedCredential: NewSharedCredentialRecord, - ): Promise { - const rows = await this.context.drizzle - .insert(sharedCredentials) - .values(sharedCredential) - .returning(); - - await this.afterWrite(); - return rows[0]; - } - - async findById(id: number): Promise { - const rows = await this.context.drizzle - .select() - .from(sharedCredentials) - .where(eq(sharedCredentials.id, id)) - .limit(1); - - return rows[0] ?? null; - } - - async listByOriginalCredentialId( - credentialId: number, - ): Promise { - return this.context.drizzle - .select() - .from(sharedCredentials) - .where(eq(sharedCredentials.originalCredentialId, credentialId)); - } - - async updateById( - id: number, - update: SharedCredentialUpdate, - ): Promise { - const rows = await this.context.drizzle - .update(sharedCredentials) - .set(update) - .where(eq(sharedCredentials.id, id)) - .returning(); - - if (rows.length > 0) { - await this.afterWrite(); - } - - return rows[0] ?? null; - } - - async deleteById(id: number): Promise { - const rows = await this.context.drizzle - .delete(sharedCredentials) - .where(eq(sharedCredentials.id, id)) - .returning({ id: sharedCredentials.id }); - - if (rows.length > 0) { - await this.afterWrite(); - } - - return rows.length > 0; - } - - async deleteByOriginalCredentialId(credentialId: number): Promise { - const rows = await this.context.drizzle - .delete(sharedCredentials) - .where(eq(sharedCredentials.originalCredentialId, credentialId)) - .returning({ id: sharedCredentials.id }); - - if (rows.length > 0) { - await this.afterWrite(); - } - - return rows.length; - } - - async deleteByTargetUserId(userId: string): Promise { - const rows = await this.context.drizzle - .delete(sharedCredentials) - .where(eq(sharedCredentials.targetUserId, userId)) - .returning({ id: sharedCredentials.id }); - - if (rows.length > 0) { - await this.afterWrite(); - } - - return rows.length; - } - - private async afterWrite(): Promise { - await this.onWrite?.(); - } -} diff --git a/src/backend/database/repositories/shared-host-secrets-repository.ts b/src/backend/database/repositories/shared-host-secrets-repository.ts new file mode 100644 index 00000000..23e2a86d --- /dev/null +++ b/src/backend/database/repositories/shared-host-secrets-repository.ts @@ -0,0 +1,201 @@ +import { and, eq, inArray, or } from "drizzle-orm"; +import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect; +export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert; + +export type ShareProtocol = "ssh" | "rdp" | "vnc" | "telnet"; + +export class SharedHostSecretsRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async upsert(record: NewSharedHostSecretRecord): Promise { + const existing = await this.context.drizzle + .select({ id: sharedHostSecrets.id }) + .from(sharedHostSecrets) + .where( + and( + eq(sharedHostSecrets.hostAccessId, record.hostAccessId), + eq(sharedHostSecrets.targetUserId, record.targetUserId), + eq(sharedHostSecrets.protocol, record.protocol ?? "ssh"), + ), + ) + .limit(1); + + if (existing.length > 0) { + await this.context.drizzle + .update(sharedHostSecrets) + .set({ ...record, updatedAt: new Date().toISOString() }) + .where(eq(sharedHostSecrets.id, existing[0].id)); + } else { + await this.context.drizzle.insert(sharedHostSecrets).values(record); + } + + await this.afterWrite(); + } + + async findForHostUserProtocol( + hostId: number, + targetUserId: string, + protocol: ShareProtocol, + ): Promise { + const rows = await this.context.drizzle + .select({ secret: sharedHostSecrets }) + .from(sharedHostSecrets) + .innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id)) + .where( + and( + eq(hostAccess.hostId, hostId), + eq(sharedHostSecrets.targetUserId, targetUserId), + eq(sharedHostSecrets.protocol, protocol), + ), + ) + .limit(1); + + return rows[0]?.secret ?? null; + } + + async existsForHostAccessAndTargetUser( + hostAccessId: number, + targetUserId: string, + ): Promise { + const rows = await this.context.drizzle + .select({ id: sharedHostSecrets.id }) + .from(sharedHostSecrets) + .where( + and( + eq(sharedHostSecrets.hostAccessId, hostAccessId), + eq(sharedHostSecrets.targetUserId, targetUserId), + ), + ) + .limit(1); + + return rows.length > 0; + } + + async deleteForHostAccessAndTarget( + hostAccessId: number, + targetUserId: string, + keepProtocols: ShareProtocol[] = [], + ): Promise { + const rows = await this.context.drizzle + .select({ + id: sharedHostSecrets.id, + protocol: sharedHostSecrets.protocol, + }) + .from(sharedHostSecrets) + .where( + and( + eq(sharedHostSecrets.hostAccessId, hostAccessId), + eq(sharedHostSecrets.targetUserId, targetUserId), + ), + ); + + const staleIds = rows + .filter((row) => !keepProtocols.includes(row.protocol as ShareProtocol)) + .map((row) => row.id); + + if (staleIds.length > 0) { + await this.context.drizzle + .delete(sharedHostSecrets) + .where(inArray(sharedHostSecrets.id, staleIds)); + await this.afterWrite(); + } + } + + async deleteByHostAccessId(hostAccessId: number): Promise { + const rows = await this.context.drizzle + .delete(sharedHostSecrets) + .where(eq(sharedHostSecrets.hostAccessId, hostAccessId)) + .returning({ id: sharedHostSecrets.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteForRoleMember( + roleId: number, + targetUserId: string, + ): Promise { + const rows = await this.context.drizzle + .select({ id: sharedHostSecrets.id }) + .from(sharedHostSecrets) + .innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id)) + .where( + and( + eq(hostAccess.roleId, roleId), + eq(sharedHostSecrets.targetUserId, targetUserId), + ), + ); + + if (rows.length === 0) return 0; + + await this.context.drizzle.delete(sharedHostSecrets).where( + inArray( + sharedHostSecrets.id, + rows.map((row) => row.id), + ), + ); + await this.afterWrite(); + return rows.length; + } + + async deleteByOriginalCredentialId(credentialId: number): Promise { + const rows = await this.context.drizzle + .delete(sharedHostSecrets) + .where(eq(sharedHostSecrets.originalCredentialId, credentialId)) + .returning({ id: sharedHostSecrets.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByTargetUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(sharedHostSecrets) + .where(eq(sharedHostSecrets.targetUserId, userId)) + .returning({ id: sharedHostSecrets.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async findHostIdsReferencingCredential( + ownerId: string, + credentialId: number, + ): Promise { + const rows = await this.context.drizzle + .select({ id: hosts.id }) + .from(hosts) + .where( + and( + eq(hosts.userId, ownerId), + or( + eq(hosts.credentialId, credentialId), + eq(hosts.rdpCredentialId, credentialId), + eq(hosts.vncCredentialId, credentialId), + eq(hosts.telnetCredentialId, credentialId), + ), + ), + ); + + return rows.map((row) => row.id); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 19284b93..a467426a 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -8,7 +8,6 @@ import { registerCredentialKeyRoutes } from "./credential-key-routes.js"; import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import { - createCurrentRbacAccessRepository, createCurrentCredentialRepository, createCurrentHostResolutionRepository, createCurrentHostRepository, @@ -523,10 +522,9 @@ router.put( credentialId, )); - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - await sharedCredManager.updateSharedCredentialsForOriginal( + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().resyncHostsForCredential( credentialId, userId, ); @@ -633,38 +631,24 @@ router.delete( authType: "password", }, ); - - for (const host of hostsUsingCredential) { - const revokedCount = - await createCurrentRbacAccessRepository().deleteHostAccessForHost( - host.id, - ); - - if (revokedCount > 0) { - authLogger.info( - "Auto-revoked host shares due to credential deletion", - { - operation: "auto_revoke_shares", - hostId: host.id, - credentialId, - revokedCount, - reason: "credential_deleted", - }, - ); - } - } } - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - await sharedCredManager.deleteSharedCredentialsForOriginal(credentialId); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const sharedSecretsManager = SharedHostSecretsManager.getInstance(); + await sharedSecretsManager.deleteForCredential(credentialId); await createCurrentCredentialRepository().deleteForUser( userId, credentialId, ); + // Shares stay in place; re-snapshot so recipients fall back to whatever + // auth the host still has (or lose the stale credential copy). + for (const host of hostsUsingCredential) { + await sharedSecretsManager.resyncHost(host.id); + } + authLogger.success("SSH credential deleted", { operation: "credential_delete_success", userId, diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index c6693983..b3ec33e1 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -24,7 +24,7 @@ import { createCurrentSessionRepository, createCurrentSessionRecordingRepository, createCurrentSettingsRepository, - createCurrentSharedCredentialRepository, + createCurrentSharedHostSecretsRepository, createCurrentSnippetRepository, createCurrentSshCredentialUsageRepository, createCurrentTermixIdentityCaRepository, @@ -40,7 +40,7 @@ import { export async function deleteUserAndRelatedData(userId: string): Promise { try { - await createCurrentSharedCredentialRepository().deleteByTargetUserId( + await createCurrentSharedHostSecretsRepository().deleteByTargetUserId( userId, ); diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 9dc4e1a7..fd406e54 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -231,6 +231,76 @@ export function stripSensitiveFields( return result; } +// Connection essentials a connect-level recipient is allowed to see. +const CONNECT_LEVEL_FIELDS = new Set([ + "id", + "userId", + "ownerId", + "ownerUsername", + "isShared", + "permissionLevel", + "sharedExpiresAt", + "name", + "ip", + "port", + "username", + "folder", + "tags", + "pin", + "authType", + "connectionType", + "credentialId", + "enableTerminal", + "enableTunnel", + "enableFileManager", + "enableDocker", + "enableProxmox", + "enableTmuxMonitor", + "showTerminalInSidebar", + "showFileManagerInSidebar", + "showTunnelInSidebar", + "showDockerInSidebar", + "showServerStatsInSidebar", + "enableSsh", + "enableRdp", + "enableVnc", + "enableTelnet", + "sshPort", + "rdpPort", + "vncPort", + "telnetPort", + "defaultPath", + "scpLegacy", + "tunnelConnections", + "jumpHosts", + "createdAt", + "updatedAt", +]); + +/** + * Shapes a shared host row for its recipient. Secrets are always stripped + * (all levels); connect-level recipients are additionally reduced to + * connection essentials since they may not view the host's configuration. + */ +export function sanitizeHostForRecipient( + host: Record, + permissionLevel: string | undefined, +): Record { + const stripped = stripSensitiveFields(host); + + if (permissionLevel !== "connect") { + return stripped; + } + + const reduced: Record = {}; + for (const [key, value] of Object.entries(stripped)) { + if (CONNECT_LEVEL_FIELDS.has(key)) { + reduced[key] = value; + } + } + return reduced; +} + export function transformHostResponse( host: Record, ): Record { diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 5e15d11d..e4a80ddd 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -29,6 +29,7 @@ import { import { isNonEmptyString, isValidPort, + sanitizeHostForRecipient, stripSensitiveFields, transformHostResponse, } from "./host-normalizers.js"; @@ -1070,7 +1071,7 @@ router.put( const accessInfo = await permissionManager.canAccessHost( userId, Number(hostId), - "write", + "edit", ); if (!accessInfo.hasAccess) { @@ -1082,17 +1083,6 @@ router.put( return res.status(403).json({ error: "Access denied" }); } - 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", - }); - } - const hostRecord = await createCurrentHostResolutionRepository().findHostUpdateState( Number(hostId), @@ -1109,57 +1099,49 @@ router.put( const ownerId = hostRecord.userId; - if ( - !accessInfo.isOwner && - sshDataObj.credentialId !== undefined && - sshDataObj.credentialId !== hostRecord.credentialId - ) { - return res.status(403).json({ - error: "Only the host owner can change the credential", - }); - } + if (!accessInfo.isOwner) { + // Shared editors work on the owner's real record but may never + // repoint it at credential/vault references (those live in the + // owner's personal vault) or switch the authentication type. + const referenceViolations: Array<[unknown, number | null, string]> = [ + [sshDataObj.credentialId, hostRecord.credentialId, "credential"], + [ + sshDataObj.rdpCredentialId, + hostRecord.rdpCredentialId, + "RDP credential", + ], + [ + sshDataObj.vncCredentialId, + hostRecord.vncCredentialId, + "VNC credential", + ], + [ + sshDataObj.telnetCredentialId, + hostRecord.telnetCredentialId, + "Telnet credential", + ], + [ + sshDataObj.vaultProfileId, + hostRecord.vaultProfileId, + "Vault profile", + ], + ]; - if ( - !accessInfo.isOwner && - sshDataObj.authType !== undefined && - sshDataObj.authType !== hostRecord.authType - ) { - return res.status(403).json({ - error: "Only the host owner can change the authentication type", - }); - } + for (const [incoming, current, label] of referenceViolations) { + if (incoming !== undefined && (incoming ?? null) !== current) { + return res.status(403).json({ + error: `Only the host owner can change the ${label}`, + }); + } + } - { - const newCredId = - sshDataObj.credentialId !== undefined - ? sshDataObj.credentialId - : hostRecord.credentialId; - const newRdpCredId = - sshDataObj.rdpCredentialId !== undefined - ? sshDataObj.rdpCredentialId - : hostRecord.rdpCredentialId; - const newVncCredId = - sshDataObj.vncCredentialId !== undefined - ? sshDataObj.vncCredentialId - : hostRecord.vncCredentialId; - const newTelnetCredId = - sshDataObj.telnetCredentialId !== undefined - ? sshDataObj.telnetCredentialId - : hostRecord.telnetCredentialId; - const hadCredential = - hostRecord.credentialId !== null || - hostRecord.rdpCredentialId !== null || - hostRecord.vncCredentialId !== null || - hostRecord.telnetCredentialId !== null; - const willHaveCredential = - newCredId !== null || - newRdpCredId !== null || - newVncCredId !== null || - newTelnetCredId !== null; - if (hadCredential && !willHaveCredential) { - await createCurrentRbacAccessRepository().deleteHostAccessForHost( - Number(hostId), - ); + if ( + sshDataObj.authType !== undefined && + sshDataObj.authType !== hostRecord.authType + ) { + return res.status(403).json({ + error: "Only the host owner can change the authentication type", + }); } } @@ -1169,6 +1151,23 @@ router.put( sshDataObj, ); + // Keep every recipient's re-encrypted secret snapshots in sync with + // the updated host record. + try { + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().resyncHost(Number(hostId)); + } catch (resyncError) { + sshLogger.warn("Failed to resync shared host secrets after update", { + operation: "host_update_resync", + hostId: parseInt(hostId), + error: + resyncError instanceof Error + ? resyncError.message + : "Unknown error", + }); + } + const updatedHost = await createCurrentHostResolutionRepository().findHostById( Number(hostId), @@ -1296,9 +1295,21 @@ router.get( } } - const sanitizedSharedHosts = sharedHosts; + const ownerUsernames = new Map(); + const userRepository = createCurrentUserRepository(); + for (const sharedHost of sharedHosts) { + const ownerId = sharedHost.userId as string; + if (!ownerUsernames.has(ownerId)) { + try { + const owner = await userRepository.findById(ownerId); + ownerUsernames.set(ownerId, owner?.username ?? ""); + } catch { + ownerUsernames.set(ownerId, ""); + } + } + } - const data = [...decryptedOwnHosts, ...sanitizedSharedHosts]; + const data = [...decryptedOwnHosts, ...sharedHosts]; const result = await Promise.all( data.map(async (row: Record) => { @@ -1307,6 +1318,9 @@ router.get( isShared: !!row.isShared, permissionLevel: row.permissionLevel || undefined, sharedExpiresAt: row.expiresAt || undefined, + ownerUsername: row.isShared + ? ownerUsernames.get(row.userId as string) || undefined + : undefined, }; const resolved = @@ -1315,7 +1329,14 @@ router.get( }), ); - const sanitized = result.map((host) => stripSensitiveFields(host)); + const sanitized = result.map((host) => + host.isShared + ? sanitizeHostForRecipient( + host, + host.permissionLevel as string | undefined, + ) + : stripSensitiveFields(host), + ); res.json(sanitized); } catch (err) { sshLogger.error("Failed to fetch SSH hosts from database", err, { @@ -1370,13 +1391,28 @@ router.get( return res.status(400).json({ error: "Invalid userId or hostId" }); } try { - const host = - await createCurrentHostResolutionRepository().findHostByIdForUser( - Number(hostId), - userId, - ); + const hostResolutionRepository = createCurrentHostResolutionRepository(); + const host = await hostResolutionRepository.findHostByIdForUser( + Number(hostId), + userId, + ); - if (!host) { + if (host) { + const result = transformHostResponse(host); + const resolved = + (await resolveHostCredentials(result, userId)) || result; + + return res.json(stripSensitiveFields(resolved)); + } + + // Not the owner: shared recipients get a sanitized view of the host. + const accessInfo = await permissionManager.canAccessHost( + userId, + Number(hostId), + "connect", + ); + + if (!accessInfo.hasAccess) { sshLogger.warn("SSH host not found", { operation: "host_fetch_by_id", hostId: parseInt(hostId), @@ -1385,10 +1421,38 @@ router.get( return res.status(404).json({ error: "SSH host not found" }); } - const result = transformHostResponse(host); - const resolved = (await resolveHostCredentials(result, userId)) || result; + const ownerId = await hostResolutionRepository.findHostOwnerId( + Number(hostId), + ); + const sharedHost = ownerId + ? await hostResolutionRepository.findHostById(Number(hostId), ownerId) + : null; - res.json(stripSensitiveFields(resolved)); + if (!sharedHost) { + return res.status(404).json({ error: "SSH host not found" }); + } + + let ownerUsername: string | undefined; + try { + const owner = ownerId + ? await createCurrentUserRepository().findById(ownerId) + : null; + ownerUsername = owner?.username ?? undefined; + } catch { + ownerUsername = undefined; + } + + const sharedResult = { + ...transformHostResponse(sharedHost), + isShared: true, + permissionLevel: accessInfo.permissionLevel, + sharedExpiresAt: accessInfo.expiresAt || undefined, + ownerUsername, + }; + + res.json( + sanitizeHostForRecipient(sharedResult, accessInfo.permissionLevel), + ); } catch (err) { sshLogger.error("Failed to fetch SSH host by ID from database", err, { operation: "host_fetch_by_id", @@ -2033,13 +2097,14 @@ async function resolveHostCredentials( if (requestingUserId && requestingUserId !== ownerId) { try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - host.id as number, - requestingUserId, - ); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const sharedCred = + await SharedHostSecretsManager.getInstance().getSecretForUser( + host.id as number, + requestingUserId, + "ssh", + ); if (sharedCred) { const resolvedHost: Record = { diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts index 9826e71d..c3a4b8fa 100644 --- a/src/backend/database/routes/proxmox.ts +++ b/src/backend/database/routes/proxmox.ts @@ -312,7 +312,7 @@ async function discoverProxmoxGuestsForHost( const { PermissionManager } = await import("../../utils/permission-manager.js"); const pm = PermissionManager.getInstance(); - const access = await pm.canAccessHost(userId, parsedHostId, "execute"); + const access = await pm.canAccessHost(userId, parsedHostId, "connect"); if (!access.hasAccess) { const error = new Error("Access denied"); (error as Error & { status?: number }).status = 403; @@ -337,12 +337,13 @@ async function discoverProxmoxGuestsForHost( if (host.credentialId) { if (userId !== host.userId) { try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); const sharedCred = - await SharedCredentialManager.getInstance().getSharedCredentialForUser( + await SharedHostSecretsManager.getInstance().getSecretForUser( host.id, userId, + "ssh", ); if (sharedCred) { resolvedCredentials = { diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index ce76047e..06fbb12e 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -3,7 +3,15 @@ import express from "express"; import type { Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { PermissionManager } from "../../utils/permission-manager.js"; +import { + PermissionManager, + SHARE_PERMISSION_LEVELS, + type SharePermissionLevel, +} from "../../utils/permission-manager.js"; +import { + PERMISSION_CATALOG, + isValidPermission, +} from "../../utils/permission-catalog.js"; import { createCurrentCredentialRepository, createCurrentHostResolutionRepository, @@ -24,12 +32,69 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function isSharePermissionLevel(value: unknown): value is SharePermissionLevel { + return SHARE_PERMISSION_LEVELS.includes(value as SharePermissionLevel); +} + +function expiryFromDuration(durationHours: unknown): string | null { + if (durationHours && typeof durationHours === "number" && durationHours > 0) { + const expiryDate = new Date(); + expiryDate.setTime(expiryDate.getTime() + durationHours * 60 * 60 * 1000); + return expiryDate.toISOString(); + } + return null; +} + +// Sharing is controlled by the owner or any recipient holding "manage". +async function canManageHostSharing( + userId: string, + hostId: number, +): Promise<{ allowed: boolean; isOwner: boolean }> { + const access = await permissionManager.canAccessHost( + userId, + hostId, + "manage", + ); + return { allowed: access.hasAccess, isOwner: access.isOwner }; +} + +interface ShareTarget { + type: "user" | "role"; + id: string | number; +} + +function parseShareTargets( + body: Record, +): ShareTarget[] | null { + const rawTargets = body.targets; + if (!Array.isArray(rawTargets) || rawTargets.length === 0) return null; + + const targets: ShareTarget[] = []; + for (const raw of rawTargets) { + if (!raw || typeof raw !== "object") return null; + const { type, id } = raw as { type?: unknown; id?: unknown }; + if (type === "user" && isNonEmptyString(id)) { + targets.push({ type: "user", id }); + } else if ( + type === "role" && + typeof id === "number" && + Number.isInteger(id) + ) { + targets.push({ type: "role", id }); + } else { + return null; + } + } + + return targets; +} + /** * @openapi * /rbac/host/{id}/share: * post: * summary: Share a host - * description: Shares a host with a user or a role. + * description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). Allowed for the host owner or recipients holding the manage level. Every auth type is shareable; per-recipient secret snapshots are created automatically. * tags: * - RBAC * parameters: @@ -44,26 +109,32 @@ function isNonEmptyString(value: unknown): value is string { * application/json: * schema: * type: object + * required: [targets] * properties: - * targetType: - * type: string - * enum: [user, role] - * targetUserId: - * type: string - * targetRoleId: - * type: integer - * durationHours: - * type: number + * targets: + * type: array + * items: + * type: object + * properties: + * type: + * type: string + * enum: [user, role] + * id: + * oneOf: + * - type: string + * - type: integer * permissionLevel: * type: string - * enum: [view] + * enum: [connect, view, edit, manage] + * durationHours: + * type: number * responses: * 200: * description: Host shared successfully. * 400: * description: Invalid request body. * 403: - * description: Not host owner. + * description: Caller may not share this host. * 404: * description: Target user or role not found. * 500: @@ -82,37 +153,25 @@ router.post( } try { - const { - targetType = "user", - targetUserId, - targetRoleId, - durationHours, - permissionLevel = "view", - } = req.body; - - if (!["user", "role"].includes(targetType)) { - return res - .status(400) - .json({ error: "Invalid target type. Must be 'user' or 'role'" }); + const targets = parseShareTargets(req.body ?? {}); + if (!targets) { + return res.status(400).json({ + error: + "targets must be a non-empty array of { type: 'user'|'role', id } entries", + }); } - if (targetType === "user" && !isNonEmptyString(targetUserId)) { - return res - .status(400) - .json({ error: "Target user ID is required when sharing with user" }); - } - if (targetType === "role" && !targetRoleId) { - return res - .status(400) - .json({ error: "Target role ID is required when sharing with role" }); + const { durationHours, permissionLevel = "connect" } = req.body; + + if (!isSharePermissionLevel(permissionLevel)) { + return res.status(400).json({ + error: "Invalid permission level", + validLevels: SHARE_PERMISSION_LEVELS, + }); } - const host = - await createCurrentHostResolutionRepository().findHostUpdateState( - hostId, - ); - - if (!host || host.userId !== userId) { + const sharing = await canManageHostSharing(userId, hostId); + if (!sharing.allowed) { databaseLogger.warn("Permission denied", { operation: "rbac_permission_denied", userId, @@ -120,141 +179,126 @@ router.post( resourceId: hostId, action: "share", }); - return res.status(403).json({ error: "Not host owner" }); + return res.status(403).json({ error: "You may not share this host" }); } - if ( - !host.credentialId && - !host.rdpCredentialId && - !host.vncCredentialId && - host.authType !== "opkssh" - ) { - return res.status(400).json({ - error: - "Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.", - code: "CREDENTIAL_REQUIRED_FOR_SHARING", - }); + const host = + await createCurrentHostResolutionRepository().findHostUpdateState( + hostId, + ); + if (!host) { + return res.status(404).json({ error: "Host not found" }); } + const ownerId = host.userId; - if (targetType === "user") { - const targetUser = - await createCurrentUserRepository().findById(targetUserId); + const userRepository = createCurrentUserRepository(); + const roleRepository = createCurrentRoleRepository(); - if (!targetUser) { - return res.status(404).json({ error: "Target user not found" }); - } - } else { - const targetRole = - await createCurrentRoleRepository().findRoleById(targetRoleId); - - if (!targetRole) { - return res.status(404).json({ error: "Target role not found" }); + for (const target of targets) { + if (target.type === "user") { + if (target.id === ownerId) { + return res + .status(400) + .json({ error: "Cannot share a host with its owner" }); + } + const targetUser = await userRepository.findById(target.id as string); + if (!targetUser) { + return res.status(404).json({ + error: "Target user not found", + targetId: target.id, + }); + } + } else { + const targetRole = await roleRepository.findRoleById( + target.id as number, + ); + if (!targetRole) { + return res.status(404).json({ + error: "Target role not found", + targetId: target.id, + }); + } } } - let expiresAt: string | null = null; - if ( - durationHours && - typeof durationHours === "number" && - durationHours > 0 - ) { - const expiryDate = new Date(); - expiryDate.setHours(expiryDate.getHours() + durationHours); - expiresAt = expiryDate.toISOString(); - } + const expiresAt = expiryFromDuration(durationHours); - const validLevels = ["view"]; - if (!validLevels.includes(permissionLevel)) { - return res.status(400).json({ - error: "Invalid permission level. Only 'view' is supported.", - validLevels, - }); - } + const rbacAccessRepository = createCurrentRbacAccessRepository(); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const secretsManager = SharedHostSecretsManager.getInstance(); - const accessGrant = - await createCurrentRbacAccessRepository().upsertHostAccess({ + const results: Array<{ + type: "user" | "role"; + id: string | number; + accessId: number; + created: boolean; + }> = []; + + for (const target of targets) { + const accessGrant = await rbacAccessRepository.upsertHostAccess({ hostId, grantedBy: userId, permissionLevel, expiresAt, - ...(targetType === "user" - ? { targetType: "user" as const, targetUserId: targetUserId! } - : { targetType: "role" as const, targetRoleId: targetRoleId! }), + ...(target.type === "user" + ? { targetType: "user" as const, targetUserId: target.id as string } + : { + targetType: "role" as const, + targetRoleId: target.id as number, + }), }); - if (!accessGrant.created) { - const activeCredentialId = - host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId; - if (activeCredentialId) { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - if (targetType === "user") { - await sharedCredManager.createSharedCredentialForUser( + try { + if (target.type === "user") { + await secretsManager.snapshotForUser( accessGrant.id, - activeCredentialId, - targetUserId!, - userId, + hostId, + target.id as string, + ownerId, ); } else { - await sharedCredManager.createSharedCredentialsForRole( + await secretsManager.snapshotForRole( accessGrant.id, - activeCredentialId, - targetRoleId!, - userId, + hostId, + target.id as number, + ownerId, ); } + } catch (snapshotError) { + databaseLogger.warn("Share created but secret snapshot failed", { + operation: "rbac_host_share_snapshot_failed", + hostId, + accessId: accessGrant.id, + error: + snapshotError instanceof Error + ? snapshotError.message + : "Unknown error", + }); } - databaseLogger.info("Permission granted", { - operation: "rbac_permission_grant", - adminId: userId, - hostId, - resource: "host", - action: "view", - }); - return res.json({ - success: true, - message: "Host access updated", - expiresAt, + results.push({ + type: target.type, + id: target.id, + accessId: accessGrant.id, + created: accessGrant.created, }); } - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - - const activeCredentialId = - host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId; - if (activeCredentialId) { - if (targetType === "user") { - await sharedCredManager.createSharedCredentialForUser( - accessGrant.id, - activeCredentialId, - targetUserId!, - userId, - ); - } else { - await sharedCredManager.createSharedCredentialsForRole( - accessGrant.id, - activeCredentialId, - targetRoleId!, - userId, - ); - } - } databaseLogger.success("Host shared successfully", { operation: "rbac_host_share_success", userId, hostId, - targetUserId: targetType === "user" ? targetUserId : undefined, + targets: results.length, permissionLevel, }); res.json({ success: true, - message: `Host shared successfully with ${targetType}`, + message: "Host shared successfully", + permissionLevel, expiresAt, + results, }); } catch (error) { databaseLogger.error("Failed to share host", error, { @@ -267,6 +311,143 @@ router.post( }, ); +/** + * @openapi + * /rbac/host/{id}/access/{accessId}: + * patch: + * summary: Update a host access grant + * description: Changes the permission level and/or expiry of an existing host access grant. Allowed for the host owner or recipients holding the manage level. + * tags: + * - RBAC + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * - in: path + * name: accessId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * permissionLevel: + * type: string + * enum: [connect, view, edit, manage] + * durationHours: + * type: number + * nullable: true + * description: Hours from now until the grant expires; null clears the expiry. + * responses: + * 200: + * description: Grant updated successfully. + * 400: + * description: Invalid request. + * 403: + * description: Caller may not manage sharing on this host. + * 404: + * description: Grant not found. + * 500: + * description: Failed to update grant. + */ +router.patch( + "/host/:id/access/:accessId", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const accessIdParam = Array.isArray(req.params.accessId) + ? req.params.accessId[0] + : req.params.accessId; + const hostId = parseInt(id, 10); + const accessId = parseInt(accessIdParam, 10); + const userId = req.userId!; + + if (isNaN(hostId) || isNaN(accessId)) { + return res.status(400).json({ error: "Invalid ID" }); + } + + try { + const sharing = await canManageHostSharing(userId, hostId); + if (!sharing.allowed) { + return res + .status(403) + .json({ error: "You may not manage sharing on this host" }); + } + + const { permissionLevel, durationHours } = req.body ?? {}; + + if (permissionLevel === undefined && durationHours === undefined) { + return res.status(400).json({ + error: "At least one of permissionLevel or durationHours is required", + }); + } + + if ( + permissionLevel !== undefined && + !isSharePermissionLevel(permissionLevel) + ) { + return res.status(400).json({ + error: "Invalid permission level", + validLevels: SHARE_PERMISSION_LEVELS, + }); + } + + const rbacAccessRepository = createCurrentRbacAccessRepository(); + const grant = await rbacAccessRepository.findHostAccessById( + accessId, + hostId, + ); + if (!grant) { + return res.status(404).json({ error: "Access grant not found" }); + } + + const update: { permissionLevel?: string; expiresAt?: string | null } = + {}; + if (permissionLevel !== undefined) { + update.permissionLevel = permissionLevel; + } + if (durationHours !== undefined) { + update.expiresAt = + durationHours === null ? null : expiryFromDuration(durationHours); + } + + await rbacAccessRepository.updateHostAccessGrant( + accessId, + hostId, + update, + ); + + databaseLogger.info("Host access grant updated", { + operation: "rbac_host_access_update", + userId, + hostId, + accessId, + permissionLevel, + }); + + res.json({ + success: true, + message: "Access updated", + expiresAt: update.expiresAt ?? grant.expiresAt, + }); + } catch (error) { + databaseLogger.error("Failed to update host access", error, { + operation: "update_host_access", + hostId, + accessId, + userId, + }); + res.status(500).json({ error: "Failed to update access" }); + } + }, +); + /** * @openapi * /rbac/host/{id}/access/{accessId}: @@ -292,7 +473,7 @@ router.post( * 400: * description: Invalid ID. * 403: - * description: Not host owner. + * description: Caller may not manage sharing on this host. * 500: * description: Failed to revoke access. */ @@ -313,14 +494,11 @@ router.delete( } try { - const isHostOwner = - await createCurrentHostResolutionRepository().isHostOwnedByUser( - hostId, - userId, - ); - - if (!isHostOwner) { - return res.status(403).json({ error: "Not host owner" }); + const sharing = await canManageHostSharing(userId, hostId); + if (!sharing.allowed) { + return res + .status(403) + .json({ error: "You may not manage sharing on this host" }); } await createCurrentRbacAccessRepository().revokeHostAccess( @@ -363,11 +541,11 @@ router.delete( * type: integer * responses: * 200: - * description: The access list for the host. + * description: The access list for the host, including each grant's permission level. * 400: * description: Invalid host ID. * 403: - * description: Not host owner. + * description: Caller may not manage sharing on this host. * 500: * description: Failed to get access list. */ @@ -384,20 +562,17 @@ router.get( } try { - const isHostOwner = - await createCurrentHostResolutionRepository().isHostOwnedByUser( - hostId, - userId, - ); - - if (!isHostOwner) { - return res.status(403).json({ error: "Not host owner" }); + const sharing = await canManageHostSharing(userId, hostId); + if (!sharing.allowed) { + return res + .status(403) + .json({ error: "You may not manage sharing on this host" }); } const accessList = await createCurrentRbacAccessRepository().listHostAccess(hostId); - res.json({ accessList }); + res.json({ accessList, isOwner: sharing.isOwner }); } catch (error) { databaseLogger.error("Failed to get host access list", error, { operation: "get_host_access_list", @@ -475,17 +650,30 @@ router.get( displayName, description, isSystem, + permissions, createdAt, updatedAt, - }) => ({ - id, - name, - displayName, - description, - isSystem, - createdAt, - updatedAt, - }), + }) => { + let parsedPermissions: string[] = []; + try { + parsedPermissions = permissions + ? (JSON.parse(permissions) as string[]) + : []; + } catch { + parsedPermissions = []; + } + + return { + id, + name, + displayName, + description, + isSystem, + permissions: parsedPermissions, + createdAt, + updatedAt, + }; + }, ); res.json({ roles: rolesList }); @@ -606,6 +794,11 @@ router.post( * type: string * description: * type: string + * permissions: + * type: array + * items: + * type: string + * description: Permission strings validated against the permissions catalog (wildcards like hosts.* and * allowed). * responses: * 200: * description: Role updated successfully. @@ -623,21 +816,47 @@ router.put( async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const roleId = parseInt(id, 10); - const { displayName, description } = req.body; + const { displayName, description, permissions } = req.body; if (isNaN(roleId)) { return res.status(400).json({ error: "Invalid role ID" }); } - if (!displayName && description === undefined) { + if ( + !displayName && + description === undefined && + permissions === undefined + ) { return res.status(400).json({ - error: "At least one field (displayName or description) is required", + error: + "At least one field (displayName, description or permissions) is required", }); } + if (permissions !== undefined) { + if ( + !Array.isArray(permissions) || + permissions.some((perm) => typeof perm !== "string") + ) { + return res + .status(400) + .json({ error: "permissions must be an array of strings" }); + } + + const invalid = (permissions as string[]).filter( + (perm) => !isValidPermission(perm), + ); + if (invalid.length > 0) { + return res.status(400).json({ + error: "Unknown permissions", + invalid, + }); + } + } + try { - const existingRole = - await createCurrentRoleRepository().findRoleById(roleId); + const roleRepository = createCurrentRoleRepository(); + const existingRole = await roleRepository.findRoleById(roleId); if (!existingRole) { return res.status(404).json({ error: "Role not found" }); @@ -646,6 +865,7 @@ router.put( const updates: { displayName?: string; description?: string | null; + permissions?: string | null; updatedAt: string; } = { updatedAt: new Date().toISOString(), @@ -659,7 +879,18 @@ router.put( updates.description = description || null; } - await createCurrentRoleRepository().updateRole(roleId, updates); + if (permissions !== undefined) { + updates.permissions = JSON.stringify(permissions); + } + + await roleRepository.updateRole(roleId, updates); + + if (permissions !== undefined) { + const memberIds = await roleRepository.listRoleUserIds(roleId); + for (const memberId of memberIds) { + permissionManager.invalidateUserPermissionCache(memberId); + } + } res.json({ success: true, @@ -675,6 +906,26 @@ router.put( }, ); +/** + * @openapi + * /rbac/permissions/catalog: + * get: + * summary: Get the role permissions catalog + * description: Returns the grouped catalog of role permission strings used by the role permissions editor. + * tags: + * - RBAC + * responses: + * 200: + * description: The permissions catalog. + */ +router.get( + "/permissions/catalog", + authenticateJWT, + async (_req: AuthenticatedRequest, res: Response) => { + res.json({ catalog: PERMISSION_CATALOG }); + }, +); + /** * @openapi * /rbac/roles/{id}: @@ -836,37 +1087,23 @@ router.post( grantedBy: currentUserId, }); - const hostsSharedWithRole = - await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources( + try { + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().snapshotForRoleMember( roleId, + targetUserId, + ); + } catch (error) { + databaseLogger.error( + "Failed to snapshot shared host secrets for new role member", + error, + { + operation: "assign_role_snapshot_secrets", + targetUserId, + roleId, + }, ); - - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - - for (const sharedHost of hostsSharedWithRole) { - if (sharedHost.credentialId) { - try { - await sharedCredManager.createSharedCredentialForUser( - sharedHost.hostAccessId, - sharedHost.credentialId, - targetUserId, - sharedHost.hostOwnerId, - ); - } catch (error) { - databaseLogger.error( - "Failed to create shared credential for new role member", - error, - { - operation: "assign_role_create_credentials", - targetUserId, - roleId, - hostId: sharedHost.hostId, - }, - ); - } - } } permissionManager.invalidateUserPermissionCache(targetUserId); @@ -959,6 +1196,28 @@ router.delete( roleId, ); + try { + const { createCurrentSharedHostSecretsRepository } = + await import("../repositories/factory.js"); + await createCurrentSharedHostSecretsRepository().deleteForRoleMember( + roleId, + targetUserId, + ); + } catch (cleanupError) { + databaseLogger.warn( + "Failed to clean shared host secrets after role removal", + { + operation: "remove_role_secret_cleanup", + targetUserId, + roleId, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }, + ); + } + permissionManager.invalidateUserPermissionCache(targetUserId); databaseLogger.info("Role removed from user", { operation: "rbac_role_remove", diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 53d98611..4539f1d3 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -58,12 +58,11 @@ async function syncSharedCredentialsForUserRoles( operation: string, ) { try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - await sharedCredManager.createSharedCredentialsForUserRoles(userId); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId); } catch (error) { - authLogger.warn("Failed to sync role shared credentials", { + authLogger.warn("Failed to sync role shared host secrets", { operation, userId, error, diff --git a/src/backend/hosts/docker/routes.ts b/src/backend/hosts/docker/routes.ts index 4bd2781f..2cebf272 100644 --- a/src/backend/hosts/docker/routes.ts +++ b/src/backend/hosts/docker/routes.ts @@ -182,7 +182,7 @@ export function registerDockerSshRoutes(app: express.Express): void { const accessInfo = await permissionManager.canAccessHost( userId, hostId, - "execute", + "connect", ); if (!accessInfo.hasAccess) { @@ -294,13 +294,13 @@ export function registerDockerSshRoutes(app: express.Express): void { if (userId !== ownerId) { try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); const sharedCred = - await sharedCredManager.getSharedCredentialForUser( + await SharedHostSecretsManager.getInstance().getSecretForUser( host.id, userId, + "ssh", ); if (sharedCred) { diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts index f02e4658..0e47c2f9 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -196,10 +196,13 @@ router.post( return res.status(400).json({ error: "Invalid host ID" }); } - const host = await createCurrentHostResolutionRepository().findHostById( - hostId, - userId, - ); + const hostResolutionRepository = createCurrentHostResolutionRepository(); + // Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields. + const hostOwnerId = + await hostResolutionRepository.findHostOwnerId(hostId); + const host = hostOwnerId + ? await hostResolutionRepository.findHostById(hostId, hostOwnerId) + : null; if (!host) { return res.status(404).json({ error: "Host not found" }); @@ -210,7 +213,7 @@ router.post( const accessInfo = await permissionManager.canAccessHost( userId, hostId, - "read", + "connect", ); if (!accessInfo.hasAccess) { @@ -294,83 +297,121 @@ router.post( } const hostRecord = host as Record; - const hostRepository = createCurrentHostResolutionRepository(); + const hostRepository = hostResolutionRepository; + const isSharedConnection = host.userId !== userId; - // Backward compat: if authType is not stored but a credentialId is, treat as credential mode - const rdpEffectiveAuthType = - (host.rdpAuthType as string) || - (host.rdpCredentialId ? "credential" : "direct"); - const vncEffectiveAuthType = - (host.vncAuthType as string) || - (host.vncCredentialId ? "credential" : "direct"); - const telnetEffectiveAuthType = - (host.telnetAuthType as string) || - (hostRecord.telnetCredentialId ? "credential" : "direct"); + if (isSharedConnection) { + // Recipients never read the owner's raw secrets; wipe them and use + // the per-recipient snapshot for the requested protocol instead. + host.password = null; + host.rdpUser = null; + host.rdpPassword = null; + host.vncUser = null; + host.vncPassword = null; + host.telnetUser = null; + host.telnetPassword = null; - if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) { try { - const cred = - await hostRepository.findCredentialByIdForOwnerDecryptedAs( + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const secret = + await SharedHostSecretsManager.getInstance().getSecretForUser( + hostId, + userId, + connectionType as "rdp" | "vnc" | "telnet", + ); + if (secret) { + if (connectionType === "rdp") { + host.rdpUser = secret.username ?? null; + host.rdpPassword = secret.password ?? null; + if (secret.domain) host.rdpDomain = secret.domain; + } else if (connectionType === "vnc") { + host.vncUser = secret.username ?? null; + host.vncPassword = secret.password ?? null; + } else if (connectionType === "telnet") { + host.telnetUser = secret.username ?? null; + host.telnetPassword = secret.password ?? null; + } + } + } catch (e) { + guacLogger.warn("Failed to resolve shared host secret", { + operation: "guac_shared_secret_resolve", + hostId, + protocol: connectionType, + error: e instanceof Error ? e.message : "Unknown", + }); + } + } else { + // Backward compat: if authType is not stored but a credentialId is, treat as credential mode + const rdpEffectiveAuthType = + (host.rdpAuthType as string) || + (host.rdpCredentialId ? "credential" : "direct"); + const vncEffectiveAuthType = + (host.vncAuthType as string) || + (host.vncCredentialId ? "credential" : "direct"); + const telnetEffectiveAuthType = + (host.telnetAuthType as string) || + (hostRecord.telnetCredentialId ? "credential" : "direct"); + + if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) { + try { + const cred = await hostRepository.findCredentialByIdForUser( host.rdpCredentialId as number, host.userId as string, - userId, ); - if (cred) { - if (cred.username) host.rdpUser = cred.username; - if (cred.password) host.rdpPassword = cred.password; - // domain is never sourced from credential + if (cred) { + if (cred.username) host.rdpUser = cred.username; + if (cred.password) host.rdpPassword = cred.password; + // domain is never sourced from credential + } + } catch (e) { + guacLogger.warn("Failed to resolve RDP credential", { + operation: "guac_rdp_credential_resolve", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); } - } catch (e) { - guacLogger.warn("Failed to resolve RDP credential", { - operation: "guac_rdp_credential_resolve", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); } - } - if (vncEffectiveAuthType === "credential" && host.vncCredentialId) { - try { - const cred = - await hostRepository.findCredentialByIdForOwnerDecryptedAs( + if (vncEffectiveAuthType === "credential" && host.vncCredentialId) { + try { + const cred = await hostRepository.findCredentialByIdForUser( host.vncCredentialId as number, host.userId as string, - userId, ); - if (cred) { - if (cred.password) host.vncPassword = cred.password; - if (cred.username) host.vncUser = cred.username; + if (cred) { + if (cred.password) host.vncPassword = cred.password; + if (cred.username) host.vncUser = cred.username; + } + } catch (e) { + guacLogger.warn("Failed to resolve VNC credential", { + operation: "guac_vnc_credential_resolve", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); } - } catch (e) { - guacLogger.warn("Failed to resolve VNC credential", { - operation: "guac_vnc_credential_resolve", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); } - } - if ( - telnetEffectiveAuthType === "credential" && - hostRecord.telnetCredentialId - ) { - try { - const cred = - await hostRepository.findCredentialByIdForOwnerDecryptedAs( + if ( + telnetEffectiveAuthType === "credential" && + hostRecord.telnetCredentialId + ) { + try { + const cred = await hostRepository.findCredentialByIdForUser( hostRecord.telnetCredentialId as number, host.userId as string, - userId, ); - if (cred) { - if (cred.username) host.telnetUser = cred.username; - if (cred.password) host.telnetPassword = cred.password; + if (cred) { + if (cred.username) host.telnetUser = cred.username; + if (cred.password) host.telnetPassword = cred.password; + } + } catch (e) { + guacLogger.warn("Failed to resolve Telnet credential", { + operation: "guac_telnet_credential_resolve", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); } - } catch (e) { - guacLogger.warn("Failed to resolve Telnet credential", { - operation: "guac_telnet_credential_resolve", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); } } diff --git a/src/backend/hosts/host-resolver.ts b/src/backend/hosts/host-resolver.ts index df1ede58..edbff318 100644 --- a/src/backend/hosts/host-resolver.ts +++ b/src/backend/hosts/host-resolver.ts @@ -9,6 +9,7 @@ import { expandOidcUsername, } from "./credential-username.js"; import type { SSHHost } from "../../types/index.js"; +import type { HostAction } from "../utils/permission-manager.js"; const sshLogger = logger; @@ -24,16 +25,28 @@ export async function resolveHostById( const access = await PermissionManager.getInstance().canAccessHost( userId, hostId, - "read", + "connect", ); if (!access.hasAccess) return null; const repository = createCurrentHostResolutionRepository(); - const resolvedHost = await repository.findHostById(hostId, userId); + + // Decrypt under the owner's DEK: shared hosts carry owner-encrypted fields + // (socks5Password, inline auth, ...) that the requester's key cannot open. + const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId; + const resolvedHost = await repository.findHostById(hostId, ownerId); if (!resolvedHost) return null; const host = resolvedHost as Record; + if (userId !== ownerId) { + // Owner-only operational secrets are never shared. + host.sudoPassword = null; + host.autostartPassword = null; + host.autostartKey = null; + host.autostartKeyPassword = null; + } + // Parse JSON fields if (typeof host.jumpHosts === "string" && host.jumpHosts) { try { @@ -78,88 +91,16 @@ export async function resolveHostById( } } - // Resolve credential if using credential-based auth - if (host.credentialId) { - const ownerId = (host.userId || userId) as string; + if (userId !== ownerId) { + const resolved = await resolveSharedSshSecrets( + host, + hostId, + userId, + repository, + ); + if (!resolved) return null; + } else if (host.credentialId) { try { - // Try user's own override credential first - if (userId !== ownerId) { - try { - const overrideCredId = await repository.findOverrideCredentialId( - hostId, - userId, - ); - if (overrideCredId) { - const cred = (await repository.findCredentialByIdForUser( - overrideCredId, - userId, - )) as Record | null; - if (cred) { - host.password = cred.password; - host.key = cred.key; - host.keyPassword = cred.keyPassword; - host.keyType = cred.keyType; - host.username = pickResolvedUsername( - host.username, - cred.username, - host.overrideCredentialUsername, - ); - host.authType = cred.key - ? "key" - : cred.password - ? "password" - : "none"; - host.username = await expandOidcUsername( - host.username as string | undefined, - userId, - ); - return host as unknown as SSHHost; - } - } - } catch { - // fall through to shared credential - } - - try { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - hostId, - userId, - ); - if (sharedCred) { - host.password = sharedCred.password; - host.key = sharedCred.key; - host.keyPassword = sharedCred.keyPassword; - host.keyType = sharedCred.keyType; - host.username = pickResolvedUsername( - host.username, - sharedCred.username, - host.overrideCredentialUsername, - ); - host.authType = sharedCred.key - ? "key" - : sharedCred.password - ? "password" - : "none"; - host.username = await expandOidcUsername( - host.username as string | undefined, - userId, - ); - return host as unknown as SSHHost; - } - } catch (e) { - sshLogger.warn("Failed to get shared credential", { - operation: "host_resolver_shared_credential", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); - } - - return null; - } - const cred = (await repository.findCredentialByIdForUser( host.credentialId as number, ownerId, @@ -218,6 +159,90 @@ export async function resolveHostById( return host as unknown as SSHHost; } +/** + * Fill in SSH auth secrets for a shared (non-owner) requester. Order: + * the recipient's own override credential, then their re-encrypted share + * snapshot. Secret-less auth types (opkssh, vault, agent, none) pass through + * untouched. Returns false when a secret-bearing host has no usable source. + */ +async function resolveSharedSshSecrets( + host: Record, + hostId: number, + userId: string, + repository: ReturnType, +): Promise { + try { + const overrideCredId = await repository.findOverrideCredentialId( + hostId, + userId, + ); + if (overrideCredId) { + const cred = (await repository.findCredentialByIdForUser( + overrideCredId, + userId, + )) as Record | null; + if (cred) { + host.password = cred.password; + host.key = (cred.privateKey || cred.key) as string | null; + host.keyPassword = cred.keyPassword; + host.keyType = cred.keyType; + host.username = pickResolvedUsername( + host.username, + cred.username, + host.overrideCredentialUsername, + ); + host.authType = host.key ? "key" : host.password ? "password" : "none"; + return true; + } + } + } catch { + // fall through to the share snapshot + } + + try { + const { SharedHostSecretsManager } = + await import("../utils/shared-host-secrets-manager.js"); + const secret = + await SharedHostSecretsManager.getInstance().getSecretForUser( + hostId, + userId, + "ssh", + ); + if (secret) { + host.password = secret.password; + host.key = secret.key; + host.keyPassword = secret.keyPassword; + host.keyType = secret.keyType; + host.username = pickResolvedUsername( + host.username, + secret.username, + host.overrideCredentialUsername, + ); + host.authType = secret.key + ? "key" + : secret.password + ? "password" + : "none"; + return true; + } + } catch (e) { + sshLogger.warn("Failed to get shared host secret", { + operation: "host_resolver_shared_secret", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); + } + + const needsSecrets = + !!host.credentialId || + host.authType === "password" || + host.authType === "key" || + host.authType === "credential"; + if (!needsSecrets) return true; + + return false; +} + /** * Check if a user has access to a host (owner or shared access). */ @@ -225,7 +250,7 @@ export async function checkHostAccess( hostId: number, userId: string, hostUserId: string, - requiredPermission: "read" | "execute" = "execute", + requiredPermission: HostAction = "connect", ): Promise { if (userId === hostUserId) return true; diff --git a/src/backend/hosts/jump-host-chain.ts b/src/backend/hosts/jump-host-chain.ts index c76d86f6..5821fe76 100644 --- a/src/backend/hosts/jump-host-chain.ts +++ b/src/backend/hosts/jump-host-chain.ts @@ -36,35 +36,36 @@ async function resolveJumpHost( ): Promise { try { const repository = createCurrentHostResolutionRepository(); - const resolvedHost = await repository.findHostById(hostId, userId); + const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId; + const resolvedHost = await repository.findHostById(hostId, ownerId); if (!resolvedHost) { return null; } const host = resolvedHost as Record; - const ownerId = (host.userId || userId) as string; if (host.credentialId) { if (userId !== ownerId) { try { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - hostId, - userId, - ); - if (sharedCred) { + const { SharedHostSecretsManager } = + await import("../utils/shared-host-secrets-manager.js"); + const secret = + await SharedHostSecretsManager.getInstance().getSecretForUser( + hostId, + userId, + "ssh", + ); + if (secret) { return { ...host, - password: sharedCred.password, - key: sharedCred.key, - keyPassword: sharedCred.keyPassword, - keyType: sharedCred.keyType, - authType: sharedCred.key + password: secret.password, + key: secret.key, + keyPassword: secret.keyPassword, + keyType: secret.keyType, + authType: secret.key ? "key" - : sharedCred.password + : secret.password ? "password" : "none", } as JumpHostConfig; diff --git a/src/backend/hosts/metrics/history-routes.ts b/src/backend/hosts/metrics/history-routes.ts index 672c35b4..03fe2840 100644 --- a/src/backend/hosts/metrics/history-routes.ts +++ b/src/backend/hosts/metrics/history-routes.ts @@ -2,13 +2,14 @@ import type { Express, RequestHandler } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js"; import { statsLogger } from "../../utils/logger.js"; +import type { HostAction } from "../../utils/permission-manager.js"; type HistoryRoutesDeps = { validateHostId: RequestHandler; canAccessHost: ( userId: string, hostId: number, - level: "read" | "write" | "execute" | "delete" | "share", + level: HostAction, ) => Promise; }; @@ -65,7 +66,7 @@ export function registerHostMetricsHistoryRoutes( const userId = (req as AuthenticatedRequest).userId; try { - const hasAccess = await canAccessHost(userId, hostId, "read"); + const hasAccess = await canAccessHost(userId, hostId, "connect"); if (!hasAccess) { return res.status(403).json({ error: "Access denied" }); } diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index fd610a32..b934a34d 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -898,7 +898,7 @@ async function fetchHostById( const accessInfo = await permissionManager.canAccessHost( userId, id, - "read", + "connect", ); if (!accessInfo.hasAccess) { @@ -991,13 +991,14 @@ async function resolveHostCredentials( const isSharedHost = userId !== ownerId; if (isSharedHost) { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - host.id as number, - userId, - ); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const sharedCred = + await SharedHostSecretsManager.getInstance().getSecretForUser( + host.id as number, + userId, + "ssh", + ); if (sharedCred) { baseHost.credentialId = host.credentialId; @@ -1730,7 +1731,7 @@ app.get("/status", async (req, res) => { const result: Record = {}; for (const [id, entry] of pollingManager.getAllStatuses().entries()) { - const access = await permissionManager.canAccessHost(userId, id, "read"); + const access = await permissionManager.canAccessHost(userId, id, "connect"); if (access.hasAccess) { result[id] = entry; } @@ -1771,7 +1772,7 @@ app.get("/status/:id", validateHostId, async (req, res) => { }); } - const access = await permissionManager.canAccessHost(userId, id, "read"); + const access = await permissionManager.canAccessHost(userId, id, "connect"); if (!access.hasAccess) { return res.status(404).json({ error: "Status not available" }); } diff --git a/src/backend/hosts/metrics/managers/cron.ts b/src/backend/hosts/metrics/managers/cron.ts index ce587db0..08ac8d02 100644 --- a/src/backend/hosts/metrics/managers/cron.ts +++ b/src/backend/hosts/metrics/managers/cron.ts @@ -85,7 +85,7 @@ export function registerCronRoutes( app.get( "/host-metrics/managers/cron/:id", validateHostId, - managerHandler(runOnHost, "read", "cron_list", async (client) => { + managerHandler(runOnHost, "connect", "cron_list", async (client) => { const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000); return { entries: parseCrontab(stdout) }; }), @@ -96,7 +96,7 @@ export function registerCronRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "cron_replace", async (client, _host, req) => { const { entries } = req.body as { entries?: CronEntry[] }; diff --git a/src/backend/hosts/metrics/managers/firewall.ts b/src/backend/hosts/metrics/managers/firewall.ts index 32f80f42..535c289b 100644 --- a/src/backend/hosts/metrics/managers/firewall.ts +++ b/src/backend/hosts/metrics/managers/firewall.ts @@ -51,7 +51,7 @@ export function registerFirewallRoutes( app.get( "/host-metrics/managers/firewall/:id", validateHostId, - managerHandler(runOnHost, "read", "firewall_read", async (client) => { + managerHandler(runOnHost, "connect", "firewall_read", async (client) => { return await collectFirewallMetrics(client); }), ); @@ -61,7 +61,7 @@ export function registerFirewallRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "firewall_rule", async (client, host, req) => { const { op, protocol, port, target } = req.body as { @@ -106,7 +106,7 @@ export function registerFirewallRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "firewall_persist", async (client, host) => { const platform = await detectPlatform(client); diff --git a/src/backend/hosts/metrics/managers/health.ts b/src/backend/hosts/metrics/managers/health.ts index bd631066..f84928a9 100644 --- a/src/backend/hosts/metrics/managers/health.ts +++ b/src/backend/hosts/metrics/managers/health.ts @@ -147,7 +147,7 @@ export function registerHealthRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "health_get", async (client, host, req) => { const userId = (req as AuthenticatedRequest).userId; @@ -191,7 +191,7 @@ export function registerHealthRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "health_config", async (_client, host, req) => { const userId = (req as AuthenticatedRequest).userId; @@ -226,7 +226,7 @@ export function registerHealthRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "health_run", async (client, host, req) => { const userId = (req as AuthenticatedRequest).userId; diff --git a/src/backend/hosts/metrics/managers/index.ts b/src/backend/hosts/metrics/managers/index.ts index 2a281dd5..f4e79d1e 100644 --- a/src/backend/hosts/metrics/managers/index.ts +++ b/src/backend/hosts/metrics/managers/index.ts @@ -45,7 +45,7 @@ export function registerManagerRoutes( app.get( "/host-metrics/platform/:id", validateHostId, - managerHandler(runOnHost, "read", "platform_detect", (client) => + managerHandler(runOnHost, "connect", "platform_detect", (client) => detectPlatform(client), ), ); diff --git a/src/backend/hosts/metrics/managers/logs.ts b/src/backend/hosts/metrics/managers/logs.ts index e717759f..60517264 100644 --- a/src/backend/hosts/metrics/managers/logs.ts +++ b/src/backend/hosts/metrics/managers/logs.ts @@ -46,7 +46,7 @@ export function registerLogRoutes( app.get( "/host-metrics/managers/logs/:id/files", validateHostId, - managerHandler(runOnHost, "read", "logs_list", async (client) => { + managerHandler(runOnHost, "connect", "logs_list", async (client) => { const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000); const found = stdout .split("\n") @@ -62,7 +62,7 @@ export function registerLogRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "logs_tail", async (client, host, req) => { const path = req.query.path as string | undefined; diff --git a/src/backend/hosts/metrics/managers/packages.ts b/src/backend/hosts/metrics/managers/packages.ts index 6c5c96d5..2dff740c 100644 --- a/src/backend/hosts/metrics/managers/packages.ts +++ b/src/backend/hosts/metrics/managers/packages.ts @@ -96,7 +96,7 @@ export function registerPackageRoutes( app.get( "/host-metrics/managers/packages/:id", validateHostId, - managerHandler(runOnHost, "read", "packages_list", async (client) => { + managerHandler(runOnHost, "connect", "packages_list", async (client) => { const platform = await detectPlatform(client); const cmd = buildListUpgradableCommand(platform.pkg); if (!cmd) return { pkg: platform.pkg, upgradable: [] }; @@ -113,7 +113,7 @@ export function registerPackageRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "packages_action", async (client, host, req) => { const { action, pkg: name } = req.body as { diff --git a/src/backend/hosts/metrics/managers/processes.ts b/src/backend/hosts/metrics/managers/processes.ts index 180dc69e..5e1e2045 100644 --- a/src/backend/hosts/metrics/managers/processes.ts +++ b/src/backend/hosts/metrics/managers/processes.ts @@ -70,7 +70,7 @@ export function registerProcessRoutes( app.get( "/host-metrics/managers/processes/:id", validateHostId, - managerHandler(runOnHost, "read", "processes_list", async (client) => { + managerHandler(runOnHost, "connect", "processes_list", async (client) => { const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000); return { processes: parseProcessList(stdout) }; }), @@ -106,7 +106,7 @@ export function registerProcessRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "processes_signal", async (client, host, req) => { const { pid, signal } = req.body as { diff --git a/src/backend/hosts/metrics/managers/route-helpers.ts b/src/backend/hosts/metrics/managers/route-helpers.ts index 52199b6d..03a11c4a 100644 --- a/src/backend/hosts/metrics/managers/route-helpers.ts +++ b/src/backend/hosts/metrics/managers/route-helpers.ts @@ -4,6 +4,7 @@ import type { AuthenticatedRequest } from "../../../../types/index.js"; import { statsLogger } from "../../../utils/logger.js"; import { ElevationError } from "./exec-elevated.js"; import type { ManagerHost, RunOnHost } from "./types.js"; +import type { HostAction } from "../../../utils/permission-manager.js"; export class AccessDeniedError extends Error { constructor(message = "No access to this host") { @@ -25,7 +26,7 @@ export class ManagerInputError extends Error { */ export function managerHandler( runOnHost: RunOnHost, - level: "read" | "execute", + level: HostAction, operation: string, fn: (client: Client, host: ManagerHost, req: Request) => Promise, ) { diff --git a/src/backend/hosts/metrics/managers/services.ts b/src/backend/hosts/metrics/managers/services.ts index 63164833..724bc517 100644 --- a/src/backend/hosts/metrics/managers/services.ts +++ b/src/backend/hosts/metrics/managers/services.ts @@ -70,7 +70,7 @@ export function registerServiceRoutes( app.get( "/host-metrics/managers/services/:id", validateHostId, - managerHandler(runOnHost, "read", "services_list", async (client) => { + managerHandler(runOnHost, "connect", "services_list", async (client) => { const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000); return { services: parseServiceList(stdout) }; }), @@ -106,7 +106,7 @@ export function registerServiceRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "services_action", async (client, host, req) => { const { unit, action } = req.body as { diff --git a/src/backend/hosts/metrics/managers/simple-reads.ts b/src/backend/hosts/metrics/managers/simple-reads.ts index 189f36e3..aa5dd2a4 100644 --- a/src/backend/hosts/metrics/managers/simple-reads.ts +++ b/src/backend/hosts/metrics/managers/simple-reads.ts @@ -112,7 +112,7 @@ export function registerSimpleReadRoutes( app.get( "/host-metrics/managers/top-memory/:id", validateHostId, - managerHandler(runOnHost, "read", "top_memory", async (client) => { + managerHandler(runOnHost, "connect", "top_memory", async (client) => { const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000); return { processes: parseTopMemory(stdout) }; }), @@ -121,7 +121,7 @@ export function registerSimpleReadRoutes( app.get( "/host-metrics/managers/timers/:id", validateHostId, - managerHandler(runOnHost, "read", "systemd_timers", async (client) => { + managerHandler(runOnHost, "connect", "systemd_timers", async (client) => { const { stdout } = await execCommand(client, TIMERS_CMD, 15000); return { timers: parseTimers(stdout) }; }), @@ -130,7 +130,7 @@ export function registerSimpleReadRoutes( app.get( "/host-metrics/managers/disk-breakdown/:id", validateHostId, - managerHandler(runOnHost, "read", "disk_breakdown", async (client) => { + managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => { const { stdout } = await execCommand(client, DF_CMD, 15000); return { mounts: parseDfMounts(stdout) }; }), diff --git a/src/backend/hosts/metrics/managers/ssl.ts b/src/backend/hosts/metrics/managers/ssl.ts index 9a17c953..96248bd8 100644 --- a/src/backend/hosts/metrics/managers/ssl.ts +++ b/src/backend/hosts/metrics/managers/ssl.ts @@ -153,7 +153,7 @@ export function registerSslRoutes( app.get( "/host-metrics/managers/ssl/:id", validateHostId, - managerHandler(runOnHost, "read", "ssl_list", async (client, host) => { + managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => { const platform = await detectPlatform(client); const certs: CertInfo[] = []; if (platform.hasCertbot) { @@ -187,7 +187,7 @@ export function registerSslRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "ssl_issue", async (client, host, req) => { const body = req.body as Partial; @@ -236,7 +236,7 @@ export function registerSslRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "ssl_renew", async (client, host, req) => { const { client: acmeClient, dryRun } = req.body as { @@ -289,7 +289,7 @@ export function registerSslRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "ssl_revoke", async (client, host, req) => { const { client: acmeClient, name } = req.body as { diff --git a/src/backend/hosts/metrics/managers/tailscale.ts b/src/backend/hosts/metrics/managers/tailscale.ts index 4c63bf1c..59f58eca 100644 --- a/src/backend/hosts/metrics/managers/tailscale.ts +++ b/src/backend/hosts/metrics/managers/tailscale.ts @@ -116,7 +116,7 @@ export function registerTailscaleRoutes( app.get( "/host-metrics/managers/tailscale/:id", validateHostId, - managerHandler(runOnHost, "read", "tailscale_read", async (client) => { + managerHandler(runOnHost, "connect", "tailscale_read", async (client) => { const { stdout } = await execCommand(client, PROBE_CMD, 15000); return parseTailscaleData(stdout); }), @@ -154,7 +154,7 @@ export function registerTailscaleRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "tailscale_action", async (client, host, req) => { const { action } = req.body as { action: unknown }; diff --git a/src/backend/hosts/metrics/managers/types.ts b/src/backend/hosts/metrics/managers/types.ts index 05ff6096..09fbee05 100644 --- a/src/backend/hosts/metrics/managers/types.ts +++ b/src/backend/hosts/metrics/managers/types.ts @@ -1,5 +1,6 @@ import type { Client } from "ssh2"; import type { RequestHandler } from "express"; +import type { HostAction } from "../../../utils/permission-manager.js"; /** Minimal host shape managers need (includes the decrypted sudo password). */ export interface ManagerHost { @@ -17,7 +18,7 @@ export interface ManagerHost { export type RunOnHost = ( hostId: number, userId: string, - level: "read" | "execute", + level: HostAction, fn: (client: Client, host: ManagerHost) => Promise, ) => Promise; diff --git a/src/backend/hosts/metrics/managers/users.ts b/src/backend/hosts/metrics/managers/users.ts index 4df2278c..764f53ec 100644 --- a/src/backend/hosts/metrics/managers/users.ts +++ b/src/backend/hosts/metrics/managers/users.ts @@ -79,7 +79,7 @@ export function registerUserRoutes( app.get( "/host-metrics/managers/users/:id", validateHostId, - managerHandler(runOnHost, "read", "users_list", async (client) => { + managerHandler(runOnHost, "connect", "users_list", async (client) => { const [passwd, groups, sudoers] = await Promise.all([ execCommand(client, READ_USERS_CMD, 15000), execCommand(client, READ_GROUPS_CMD, 15000), @@ -98,7 +98,7 @@ export function registerUserRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "users_action", async (client, host, req) => { const { action, username, group } = req.body as { diff --git a/src/backend/hosts/metrics/managers/wireguard.ts b/src/backend/hosts/metrics/managers/wireguard.ts index 045b8ef0..018bc6ca 100644 --- a/src/backend/hosts/metrics/managers/wireguard.ts +++ b/src/backend/hosts/metrics/managers/wireguard.ts @@ -143,7 +143,7 @@ export function registerWireGuardRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "wireguard_read", async (client, host) => { const result = await execElevated( @@ -194,7 +194,7 @@ export function registerWireGuardRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "wireguard_action", async (client, host, req) => { const { interface: iface, action } = req.body as { diff --git a/src/backend/hosts/metrics/preferences-routes.ts b/src/backend/hosts/metrics/preferences-routes.ts index 63b983ad..3a6e785a 100644 --- a/src/backend/hosts/metrics/preferences-routes.ts +++ b/src/backend/hosts/metrics/preferences-routes.ts @@ -7,6 +7,7 @@ import { defaultLayoutFromWidgets, type HostMetricsLayout, } from "../../../types/host-metrics.js"; +import type { HostAction } from "../../utils/permission-manager.js"; interface PrefStatsConfig { enabledWidgets?: string[]; @@ -25,7 +26,7 @@ type HostMetricsPreferencesRoutesDeps = { canAccessHost: ( userId: string, hostId: number, - level: "read" | "execute", + level: HostAction, ) => Promise; }; @@ -88,7 +89,7 @@ export function registerHostMetricsPreferencesRoutes( const userId = (req as AuthenticatedRequest).userId; const hostId = parseInt(String(req.params.id), 10); try { - if (!(await canAccessHost(userId, hostId, "read"))) { + if (!(await canAccessHost(userId, hostId, "connect"))) { return res.status(403).json({ error: "No access to this host" }); } const host = await fetchHostById(hostId, userId); @@ -166,7 +167,7 @@ export function registerHostMetricsPreferencesRoutes( const userId = (req as AuthenticatedRequest).userId; const hostId = parseInt(String(req.params.id), 10); try { - if (!(await canAccessHost(userId, hostId, "read"))) { + if (!(await canAccessHost(userId, hostId, "connect"))) { return res.status(403).json({ error: "No access to this host" }); } const host = await fetchHostById(hostId, userId); diff --git a/src/backend/hosts/tmux/index.ts b/src/backend/hosts/tmux/index.ts index 6d91ccce..1755c5cb 100644 --- a/src/backend/hosts/tmux/index.ts +++ b/src/backend/hosts/tmux/index.ts @@ -14,6 +14,7 @@ import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; import { SSHHostKeyVerifier } from "../host-key-verifier.js"; import { resolveHostById, checkHostAccess } from "../host-resolver.js"; +import type { HostAction } from "../../utils/permission-manager.js"; import { createJumpHostChain } from "../jump-host-chain.js"; import { applyAgentAuth } from "../terminal-auth-helpers.js"; import { @@ -343,7 +344,7 @@ app.use(authManager.createAuthMiddleware()); async function requireHost( req: express.Request, res: express.Response, - permission: "read" | "execute" = "read", + permission: HostAction = "connect", ): Promise { const userId = (req as unknown as AuthenticatedRequest).userId; const hostId = parseInt(String(req.params.hostId), 10); @@ -503,7 +504,7 @@ app.get("/tmux_monitor/:hostId/overview", async (req, res) => { // Focus a pane: select its window and pane on the server so every attached // client (including the monitor's embedded terminal) switches to it. app.post("/tmux_monitor/:hostId/focus", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const paneId = String((req.body as { paneId?: string })?.paneId || ""); @@ -530,7 +531,7 @@ app.post("/tmux_monitor/:hostId/focus", async (req, res) => { // Create a detached session. Starts the tmux server if none is running. app.post("/tmux_monitor/:hostId/sessions", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const name = String((req.body as { name?: string })?.name || "").trim(); @@ -563,7 +564,7 @@ app.post("/tmux_monitor/:hostId/sessions", async (req, res) => { // target's meaning (":" and "." are window/pane separators in tmux targets); // "=" prefixes the target for an exact-name match. app.post("/tmux_monitor/:hostId/windows", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const sessionName = String( @@ -597,7 +598,7 @@ app.post("/tmux_monitor/:hostId/windows", async (req, res) => { // Rename a session. Saved tags follow the session to its new name (for every // user — the session itself is shared on the host). app.post("/tmux_monitor/:hostId/rename", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const body = req.body as { sessionName?: string; newName?: string }; @@ -649,7 +650,7 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => { // Kill a session and drop its saved tags. app.post("/tmux_monitor/:hostId/kill", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const sessionName = String( @@ -688,7 +689,7 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => { // Kill a window (and every pane in it). Killing the last window of a session // ends the session — tmux semantics. app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const body = req.body as { sessionName?: string; windowIndex?: number }; @@ -735,7 +736,7 @@ app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => { // Kill a single pane. Killing the last pane of a window closes the window, // and the last window of a session ends the session — tmux semantics. app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const paneId = String((req.body as { paneId?: string })?.paneId || ""); @@ -766,7 +767,7 @@ app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => { // "v" below — matching tmux's own -h/-v semantics. The new pane starts in the // source pane's working directory. app.post("/tmux_monitor/:hostId/split", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const body = req.body as { paneId?: string; direction?: string }; diff --git a/src/backend/hosts/tunnel/c2s-relay.ts b/src/backend/hosts/tunnel/c2s-relay.ts index 1d03d870..e9cd6584 100644 --- a/src/backend/hosts/tunnel/c2s-relay.ts +++ b/src/backend/hosts/tunnel/c2s-relay.ts @@ -36,7 +36,7 @@ async function resolveC2STunnelSource( const accessInfo = await permissionManager.canAccessHost( userId, tunnelConfig.sourceHostId, - "read", + "connect", ); if (!accessInfo.hasAccess) { throw new Error("Access denied to this host"); diff --git a/src/backend/hosts/tunnel/routes.ts b/src/backend/hosts/tunnel/routes.ts index 9e9d77cd..6eadcf9f 100644 --- a/src/backend/hosts/tunnel/routes.ts +++ b/src/backend/hosts/tunnel/routes.ts @@ -199,7 +199,7 @@ export function registerTunnelRoutes(app: express.Express): void { const accessInfo = await permissionManager.canAccessHost( userId, tunnelConfig.sourceHostId, - "read", + "connect", ); if (!accessInfo.hasAccess) { @@ -285,7 +285,7 @@ export function registerTunnelRoutes(app: express.Express): void { const endpointAccess = await permissionManager.canAccessHost( userId, endpointHost.id, - "read", + "connect", ); if (!endpointAccess.hasAccess) { tunnelLogger.warn( @@ -442,7 +442,7 @@ export function registerTunnelRoutes(app: express.Express): void { const accessInfo = await permissionManager.canAccessHost( userId, config.sourceHostId, - "read", + "connect", ); if (!accessInfo.hasAccess) { return res.status(403).json({ error: "Access denied" }); @@ -547,7 +547,7 @@ export function registerTunnelRoutes(app: express.Express): void { const accessInfo = await permissionManager.canAccessHost( userId, config.sourceHostId, - "read", + "connect", ); if (!accessInfo.hasAccess) { return res.status(403).json({ error: "Access denied" }); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 67f0257d..dfc0a8f9 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -101,6 +101,10 @@ import { await authManager.initialize(); DataCrypto.initialize(); + const { runSharedHostSecretsMigration } = + await import("./utils/crypto-migration/shared-host-secrets-migration.js"); + await runSharedHostSecretsMigration(); + import("./utils/opkssh-binary-manager.js").then( ({ OPKSSHBinaryManager }) => { OPKSSHBinaryManager.ensureBinary().catch((error) => { diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts index 8314c989..e90eb576 100644 --- a/src/backend/tests/database/repositories/host-resolution-repository.test.ts +++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts @@ -322,6 +322,7 @@ describe("HostResolutionRepository", () => { rdpCredentialId: null, vncCredentialId: null, telnetCredentialId: null, + vaultProfileId: null, authType: "password", }); await expect(repository.findHostUpdateState(999)).resolves.toBeNull(); diff --git a/src/backend/tests/database/repositories/rbac-access-repository.test.ts b/src/backend/tests/database/repositories/rbac-access-repository.test.ts index 0d29a23e..1cc8e509 100644 --- a/src/backend/tests/database/repositories/rbac-access-repository.test.ts +++ b/src/backend/tests/database/repositories/rbac-access-repository.test.ts @@ -52,19 +52,23 @@ describe("RbacAccessRepository", () => { override_credential_id INTEGER ); - CREATE TABLE shared_credentials ( + CREATE TABLE shared_host_secrets ( 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, + protocol TEXT NOT NULL DEFAULT 'ssh', + source_type TEXT NOT NULL DEFAULT 'credential', + original_credential_id INTEGER, + encrypted_username TEXT, + encrypted_auth_type TEXT, encrypted_password TEXT, encrypted_key TEXT, encrypted_key_password TEXT, encrypted_key_type TEXT, + encrypted_domain TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(host_access_id, target_user_id, protocol) ); CREATE TABLE ssh_data ( @@ -128,10 +132,12 @@ describe("RbacAccessRepository", () => { (2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'), (5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z'); - INSERT INTO shared_credentials ( - id, host_access_id, original_credential_id, target_user_id, encrypted_username, encrypted_auth_type + INSERT INTO shared_host_secrets ( + id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type ) - VALUES (8, 2, 123, 'user-1', 'enc-user', 'enc-auth'); + VALUES + (8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'), + (9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct'); INSERT INTO snippets (id, user_id, name, content) VALUES (99, 'owner-1', 'deploy', 'echo deploy'); @@ -261,14 +267,16 @@ describe("RbacAccessRepository", () => { ]); }); - it("finds shared credential and host access owner for shared credential reads", async () => { + it("finds shared secrets per protocol and host access owner", async () => { const repo = await createRepository(); await expect( - repo.findSharedCredentialForHostAndUser(42, "user-1"), + repo.findSharedSecretForHostUserProtocol(42, "user-1", "ssh"), ).resolves.toMatchObject({ id: 8, hostAccessId: 2, + protocol: "ssh", + sourceType: "credential", originalCredentialId: 123, targetUserId: "user-1", encryptedUsername: "enc-user", @@ -276,12 +284,62 @@ describe("RbacAccessRepository", () => { }); await expect( - repo.findSharedCredentialForHostAndUser(99, "user-1"), + repo.findSharedSecretForHostUserProtocol(42, "user-1", "rdp"), + ).resolves.toMatchObject({ + id: 9, + protocol: "rdp", + sourceType: "inline", + originalCredentialId: null, + }); + + await expect( + repo.findSharedSecretForHostUserProtocol(42, "user-1", "vnc"), + ).resolves.toBeNull(); + await expect( + repo.findSharedSecretForHostUserProtocol(99, "user-1", "ssh"), ).resolves.toBeNull(); await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1"); await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull(); }); + it("lists active grants, finds grants by id and updates grant level/expiry", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const grants = await repo.listActiveHostAccessGrants(42, activeAccessTime); + expect(grants.map((grant) => grant.id).sort()).toEqual([1, 2]); + + // Host 44's only grant expired before activeAccessTime. + await expect( + repo.listActiveHostAccessGrants(44, activeAccessTime), + ).resolves.toEqual([]); + + await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({ + id: 1, + hostId: 42, + permissionLevel: "view", + }); + await expect(repo.findHostAccessById(1, 99)).resolves.toBeNull(); + + await expect( + repo.updateHostAccessGrant(1, 42, { + permissionLevel: "manage", + expiresAt: "2026-07-01T00:00:00.000Z", + }), + ).resolves.toBe(true); + await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({ + permissionLevel: "manage", + expiresAt: "2026-07-01T00:00:00.000Z", + }); + + await expect( + repo.updateHostAccessGrant(999, 42, { permissionLevel: "view" }), + ).resolves.toBe(false); + expect(writeCount).toBe(1); + }); + it("lists shared snippets and preserves route-level direct-over-role behavior", async () => { const repo = await createRepository(); diff --git a/src/backend/tests/database/repositories/session-recording-repository.test.ts b/src/backend/tests/database/repositories/session-recording-repository.test.ts index 0bc47454..7d2437cf 100644 --- a/src/backend/tests/database/repositories/session-recording-repository.test.ts +++ b/src/backend/tests/database/repositories/session-recording-repository.test.ts @@ -92,6 +92,8 @@ describe("SessionRecordingRepository", () => { expect(rows[0]).toMatchObject({ hostIp: "10.0.0.2", recordingPath: "/tmp/two.log", + protocol: "ssh", + format: "text", }); }); diff --git a/src/backend/tests/database/repositories/shared-credential-repository.test.ts b/src/backend/tests/database/repositories/shared-credential-repository.test.ts deleted file mode 100644 index 84dd2edc..00000000 --- a/src/backend/tests/database/repositories/shared-credential-repository.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { SharedCredentialRepository } from "../../../database/repositories/shared-credential-repository.js"; - -describe("SharedCredentialRepository", () => { - let adapter: TestSqliteDatabase | null = null; - - afterEach(async () => { - if (adapter) { - await adapter.close(); - adapter = null; - } - }); - - async function createRepository( - onWrite?: () => void | Promise, - ): Promise<{ - repository: SharedCredentialRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; - }> { - adapter = new TestSqliteDatabase(); - const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE 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 - ); - - INSERT INTO shared_credentials ( - id, - host_access_id, - original_credential_id, - target_user_id, - encrypted_username, - encrypted_auth_type, - encrypted_password - ) - VALUES - (1, 10, 100, 'user-1', 'u1', 'password', 'p1'), - (2, 10, 100, 'user-2', 'u2', 'password', 'p2'), - (3, 11, 101, 'user-2', 'u3', 'key', NULL); - `); - - return { - repository: new SharedCredentialRepository(context, onWrite), - sqlite: context.sqlite!, - }; - } - - it("checks existence and creates shared credentials", async () => { - let writes = 0; - const { repository } = await createRepository(() => { - writes += 1; - }); - - await expect( - repository.existsForHostAccessAndTargetUser(10, "user-1"), - ).resolves.toBe(true); - await expect( - repository.existsForHostAccessAndTargetUser(10, "missing"), - ).resolves.toBe(false); - - const created = await repository.create({ - hostAccessId: 12, - originalCredentialId: 102, - targetUserId: "user-3", - encryptedUsername: "u4", - encryptedAuthType: "password", - encryptedPassword: "p4", - }); - - expect(created).toMatchObject({ - hostAccessId: 12, - originalCredentialId: 102, - targetUserId: "user-3", - encryptedUsername: "u4", - }); - expect(writes).toBe(1); - }); - - it("finds, lists, and updates shared credentials", async () => { - let writes = 0; - const { repository } = await createRepository(() => { - writes += 1; - }); - - await expect(repository.findById(1)).resolves.toMatchObject({ - targetUserId: "user-1", - }); - await expect(repository.findById(999)).resolves.toBeNull(); - await expect( - repository.listByOriginalCredentialId(100), - ).resolves.toHaveLength(2); - - await expect( - repository.updateById(2, { - encryptedPassword: "updated", - }), - ).resolves.toMatchObject({ - encryptedPassword: "updated", - }); - expect(writes).toBe(1); - }); - - it("deletes shared credentials", async () => { - let writes = 0; - const { repository, sqlite } = await createRepository(() => { - writes += 1; - }); - - await expect(repository.deleteById(1)).resolves.toBe(true); - await expect(repository.deleteById(999)).resolves.toBe(false); - await expect(repository.deleteByTargetUserId("user-2")).resolves.toBe(2); - await expect(repository.deleteByOriginalCredentialId(100)).resolves.toBe(0); - - expect( - sqlite.prepare("SELECT id FROM shared_credentials ORDER BY id").all(), - ).toEqual([]); - expect(writes).toBe(2); - }); -}); diff --git a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts new file mode 100644 index 00000000..75acf718 --- /dev/null +++ b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SharedHostSecretsRepository } from "../../../database/repositories/shared-host-secrets-repository.js"; + +describe("SharedHostSecretsRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise<{ + repository: SharedHostSecretsRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite!.exec(` + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'connect', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + override_credential_id INTEGER + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + credential_id INTEGER, + rdp_credential_id INTEGER, + vnc_credential_id INTEGER, + telnet_credential_id INTEGER + ); + + CREATE TABLE shared_host_secrets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_access_id INTEGER NOT NULL, + target_user_id TEXT NOT NULL, + protocol TEXT NOT NULL DEFAULT 'ssh', + source_type TEXT NOT NULL DEFAULT 'credential', + original_credential_id INTEGER, + encrypted_username TEXT, + encrypted_auth_type TEXT, + encrypted_password TEXT, + encrypted_key TEXT, + encrypted_key_password TEXT, + encrypted_key_type TEXT, + encrypted_domain TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(host_access_id, target_user_id, protocol) + ); + + INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id) + VALUES + (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124), + (43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL), + (44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL); + + INSERT INTO host_access (id, host_id, user_id, role_id, granted_by) + VALUES + (1, 42, 'user-1', NULL, 'owner-1'), + (2, 42, NULL, 7, 'owner-1'), + (3, 43, 'user-2', NULL, 'owner-1'); + `); + + return { + repository: new SharedHostSecretsRepository(context, onWrite), + sqlite: context.sqlite!, + }; + } + + it("upserts snapshots per protocol and finds them by host/user/protocol", async () => { + let writeCount = 0; + const { repository } = await createRepository(() => { + writeCount += 1; + }); + + await repository.upsert({ + hostAccessId: 1, + targetUserId: "user-1", + protocol: "ssh", + sourceType: "credential", + originalCredentialId: 123, + encryptedUsername: "enc-user", + encryptedAuthType: "password", + encryptedPassword: "enc-pass", + }); + + await expect( + repository.findForHostUserProtocol(42, "user-1", "ssh"), + ).resolves.toMatchObject({ + hostAccessId: 1, + protocol: "ssh", + encryptedPassword: "enc-pass", + }); + + // Upsert on the same (grant, user, protocol) updates in place. + await repository.upsert({ + hostAccessId: 1, + targetUserId: "user-1", + protocol: "ssh", + sourceType: "inline", + originalCredentialId: null, + encryptedUsername: "enc-user-2", + encryptedAuthType: "key", + encryptedKey: "enc-key", + }); + + const updated = await repository.findForHostUserProtocol( + 42, + "user-1", + "ssh", + ); + expect(updated).toMatchObject({ + sourceType: "inline", + encryptedUsername: "enc-user-2", + encryptedKey: "enc-key", + }); + + await expect( + repository.findForHostUserProtocol(42, "user-1", "rdp"), + ).resolves.toBeNull(); + await expect( + repository.findForHostUserProtocol(43, "user-1", "ssh"), + ).resolves.toBeNull(); + expect(writeCount).toBe(2); + }); + + it("deletes stale protocols while keeping the listed ones", async () => { + const { repository } = await createRepository(); + + for (const protocol of ["ssh", "rdp", "vnc"] as const) { + await repository.upsert({ + hostAccessId: 1, + targetUserId: "user-1", + protocol, + sourceType: "inline", + encryptedAuthType: "direct", + }); + } + + await repository.deleteForHostAccessAndTarget(1, "user-1", ["ssh"]); + + await expect( + repository.findForHostUserProtocol(42, "user-1", "ssh"), + ).resolves.not.toBeNull(); + await expect( + repository.findForHostUserProtocol(42, "user-1", "rdp"), + ).resolves.toBeNull(); + await expect( + repository.findForHostUserProtocol(42, "user-1", "vnc"), + ).resolves.toBeNull(); + }); + + it("deletes by host access, target user, credential and role membership", async () => { + const { repository } = await createRepository(); + + await repository.upsert({ + hostAccessId: 1, + targetUserId: "user-1", + protocol: "ssh", + sourceType: "credential", + originalCredentialId: 123, + encryptedAuthType: "password", + }); + await repository.upsert({ + hostAccessId: 2, + targetUserId: "user-1", + protocol: "ssh", + sourceType: "credential", + originalCredentialId: 123, + encryptedAuthType: "password", + }); + await repository.upsert({ + hostAccessId: 3, + targetUserId: "user-2", + protocol: "ssh", + sourceType: "inline", + encryptedAuthType: "password", + }); + + // Role-membership cleanup: only grant 2 targets role 7. + expect(await repository.deleteForRoleMember(7, "user-1")).toBe(1); + await expect( + repository.existsForHostAccessAndTargetUser(2, "user-1"), + ).resolves.toBe(false); + await expect( + repository.existsForHostAccessAndTargetUser(1, "user-1"), + ).resolves.toBe(true); + + expect(await repository.deleteByHostAccessId(1)).toBe(1); + expect(await repository.deleteByTargetUserId("user-2")).toBe(1); + expect(await repository.deleteByOriginalCredentialId(123)).toBe(0); + }); + + it("finds host ids referencing a credential for the owner", async () => { + const { repository } = await createRepository(); + + await expect( + repository.findHostIdsReferencingCredential("owner-1", 123), + ).resolves.toEqual([42]); + await expect( + repository.findHostIdsReferencingCredential("owner-1", 124), + ).resolves.toEqual([42]); + await expect( + repository.findHostIdsReferencingCredential("owner-1", 999), + ).resolves.toEqual([]); + await expect( + repository.findHostIdsReferencingCredential("owner-2", 123), + ).resolves.toEqual([44]); + }); +}); diff --git a/src/backend/tests/database/routes/host-normalizers.test.ts b/src/backend/tests/database/routes/host-normalizers.test.ts index 09a58670..eb0a863f 100644 --- a/src/backend/tests/database/routes/host-normalizers.test.ts +++ b/src/backend/tests/database/routes/host-normalizers.test.ts @@ -4,6 +4,7 @@ import { isValidPort, normalizeImportedHost, renameFolderPath, + sanitizeHostForRecipient, stripSensitiveFields, transformHostResponse, } from "../../../database/routes/host-normalizers.js"; @@ -219,3 +220,58 @@ describe("transformHostResponse", () => { expect(result.proxmoxConfig).toBeUndefined(); }); }); + +describe("sanitizeHostForRecipient", () => { + const sharedHost = { + id: 42, + userId: "owner", + ownerUsername: "owner", + isShared: true, + permissionLevel: "view", + name: "prod", + ip: "10.0.0.42", + port: 22, + username: "root", + folder: "servers", + tags: ["linux"], + notes: "secret runbook", + quickActions: [{ name: "restart", snippetId: "1" }], + password: "hunter2", + key: "PRIVATE", + sudoPassword: "sudo", + rdpPassword: "rdp", + socks5Password: "socks", + enableSsh: true, + enableRdp: true, + sshPort: 22, + rdpPort: 3389, + defaultPath: "/srv", + }; + + it("always strips secrets for recipients", () => { + const result = sanitizeHostForRecipient({ ...sharedHost }, "view"); + expect(result.password).toBeUndefined(); + expect(result.key).toBeUndefined(); + expect(result.sudoPassword).toBeUndefined(); + expect(result.rdpPassword).toBeUndefined(); + expect(result.socks5Password).toBeUndefined(); + // view keeps configuration fields + expect(result.notes).toBe("secret runbook"); + expect(result.quickActions).toEqual(sharedHost.quickActions); + }); + + it("reduces connect-level hosts to connection essentials", () => { + const result = sanitizeHostForRecipient( + { ...sharedHost, permissionLevel: "connect" }, + "connect", + ); + expect(result.name).toBe("prod"); + expect(result.ip).toBe("10.0.0.42"); + expect(result.enableRdp).toBe(true); + expect(result.rdpPort).toBe(3389); + expect(result.permissionLevel).toBe("connect"); + expect(result.notes).toBeUndefined(); + expect(result.quickActions).toBeUndefined(); + expect(result.password).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/hosts/host-resolver.test.ts b/src/backend/tests/hosts/host-resolver.test.ts new file mode 100644 index 00000000..5955d7d9 --- /dev/null +++ b/src/backend/tests/hosts/host-resolver.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + host: null as Record | null, + hasAccess: true, + overrideCredentialId: null as number | null, + credentials: new Map>(), + sharedSecret: null as Record | null, +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + findHostOwnerId: async () => (state.host?.userId as string) ?? null, + findHostById: async () => (state.host ? { ...state.host } : null), + findOverrideCredentialId: async () => state.overrideCredentialId, + findCredentialByIdForUser: async (credentialId: number, userId: string) => + state.credentials.get(`${credentialId}:${userId}`) ?? null, + }), + createCurrentVaultProfileRepository: () => ({ + findById: async () => null, + }), +})); + +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + canAccessHost: async () => ({ hasAccess: state.hasAccess }), + }), + }, +})); + +vi.mock("../../utils/shared-host-secrets-manager.js", () => ({ + SharedHostSecretsManager: { + getInstance: () => ({ + getSecretForUser: async () => state.sharedSecret, + }), + }, +})); + +vi.mock("../../utils/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { resolveHostById } from "../../hosts/host-resolver.js"; + +function baseHost(overrides: Record = {}) { + return { + id: 42, + userId: "owner", + name: "prod", + ip: "10.0.0.42", + port: 22, + username: "root", + authType: "password", + password: "owner-secret", + key: null, + keyPassword: null, + keyType: null, + credentialId: null, + vaultProfileId: null, + sudoPassword: "owner-sudo", + autostartPassword: "auto-pass", + autostartKey: null, + autostartKeyPassword: null, + jumpHosts: null, + tunnelConnections: null, + statsConfig: null, + terminalConfig: null, + socks5ProxyChain: null, + quickActions: null, + overrideCredentialUsername: false, + ...overrides, + }; +} + +beforeEach(() => { + state.host = baseHost(); + state.hasAccess = true; + state.overrideCredentialId = null; + state.credentials.clear(); + state.sharedSecret = null; +}); + +describe("resolveHostById", () => { + it("returns null when access is denied", async () => { + state.hasAccess = false; + expect(await resolveHostById(42, "stranger")).toBeNull(); + }); + + it("resolves the owner's credential on the owner path", async () => { + // Empty host username so the credential's username is used as fallback. + state.host = baseHost({ + authType: "credential", + credentialId: 9, + username: "", + }); + state.credentials.set("9:owner", { + id: 9, + username: "cred-user", + authType: "key", + password: null, + privateKey: "PRIVATE-KEY", + key: null, + keyPassword: "kp", + keyType: "ssh-ed25519", + certPublicKey: null, + }); + + const host = (await resolveHostById(42, "owner")) as Record< + string, + unknown + >; + expect(host.key).toBe("PRIVATE-KEY"); + expect(host.username).toBe("cred-user"); + expect(host.authType).toBe("key"); + expect(host.sudoPassword).toBe("owner-sudo"); + }); + + it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => { + state.host = baseHost({ username: "" }); + state.sharedSecret = { + username: "shared-user", + authType: "password", + password: "shared-pass", + }; + + const host = (await resolveHostById(42, "recipient")) as Record< + string, + unknown + >; + expect(host.password).toBe("shared-pass"); + expect(host.username).toBe("shared-user"); + expect(host.sudoPassword).toBeNull(); + expect(host.autostartPassword).toBeNull(); + }); + + it("prefers the recipient's override credential over the snapshot", async () => { + state.host = baseHost({ username: "" }); + state.overrideCredentialId = 5; + state.credentials.set("5:recipient", { + id: 5, + username: "my-user", + authType: "password", + password: "my-pass", + privateKey: null, + key: null, + keyPassword: null, + keyType: null, + }); + state.sharedSecret = { + username: "shared-user", + authType: "password", + password: "shared-pass", + }; + + const host = (await resolveHostById(42, "recipient")) as Record< + string, + unknown + >; + expect(host.password).toBe("my-pass"); + expect(host.username).toBe("my-user"); + }); + + it("denies a non-owner when a secret-bearing host has no snapshot", async () => { + expect(await resolveHostById(42, "recipient")).toBeNull(); + }); + + it("lets a non-owner through on secret-less auth types without a snapshot", async () => { + state.host = baseHost({ authType: "none", password: null }); + const host = await resolveHostById(42, "recipient"); + expect(host).not.toBeNull(); + }); +}); diff --git a/src/backend/tests/utils/crypto-migration/shared-host-secrets-migration.test.ts b/src/backend/tests/utils/crypto-migration/shared-host-secrets-migration.test.ts new file mode 100644 index 00000000..fd2a4a59 --- /dev/null +++ b/src/backend/tests/utils/crypto-migration/shared-host-secrets-migration.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + settings: new Map(), + grants: [] as Array>, + roleMembers: new Map(), + executedSql: [] as string[], + snapshots: [] as Array<[number, number, string, string]>, + usersWithKeys: new Set(), + saves: [] as string[], +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.settings.get(key) ?? null, + set: async (key: string, value: string) => { + state.settings.set(key, value); + }, + }), + getCurrentRepositorySqlite: () => ({ + prepare: (sql: string) => ({ + all: (..._params: unknown[]) => { + if (sql.includes("FROM host_access")) return state.grants; + if (sql.includes("FROM user_roles")) { + const roleId = _params[0] as number; + return (state.roleMembers.get(roleId) ?? []).map((id) => ({ + user_id: id, + })); + } + return []; + }, + }), + exec: (sql: string) => { + state.executedSql.push(sql); + }, + }), +})); + +vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({ + SharedHostSecretsManager: { + getInstance: () => ({ + snapshotForUser: async ( + hostAccessId: number, + hostId: number, + targetUserId: string, + ownerId: string, + ) => { + if (targetUserId === "broken") throw new Error("boom"); + state.snapshots.push([hostAccessId, hostId, targetUserId, ownerId]); + }, + }), + }, +})); + +vi.mock("../../../utils/data-crypto.js", () => ({ + DataCrypto: { + canUserAccessData: (userId: string) => state.usersWithKeys.has(userId), + }, +})); + +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + forceSave: async (reason: string) => { + state.saves.push(reason); + }, + }, +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { runSharedHostSecretsMigration } from "../../../utils/crypto-migration/shared-host-secrets-migration.js"; + +beforeEach(() => { + state.settings.clear(); + state.grants = []; + state.roleMembers.clear(); + state.executedSql = []; + state.snapshots = []; + state.usersWithKeys = new Set(["owner", "alice", "bob"]); + state.saves = []; +}); + +describe("runSharedHostSecretsMigration", () => { + it("re-snapshots direct and role grants, drops the legacy table and sets the flag", async () => { + state.grants = [ + { + hostAccessId: 1, + hostId: 42, + userId: "alice", + roleId: null, + ownerId: "owner", + }, + { + hostAccessId: 2, + hostId: 42, + userId: null, + roleId: 9, + ownerId: "owner", + }, + ]; + state.roleMembers.set(9, ["bob", "owner"]); + + const result = await runSharedHostSecretsMigration(); + + expect(result).toEqual({ snapshotted: 2, skipped: 0 }); + expect(state.snapshots).toEqual([ + [1, 42, "alice", "owner"], + [2, 42, "bob", "owner"], + ]); + expect( + state.executedSql.some((sql) => + sql.includes("DROP TABLE IF EXISTS shared_credentials"), + ), + ).toBe(true); + expect(state.settings.get("shared_host_secrets_migrated_v1")).toBe("done"); + expect(state.saves).toContain("shared_host_secrets_migration"); + }); + + it("skips grants with missing DEKs and failed snapshots without crashing", async () => { + state.usersWithKeys = new Set(["owner", "alice", "broken"]); + state.grants = [ + { + hostAccessId: 1, + hostId: 42, + userId: "alice", + roleId: null, + ownerId: "owner", + }, + { + hostAccessId: 2, + hostId: 42, + userId: "no-key", + roleId: null, + ownerId: "owner", + }, + { + hostAccessId: 3, + hostId: 42, + userId: "broken", + roleId: null, + ownerId: "owner", + }, + ]; + + const result = await runSharedHostSecretsMigration(); + + expect(result).toEqual({ snapshotted: 1, skipped: 2 }); + expect(state.settings.get("shared_host_secrets_migrated_v1")).toBe("done"); + }); + + it("does nothing when the migration flag is already set", async () => { + state.settings.set("shared_host_secrets_migrated_v1", "done"); + state.grants = [ + { + hostAccessId: 1, + hostId: 42, + userId: "alice", + roleId: null, + ownerId: "owner", + }, + ]; + + const result = await runSharedHostSecretsMigration(); + + expect(result).toBeNull(); + expect(state.snapshots).toEqual([]); + expect(state.executedSql).toEqual([]); + }); +}); diff --git a/src/backend/tests/utils/permission-catalog.test.ts b/src/backend/tests/utils/permission-catalog.test.ts new file mode 100644 index 00000000..eff44cf7 --- /dev/null +++ b/src/backend/tests/utils/permission-catalog.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + PERMISSION_CATALOG, + isValidPermission, +} from "../../utils/permission-catalog.js"; + +describe("permission catalog", () => { + it("accepts every cataloged permission and group wildcard", () => { + for (const entry of PERMISSION_CATALOG) { + expect(isValidPermission(`${entry.group}.*`)).toBe(true); + for (const permission of entry.permissions) { + expect(isValidPermission(permission)).toBe(true); + } + } + }); + + it("accepts the global wildcard", () => { + expect(isValidPermission("*")).toBe(true); + }); + + it("rejects unknown permissions and malformed wildcards", () => { + expect(isValidPermission("hosts.hack")).toBe(false); + expect(isValidPermission("unknown.*")).toBe(false); + expect(isValidPermission("")).toBe(false); + expect(isValidPermission("hosts")).toBe(false); + }); +}); diff --git a/src/backend/tests/utils/permission-manager.test.ts b/src/backend/tests/utils/permission-manager.test.ts index f4c17ba3..249bf9b4 100644 --- a/src/backend/tests/utils/permission-manager.test.ts +++ b/src/backend/tests/utils/permission-manager.test.ts @@ -15,6 +15,39 @@ vi.mock("../../utils/logger.js", () => ({ }, })); +const accessState = vi.hoisted(() => ({ + ownerId: "owner" as string, + grant: null as { + id: number; + permissionLevel: string; + expiresAt: string | null; + } | null, + touched: [] as number[], +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + isHostOwnedByUser: async (_hostId: number, userId: string) => + userId === accessState.ownerId, + findHostOwnerId: async () => accessState.ownerId, + }), + createCurrentRbacAccessRepository: () => ({ + findActiveHostAccess: async () => accessState.grant, + touchHostAccess: async (id: number) => { + accessState.touched.push(id); + }, + deleteExpiredHostAccess: async () => 0, + }), + createCurrentRoleRepository: () => ({ + listUserRoleIds: async () => [], + listUserRolePermissions: async () => [], + userHasAnyRoleName: async () => false, + }), + createCurrentUserRepository: () => ({ + findById: async () => null, + }), +})); + const { PermissionManager } = await import("../../utils/permission-manager.js"); type PermissionManagerInstance = ReturnType< @@ -71,3 +104,65 @@ describe("PermissionManager.hasPermission wildcard matching", () => { expect(await manager.hasPermission("u1", "hosts.write")).toBe(false); }); }); + +describe("PermissionManager.canAccessHost level hierarchy", () => { + const manager = PermissionManager.getInstance(); + const actions = ["connect", "view", "edit", "manage"] as const; + const levels = ["connect", "view", "edit", "manage"] as const; + const rank = { connect: 1, view: 2, edit: 3, manage: 4 } as const; + + beforeEach(() => { + vi.restoreAllMocks(); + accessState.ownerId = "owner"; + accessState.grant = null; + accessState.touched = []; + }); + + it("grants the owner every action including delete", async () => { + for (const action of [...actions, "delete"] as const) { + const info = await manager.canAccessHost("owner", 42, action); + expect(info).toMatchObject({ hasAccess: true, isOwner: true }); + } + }); + + it("denies everything without a grant", async () => { + const info = await manager.canAccessHost("stranger", 42, "connect"); + expect(info).toMatchObject({ hasAccess: false, isShared: false }); + }); + + it("enforces the connect < view < edit < manage hierarchy", async () => { + for (const level of levels) { + accessState.grant = { id: 5, permissionLevel: level, expiresAt: null }; + for (const action of actions) { + const info = await manager.canAccessHost("recipient", 42, action); + expect(info.hasAccess).toBe(rank[level] >= rank[action]); + expect(info.permissionLevel).toBe(level); + expect(info.isShared).toBe(true); + } + } + }); + + it("never grants delete to a shared recipient", async () => { + accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null }; + const info = await manager.canAccessHost("recipient", 42, "delete"); + expect(info.hasAccess).toBe(false); + }); + + it("normalizes the legacy 'view' string mapping and unknown levels to connect", async () => { + accessState.grant = { id: 5, permissionLevel: "bogus", expiresAt: null }; + const connect = await manager.canAccessHost("recipient", 42, "connect"); + expect(connect.hasAccess).toBe(true); + expect(connect.permissionLevel).toBe("connect"); + + const view = await manager.canAccessHost("recipient", 42, "view"); + expect(view.hasAccess).toBe(false); + }); + + it("only touches the grant timestamp on connect", async () => { + accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null }; + await manager.canAccessHost("recipient", 42, "manage"); + expect(accessState.touched).toEqual([]); + await manager.canAccessHost("recipient", 42, "connect"); + expect(accessState.touched).toEqual([5]); + }); +}); diff --git a/src/backend/tests/utils/shared-credential-manager.test.ts b/src/backend/tests/utils/shared-credential-manager.test.ts deleted file mode 100644 index b8bb2995..00000000 --- a/src/backend/tests/utils/shared-credential-manager.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import crypto from "crypto"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const ownerDEK = crypto.randomBytes(32); -const targetDEK = crypto.randomBytes(32); - -const state = vi.hoisted(() => ({ - sharedRows: [] as Array>, - ownerCredential: null as Record | null, -})); - -vi.mock("../../database/repositories/factory.js", () => ({ - createCurrentCredentialRepository: () => ({ - findByIdForUser: async (_userId: string, _id: number) => - state.ownerCredential, - }), - createCurrentSharedCredentialRepository: () => ({ - existsForHostAccessAndTargetUser: async () => state.sharedRows.length > 0, - create: async (row: Record) => { - const created = { id: state.sharedRows.length + 1, ...row }; - state.sharedRows.push(created); - return created; - }, - listByOriginalCredentialId: async () => state.sharedRows, - updateById: async (id: number, update: Record) => { - const row = state.sharedRows.find((r) => r.id === id); - if (row) Object.assign(row, update); - return row ?? null; - }, - deleteByOriginalCredentialId: async () => 0, - }), - createCurrentRbacAccessRepository: () => ({ - findSharedCredentialForHostAndUser: async () => state.sharedRows[0] ?? null, - }), - createCurrentRoleRepository: () => ({ - listRoleUserIds: async () => [], - listUserRoleIds: async () => [], - }), -})); - -vi.mock("../../utils/data-crypto.js", () => ({ - DataCrypto: { - validateUserAccess: (userId: string) => { - if (userId === "owner") return ownerDEK; - if (userId === "target") return targetDEK; - throw new Error(`User ${userId} has no data encryption key`); - }, - getUserDataKey: (userId: string) => - userId === "owner" ? ownerDEK : userId === "target" ? targetDEK : null, - canUserAccessData: () => true, - }, -})); - -import { FieldCrypto } from "../../utils/field-crypto.js"; -import { SharedCredentialManager } from "../../utils/shared-credential-manager.js"; - -const manager = SharedCredentialManager.getInstance(); - -beforeEach(() => { - state.sharedRows = []; - state.ownerCredential = { - id: 42, - userId: "owner", - username: "root", - authType: "password", - password: FieldCrypto.encryptField("hunter2", ownerDEK, "42", "password"), - key: null, - keyPassword: null, - keyType: null, - }; -}); - -describe("SharedCredentialManager", () => { - it("shares a credential and the target can decrypt it", async () => { - await manager.createSharedCredentialForUser(7, 42, "target", "owner"); - - expect(state.sharedRows).toHaveLength(1); - const row = state.sharedRows[0]; - expect(row.encryptedPassword).not.toBe("hunter2"); - expect(row.targetUserId).toBe("target"); - - const resolved = await manager.getSharedCredentialForUser(1, "target"); - expect(resolved).toMatchObject({ - username: "root", - authType: "password", - password: "hunter2", - }); - }); - - it("fails the share when a participant has no key instead of queueing", async () => { - await expect( - manager.createSharedCredentialForUser(7, 42, "locked-user", "owner"), - ).rejects.toThrow(/no data encryption key/); - expect(state.sharedRows).toHaveLength(0); - }); - - it("re-encrypts existing share copies when the original changes", async () => { - await manager.createSharedCredentialForUser(7, 42, "target", "owner"); - state.ownerCredential!.password = FieldCrypto.encryptField( - "new-password", - ownerDEK, - "42", - "password", - ); - - await manager.updateSharedCredentialsForOriginal(42, "owner"); - - const resolved = await manager.getSharedCredentialForUser(1, "target"); - expect(resolved?.password).toBe("new-password"); - }); -}); diff --git a/src/backend/tests/utils/shared-host-secrets-manager.test.ts b/src/backend/tests/utils/shared-host-secrets-manager.test.ts new file mode 100644 index 00000000..9c41e806 --- /dev/null +++ b/src/backend/tests/utils/shared-host-secrets-manager.test.ts @@ -0,0 +1,347 @@ +import crypto from "crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ownerDEK = crypto.randomBytes(32); +const targetDEK = crypto.randomBytes(32); + +type SecretRow = Record & { + id: number; + hostAccessId: number; + targetUserId: string; + protocol: string; +}; + +const state = vi.hoisted(() => ({ + hosts: new Map>(), + credentials: new Map>(), + secretRows: [] as Array>, + // hostAccessId -> hostId, used by findForHostUserProtocol + accessToHost: new Map(), + grants: [] as Array>, + roleMembers: new Map(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null, + findHostOwnerId: async (hostId: number) => + (state.hosts.get(hostId)?.userId as string) ?? null, + findCredentialByIdForUser: async (credentialId: number) => + state.credentials.get(credentialId) ?? null, + }), + createCurrentSharedHostSecretsRepository: () => ({ + upsert: async (row: Record) => { + const existing = state.secretRows.find( + (r) => + r.hostAccessId === row.hostAccessId && + r.targetUserId === row.targetUserId && + r.protocol === row.protocol, + ); + if (existing) Object.assign(existing, row); + else state.secretRows.push({ id: state.secretRows.length + 1, ...row }); + }, + deleteForHostAccessAndTarget: async ( + hostAccessId: number, + targetUserId: string, + keepProtocols: string[], + ) => { + state.secretRows = state.secretRows.filter( + (r) => + !( + r.hostAccessId === hostAccessId && + r.targetUserId === targetUserId && + !keepProtocols.includes(r.protocol as string) + ), + ); + }, + findForHostUserProtocol: async ( + hostId: number, + targetUserId: string, + protocol: string, + ) => + state.secretRows.find( + (r) => + state.accessToHost.get(r.hostAccessId as number) === hostId && + r.targetUserId === targetUserId && + r.protocol === protocol, + ) ?? null, + deleteByHostAccessId: async () => 0, + deleteByTargetUserId: async () => 0, + deleteByOriginalCredentialId: async () => 0, + findHostIdsReferencingCredential: async ( + _ownerId: string, + credentialId: number, + ) => + [...state.hosts.values()] + .filter((h) => h.credentialId === credentialId) + .map((h) => h.id as number), + }), + createCurrentRbacAccessRepository: () => ({ + listActiveHostAccessGrants: async (hostId: number) => + state.grants.filter((g) => g.hostId === hostId), + listRoleHostAccessCredentialSources: async (roleId: number) => + state.grants + .filter((g) => g.roleId === roleId) + .map((g) => ({ + hostAccessId: g.id, + hostId: g.hostId, + hostOwnerId: state.hosts.get(g.hostId as number)?.userId, + })), + }), + createCurrentRoleRepository: () => ({ + listRoleUserIds: async (roleId: number) => + state.roleMembers.get(roleId) ?? [], + listUserRoleIds: async () => [], + }), +})); + +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { + validateUserAccess: (userId: string) => { + if (userId === "owner") return ownerDEK; + if (userId === "target" || userId === "member-1") return targetDEK; + throw new Error(`User ${userId} has no data encryption key`); + }, + getUserDataKey: (userId: string) => + userId === "owner" + ? ownerDEK + : userId === "target" || userId === "member-1" + ? targetDEK + : null, + canUserAccessData: () => true, + }, +})); + +vi.mock("../../utils/logger.js", () => ({ + databaseLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { FieldCrypto } from "../../utils/field-crypto.js"; +import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js"; + +const manager = SharedHostSecretsManager.getInstance(); + +function baseHost(overrides: Record = {}) { + // Records come back from the resolution repository already decrypted. + return { + id: 42, + userId: "owner", + connectionType: "ssh", + name: "prod", + ip: "10.0.0.42", + username: "root", + authType: "password", + password: "hunter2", + key: null, + keyPassword: null, + keyType: null, + credentialId: null, + enableSsh: true, + enableRdp: false, + enableVnc: false, + enableTelnet: false, + rdpAuthType: null, + vncAuthType: null, + telnetAuthType: null, + rdpCredentialId: null, + vncCredentialId: null, + telnetCredentialId: null, + rdpUser: null, + rdpPassword: null, + rdpDomain: null, + vncUser: null, + vncPassword: null, + telnetUser: null, + telnetPassword: null, + ...overrides, + }; +} + +beforeEach(() => { + state.hosts.clear(); + state.credentials.clear(); + state.secretRows = []; + state.accessToHost = new Map([[7, 42]]); + state.grants = []; + state.roleMembers.clear(); +}); + +describe("SharedHostSecretsManager", () => { + it("snapshots an inline-password SSH host and the target can decrypt it", async () => { + state.hosts.set(42, baseHost()); + + await manager.snapshotForUser(7, 42, "target", "owner"); + + expect(state.secretRows).toHaveLength(1); + const row = state.secretRows[0]; + expect(row.protocol).toBe("ssh"); + expect(row.sourceType).toBe("inline"); + expect(row.encryptedPassword).not.toBe("hunter2"); + + const secret = await manager.getSecretForUser(42, "target", "ssh"); + expect(secret).toMatchObject({ + username: "root", + authType: "password", + password: "hunter2", + }); + }); + + it("snapshots every enabled protocol from credential and inline sources", async () => { + state.credentials.set(123, { + id: 123, + userId: "owner", + username: "cred-user", + authType: "key", + password: null, + privateKey: "PRIVATE-KEY", + key: null, + keyPassword: "kp", + keyType: "ssh-ed25519", + }); + state.hosts.set( + 42, + baseHost({ + authType: "credential", + credentialId: 123, + password: null, + enableRdp: true, + rdpUser: "rdp-admin", + rdpPassword: "rdp-pass", + rdpDomain: "CORP", + enableTelnet: true, + telnetUser: "tel-user", + telnetPassword: "tel-pass", + }), + ); + + await manager.snapshotForUser(7, 42, "target", "owner"); + + expect(state.secretRows.map((row) => row.protocol).sort()).toEqual([ + "rdp", + "ssh", + "telnet", + ]); + + const ssh = await manager.getSecretForUser(42, "target", "ssh"); + expect(ssh).toMatchObject({ + username: "cred-user", + authType: "key", + key: "PRIVATE-KEY", + keyPassword: "kp", + keyType: "ssh-ed25519", + }); + + const rdp = await manager.getSecretForUser(42, "target", "rdp"); + expect(rdp).toMatchObject({ + username: "rdp-admin", + password: "rdp-pass", + domain: "CORP", + authType: "direct", + }); + + const telnet = await manager.getSecretForUser(42, "target", "telnet"); + expect(telnet).toMatchObject({ + username: "tel-user", + password: "tel-pass", + }); + }); + + it("produces no snapshot rows for secret-less auth types", async () => { + state.hosts.set(42, baseHost({ authType: "opkssh", password: null })); + + await manager.snapshotForUser(7, 42, "target", "owner"); + expect(state.secretRows).toHaveLength(0); + }); + + it("removes stale protocol rows on re-snapshot", async () => { + state.hosts.set( + 42, + baseHost({ + enableRdp: true, + rdpUser: "rdp-admin", + rdpPassword: "rdp-pass", + }), + ); + await manager.snapshotForUser(7, 42, "target", "owner"); + expect(state.secretRows).toHaveLength(2); + + // Owner turns RDP off; the RDP snapshot must disappear. + state.hosts.set(42, baseHost()); + await manager.snapshotForUser(7, 42, "target", "owner"); + expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]); + }); + + it("fails fast when a participant has no DEK", async () => { + state.hosts.set(42, baseHost()); + await expect( + manager.snapshotForUser(7, 42, "locked-user", "owner"), + ).rejects.toThrow(/no data encryption key/); + expect(state.secretRows).toHaveLength(0); + }); + + it("cannot be decrypted with the wrong DEK", async () => { + state.hosts.set(42, baseHost()); + await manager.snapshotForUser(7, 42, "target", "owner"); + + const row = state.secretRows[0]; + expect(() => + FieldCrypto.decryptField( + row.encryptedPassword as string, + ownerDEK, + "shared-7-target-ssh", + "password", + ), + ).toThrow(); + }); + + it("resyncHost re-snapshots direct grants and role members", async () => { + state.hosts.set(42, baseHost()); + state.accessToHost = new Map([ + [1, 42], + [2, 42], + ]); + state.grants = [ + { id: 1, hostId: 42, userId: "target", roleId: null }, + { id: 2, hostId: 42, userId: null, roleId: 9 }, + ]; + state.roleMembers.set(9, ["member-1", "owner"]); + + await manager.resyncHost(42); + + // target via grant 1, member-1 via grant 2; owner skipped. + expect( + state.secretRows.map((row) => [row.hostAccessId, row.targetUserId]), + ).toEqual([ + [1, "target"], + [2, "member-1"], + ]); + + // Owner rotates the inline password; resync updates the copies. + state.hosts.set(42, baseHost({ password: "rotated" })); + await manager.resyncHost(42); + + const secret = await manager.getSecretForUser(42, "target", "ssh"); + expect(secret?.password).toBe("rotated"); + }); + + it("snapshotForRoleMember fans out from role grants", async () => { + state.hosts.set(42, baseHost()); + state.accessToHost = new Map([[2, 42]]); + state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }]; + + await manager.snapshotForRoleMember(9, "member-1"); + + expect(state.secretRows).toHaveLength(1); + expect(state.secretRows[0]).toMatchObject({ + hostAccessId: 2, + targetUserId: "member-1", + protocol: "ssh", + }); + }); +}); diff --git a/src/backend/utils/crypto-migration/legacy-share-cleanup.ts b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts index a285d683..64cbb058 100644 --- a/src/backend/utils/crypto-migration/legacy-share-cleanup.ts +++ b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts @@ -1,8 +1,6 @@ import { databaseLogger } from "../logger.js"; -import { DataCrypto } from "../data-crypto.js"; import { DatabaseSaveTrigger } from "../database-save-trigger.js"; import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js"; -import { SharedCredentialManager } from "../shared-credential-manager.js"; interface SqliteLike { prepare(sql: string): { @@ -34,83 +32,17 @@ function dropColumnIfExists( return true; } -interface PendingShareRow { - id: number; - host_access_id: number; - original_credential_id: number; - target_user_id: string; -} - -// One-time cleanup of the pre-2.5 sharing machinery: pending share copies -// (rows that were waiting for an offline user's DEK) are re-created now that -// every migrated user's DEK is server-side, then the queue flag and the -// CREDENTIAL_SHARING_KEY shadow columns are dropped. +// One-time cleanup of the pre-2.5 sharing machinery: the +// CREDENTIAL_SHARING_KEY shadow columns are dropped. Pending share copies are +// no longer re-created here; the shared_host_secrets migration rebuilds every +// active share from the live host_access grants instead. export async function runLegacySharedCredentialCleanup(): Promise<{ - resolved: number; - dropped: number; columnsDropped: number; }> { const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike; - const result = { resolved: 0, dropped: 0, columnsDropped: 0 }; - - if (tableHasColumn(sqlite, "shared_credentials", "needs_re_encryption")) { - const pendingRows = sqlite - .prepare( - `SELECT id, host_access_id, original_credential_id, target_user_id - FROM shared_credentials - WHERE needs_re_encryption = 1 OR encrypted_username = ''`, - ) - .all() as PendingShareRow[]; - - for (const row of pendingRows) { - const owner = sqlite - .prepare( - `SELECT h.user_id AS ownerId - FROM host_access ha JOIN ssh_data h ON h.id = ha.host_id - WHERE ha.id = ?`, - ) - .get(row.host_access_id) as { ownerId?: string } | undefined; - - sqlite.prepare(`DELETE FROM shared_credentials WHERE id = ?`).run(row.id); - - const ownerId = owner?.ownerId; - if ( - ownerId && - DataCrypto.canUserAccessData(ownerId) && - DataCrypto.canUserAccessData(row.target_user_id) - ) { - try { - await SharedCredentialManager.getInstance().createSharedCredentialForUser( - row.host_access_id, - row.original_credential_id, - row.target_user_id, - ownerId, - ); - result.resolved++; - continue; - } catch (error) { - databaseLogger.warn("Could not re-create pending shared credential", { - operation: "legacy_share_cleanup_recreate_failed", - sharedCredentialId: row.id, - error: error instanceof Error ? error.message : "Unknown error", - }); - } - } - - result.dropped++; - databaseLogger.warn( - "Dropped unresolvable pending shared credential; the owner can re-share the host", - { - operation: "legacy_share_cleanup_dropped", - hostAccessId: row.host_access_id, - targetUserId: row.target_user_id, - }, - ); - } - } + const result = { columnsDropped: 0 }; for (const [table, column] of [ - ["shared_credentials", "needs_re_encryption"], ["ssh_credentials", "system_password"], ["ssh_credentials", "system_key"], ["ssh_credentials", "system_key_password"], @@ -120,7 +52,7 @@ export async function runLegacySharedCredentialCleanup(): Promise<{ } } - if (result.resolved || result.dropped || result.columnsDropped) { + if (result.columnsDropped) { await DatabaseSaveTrigger.forceSave("legacy_share_cleanup"); databaseLogger.info("Legacy shared-credential cleanup finished", { operation: "legacy_share_cleanup", diff --git a/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts b/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts new file mode 100644 index 00000000..963bdbeb --- /dev/null +++ b/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts @@ -0,0 +1,121 @@ +import { databaseLogger } from "../logger.js"; +import { DataCrypto } from "../data-crypto.js"; +import { DatabaseSaveTrigger } from "../database-save-trigger.js"; +import { + createCurrentSettingsRepository, + getCurrentRepositorySqlite, +} from "../../database/repositories/factory.js"; +import { SharedHostSecretsManager } from "../shared-host-secrets-manager.js"; + +const MIGRATION_FLAG = "shared_host_secrets_migrated_v1"; + +interface GrantRow { + hostAccessId: number; + hostId: number; + userId: string | null; + roleId: number | null; + ownerId: string; +} + +// One-time 2.5.x migration: rebuild every active share as per-protocol +// secret snapshots in shared_host_secrets, then drop the SSH-only +// shared_credentials table it replaces. Grants whose owner or recipient DEK +// is unavailable are skipped (logged), never fatal to boot. +export async function runSharedHostSecretsMigration(): Promise<{ + snapshotted: number; + skipped: number; +} | null> { + const settingsRepository = createCurrentSettingsRepository(); + + try { + if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") { + return null; + } + } catch { + return null; + } + + const sqlite = getCurrentRepositorySqlite(); + const result = { snapshotted: 0, skipped: 0 }; + + try { + const grants = sqlite + .prepare( + `SELECT ha.id AS hostAccessId, ha.host_id AS hostId, + ha.user_id AS userId, ha.role_id AS roleId, + h.user_id AS ownerId + FROM host_access ha + JOIN ssh_data h ON h.id = ha.host_id + WHERE ha.expires_at IS NULL OR ha.expires_at >= ?`, + ) + .all(new Date().toISOString()) as GrantRow[]; + + const manager = SharedHostSecretsManager.getInstance(); + + for (const grant of grants) { + const targetUserIds = grant.userId + ? [grant.userId] + : grant.roleId + ? ( + sqlite + .prepare(`SELECT user_id FROM user_roles WHERE role_id = ?`) + .all(grant.roleId) as Array<{ user_id: string }> + ).map((row) => row.user_id) + : []; + + for (const targetUserId of targetUserIds) { + if (targetUserId === grant.ownerId) continue; + + if ( + !DataCrypto.canUserAccessData(grant.ownerId) || + !DataCrypto.canUserAccessData(targetUserId) + ) { + result.skipped++; + databaseLogger.warn( + "Skipping share snapshot migration: missing DEK", + { + operation: "shared_host_secrets_migration_skip", + hostAccessId: grant.hostAccessId, + targetUserId, + }, + ); + continue; + } + + try { + await manager.snapshotForUser( + grant.hostAccessId, + grant.hostId, + targetUserId, + grant.ownerId, + ); + result.snapshotted++; + } catch (error) { + result.skipped++; + databaseLogger.warn("Failed to migrate share snapshot", { + operation: "shared_host_secrets_migration_failed", + hostAccessId: grant.hostAccessId, + targetUserId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + } + + sqlite.exec("DROP TABLE IF EXISTS shared_credentials"); + await settingsRepository.set(MIGRATION_FLAG, "done"); + await DatabaseSaveTrigger.forceSave("shared_host_secrets_migration"); + + databaseLogger.info("Shared host secrets migration finished", { + operation: "shared_host_secrets_migration", + ...result, + }); + + return result; + } catch (error) { + databaseLogger.error("Shared host secrets migration failed", error, { + operation: "shared_host_secrets_migration", + }); + return result; + } +} diff --git a/src/backend/utils/permission-catalog.ts b/src/backend/utils/permission-catalog.ts new file mode 100644 index 00000000..6e78af0d --- /dev/null +++ b/src/backend/utils/permission-catalog.ts @@ -0,0 +1,60 @@ +// Single source of truth for role permission strings. The admin role editor +// renders this catalog and PUT /rbac/roles/:id validates against it; +// PermissionManager.hasPermission resolves wildcards ("*", ".*"). +export interface PermissionCatalogEntry { + group: string; + permissions: string[]; +} + +export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [ + { + group: "hosts", + permissions: [ + "hosts.view", + "hosts.create", + "hosts.edit", + "hosts.delete", + "hosts.share", + ], + }, + { + group: "snippets", + permissions: [ + "snippets.view", + "snippets.create", + "snippets.edit", + "snippets.delete", + "snippets.share", + ], + }, + { + group: "credentials", + permissions: [ + "credentials.view", + "credentials.create", + "credentials.edit", + "credentials.delete", + ], + }, + { + group: "admin", + permissions: [ + "admin.users.view", + "admin.users.manage", + "admin.roles.manage", + "admin.settings.manage", + "admin.sessions.manage", + ], + }, +]; + +const VALID_PERMISSIONS = new Set( + PERMISSION_CATALOG.flatMap((entry) => [ + ...entry.permissions, + `${entry.group}.*`, + ]).concat("*"), +); + +export function isValidPermission(permission: string): boolean { + return VALID_PERMISSIONS.has(permission); +} diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts index 93b0370d..76151038 100644 --- a/src/backend/utils/permission-manager.ts +++ b/src/backend/utils/permission-manager.ts @@ -12,11 +12,32 @@ interface AuthenticatedRequest extends Request { dataKey?: Buffer; } +const SHARE_PERMISSION_LEVELS = ["connect", "view", "edit", "manage"] as const; + +type SharePermissionLevel = (typeof SHARE_PERMISSION_LEVELS)[number]; + +type HostAction = SharePermissionLevel | "delete"; + +const LEVEL_RANK: Record = { + connect: 1, + view: 2, + edit: 3, + manage: 4, +}; + +function normalizeSharePermissionLevel( + level: string | null | undefined, +): SharePermissionLevel { + return SHARE_PERMISSION_LEVELS.includes(level as SharePermissionLevel) + ? (level as SharePermissionLevel) + : "connect"; +} + interface HostAccessInfo { hasAccess: boolean; isOwner: boolean; isShared: boolean; - permissionLevel?: "view"; + permissionLevel?: SharePermissionLevel; expiresAt?: string | null; } @@ -146,7 +167,7 @@ class PermissionManager { async canAccessHost( userId: string, hostId: number, - action: "read" | "write" | "execute" | "delete" | "share" = "read", + action: HostAction = "connect", ): Promise { try { const hostResolutionRepository = createCurrentHostResolutionRepository(); @@ -180,30 +201,41 @@ class PermissionManager { }; } - if (action === "write" || action === "delete") { + const grantedLevel = normalizeSharePermissionLevel( + access.permissionLevel, + ); + + if ( + action === "delete" || + LEVEL_RANK[grantedLevel] < LEVEL_RANK[action] + ) { return { hasAccess: false, isOwner: false, isShared: true, - permissionLevel: access.permissionLevel as "view", + permissionLevel: grantedLevel, expiresAt: access.expiresAt, }; } - try { - await createCurrentRbacAccessRepository().touchHostAccess(access.id); - } catch (error) { - databaseLogger.warn("Failed to update host access timestamp", { - operation: "update_host_access_timestamp", - error, - }); + if (action === "connect") { + try { + await createCurrentRbacAccessRepository().touchHostAccess( + access.id, + ); + } catch (error) { + databaseLogger.warn("Failed to update host access timestamp", { + operation: "update_host_access_timestamp", + error, + }); + } } return { hasAccess: true, isOwner: false, isShared: true, - permissionLevel: access.permissionLevel as "view", + permissionLevel: grantedLevel, expiresAt: access.expiresAt, }; } @@ -283,7 +315,7 @@ class PermissionManager { requireHostAccess( hostIdParam: string = "id", - action: "read" | "write" | "execute" | "delete" | "share" = "read", + action: HostAction = "connect", ) { return async ( req: AuthenticatedRequest, @@ -358,5 +390,11 @@ class PermissionManager { } } -export { PermissionManager }; -export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult }; +export { PermissionManager, SHARE_PERMISSION_LEVELS, LEVEL_RANK }; +export type { + AuthenticatedRequest, + HostAccessInfo, + PermissionCheckResult, + SharePermissionLevel, + HostAction, +}; diff --git a/src/backend/utils/shared-credential-manager.ts b/src/backend/utils/shared-credential-manager.ts deleted file mode 100644 index cb3a9fc4..00000000 --- a/src/backend/utils/shared-credential-manager.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { - createCurrentCredentialRepository, - createCurrentRbacAccessRepository, - createCurrentRoleRepository, - createCurrentSharedCredentialRepository, -} from "../database/repositories/factory.js"; -import type { SharedCredentialRecord } from "../database/repositories/shared-credential-repository.js"; -import { DataCrypto } from "./data-crypto.js"; -import { FieldCrypto } from "./field-crypto.js"; -import { databaseLogger } from "./logger.js"; - -interface CredentialData { - username: string; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; -} - -// Shared credentials are per-target copies of the owner's credential, -// re-encrypted under the target user's DEK at share time. -class SharedCredentialManager { - private static instance: SharedCredentialManager; - - private constructor() {} - - static getInstance(): SharedCredentialManager { - if (!this.instance) { - this.instance = new SharedCredentialManager(); - } - return this.instance; - } - - async createSharedCredentialForUser( - hostAccessId: number, - originalCredentialId: number, - targetUserId: string, - ownerId: string, - ): Promise { - try { - const sharedCredentialRepository = - createCurrentSharedCredentialRepository(); - const existing = - await sharedCredentialRepository.existsForHostAccessAndTargetUser( - hostAccessId, - targetUserId, - ); - - if (existing) return; - - const ownerDEK = DataCrypto.validateUserAccess(ownerId); - const targetDEK = DataCrypto.validateUserAccess(targetUserId); - - const credentialData = await this.getDecryptedCredential( - originalCredentialId, - ownerId, - ownerDEK, - ); - - const encryptedForTarget = this.encryptCredentialForUser( - credentialData, - targetUserId, - targetDEK, - hostAccessId, - ); - - await sharedCredentialRepository.create({ - hostAccessId, - originalCredentialId, - targetUserId, - ...encryptedForTarget, - }); - } catch (error) { - databaseLogger.error("Failed to create shared credential", error, { - operation: "create_shared_credential", - hostAccessId, - targetUserId, - }); - throw error; - } - } - - async createSharedCredentialsForRole( - hostAccessId: number, - originalCredentialId: number, - roleId: number, - ownerId: string, - ): Promise { - try { - const roleUserIds = - await createCurrentRoleRepository().listRoleUserIds(roleId); - - for (const userId of roleUserIds) { - try { - await this.createSharedCredentialForUser( - hostAccessId, - originalCredentialId, - userId, - ownerId, - ); - } catch (error) { - databaseLogger.error( - "Failed to create shared credential for role member", - error, - { - operation: "create_shared_credentials_role", - hostAccessId, - roleId, - userId, - }, - ); - } - } - } catch (error) { - databaseLogger.error( - "Failed to create shared credentials for role", - error, - { - operation: "create_shared_credentials_role", - hostAccessId, - roleId, - }, - ); - throw error; - } - } - - async createSharedCredentialsForRoleMember( - roleId: number, - targetUserId: string, - ): Promise { - try { - const hostsSharedWithRole = - await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources( - roleId, - ); - - for (const sharedHost of hostsSharedWithRole) { - const activeCredentialId = - sharedHost.credentialId ?? - sharedHost.rdpCredentialId ?? - sharedHost.vncCredentialId ?? - sharedHost.telnetCredentialId; - - if (!activeCredentialId) continue; - - try { - await this.createSharedCredentialForUser( - sharedHost.hostAccessId, - activeCredentialId, - targetUserId, - sharedHost.hostOwnerId, - ); - } catch (error) { - databaseLogger.error( - "Failed to create shared credential for role member", - error, - { - operation: "create_shared_credentials_role_member", - roleId, - targetUserId, - hostId: sharedHost.hostId, - }, - ); - } - } - } catch (error) { - databaseLogger.error( - "Failed to create shared credentials for role member", - error, - { - operation: "create_shared_credentials_role_member", - roleId, - targetUserId, - }, - ); - throw error; - } - } - - async createSharedCredentialsForUserRoles(userId: string): Promise { - try { - const roleIds = - await createCurrentRoleRepository().listUserRoleIds(userId); - - for (const roleId of roleIds) { - await this.createSharedCredentialsForRoleMember(roleId, userId); - } - } catch (error) { - databaseLogger.error( - "Failed to create shared credentials for user roles", - error, - { - operation: "create_shared_credentials_user_roles", - userId, - }, - ); - throw error; - } - } - - async getSharedCredentialForUser( - hostId: number, - userId: string, - ): Promise { - try { - const userDEK = DataCrypto.validateUserAccess(userId); - - const cred = - await createCurrentRbacAccessRepository().findSharedCredentialForHostAndUser( - hostId, - userId, - ); - - if (!cred) { - return null; - } - - return this.decryptSharedCredential(cred, userDEK); - } catch (error) { - databaseLogger.error("Failed to get shared credential", error, { - operation: "get_shared_credential", - hostId, - userId, - }); - throw error; - } - } - - async updateSharedCredentialsForOriginal( - credentialId: number, - ownerId: string, - ): Promise { - try { - const sharedCredentialRepository = - createCurrentSharedCredentialRepository(); - const sharedCreds = - await sharedCredentialRepository.listByOriginalCredentialId( - credentialId, - ); - - if (sharedCreds.length === 0) return; - - const ownerDEK = DataCrypto.validateUserAccess(ownerId); - const credentialData = await this.getDecryptedCredential( - credentialId, - ownerId, - ownerDEK, - ); - - for (const sharedCred of sharedCreds) { - const targetDEK = DataCrypto.validateUserAccess( - sharedCred.targetUserId, - ); - - const encryptedForTarget = this.encryptCredentialForUser( - credentialData, - sharedCred.targetUserId, - targetDEK, - sharedCred.hostAccessId, - ); - - await sharedCredentialRepository.updateById(sharedCred.id, { - ...encryptedForTarget, - updatedAt: new Date().toISOString(), - }); - } - } catch (error) { - databaseLogger.error("Failed to update shared credentials", error, { - operation: "update_shared_credentials", - credentialId, - }); - } - } - - async deleteSharedCredentialsForOriginal( - credentialId: number, - ): Promise { - try { - await createCurrentSharedCredentialRepository().deleteByOriginalCredentialId( - credentialId, - ); - } catch (error) { - databaseLogger.error("Failed to delete shared credentials", error, { - operation: "delete_shared_credentials", - credentialId, - }); - } - } - - private async getDecryptedCredential( - credentialId: number, - ownerId: string, - ownerDEK: Buffer, - ): Promise { - const cred = await createCurrentCredentialRepository().findByIdForUser( - ownerId, - credentialId, - ); - - if (!cred) { - throw new Error(`Credential ${credentialId} not found`); - } - - return { - username: cred.username, - authType: cred.authType, - password: cred.password - ? this.decryptField(cred.password, ownerDEK, credentialId, "password") - : undefined, - key: cred.key - ? this.decryptField(cred.key, ownerDEK, credentialId, "key") - : undefined, - keyPassword: cred.keyPassword - ? this.decryptField( - cred.keyPassword, - ownerDEK, - credentialId, - "keyPassword", - ) - : undefined, - keyType: cred.keyType, - }; - } - - private encryptCredentialForUser( - credentialData: CredentialData, - targetUserId: string, - targetDEK: Buffer, - hostAccessId: number, - ): { - encryptedUsername: string; - encryptedAuthType: string; - encryptedPassword: string | null; - encryptedKey: string | null; - encryptedKeyPassword: string | null; - encryptedKeyType: string | null; - } { - const recordId = `shared-${hostAccessId}-${targetUserId}`; - - return { - encryptedUsername: FieldCrypto.encryptField( - credentialData.username, - targetDEK, - recordId, - "username", - ), - encryptedAuthType: credentialData.authType, - encryptedPassword: credentialData.password - ? FieldCrypto.encryptField( - credentialData.password, - targetDEK, - recordId, - "password", - ) - : null, - encryptedKey: credentialData.key - ? FieldCrypto.encryptField( - credentialData.key, - targetDEK, - recordId, - "key", - ) - : null, - encryptedKeyPassword: credentialData.keyPassword - ? FieldCrypto.encryptField( - credentialData.keyPassword, - targetDEK, - recordId, - "key_password", - ) - : null, - encryptedKeyType: credentialData.keyType || null, - }; - } - - private decryptSharedCredential( - sharedCred: SharedCredentialRecord, - userDEK: Buffer, - ): CredentialData { - const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`; - - return { - username: FieldCrypto.decryptField( - sharedCred.encryptedUsername, - userDEK, - recordId, - "username", - ), - authType: sharedCred.encryptedAuthType, - password: sharedCred.encryptedPassword - ? FieldCrypto.decryptField( - sharedCred.encryptedPassword, - userDEK, - recordId, - "password", - ) - : undefined, - key: sharedCred.encryptedKey - ? FieldCrypto.decryptField( - sharedCred.encryptedKey, - userDEK, - recordId, - "key", - ) - : undefined, - keyPassword: sharedCred.encryptedKeyPassword - ? FieldCrypto.decryptField( - sharedCred.encryptedKeyPassword, - userDEK, - recordId, - "key_password", - ) - : undefined, - keyType: sharedCred.encryptedKeyType || undefined, - }; - } - - private decryptField( - encryptedValue: string, - dek: Buffer, - recordId: number | string, - fieldName: string, - ): string { - try { - return FieldCrypto.decryptField( - encryptedValue, - dek, - recordId.toString(), - fieldName, - ); - } catch { - databaseLogger.warn("Field decryption failed, returning as-is", { - operation: "decrypt_field", - fieldName, - recordId, - }); - return encryptedValue; - } - } -} - -export { SharedCredentialManager }; diff --git a/src/backend/utils/shared-host-secrets-manager.ts b/src/backend/utils/shared-host-secrets-manager.ts new file mode 100644 index 00000000..be70acd2 --- /dev/null +++ b/src/backend/utils/shared-host-secrets-manager.ts @@ -0,0 +1,544 @@ +import { + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + createCurrentSharedHostSecretsRepository, +} from "../database/repositories/factory.js"; +import type { + SharedHostSecretRecord, + ShareProtocol, +} from "../database/repositories/shared-host-secrets-repository.js"; +import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js"; +import { DataCrypto } from "./data-crypto.js"; +import { FieldCrypto } from "./field-crypto.js"; +import { databaseLogger } from "./logger.js"; + +export interface SharedSecretData { + username?: string; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + domain?: string; +} + +interface ProtocolSnapshot { + protocol: ShareProtocol; + sourceType: "credential" | "inline"; + originalCredentialId: number | null; + data: SharedSecretData; +} + +function snapshotRecordId( + hostAccessId: number, + targetUserId: string, + protocol: ShareProtocol, +): string { + return `shared-${hostAccessId}-${targetUserId}-${protocol}`; +} + +// Mirrors the connection-type migration fallback in transformHostResponse(): +// old hosts only set connectionType, the per-protocol enable flags came later. +function enabledProtocols( + host: HostResolutionHostRecord, +): Record { + const ct = host.connectionType; + const rdp = !!host.enableRdp; + const vnc = !!host.enableVnc; + const telnet = !!host.enableTelnet; + const isMigratedNonSsh = !rdp && !vnc && !telnet && !!ct && ct !== "ssh"; + + return { + ssh: isMigratedNonSsh ? false : host.enableSsh !== false, + rdp: isMigratedNonSsh ? ct === "rdp" : rdp, + vnc: isMigratedNonSsh ? ct === "vnc" : vnc, + telnet: isMigratedNonSsh ? ct === "telnet" : telnet, + }; +} + +// Per-recipient copies of a shared host's connection secrets, re-encrypted +// under the recipient's DEK. Every enabled protocol gets its own snapshot; +// secret-less auth types (opkssh, vault, agent, none, ...) produce none. +class SharedHostSecretsManager { + private static instance: SharedHostSecretsManager; + + private constructor() {} + + static getInstance(): SharedHostSecretsManager { + if (!this.instance) { + this.instance = new SharedHostSecretsManager(); + } + return this.instance; + } + + async snapshotForUser( + hostAccessId: number, + hostId: number, + targetUserId: string, + ownerId: string, + ): Promise { + if (targetUserId === ownerId) return; + + try { + const targetDEK = DataCrypto.validateUserAccess(targetUserId); + DataCrypto.validateUserAccess(ownerId); + + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, + ownerId, + ); + if (!host) { + throw new Error(`Host ${hostId} not found`); + } + + const snapshots = await this.collectProtocolSnapshots(host, ownerId); + const repository = createCurrentSharedHostSecretsRepository(); + + for (const snapshot of snapshots) { + const recordId = snapshotRecordId( + hostAccessId, + targetUserId, + snapshot.protocol, + ); + const encrypt = (value: string | undefined, fieldName: string) => + value + ? FieldCrypto.encryptField(value, targetDEK, recordId, fieldName) + : null; + + await repository.upsert({ + hostAccessId, + targetUserId, + protocol: snapshot.protocol, + sourceType: snapshot.sourceType, + originalCredentialId: snapshot.originalCredentialId, + encryptedUsername: encrypt(snapshot.data.username, "username"), + encryptedAuthType: snapshot.data.authType, + encryptedPassword: encrypt(snapshot.data.password, "password"), + encryptedKey: encrypt(snapshot.data.key, "key"), + encryptedKeyPassword: encrypt( + snapshot.data.keyPassword, + "key_password", + ), + encryptedKeyType: snapshot.data.keyType || null, + encryptedDomain: encrypt(snapshot.data.domain, "domain"), + }); + } + + await repository.deleteForHostAccessAndTarget( + hostAccessId, + targetUserId, + snapshots.map((snapshot) => snapshot.protocol), + ); + } catch (error) { + databaseLogger.error("Failed to snapshot shared host secrets", error, { + operation: "shared_host_secrets_snapshot", + hostAccessId, + hostId, + targetUserId, + }); + throw error; + } + } + + async snapshotForRole( + hostAccessId: number, + hostId: number, + roleId: number, + ownerId: string, + ): Promise { + const roleUserIds = + await createCurrentRoleRepository().listRoleUserIds(roleId); + + for (const userId of roleUserIds) { + try { + await this.snapshotForUser(hostAccessId, hostId, userId, ownerId); + } catch (error) { + databaseLogger.error( + "Failed to snapshot shared host secrets for role member", + error, + { + operation: "shared_host_secrets_snapshot_role", + hostAccessId, + roleId, + userId, + }, + ); + } + } + } + + async snapshotForRoleMember( + roleId: number, + targetUserId: string, + ): Promise { + const hostsSharedWithRole = + await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources( + roleId, + ); + + for (const sharedHost of hostsSharedWithRole) { + try { + await this.snapshotForUser( + sharedHost.hostAccessId, + sharedHost.hostId, + targetUserId, + sharedHost.hostOwnerId, + ); + } catch (error) { + databaseLogger.error( + "Failed to snapshot shared host secrets for role member", + error, + { + operation: "shared_host_secrets_snapshot_role_member", + roleId, + targetUserId, + hostId: sharedHost.hostId, + }, + ); + } + } + } + + async snapshotForUserRoles(userId: string): Promise { + const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); + + for (const roleId of roleIds) { + await this.snapshotForRoleMember(roleId, userId); + } + } + + // Re-snapshot every active grant on a host. Called after the owner (or an + // editor) changes the host so recipients keep working secrets. + async resyncHost(hostId: number): Promise { + const hostResolutionRepository = createCurrentHostResolutionRepository(); + const ownerId = await hostResolutionRepository.findHostOwnerId(hostId); + if (!ownerId) return; + + const grants = + await createCurrentRbacAccessRepository().listActiveHostAccessGrants( + hostId, + ); + if (grants.length === 0) return; + + const roleRepository = createCurrentRoleRepository(); + + for (const grant of grants) { + const targetUserIds = grant.userId + ? [grant.userId] + : grant.roleId + ? await roleRepository.listRoleUserIds(grant.roleId) + : []; + + for (const targetUserId of targetUserIds) { + if (targetUserId === ownerId) continue; + try { + await this.snapshotForUser(grant.id, hostId, targetUserId, ownerId); + } catch (error) { + databaseLogger.warn( + "Skipping shared host secret resync for recipient", + { + operation: "shared_host_secrets_resync_skip", + hostId, + hostAccessId: grant.id, + targetUserId, + error: error instanceof Error ? error.message : "Unknown error", + }, + ); + } + } + } + } + + // Re-snapshot every shared host that references a credential after the + // owner edits that credential. + async resyncHostsForCredential( + credentialId: number, + ownerId: string, + ): Promise { + try { + const hostIds = + await createCurrentSharedHostSecretsRepository().findHostIdsReferencingCredential( + ownerId, + credentialId, + ); + + for (const hostId of hostIds) { + await this.resyncHost(hostId); + } + } catch (error) { + databaseLogger.error( + "Failed to resync shared host secrets for credential", + error, + { + operation: "shared_host_secrets_resync_credential", + credentialId, + }, + ); + } + } + + async getSecretForUser( + hostId: number, + userId: string, + protocol: ShareProtocol, + ): Promise { + try { + const userDEK = DataCrypto.validateUserAccess(userId); + + const secret = + await createCurrentSharedHostSecretsRepository().findForHostUserProtocol( + hostId, + userId, + protocol, + ); + + if (!secret) return null; + + return this.decryptSnapshot(secret, userDEK); + } catch (error) { + databaseLogger.error("Failed to get shared host secret", error, { + operation: "shared_host_secrets_get", + hostId, + userId, + protocol, + }); + throw error; + } + } + + async deleteForHostAccess(hostAccessId: number): Promise { + try { + await createCurrentSharedHostSecretsRepository().deleteByHostAccessId( + hostAccessId, + ); + } catch (error) { + databaseLogger.error( + "Failed to delete shared host secrets for access grant", + error, + { + operation: "shared_host_secrets_delete_access", + hostAccessId, + }, + ); + } + } + + async deleteForUser(userId: string): Promise { + try { + await createCurrentSharedHostSecretsRepository().deleteByTargetUserId( + userId, + ); + } catch (error) { + databaseLogger.error( + "Failed to delete shared host secrets for user", + error, + { + operation: "shared_host_secrets_delete_user", + userId, + }, + ); + } + } + + async deleteForCredential(credentialId: number): Promise { + try { + await createCurrentSharedHostSecretsRepository().deleteByOriginalCredentialId( + credentialId, + ); + } catch (error) { + databaseLogger.error( + "Failed to delete shared host secrets for credential", + error, + { + operation: "shared_host_secrets_delete_credential", + credentialId, + }, + ); + } + } + + private async collectProtocolSnapshots( + host: HostResolutionHostRecord, + ownerId: string, + ): Promise { + const repository = createCurrentHostResolutionRepository(); + const enabled = enabledProtocols(host); + const snapshots: ProtocolSnapshot[] = []; + + if (enabled.ssh) { + if (host.credentialId) { + const credential = await repository.findCredentialByIdForUser( + host.credentialId, + ownerId, + ); + if (credential) { + snapshots.push({ + protocol: "ssh", + sourceType: "credential", + originalCredentialId: host.credentialId, + data: { + username: credential.username || undefined, + authType: credential.authType, + password: credential.password || undefined, + key: credential.privateKey || credential.key || undefined, + keyPassword: credential.keyPassword || undefined, + keyType: credential.keyType || undefined, + }, + }); + } + } else if ( + (host.authType === "password" && host.password) || + (host.authType === "key" && host.key) + ) { + snapshots.push({ + protocol: "ssh", + sourceType: "inline", + originalCredentialId: null, + data: { + username: host.username || undefined, + authType: host.authType, + password: host.password || undefined, + key: host.key || undefined, + keyPassword: host.keyPassword || undefined, + keyType: host.keyType || undefined, + }, + }); + } + } + + if (enabled.rdp) { + const rdpAuthType = + host.rdpAuthType || (host.rdpCredentialId ? "credential" : "direct"); + if (rdpAuthType === "credential" && host.rdpCredentialId) { + const credential = await repository.findCredentialByIdForUser( + host.rdpCredentialId, + ownerId, + ); + if (credential) { + snapshots.push({ + protocol: "rdp", + sourceType: "credential", + originalCredentialId: host.rdpCredentialId, + data: { + username: credential.username || undefined, + authType: "credential", + password: credential.password || undefined, + domain: host.rdpDomain || undefined, + }, + }); + } + } else if (host.rdpUser || host.rdpPassword) { + snapshots.push({ + protocol: "rdp", + sourceType: "inline", + originalCredentialId: null, + data: { + username: host.rdpUser || undefined, + authType: "direct", + password: host.rdpPassword || undefined, + domain: host.rdpDomain || undefined, + }, + }); + } + } + + if (enabled.vnc) { + const vncAuthType = + host.vncAuthType || (host.vncCredentialId ? "credential" : "direct"); + if (vncAuthType === "credential" && host.vncCredentialId) { + const credential = await repository.findCredentialByIdForUser( + host.vncCredentialId, + ownerId, + ); + if (credential) { + snapshots.push({ + protocol: "vnc", + sourceType: "credential", + originalCredentialId: host.vncCredentialId, + data: { + username: credential.username || undefined, + authType: "credential", + password: credential.password || undefined, + }, + }); + } + } else if (host.vncUser || host.vncPassword) { + snapshots.push({ + protocol: "vnc", + sourceType: "inline", + originalCredentialId: null, + data: { + username: host.vncUser || undefined, + authType: "direct", + password: host.vncPassword || undefined, + }, + }); + } + } + + if (enabled.telnet) { + const telnetAuthType = + host.telnetAuthType || + (host.telnetCredentialId ? "credential" : "direct"); + if (telnetAuthType === "credential" && host.telnetCredentialId) { + const credential = await repository.findCredentialByIdForUser( + host.telnetCredentialId, + ownerId, + ); + if (credential) { + snapshots.push({ + protocol: "telnet", + sourceType: "credential", + originalCredentialId: host.telnetCredentialId, + data: { + username: credential.username || undefined, + authType: "credential", + password: credential.password || undefined, + }, + }); + } + } else if (host.telnetUser || host.telnetPassword) { + snapshots.push({ + protocol: "telnet", + sourceType: "inline", + originalCredentialId: null, + data: { + username: host.telnetUser || undefined, + authType: "direct", + password: host.telnetPassword || undefined, + }, + }); + } + } + + return snapshots; + } + + private decryptSnapshot( + secret: SharedHostSecretRecord, + userDEK: Buffer, + ): SharedSecretData { + const recordId = snapshotRecordId( + secret.hostAccessId, + secret.targetUserId, + secret.protocol as ShareProtocol, + ); + const decrypt = (value: string | null, fieldName: string) => + value + ? FieldCrypto.decryptField(value, userDEK, recordId, fieldName) + : undefined; + + return { + username: decrypt(secret.encryptedUsername, "username"), + authType: secret.encryptedAuthType || "password", + password: decrypt(secret.encryptedPassword, "password"), + key: decrypt(secret.encryptedKey, "key"), + keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"), + keyType: secret.encryptedKeyType || undefined, + domain: decrypt(secret.encryptedDomain, "domain"), + }; + } +} + +export { SharedHostSecretsManager }; diff --git a/src/types/index.ts b/src/types/index.ts index 4a4265b8..6d83c5ea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -217,8 +217,9 @@ export interface Host { hasKeyPassword?: boolean; isShared?: boolean; - permissionLevel?: "view"; + permissionLevel?: "connect" | "view" | "edit" | "manage"; sharedExpiresAt?: string; + ownerUsername?: string; } export interface JumpHostData { diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 4c74aa95..304d4127 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -168,8 +168,15 @@ export type Host = { guacamoleConfig?: Record; forceKeyboardInteractive?: boolean; + + isShared?: boolean; + permissionLevel?: SharePermissionLevel; + sharedExpiresAt?: string; + ownerUsername?: string; }; +export type SharePermissionLevel = "connect" | "view" | "edit" | "manage"; + export type Credential = { id: string; name: string; diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts index 628910bb..63f2553a 100644 --- a/src/ui/api/rbac-api.ts +++ b/src/ui/api/rbac-api.ts @@ -28,6 +28,7 @@ export async function updateRole( roleData: { displayName?: string; description?: string | null; + permissions?: string[]; }, ): Promise<{ role: Role }> { try { @@ -88,16 +89,30 @@ export async function removeRoleFromUser( } } +export type SharePermissionLevel = "connect" | "view" | "edit" | "manage"; + +export interface ShareTarget { + type: "user" | "role"; + id: string | number; +} + export async function shareHost( hostId: number, shareData: { - targetType: "user" | "role"; - targetUserId?: string; - targetRoleId?: number; - permissionLevel: "view"; + targets: ShareTarget[]; + permissionLevel: SharePermissionLevel; durationHours?: number; }, -): Promise<{ success: boolean }> { +): Promise<{ + success: boolean; + expiresAt: string | null; + results: Array<{ + type: "user" | "role"; + id: string | number; + accessId: number; + created: boolean; + }>; +}> { try { const response = await rbacApi.post( `/rbac/host/${hostId}/share`, @@ -109,9 +124,28 @@ export async function shareHost( } } +export async function updateHostAccess( + hostId: number, + accessId: number, + update: { + permissionLevel?: SharePermissionLevel; + durationHours?: number | null; + }, +): Promise<{ success: boolean; expiresAt: string | null }> { + try { + const response = await rbacApi.patch( + `/rbac/host/${hostId}/access/${accessId}`, + update, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "update host access"); + } +} + export async function getHostAccess( hostId: number, -): Promise<{ accessList: AccessRecord[] }> { +): Promise<{ accessList: AccessRecord[]; isOwner?: boolean }> { try { const response = await rbacApi.get(`/rbac/host/${hostId}/access`); return response.data; @@ -120,6 +154,45 @@ export async function getHostAccess( } } +export interface PermissionCatalogEntry { + group: string; + permissions: string[]; +} + +export async function getPermissionsCatalog(): Promise<{ + catalog: PermissionCatalogEntry[]; +}> { + try { + const response = await rbacApi.get("/rbac/permissions/catalog"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch permissions catalog"); + } +} + +export async function getSharedHosts(): Promise<{ + sharedHosts: Array<{ + id: number; + name: string | null; + ip: string; + port: number; + username: string; + folder: string | null; + tags: string | null; + permissionLevel: SharePermissionLevel; + expiresAt: string | null; + grantedBy: string; + ownerUsername: string; + }>; +}> { + try { + const response = await rbacApi.get("/rbac/shared-hosts"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch shared hosts"); + } +} + export async function revokeHostAccess( hostId: number, accessId: number, diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 0be8e994..37ca97fb 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -106,8 +106,265 @@ "filterTagsGroup": "Tags" }, "homepage": { - "failedToLoadAlerts": "Failed to load alerts", - "failedToDismissAlert": "Failed to dismiss alert" + "title": "Homepage", + "addWidget": "Add Widget", + "editWidget": "Edit Widget", + "deleteWidget": "Delete", + "widgetTypes": "Widget Types", + "serviceLink": "Service Link", + "folder": "Folder", + "clock": "Clock", + "notes": "Notes", + "hostStatus": "Host Status", + "bookmarkList": "Bookmarks", + "zoomIn": "Zoom In", + "zoomOut": "Zoom Out", + "resetView": "Reset View", + "lockLayout": "Lock Layout", + "unlockLayout": "Unlock Layout", + "noWidgets": "Right-click or click + to add your first widget", + "openFullView": "Open Full View", + "previewTitle": "Homepage Preview", + "cancel": "Cancel", + "save": "Save", + "title_label": "Title", + "widgetTitlePlaceholder": "Widget title (optional)", + "url": "URL", + "imageUrl": "Custom Image URL", + "imageUrlHint": "Leave blank to use the site favicon automatically", + "showImage": "Show Image", + "description": "Description", + "color": "Color", + "icon": "Icon", + "expanded": "Expanded by default", + "timezone": "Timezone", + "showSeconds": "Show seconds", + "format12h": "12-hour", + "format24h": "24-hour", + "content": "Content", + "backgroundColor": "Background Color", + "host": "Host", + "showMetrics": "Show Metrics", + "links": "Links", + "addLink": "Add Link", + "linkLabel": "Label", + "linkUrl": "URL", + "removeLink": "Remove", + "categoryLinks": "Links", + "categoryInfo": "Info", + "categorySystem": "System", + "widgetServiceLinkDesc": "A clickable tile linking to a service URL", + "widgetFolderDesc": "A container to group related widgets", + "widgetClockDesc": "A live clock with configurable timezone", + "widgetNotesDesc": "A markdown notes widget", + "widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host", + "widgetBookmarkListDesc": "A list of quick links", + "copyLink": "Copy Link", + "linkCopied": "Link copied!", + "location": "Location", + "temperatureUnit": "Temperature Unit", + "showForecast": "Show 3-day forecast", + "scrolling": "Allow Scrolling", + "feedUrl": "Feed URL", + "maxItems": "Max Items", + "showDescription": "Show Description", + "widgetWeatherName": "Weather", + "widgetWeatherDesc": "Live weather for any location", + "widgetIframeName": "iFrame Embed", + "widgetIframeDesc": "Embed any URL in an iframe", + "widgetRssName": "RSS Feed", + "widgetRssDesc": "Display items from an RSS or Atom feed", + "dragToFolder": "Drag widgets here or use + to add", + "showDisk": "Show Disk Usage", + "addToFolder": "Add widget to folder", + "noHostSelected": "No host selected", + "metricsNotAvailable": "Metrics not available", + "selectHost": "Select a host...", + "displayedMetrics": "Displayed Metrics", + "metricCpu": "CPU", + "metricMemory": "Memory", + "metricDisk": "Disk", + "metricUptime": "Uptime", + "metricSystem": "System", + "metricOs": "OS", + "metricKernel": "Kernel", + "metricHostname": "Hostname", + "metricNetwork": "Network", + "metricProcesses": "Processes", + "metricProcessesTotal": "total", + "metricProcessesRunning": "running", + "categoryMonitoring": "Monitoring", + "loading": "Loading...", + "noData": "No data available", + "allClear": "All clear", + "acknowledgeAlert": "Acknowledge", + "noPingUrls": "No URLs configured", + "pingLabel": "Label", + "addPingUrl": "Add URL", + "showLatency": "Show Latency", + "refreshInterval": "Refresh Interval", + "seconds": "seconds", + "filterActivityTypes": "Filter Types", + "filterTypesHint": "Leave empty to show all", + "showTimestamp": "Show Timestamp", + "uptimeUnavailable": "Uptime unavailable", + "uptimeLabel": "Uptime", + "overviewVersion": "Version", + "overviewUpdate": "Up to date", + "overviewUpdateAvailable": "Update available", + "overviewDatabase": "Database", + "overviewUptime": "Uptime", + "noHosts": "No hosts configured", + "hostGridHosts": "Hosts", + "hostGridAllHint": "Leave empty to show all hosts", + "columns": "Columns", + "showIp": "Show IP Address", + "connectionType": "Connection Type", + "layout": "Layout", + "showStatus": "Show Status", + "showHostName": "Show Host Name", + "noDockerActivity": "No Docker activity", + "noActivity": "No activity", + "showAcknowledged": "Show Acknowledged", + "showCurrentValue": "Show Current Value", + "chartMetric": "Metric", + "metricRange": "Range", + "widgetMetricsChartName": "Metrics Chart", + "widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host", + "widgetHostGridName": "Host Grid", + "widgetHostGridDesc": "Grid view of SSH host statuses", + "widgetAlertFeedName": "Alert Feed", + "widgetAlertFeedDesc": "Live alert firings with acknowledge support", + "widgetPingStatusName": "Ping Status", + "widgetPingStatusDesc": "HTTP ping status for one or more URLs", + "widgetRecentActivityName": "Recent Activity", + "widgetRecentActivityDesc": "Scrollable feed of recent Termix activity", + "widgetTermixUptimeName": "Termix Uptime", + "widgetTermixUptimeDesc": "Live uptime counter for the Termix server", + "widgetSystemOverviewName": "System Overview", + "widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance", + "widgetSshQuickConnectName": "SSH Quick Connect", + "widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions", + "widgetDockerActivityName": "Docker Activity", + "widgetDockerActivityDesc": "Recent Docker container events across all hosts", + "widgetCalendarName": "Calendar", + "widgetCalendarDesc": "A monthly calendar with today highlighted", + "widgetCountdownName": "Countdown", + "widgetCountdownDesc": "Countdown timer to a target date", + "widgetSearchBarName": "Search Bar", + "widgetSearchBarDesc": "Quick web search widget", + "widgetTextBannerName": "Text Banner", + "widgetTextBannerDesc": "A bold label or section header for the canvas", + "widgetImageWidgetName": "Image", + "widgetImageWidgetDesc": "Display an image from a URL", + "widgetMarkdownNotesName": "Markdown Notes", + "widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering", + "widgetCustomApiName": "Custom API", + "widgetCustomApiDesc": "Fetch and display data from any JSON API", + "widgetServiceGridName": "Service Grid", + "widgetServiceGridDesc": "A configurable grid of service tile links", + "widgetDashboardLinksName": "Dashboard Links", + "widgetDashboardLinksDesc": "Display your configured service links from the dashboard", + "widgetSearchLinksName": "Search Shortcuts", + "widgetSearchLinksDesc": "Quick search shortcut buttons with inline input", + "widgetLinkTreeName": "Link Tree", + "widgetLinkTreeDesc": "Grouped sections of links with headings", + "calMon": "Mo", + "calTue": "Tu", + "calWed": "We", + "calThu": "Th", + "calFri": "Fr", + "calSat": "Sa", + "calSun": "Su", + "startOnMonday": "Start week on Monday", + "countdownNoDate": "No target date set", + "countdownPast": "Event has passed", + "countdownDays": "days", + "countdownHours": "hrs", + "countdownMinutes": "min", + "countdownSeconds": "sec", + "countdownLabel": "Label", + "countdownLabelPlaceholder": "e.g. Launch Day", + "countdownShowDays": "Show Days", + "countdownShowHours": "Show Hours", + "targetDate": "Target Date", + "searchEngine": "Search Engine", + "customSearchUrl": "Custom Search URL", + "searchPlaceholder": "Search...", + "searchGo": "Go", + "searchPlaceholderLabel": "Placeholder Text", + "searchPlaceholderHint": "Text shown inside the search input", + "openInNewTab": "Open in New Tab", + "searchQueryPlaceholder": "Enter query...", + "noSearchShortcuts": "No shortcuts configured", + "addSearchShortcut": "Add Shortcut", + "fontSize": "Font Size", + "textAlign": "Text Align", + "fontWeight": "Font Weight", + "clearColor": "Clear Color", + "imageFit": "Image Fit", + "imageLinkUrl": "Link URL", + "noImage": "No image URL set", + "altText": "Alt Text", + "altTextPlaceholder": "Describe the image", + "renderMarkdown": "Render Markdown", + "displayMode": "Display Mode", + "displayField": "Display Field", + "jsonPath": "JSON Path", + "customApiLabel": "Label", + "customApiLabelPlaceholder": "e.g. Temperature", + "customApiUnit": "Unit", + "customApiNoUrl": "No API URL configured", + "customApiError": "Failed to fetch", + "customApiNotArray": "Response is not an array", + "addService": "Add Service", + "showLabels": "Show Labels", + "iconSize": "Icon Size", + "noDashboardLinks": "No dashboard links configured", + "noLimit": "No limit", + "sectionHeading": "Section Heading", + "addSection": "Add Section", + "compactMode": "Compact Mode", + "showDetailed": "Show Detailed", + "selectHosts": "Select Hosts", + "allHosts": "All hosts", + "listLayout": "List", + "gridLayout": "Grid", + "terminal": "Terminal", + "files": "Files", + "docker": "Docker", + "range15m": "15 minutes", + "range1h": "1 hour", + "range6h": "6 hours", + "range24h": "24 hours", + "metricNetRx": "Net Download", + "metricNetTx": "Net Upload", + "severityFilter": "Severity Filter", + "filterAll": "All", + "accentColor": "Accent Color", + "widgetSshTerminalName": "SSH Terminal", + "widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host", + "sshTerminalNoHost": "No host configured", + "sshTerminalConnect": "Connect", + "sshTerminalAutoConnect": "Auto-connect on load", + "widgetQuickConnectName": "Quick Connect", + "widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts", + "connectionTypes": "Connection Types", + "connType_terminal": "Terminal", + "connType_files": "File Manager", + "connType_docker": "Docker", + "connType_tunnel": "Tunnel", + "connType_host-metrics": "Host Metrics", + "connType_rdp": "RDP", + "connType_vnc": "VNC", + "connType_telnet": "Telnet", + "widgetFileManagerName": "File Manager", + "widgetFileManagerDesc": "Embedded SFTP file manager for a configured host", + "widgetDockerName": "Docker Manager", + "widgetDockerDesc": "Embedded Docker container manager for a configured host", + "widgetTunnelName": "Tunnel Manager", + "widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host", + "widgetNoHostSelected": "No host configured" }, "serverConfig": { "title": "Server Configuration", @@ -317,7 +574,7 @@ "status": "Status", "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "failedToRenameFolder": "Failed to rename folder", - "movedToFolder": "Moved to \"{{folder}}\"", + "movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"", "editHostTooltip": "Edit host", "statusChecks": "Status Checks", "metricsCollection": "Metrics Collection", @@ -756,7 +1013,6 @@ "failedToSaveFolder": "Failed to save folder", "folderDeleted": "Deleted folder \"{{name}}\"", "deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.", - "movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"", "failedToMoveHosts": "Failed to move hosts", "expandAll": "Expand all folders", "collapseAll": "Collapse all folders", @@ -806,7 +1062,6 @@ "failedToDeployKey": "Failed to deploy key", "deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.", "movedToRoot": "Moved to root", - "failedToMoveHosts": "Failed to move hosts", "enableTerminalFeature": "Enable Terminal", "disableTerminalFeature": "Disable Terminal", "enableFilesFeature": "Enable Files", @@ -927,7 +1182,57 @@ "shareHost": "Share Host", "shareHostTitle": "Share: {{name}}", "sharing": { - "requiresCredential": "This host must use a credential to enable sharing. Edit the host and assign a credential first." + "loadError": "Failed to load sharing data. Please try again.", + "shareWithSection": "Share with", + "usersTab": "Users", + "rolesTab": "Roles", + "searchPlaceholder": "Search users or roles...", + "noMatches": "No matches found", + "permissionLevelLabel": "Permission level", + "levels": { + "connect": { + "label": "Connect", + "description": "Open sessions only: terminal, remote desktop, file manager, tunnels and Docker. No access to the host configuration." + }, + "view": { + "label": "View", + "description": "Connect, plus see the host configuration. Secrets are never shown." + }, + "edit": { + "label": "Edit", + "description": "View, plus modify the host. Secrets can be replaced but never read; credential assignments stay owner-only." + }, + "manage": { + "label": "Manage", + "description": "Edit, plus share the host with others, change permission levels and revoke access." + } + }, + "expiryLabel": "Access expiry", + "expiry": { + "never": "Never", + "oneHour": "1 hour", + "oneDay": "24 hours", + "sevenDays": "7 days", + "thirtyDays": "30 days", + "custom": "Custom" + }, + "customHoursPlaceholder": "Hours until access expires", + "shareButton": "Share", + "shareWithCount": "Share ({{count}})", + "currentAccess": "Current access", + "noAccessEntries": "This host has not been shared yet", + "grantedBy": "Granted by", + "expires": "Expires", + "expired": "Expired", + "never": "Never", + "revoke": "Revoke", + "accessUpdated": "Access updated", + "accessUpdateFailed": "Failed to update access", + "sharedBadge": "Shared", + "sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)", + "viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.", + "sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.", + "ownerOnlyControl": "Only the host owner can change this" }, "guac": { "connection": "Connection", @@ -1062,28 +1367,10 @@ "backspaceKey": "Backspace Key", "saveHostFirst": "Save the host first.", "sharingOptionsAfterSave": "Sharing options are available after the host has been saved.", - "sharingLoadError": "Failed to load sharing data. Check your connection and try again.", - "shareHostSection": "Share Host", - "shareWithUser": "Share with User", - "shareWithRole": "Share with Role", - "selectUser": "Select User", - "selectRole": "Select Role", - "selectUserOption": "Select a user...", - "selectRoleOption": "Select a role...", "permissionLevel": "Permission Level", - "expiresInHours": "Expires in (hours)", - "noExpiryPlaceholder": "Leave empty for no expiry", - "shareBtn": "Share", - "currentAccess": "Current Access", "typeHeader": "Type", "targetHeader": "Target", "permissionHeader": "Permission", - "grantedByHeader": "Granted By", - "expiresHeader": "Expires", - "noAccessEntries": "No access entries yet.", - "expiredLabel": "Expired", - "neverLabel": "Never", - "revokeBtn": "Revoke", "cancelBtn": "Cancel", "savingBtn": "Saving...", "updateHostBtn": "Update Host", @@ -2583,7 +2870,16 @@ "hostDefaultsCommandHistory": "Command History", "hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default", "hostDefaultsSaved": "Host defaults saved", - "hostDefaultsSaveFailed": "Failed to save host defaults" + "hostDefaultsSaveFailed": "Failed to save host defaults", + "rolePermissions": { + "count": "{{count}} permissions", + "editAction": "Edit permissions", + "loadError": "Failed to load the permissions catalog", + "saved": "Role permissions saved", + "saveError": "Failed to save role permissions", + "save": "Save", + "saving": "Saving..." + } }, "newUi": { "sidebar": { @@ -3092,266 +3388,5 @@ "channels": "Notification Channels", "noChannelsHint": "Add channels in the Channels tab first", "ruleSaveFailed": "Failed to save rule" - }, - "homepage": { - "title": "Homepage", - "addWidget": "Add Widget", - "editWidget": "Edit Widget", - "deleteWidget": "Delete", - "widgetTypes": "Widget Types", - "serviceLink": "Service Link", - "folder": "Folder", - "clock": "Clock", - "notes": "Notes", - "hostStatus": "Host Status", - "bookmarkList": "Bookmarks", - "zoomIn": "Zoom In", - "zoomOut": "Zoom Out", - "resetView": "Reset View", - "lockLayout": "Lock Layout", - "unlockLayout": "Unlock Layout", - "noWidgets": "Right-click or click + to add your first widget", - "openFullView": "Open Full View", - "previewTitle": "Homepage Preview", - "cancel": "Cancel", - "save": "Save", - "title_label": "Title", - "widgetTitlePlaceholder": "Widget title (optional)", - "url": "URL", - "imageUrl": "Custom Image URL", - "imageUrlHint": "Leave blank to use the site favicon automatically", - "showImage": "Show Image", - "description": "Description", - "color": "Color", - "icon": "Icon", - "expanded": "Expanded by default", - "timezone": "Timezone", - "showSeconds": "Show seconds", - "format12h": "12-hour", - "format24h": "24-hour", - "content": "Content", - "backgroundColor": "Background Color", - "host": "Host", - "showMetrics": "Show Metrics", - "links": "Links", - "addLink": "Add Link", - "linkLabel": "Label", - "linkUrl": "URL", - "removeLink": "Remove", - "categoryLinks": "Links", - "categoryInfo": "Info", - "categorySystem": "System", - "widgetServiceLinkDesc": "A clickable tile linking to a service URL", - "widgetFolderDesc": "A container to group related widgets", - "widgetClockDesc": "A live clock with configurable timezone", - "widgetNotesDesc": "A markdown notes widget", - "widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host", - "widgetBookmarkListDesc": "A list of quick links", - "copyLink": "Copy Link", - "linkCopied": "Link copied!", - "location": "Location", - "temperatureUnit": "Temperature Unit", - "showForecast": "Show 3-day forecast", - "scrolling": "Allow Scrolling", - "feedUrl": "Feed URL", - "maxItems": "Max Items", - "showDescription": "Show Description", - "widgetWeatherName": "Weather", - "widgetWeatherDesc": "Live weather for any location", - "widgetIframeName": "iFrame Embed", - "widgetIframeDesc": "Embed any URL in an iframe", - "widgetRssName": "RSS Feed", - "widgetRssDesc": "Display items from an RSS or Atom feed", - "dragToFolder": "Drag widgets here or use + to add", - "showDisk": "Show Disk Usage", - "addToFolder": "Add widget to folder", - "noHostSelected": "No host selected", - "metricsNotAvailable": "Metrics not available", - "selectHost": "Select a host...", - "displayedMetrics": "Displayed Metrics", - "metricCpu": "CPU", - "metricMemory": "Memory", - "metricDisk": "Disk", - "metricUptime": "Uptime", - "metricSystem": "System", - "metricOs": "OS", - "metricKernel": "Kernel", - "metricHostname": "Hostname", - "metricNetwork": "Network", - "metricProcesses": "Processes", - "metricProcessesTotal": "total", - "metricProcessesRunning": "running", - "categoryMonitoring": "Monitoring", - "loading": "Loading...", - "noData": "No data available", - "allClear": "All clear", - "acknowledgeAlert": "Acknowledge", - "noPingUrls": "No URLs configured", - "pingLabel": "Label", - "addPingUrl": "Add URL", - "showLatency": "Show Latency", - "refreshInterval": "Refresh Interval", - "seconds": "seconds", - "filterActivityTypes": "Filter Types", - "filterTypesHint": "Leave empty to show all", - "showTimestamp": "Show Timestamp", - "uptimeUnavailable": "Uptime unavailable", - "uptimeLabel": "Uptime", - "overviewVersion": "Version", - "overviewUpdate": "Up to date", - "overviewUpdateAvailable": "Update available", - "overviewDatabase": "Database", - "overviewUptime": "Uptime", - "noHosts": "No hosts configured", - "hostGridHosts": "Hosts", - "hostGridAllHint": "Leave empty to show all hosts", - "columns": "Columns", - "showIp": "Show IP Address", - "connectionType": "Connection Type", - "layout": "Layout", - "showStatus": "Show Status", - "showHostName": "Show Host Name", - "noDockerActivity": "No Docker activity", - "noActivity": "No activity", - "showAcknowledged": "Show Acknowledged", - "showCurrentValue": "Show Current Value", - "chartMetric": "Metric", - "metricRange": "Range", - "widgetMetricsChartName": "Metrics Chart", - "widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host", - "widgetHostGridName": "Host Grid", - "widgetHostGridDesc": "Grid view of SSH host statuses", - "widgetAlertFeedName": "Alert Feed", - "widgetAlertFeedDesc": "Live alert firings with acknowledge support", - "widgetPingStatusName": "Ping Status", - "widgetPingStatusDesc": "HTTP ping status for one or more URLs", - "widgetRecentActivityName": "Recent Activity", - "widgetRecentActivityDesc": "Scrollable feed of recent Termix activity", - "widgetTermixUptimeName": "Termix Uptime", - "widgetTermixUptimeDesc": "Live uptime counter for the Termix server", - "widgetSystemOverviewName": "System Overview", - "widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance", - "widgetSshQuickConnectName": "SSH Quick Connect", - "widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions", - "widgetDockerActivityName": "Docker Activity", - "widgetDockerActivityDesc": "Recent Docker container events across all hosts", - "widgetCalendarName": "Calendar", - "widgetCalendarDesc": "A monthly calendar with today highlighted", - "widgetCountdownName": "Countdown", - "widgetCountdownDesc": "Countdown timer to a target date", - "widgetSearchBarName": "Search Bar", - "widgetSearchBarDesc": "Quick web search widget", - "widgetTextBannerName": "Text Banner", - "widgetTextBannerDesc": "A bold label or section header for the canvas", - "widgetImageWidgetName": "Image", - "widgetImageWidgetDesc": "Display an image from a URL", - "widgetMarkdownNotesName": "Markdown Notes", - "widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering", - "widgetCustomApiName": "Custom API", - "widgetCustomApiDesc": "Fetch and display data from any JSON API", - "widgetServiceGridName": "Service Grid", - "widgetServiceGridDesc": "A configurable grid of service tile links", - "widgetDashboardLinksName": "Dashboard Links", - "widgetDashboardLinksDesc": "Display your configured service links from the dashboard", - "widgetSearchLinksName": "Search Shortcuts", - "widgetSearchLinksDesc": "Quick search shortcut buttons with inline input", - "widgetLinkTreeName": "Link Tree", - "widgetLinkTreeDesc": "Grouped sections of links with headings", - "calMon": "Mo", - "calTue": "Tu", - "calWed": "We", - "calThu": "Th", - "calFri": "Fr", - "calSat": "Sa", - "calSun": "Su", - "startOnMonday": "Start week on Monday", - "countdownNoDate": "No target date set", - "countdownPast": "Event has passed", - "countdownDays": "days", - "countdownHours": "hrs", - "countdownMinutes": "min", - "countdownSeconds": "sec", - "countdownLabel": "Label", - "countdownLabelPlaceholder": "e.g. Launch Day", - "countdownShowDays": "Show Days", - "countdownShowHours": "Show Hours", - "targetDate": "Target Date", - "searchEngine": "Search Engine", - "customSearchUrl": "Custom Search URL", - "searchPlaceholder": "Search...", - "searchGo": "Go", - "searchPlaceholderLabel": "Placeholder Text", - "searchPlaceholderHint": "Text shown inside the search input", - "openInNewTab": "Open in New Tab", - "searchQueryPlaceholder": "Enter query...", - "noSearchShortcuts": "No shortcuts configured", - "addSearchShortcut": "Add Shortcut", - "fontSize": "Font Size", - "textAlign": "Text Align", - "fontWeight": "Font Weight", - "clearColor": "Clear Color", - "imageFit": "Image Fit", - "imageLinkUrl": "Link URL", - "noImage": "No image URL set", - "altText": "Alt Text", - "altTextPlaceholder": "Describe the image", - "renderMarkdown": "Render Markdown", - "displayMode": "Display Mode", - "displayField": "Display Field", - "jsonPath": "JSON Path", - "customApiLabel": "Label", - "customApiLabelPlaceholder": "e.g. Temperature", - "customApiUnit": "Unit", - "customApiNoUrl": "No API URL configured", - "customApiError": "Failed to fetch", - "customApiNotArray": "Response is not an array", - "addService": "Add Service", - "showLabels": "Show Labels", - "iconSize": "Icon Size", - "noDashboardLinks": "No dashboard links configured", - "noLimit": "No limit", - "sectionHeading": "Section Heading", - "addSection": "Add Section", - "compactMode": "Compact Mode", - "showDetailed": "Show Detailed", - "selectHosts": "Select Hosts", - "allHosts": "All hosts", - "listLayout": "List", - "gridLayout": "Grid", - "terminal": "Terminal", - "files": "Files", - "docker": "Docker", - "range15m": "15 minutes", - "range1h": "1 hour", - "range6h": "6 hours", - "range24h": "24 hours", - "metricNetRx": "Net Download", - "metricNetTx": "Net Upload", - "severityFilter": "Severity Filter", - "filterAll": "All", - "accentColor": "Accent Color", - "widgetSshTerminalName": "SSH Terminal", - "widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host", - "sshTerminalNoHost": "No host configured", - "sshTerminalConnect": "Connect", - "sshTerminalAutoConnect": "Auto-connect on load", - "widgetQuickConnectName": "Quick Connect", - "widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts", - "connectionTypes": "Connection Types", - "connType_terminal": "Terminal", - "connType_files": "File Manager", - "connType_docker": "Docker", - "connType_tunnel": "Tunnel", - "connType_host-metrics": "Host Metrics", - "connType_rdp": "RDP", - "connType_vnc": "VNC", - "connType_telnet": "Telnet", - "widgetFileManagerName": "File Manager", - "widgetFileManagerDesc": "Embedded SFTP file manager for a configured host", - "widgetDockerName": "Docker Manager", - "widgetDockerDesc": "Embedded Docker container manager for a configured host", - "widgetTunnelName": "Tunnel Manager", - "widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host", - "widgetNoHostSelected": "No host configured" } } diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index c141d15d..9c66a138 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -15,7 +15,7 @@ export interface Role { displayName: string; description: string | null; isSystem: boolean; - permissions: string | null; + permissions: string[] | string | null; createdAt: string; updatedAt: string; } @@ -40,7 +40,7 @@ export interface AccessRecord { roleDisplayName: string | null; grantedBy: string; grantedByUsername: string; - permissionLevel: "view"; + permissionLevel: "connect" | "view" | "edit" | "manage"; expiresAt: string | null; createdAt: string; } @@ -2087,13 +2087,21 @@ export { assignRoleToUser, removeRoleFromUser, shareHost, + updateHostAccess, getHostAccess, revokeHostAccess, + getPermissionsCatalog, + getSharedHosts, shareSnippet, getSnippetAccess, revokeSnippetAccess, getSharedSnippets, } from "@/api/rbac-api"; +export type { + SharePermissionLevel, + ShareTarget, + PermissionCatalogEntry, +} from "@/api/rbac-api"; // ============================================================================ // DOCKER MANAGEMENT API diff --git a/src/ui/shell/CommandPalette.tsx b/src/ui/shell/CommandPalette.tsx index 28e1180a..884025d0 100644 --- a/src/ui/shell/CommandPalette.tsx +++ b/src/ui/shell/CommandPalette.tsx @@ -34,6 +34,7 @@ import { } from "lucide-react"; import { getRecentActivity, type RecentActivityItem } from "@/main-axios"; import type { Host, TabType } from "@/types/ui-types"; +import { canEditHost } from "@/sidebar/host-permissions"; interface CommandPaletteProps { isOpen: boolean; @@ -403,6 +404,11 @@ export function CommandPalette({ {host.name} + {host.isShared && ( + + {t("hosts.sharing.sharedBadge")} + + )} {host.online && ( )} @@ -481,25 +487,32 @@ export function CommandPalette({ Telnet )} -
- + {canEditHost(host) && ( + <> +
+ + + )}
))} diff --git a/src/ui/sidebar/AdminManagementSections.tsx b/src/ui/sidebar/AdminManagementSections.tsx index e1b0e3d5..81a6ba4a 100644 --- a/src/ui/sidebar/AdminManagementSections.tsx +++ b/src/ui/sidebar/AdminManagementSections.tsx @@ -1,16 +1,19 @@ -import type { Dispatch, SetStateAction } from "react"; +import { useState, type Dispatch, type SetStateAction } from "react"; import { useTranslation } from "react-i18next"; import { deleteRole, deleteUser, + getPermissionsCatalog, revokeAllUserSessions, revokeSession, + updateRole, } from "@/main-axios"; -import type { Role } from "@/main-axios"; +import type { PermissionCatalogEntry, Role } from "@/main-axios"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Activity, + Check, KeyRound, Pencil, Plus, @@ -375,6 +378,69 @@ export function AdminRolesSection({ createRoleLoading, }: RolesSectionProps) { const { t } = useTranslation(); + const [catalog, setCatalog] = useState([]); + const [editingRoleId, setEditingRoleId] = useState(null); + const [editingPermissions, setEditingPermissions] = useState>( + new Set(), + ); + const [savingPermissions, setSavingPermissions] = useState(false); + + function rolePermissions(role: Role): string[] { + if (Array.isArray(role.permissions)) return role.permissions; + if (typeof role.permissions === "string") { + try { + return JSON.parse(role.permissions) as string[]; + } catch { + return []; + } + } + return []; + } + + async function openPermissionsEditor(role: Role) { + if (editingRoleId === role.id) { + setEditingRoleId(null); + return; + } + try { + if (catalog.length === 0) { + const res = await getPermissionsCatalog(); + setCatalog(res.catalog ?? []); + } + setEditingPermissions(new Set(rolePermissions(role))); + setEditingRoleId(role.id); + } catch { + toast.error(t("admin.rolePermissions.loadError")); + } + } + + function togglePermission(permission: string) { + setEditingPermissions((prev) => { + const next = new Set(prev); + if (next.has(permission)) next.delete(permission); + else next.add(permission); + return next; + }); + } + + async function savePermissions(role: Role) { + setSavingPermissions(true); + try { + const permissions = [...editingPermissions]; + await updateRole(role.id, { permissions }); + setRoles((prev) => + prev.map((entry) => + entry.id === role.id ? { ...entry, permissions } : entry, + ), + ); + setEditingRoleId(null); + toast.success(t("admin.rolePermissions.saved")); + } catch { + toast.error(t("admin.rolePermissions.saveError")); + } finally { + setSavingPermissions(false); + } + } return (
)} - {roles.map((role) => ( -
-
-
- - {role.displayName} - - {role.isSystem ? ( - - {t("admin.systemBadge")} - - ) : ( - - {t("admin.customBadge")} + {roles.map((role) => { + const permissionCount = rolePermissions(role).length; + return ( +
+
+
+
+ + {role.displayName} + + {role.isSystem ? ( + + {t("admin.systemBadge")} + + ) : ( + + {t("admin.customBadge")} + + )} + {permissionCount > 0 && ( + + {t("admin.rolePermissions.count", { + count: permissionCount, + })} + + )} +
+ + {role.name} +
+ {!role.isSystem && ( +
+ + +
)}
- - {role.name} - -
- {!role.isSystem && ( -
- +
+ {entry.permissions.map((permission) => { + const checked = + wildcardOn || editingPermissions.has(permission); + return ( + + ); + })} +
+
); - }} - > - - -
- )} -
- ))} + })} +
+ + +
+
+ )} + + ); + })} ); diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index f26d15a1..c403f800 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -57,6 +57,7 @@ import { HostFilesTab, } from "./HostEditorFeatureTabs"; import { HostEditorGeneralTab } from "./HostEditorGeneralTab"; +import { canEditHost } from "./host-permissions"; import { HostEditorRdpTab, HostEditorTelnetTab, @@ -201,6 +202,13 @@ export function HostEditor({ (c) => c.id === form.credentialId, ); + // Shared hosts: view-level recipients see a read-only editor; edit-level + // recipients may change the host but never its credential/vault references + // or auth type (owner-only, enforced server-side too). + const isSharedHost = !!host?.isShared; + const readOnly = isSharedHost && host !== null && !canEditHost(host); + const lockAuthReferences = isSharedHost && !readOnly; + const handleProtocolToggle = ( proto: keyof typeof protocols, value: boolean, @@ -232,821 +240,827 @@ export function HostEditor({ return (
-
- {activeTab === "general" && ( - - )} + {isSharedHost && ( +
+ +
+ {readOnly + ? t("hosts.sharing.viewOnlyBanner", { + owner: host?.ownerUsername || "?", + }) + : t("hosts.sharing.sharedEditBanner", { + owner: host?.ownerUsername || "?", + })} +
+
+ )} +
+
+ {activeTab === "general" && ( + + )} - {activeTab === "ssh" && ( - <> - } - > -
-
- - - setField("sshPort", Number(e.target.value)) - } - className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
-
-
- } - > -
-
- -
- {[ - "password", - "key", - "credential", - "vault", - "none", - "opkssh", - "tailscale", - "agent", - ].map((m) => ( - - ))} -
-
-
+ {activeTab === "ssh" && ( + <> + } + > +
+ setField("sshPort", Number(e.target.value)) } - onFocus={() => { - if (form.username === "root") setField("username", ""); - }} - onBlur={() => { - if (form.username === "") setField("username", "root"); - }} - onChange={(e) => setField("username", e.target.value)} + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> - {isOidcUser && ( +
+
+
+ } + > +
+
+ +
+ {[ + "password", + "key", + "credential", + "vault", + "none", + "opkssh", + "tailscale", + "agent", + ].map((m) => ( + + ))} +
+ {lockAuthReferences && (

- {t("hosts.oidcUsernameHint")} + {t("hosts.sharing.ownerOnlyControl")}

)}
- {authMethod === "password" && ( +
+ { + if (form.username === "root") + setField("username", ""); + }} + onBlur={() => { + if (form.username === "") + setField("username", "root"); + }} + onChange={(e) => setField("username", e.target.value)} + /> + {isOidcUser && ( +

+ {t("hosts.oidcUsernameHint")} +

+ )} +
+ {authMethod === "password" && ( +
+ + setField("password", e.target.value)} + /> +
+ )} + {authMethod === "key" && ( + <> +
+
+ +
+ {(["paste", "upload"] as const).map((tab) => ( + + ))} +
+
+ {form.keySubTab === "paste" ? ( +
+ {form.key === "existing_key" && ( +
+ {t("hosts.keySaved")} —{" "} + {t("hosts.keyReplaceNotice")} +
+ )} +