feat: refactor rbac/sharing to support new permissions and auth types

This commit is contained in:
LukeGus
2026-07-16 17:36:32 -05:00
parent 552ceefec2
commit 3cc51fe920
72 changed files with 6258 additions and 3761 deletions
+24 -6
View File
@@ -1585,36 +1585,54 @@ const migrateSchema = () => {
} }
try { try {
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get(); sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
} catch { } catch {
try { try {
sqlite.exec(` sqlite.exec(`
CREATE TABLE IF NOT EXISTS shared_credentials ( CREATE TABLE IF NOT EXISTS shared_host_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
host_access_id INTEGER NOT NULL, host_access_id INTEGER NOT NULL,
original_credential_id INTEGER NOT NULL,
target_user_id TEXT NOT NULL, target_user_id TEXT NOT NULL,
encrypted_username TEXT NOT NULL, protocol TEXT NOT NULL DEFAULT 'ssh',
encrypted_auth_type TEXT NOT NULL, source_type TEXT NOT NULL DEFAULT 'credential',
original_credential_id INTEGER,
encrypted_username TEXT,
encrypted_auth_type TEXT,
encrypted_password TEXT, encrypted_password TEXT,
encrypted_key TEXT, encrypted_key TEXT,
encrypted_key_password TEXT, encrypted_key_password TEXT,
encrypted_key_type TEXT, encrypted_key_type TEXT,
encrypted_domain TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 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),
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE, 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 (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
); );
`); `);
} catch (createError) { } catch (createError) {
databaseLogger.warn("Failed to create shared_credentials table", { databaseLogger.warn("Failed to create shared_host_secrets table", {
operation: "schema_migration", operation: "schema_migration",
error: createError, 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 { try {
sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get(); sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
} catch { } catch {
+13 -8
View File
@@ -523,7 +523,7 @@ export const hostAccess = sqliteTable("host_access", {
permissionLevel: text("permission_level") permissionLevel: text("permission_level")
.notNull() .notNull()
.default("view"), .default("connect"),
expiresAt: text("expires_at"), 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 }), id: integer("id").primaryKey({ autoIncrement: true }),
hostAccessId: integer("host_access_id") hostAccessId: integer("host_access_id")
.notNull() .notNull()
.references(() => hostAccess.id, { onDelete: "cascade" }), .references(() => hostAccess.id, { onDelete: "cascade" }),
originalCredentialId: integer("original_credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
targetUserId: text("target_user_id") targetUserId: text("target_user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username").notNull(), protocol: text("protocol").notNull().default("ssh"),
encryptedAuthType: text("encrypted_auth_type").notNull(), 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"), encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key", { length: 16384 }), encryptedKey: text("encrypted_key", { length: 16384 }),
encryptedKeyPassword: text("encrypted_key_password"), encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"), encryptedKeyType: text("encrypted_key_type"),
encryptedDomain: text("encrypted_domain"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
+4 -4
View File
@@ -28,7 +28,7 @@ import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js"; import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js"; import { SessionRepository } from "./session-repository.js";
import { SettingsRepository } from "./settings-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 { SnippetRepository } from "./snippet-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js"; import { SsoProviderRepository } from "./sso-provider-repository.js";
@@ -260,10 +260,10 @@ export function createCurrentSettingsRepository(): SettingsRepository {
); );
} }
export function createCurrentSharedCredentialRepository(): SharedCredentialRepository { export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRepository {
return new SharedCredentialRepository( return new SharedHostSecretsRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("shared_credential_repository_write"), createCurrentRepositoryWriteHook("shared_host_secrets_repository_write"),
); );
} }
@@ -18,6 +18,7 @@ export interface HostUpdateStateRecord {
rdpCredentialId: number | null; rdpCredentialId: number | null;
vncCredentialId: number | null; vncCredentialId: number | null;
telnetCredentialId: number | null; telnetCredentialId: number | null;
vaultProfileId: number | null;
authType: string; authType: string;
} }
export interface HostListAccessEntry { export interface HostListAccessEntry {
@@ -74,6 +75,7 @@ export class HostResolutionRepository {
rdpCredentialId: hosts.rdpCredentialId, rdpCredentialId: hosts.rdpCredentialId,
vncCredentialId: hosts.vncCredentialId, vncCredentialId: hosts.vncCredentialId,
telnetCredentialId: hosts.telnetCredentialId, telnetCredentialId: hosts.telnetCredentialId,
vaultProfileId: hosts.vaultProfileId,
authType: hosts.authType, authType: hosts.authType,
}) })
.from(hosts) .from(hosts)
@@ -3,7 +3,7 @@ import {
hostAccess, hostAccess,
hosts, hosts,
roles, roles,
sharedCredentials, sharedHostSecrets,
snippetAccess, snippetAccess,
snippets, snippets,
users, users,
@@ -152,10 +152,6 @@ export class RbacAccessRepository {
}) })
.where(eq(hostAccess.id, existing.id)); .where(eq(hostAccess.id, existing.id));
await this.context.drizzle
.delete(sharedCredentials)
.where(eq(sharedCredentials.hostAccessId, existing.id));
await this.afterWrite(); await this.afterWrite();
return { id: existing.id, created: false }; return { id: existing.id, created: false };
} }
@@ -583,25 +579,73 @@ export class RbacAccessRepository {
.where(eq(hostAccess.roleId, roleId)); .where(eq(hostAccess.roleId, roleId));
} }
async findSharedCredentialForHostAndUser( async findSharedSecretForHostUserProtocol(
hostId: number, hostId: number,
userId: string, userId: string,
): Promise<typeof sharedCredentials.$inferSelect | null> { protocol: string,
): Promise<typeof sharedHostSecrets.$inferSelect | null> {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.select({ .select({
sharedCredential: sharedCredentials, secret: sharedHostSecrets,
}) })
.from(sharedCredentials) .from(sharedHostSecrets)
.innerJoin(hostAccess, eq(sharedCredentials.hostAccessId, hostAccess.id)) .innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
.where( .where(
and( and(
eq(hostAccess.hostId, hostId), eq(hostAccess.hostId, hostId),
eq(sharedCredentials.targetUserId, userId), eq(sharedHostSecrets.targetUserId, userId),
eq(sharedHostSecrets.protocol, protocol),
), ),
) )
.limit(1); .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<typeof hostAccess.$inferSelect | null> {
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<boolean> {
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<string | null> { async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
@@ -27,6 +27,8 @@ export interface SessionRecordingListRecord {
endedAt: string | null; endedAt: string | null;
duration: number | null; duration: number | null;
recordingPath: string | null; recordingPath: string | null;
protocol: string;
format: string | null;
hostName: string | null; hostName: string | null;
hostIp: string | null; hostIp: string | null;
} }
@@ -106,6 +108,8 @@ export class SessionRecordingRepository {
endedAt: sessionRecordings.endedAt, endedAt: sessionRecordings.endedAt,
duration: sessionRecordings.duration, duration: sessionRecordings.duration,
recordingPath: sessionRecordings.recordingPath, recordingPath: sessionRecordings.recordingPath,
protocol: sessionRecordings.protocol,
format: sessionRecordings.format,
hostName: hosts.name, hostName: hosts.name,
hostIp: hosts.ip, hostIp: hosts.ip,
}) })
@@ -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<NewSharedCredentialRecord, "id">
>;
export class SharedCredentialRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async existsForHostAccessAndTargetUser(
hostAccessId: number,
targetUserId: string,
): Promise<boolean> {
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<SharedCredentialRecord> {
const rows = await this.context.drizzle
.insert(sharedCredentials)
.values(sharedCredential)
.returning();
await this.afterWrite();
return rows[0];
}
async findById(id: number): Promise<SharedCredentialRecord | null> {
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<SharedCredentialRecord[]> {
return this.context.drizzle
.select()
.from(sharedCredentials)
.where(eq(sharedCredentials.originalCredentialId, credentialId));
}
async updateById(
id: number,
update: SharedCredentialUpdate,
): Promise<SharedCredentialRecord | null> {
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<boolean> {
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<number> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
) {}
async upsert(record: NewSharedHostSecretRecord): Promise<void> {
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<SharedHostSecretRecord | null> {
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<boolean> {
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<void> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number[]> {
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<void> {
await this.onWrite?.();
}
}
+13 -29
View File
@@ -8,7 +8,6 @@ import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js"; import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { import {
createCurrentRbacAccessRepository,
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
createCurrentHostRepository, createCurrentHostRepository,
@@ -523,10 +522,9 @@ router.put(
credentialId, credentialId,
)); ));
const { SharedCredentialManager } = const { SharedHostSecretsManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-host-secrets-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
await sharedCredManager.updateSharedCredentialsForOriginal(
credentialId, credentialId,
userId, userId,
); );
@@ -633,38 +631,24 @@ router.delete(
authType: "password", 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 } = const { SharedHostSecretsManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-host-secrets-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedSecretsManager = SharedHostSecretsManager.getInstance();
await sharedCredManager.deleteSharedCredentialsForOriginal(credentialId); await sharedSecretsManager.deleteForCredential(credentialId);
await createCurrentCredentialRepository().deleteForUser( await createCurrentCredentialRepository().deleteForUser(
userId, userId,
credentialId, 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", { authLogger.success("SSH credential deleted", {
operation: "credential_delete_success", operation: "credential_delete_success",
userId, userId,
@@ -24,7 +24,7 @@ import {
createCurrentSessionRepository, createCurrentSessionRepository,
createCurrentSessionRecordingRepository, createCurrentSessionRecordingRepository,
createCurrentSettingsRepository, createCurrentSettingsRepository,
createCurrentSharedCredentialRepository, createCurrentSharedHostSecretsRepository,
createCurrentSnippetRepository, createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository, createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository, createCurrentTermixIdentityCaRepository,
@@ -40,7 +40,7 @@ import {
export async function deleteUserAndRelatedData(userId: string): Promise<void> { export async function deleteUserAndRelatedData(userId: string): Promise<void> {
try { try {
await createCurrentSharedCredentialRepository().deleteByTargetUserId( await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
userId, userId,
); );
@@ -231,6 +231,76 @@ export function stripSensitiveFields(
return result; 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<string, unknown>,
permissionLevel: string | undefined,
): Record<string, unknown> {
const stripped = stripSensitiveFields(host);
if (permissionLevel !== "connect") {
return stripped;
}
const reduced: Record<string, unknown> = {};
for (const [key, value] of Object.entries(stripped)) {
if (CONNECT_LEVEL_FIELDS.has(key)) {
reduced[key] = value;
}
}
return reduced;
}
export function transformHostResponse( export function transformHostResponse(
host: Record<string, unknown>, host: Record<string, unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
+145 -80
View File
@@ -29,6 +29,7 @@ import {
import { import {
isNonEmptyString, isNonEmptyString,
isValidPort, isValidPort,
sanitizeHostForRecipient,
stripSensitiveFields, stripSensitiveFields,
transformHostResponse, transformHostResponse,
} from "./host-normalizers.js"; } from "./host-normalizers.js";
@@ -1070,7 +1071,7 @@ router.put(
const accessInfo = await permissionManager.canAccessHost( const accessInfo = await permissionManager.canAccessHost(
userId, userId,
Number(hostId), Number(hostId),
"write", "edit",
); );
if (!accessInfo.hasAccess) { if (!accessInfo.hasAccess) {
@@ -1082,17 +1083,6 @@ router.put(
return res.status(403).json({ error: "Access denied" }); 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 = const hostRecord =
await createCurrentHostResolutionRepository().findHostUpdateState( await createCurrentHostResolutionRepository().findHostUpdateState(
Number(hostId), Number(hostId),
@@ -1109,57 +1099,49 @@ router.put(
const ownerId = hostRecord.userId; const ownerId = hostRecord.userId;
if ( if (!accessInfo.isOwner) {
!accessInfo.isOwner && // Shared editors work on the owner's real record but may never
sshDataObj.credentialId !== undefined && // repoint it at credential/vault references (those live in the
sshDataObj.credentialId !== hostRecord.credentialId // owner's personal vault) or switch the authentication type.
) { const referenceViolations: Array<[unknown, number | null, string]> = [
return res.status(403).json({ [sshDataObj.credentialId, hostRecord.credentialId, "credential"],
error: "Only the host owner can change the 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 ( for (const [incoming, current, label] of referenceViolations) {
!accessInfo.isOwner && if (incoming !== undefined && (incoming ?? null) !== current) {
sshDataObj.authType !== undefined && return res.status(403).json({
sshDataObj.authType !== hostRecord.authType error: `Only the host owner can change the ${label}`,
) { });
return res.status(403).json({ }
error: "Only the host owner can change the authentication type", }
});
}
{ if (
const newCredId = sshDataObj.authType !== undefined &&
sshDataObj.credentialId !== undefined sshDataObj.authType !== hostRecord.authType
? sshDataObj.credentialId ) {
: hostRecord.credentialId; return res.status(403).json({
const newRdpCredId = error: "Only the host owner can change the authentication type",
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),
);
} }
} }
@@ -1169,6 +1151,23 @@ router.put(
sshDataObj, 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 = const updatedHost =
await createCurrentHostResolutionRepository().findHostById( await createCurrentHostResolutionRepository().findHostById(
Number(hostId), Number(hostId),
@@ -1296,9 +1295,21 @@ router.get(
} }
} }
const sanitizedSharedHosts = sharedHosts; const ownerUsernames = new Map<string, string>();
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( const result = await Promise.all(
data.map(async (row: Record<string, unknown>) => { data.map(async (row: Record<string, unknown>) => {
@@ -1307,6 +1318,9 @@ router.get(
isShared: !!row.isShared, isShared: !!row.isShared,
permissionLevel: row.permissionLevel || undefined, permissionLevel: row.permissionLevel || undefined,
sharedExpiresAt: row.expiresAt || undefined, sharedExpiresAt: row.expiresAt || undefined,
ownerUsername: row.isShared
? ownerUsernames.get(row.userId as string) || undefined
: undefined,
}; };
const resolved = 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); res.json(sanitized);
} catch (err) { } catch (err) {
sshLogger.error("Failed to fetch SSH hosts from database", 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" }); return res.status(400).json({ error: "Invalid userId or hostId" });
} }
try { try {
const host = const hostResolutionRepository = createCurrentHostResolutionRepository();
await createCurrentHostResolutionRepository().findHostByIdForUser( const host = await hostResolutionRepository.findHostByIdForUser(
Number(hostId), Number(hostId),
userId, 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", { sshLogger.warn("SSH host not found", {
operation: "host_fetch_by_id", operation: "host_fetch_by_id",
hostId: parseInt(hostId), hostId: parseInt(hostId),
@@ -1385,10 +1421,38 @@ router.get(
return res.status(404).json({ error: "SSH host not found" }); return res.status(404).json({ error: "SSH host not found" });
} }
const result = transformHostResponse(host); const ownerId = await hostResolutionRepository.findHostOwnerId(
const resolved = (await resolveHostCredentials(result, userId)) || result; 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) { } catch (err) {
sshLogger.error("Failed to fetch SSH host by ID from database", err, { sshLogger.error("Failed to fetch SSH host by ID from database", err, {
operation: "host_fetch_by_id", operation: "host_fetch_by_id",
@@ -2033,13 +2097,14 @@ async function resolveHostCredentials(
if (requestingUserId && requestingUserId !== ownerId) { if (requestingUserId && requestingUserId !== ownerId) {
try { try {
const { SharedCredentialManager } = const { SharedHostSecretsManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-host-secrets-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred =
const sharedCred = await sharedCredManager.getSharedCredentialForUser( await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id as number, host.id as number,
requestingUserId, requestingUserId,
); "ssh",
);
if (sharedCred) { if (sharedCred) {
const resolvedHost: Record<string, unknown> = { const resolvedHost: Record<string, unknown> = {
+5 -4
View File
@@ -312,7 +312,7 @@ async function discoverProxmoxGuestsForHost(
const { PermissionManager } = const { PermissionManager } =
await import("../../utils/permission-manager.js"); await import("../../utils/permission-manager.js");
const pm = PermissionManager.getInstance(); const pm = PermissionManager.getInstance();
const access = await pm.canAccessHost(userId, parsedHostId, "execute"); const access = await pm.canAccessHost(userId, parsedHostId, "connect");
if (!access.hasAccess) { if (!access.hasAccess) {
const error = new Error("Access denied"); const error = new Error("Access denied");
(error as Error & { status?: number }).status = 403; (error as Error & { status?: number }).status = 403;
@@ -337,12 +337,13 @@ async function discoverProxmoxGuestsForHost(
if (host.credentialId) { if (host.credentialId) {
if (userId !== host.userId) { if (userId !== host.userId) {
try { try {
const { SharedCredentialManager } = const { SharedHostSecretsManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-host-secrets-manager.js");
const sharedCred = const sharedCred =
await SharedCredentialManager.getInstance().getSharedCredentialForUser( await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id, host.id,
userId, userId,
"ssh",
); );
if (sharedCred) { if (sharedCred) {
resolvedCredentials = { resolvedCredentials = {
+461 -202
View File
@@ -3,7 +3,15 @@ import express from "express";
import type { Response } from "express"; import type { Response } from "express";
import { databaseLogger } from "../../utils/logger.js"; import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.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 { import {
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
@@ -24,12 +32,69 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0; 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<string, unknown>,
): 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 * @openapi
* /rbac/host/{id}/share: * /rbac/host/{id}/share:
* post: * post:
* summary: Share a host * 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: * tags:
* - RBAC * - RBAC
* parameters: * parameters:
@@ -44,26 +109,32 @@ function isNonEmptyString(value: unknown): value is string {
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* required: [targets]
* properties: * properties:
* targetType: * targets:
* type: string * type: array
* enum: [user, role] * items:
* targetUserId: * type: object
* type: string * properties:
* targetRoleId: * type:
* type: integer * type: string
* durationHours: * enum: [user, role]
* type: number * id:
* oneOf:
* - type: string
* - type: integer
* permissionLevel: * permissionLevel:
* type: string * type: string
* enum: [view] * enum: [connect, view, edit, manage]
* durationHours:
* type: number
* responses: * responses:
* 200: * 200:
* description: Host shared successfully. * description: Host shared successfully.
* 400: * 400:
* description: Invalid request body. * description: Invalid request body.
* 403: * 403:
* description: Not host owner. * description: Caller may not share this host.
* 404: * 404:
* description: Target user or role not found. * description: Target user or role not found.
* 500: * 500:
@@ -82,37 +153,25 @@ router.post(
} }
try { try {
const { const targets = parseShareTargets(req.body ?? {});
targetType = "user", if (!targets) {
targetUserId, return res.status(400).json({
targetRoleId, error:
durationHours, "targets must be a non-empty array of { type: 'user'|'role', id } entries",
permissionLevel = "view", });
} = req.body;
if (!["user", "role"].includes(targetType)) {
return res
.status(400)
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
} }
if (targetType === "user" && !isNonEmptyString(targetUserId)) { const { durationHours, permissionLevel = "connect" } = req.body;
return res
.status(400) if (!isSharePermissionLevel(permissionLevel)) {
.json({ error: "Target user ID is required when sharing with user" }); return res.status(400).json({
} error: "Invalid permission level",
if (targetType === "role" && !targetRoleId) { validLevels: SHARE_PERMISSION_LEVELS,
return res });
.status(400)
.json({ error: "Target role ID is required when sharing with role" });
} }
const host = const sharing = await canManageHostSharing(userId, hostId);
await createCurrentHostResolutionRepository().findHostUpdateState( if (!sharing.allowed) {
hostId,
);
if (!host || host.userId !== userId) {
databaseLogger.warn("Permission denied", { databaseLogger.warn("Permission denied", {
operation: "rbac_permission_denied", operation: "rbac_permission_denied",
userId, userId,
@@ -120,141 +179,126 @@ router.post(
resourceId: hostId, resourceId: hostId,
action: "share", action: "share",
}); });
return res.status(403).json({ error: "Not host owner" }); return res.status(403).json({ error: "You may not share this host" });
} }
if ( const host =
!host.credentialId && await createCurrentHostResolutionRepository().findHostUpdateState(
!host.rdpCredentialId && hostId,
!host.vncCredentialId && );
host.authType !== "opkssh" if (!host) {
) { return res.status(404).json({ error: "Host not found" });
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 ownerId = host.userId;
if (targetType === "user") { const userRepository = createCurrentUserRepository();
const targetUser = const roleRepository = createCurrentRoleRepository();
await createCurrentUserRepository().findById(targetUserId);
if (!targetUser) { for (const target of targets) {
return res.status(404).json({ error: "Target user not found" }); if (target.type === "user") {
} if (target.id === ownerId) {
} else { return res
const targetRole = .status(400)
await createCurrentRoleRepository().findRoleById(targetRoleId); .json({ error: "Cannot share a host with its owner" });
}
if (!targetRole) { const targetUser = await userRepository.findById(target.id as string);
return res.status(404).json({ error: "Target role not found" }); 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; const expiresAt = expiryFromDuration(durationHours);
if (
durationHours &&
typeof durationHours === "number" &&
durationHours > 0
) {
const expiryDate = new Date();
expiryDate.setHours(expiryDate.getHours() + durationHours);
expiresAt = expiryDate.toISOString();
}
const validLevels = ["view"]; const rbacAccessRepository = createCurrentRbacAccessRepository();
if (!validLevels.includes(permissionLevel)) { const { SharedHostSecretsManager } =
return res.status(400).json({ await import("../../utils/shared-host-secrets-manager.js");
error: "Invalid permission level. Only 'view' is supported.", const secretsManager = SharedHostSecretsManager.getInstance();
validLevels,
});
}
const accessGrant = const results: Array<{
await createCurrentRbacAccessRepository().upsertHostAccess({ type: "user" | "role";
id: string | number;
accessId: number;
created: boolean;
}> = [];
for (const target of targets) {
const accessGrant = await rbacAccessRepository.upsertHostAccess({
hostId, hostId,
grantedBy: userId, grantedBy: userId,
permissionLevel, permissionLevel,
expiresAt, expiresAt,
...(targetType === "user" ...(target.type === "user"
? { targetType: "user" as const, targetUserId: targetUserId! } ? { targetType: "user" as const, targetUserId: target.id as string }
: { targetType: "role" as const, targetRoleId: targetRoleId! }), : {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
}); });
if (!accessGrant.created) { try {
const activeCredentialId = if (target.type === "user") {
host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId; await secretsManager.snapshotForUser(
if (activeCredentialId) {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
await sharedCredManager.createSharedCredentialForUser(
accessGrant.id, accessGrant.id,
activeCredentialId, hostId,
targetUserId!, target.id as string,
userId, ownerId,
); );
} else { } else {
await sharedCredManager.createSharedCredentialsForRole( await secretsManager.snapshotForRole(
accessGrant.id, accessGrant.id,
activeCredentialId, hostId,
targetRoleId!, target.id as number,
userId, 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({ results.push({
success: true, type: target.type,
message: "Host access updated", id: target.id,
expiresAt, 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", { databaseLogger.success("Host shared successfully", {
operation: "rbac_host_share_success", operation: "rbac_host_share_success",
userId, userId,
hostId, hostId,
targetUserId: targetType === "user" ? targetUserId : undefined, targets: results.length,
permissionLevel, permissionLevel,
}); });
res.json({ res.json({
success: true, success: true,
message: `Host shared successfully with ${targetType}`, message: "Host shared successfully",
permissionLevel,
expiresAt, expiresAt,
results,
}); });
} catch (error) { } catch (error) {
databaseLogger.error("Failed to share host", 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 * @openapi
* /rbac/host/{id}/access/{accessId}: * /rbac/host/{id}/access/{accessId}:
@@ -292,7 +473,7 @@ router.post(
* 400: * 400:
* description: Invalid ID. * description: Invalid ID.
* 403: * 403:
* description: Not host owner. * description: Caller may not manage sharing on this host.
* 500: * 500:
* description: Failed to revoke access. * description: Failed to revoke access.
*/ */
@@ -313,14 +494,11 @@ router.delete(
} }
try { try {
const isHostOwner = const sharing = await canManageHostSharing(userId, hostId);
await createCurrentHostResolutionRepository().isHostOwnedByUser( if (!sharing.allowed) {
hostId, return res
userId, .status(403)
); .json({ error: "You may not manage sharing on this host" });
if (!isHostOwner) {
return res.status(403).json({ error: "Not host owner" });
} }
await createCurrentRbacAccessRepository().revokeHostAccess( await createCurrentRbacAccessRepository().revokeHostAccess(
@@ -363,11 +541,11 @@ router.delete(
* type: integer * type: integer
* responses: * responses:
* 200: * 200:
* description: The access list for the host. * description: The access list for the host, including each grant's permission level.
* 400: * 400:
* description: Invalid host ID. * description: Invalid host ID.
* 403: * 403:
* description: Not host owner. * description: Caller may not manage sharing on this host.
* 500: * 500:
* description: Failed to get access list. * description: Failed to get access list.
*/ */
@@ -384,20 +562,17 @@ router.get(
} }
try { try {
const isHostOwner = const sharing = await canManageHostSharing(userId, hostId);
await createCurrentHostResolutionRepository().isHostOwnedByUser( if (!sharing.allowed) {
hostId, return res
userId, .status(403)
); .json({ error: "You may not manage sharing on this host" });
if (!isHostOwner) {
return res.status(403).json({ error: "Not host owner" });
} }
const accessList = const accessList =
await createCurrentRbacAccessRepository().listHostAccess(hostId); await createCurrentRbacAccessRepository().listHostAccess(hostId);
res.json({ accessList }); res.json({ accessList, isOwner: sharing.isOwner });
} catch (error) { } catch (error) {
databaseLogger.error("Failed to get host access list", error, { databaseLogger.error("Failed to get host access list", error, {
operation: "get_host_access_list", operation: "get_host_access_list",
@@ -475,17 +650,30 @@ router.get(
displayName, displayName,
description, description,
isSystem, isSystem,
permissions,
createdAt, createdAt,
updatedAt, updatedAt,
}) => ({ }) => {
id, let parsedPermissions: string[] = [];
name, try {
displayName, parsedPermissions = permissions
description, ? (JSON.parse(permissions) as string[])
isSystem, : [];
createdAt, } catch {
updatedAt, parsedPermissions = [];
}), }
return {
id,
name,
displayName,
description,
isSystem,
permissions: parsedPermissions,
createdAt,
updatedAt,
};
},
); );
res.json({ roles: rolesList }); res.json({ roles: rolesList });
@@ -606,6 +794,11 @@ router.post(
* type: string * type: string
* description: * description:
* type: string * type: string
* permissions:
* type: array
* items:
* type: string
* description: Permission strings validated against the permissions catalog (wildcards like hosts.* and * allowed).
* responses: * responses:
* 200: * 200:
* description: Role updated successfully. * description: Role updated successfully.
@@ -623,21 +816,47 @@ router.put(
async (req: AuthenticatedRequest, res: Response) => { async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const roleId = parseInt(id, 10); const roleId = parseInt(id, 10);
const { displayName, description } = req.body; const { displayName, description, permissions } = req.body;
if (isNaN(roleId)) { if (isNaN(roleId)) {
return res.status(400).json({ error: "Invalid role ID" }); return res.status(400).json({ error: "Invalid role ID" });
} }
if (!displayName && description === undefined) { if (
!displayName &&
description === undefined &&
permissions === undefined
) {
return res.status(400).json({ 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 { try {
const existingRole = const roleRepository = createCurrentRoleRepository();
await createCurrentRoleRepository().findRoleById(roleId); const existingRole = await roleRepository.findRoleById(roleId);
if (!existingRole) { if (!existingRole) {
return res.status(404).json({ error: "Role not found" }); return res.status(404).json({ error: "Role not found" });
@@ -646,6 +865,7 @@ router.put(
const updates: { const updates: {
displayName?: string; displayName?: string;
description?: string | null; description?: string | null;
permissions?: string | null;
updatedAt: string; updatedAt: string;
} = { } = {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
@@ -659,7 +879,18 @@ router.put(
updates.description = description || null; 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({ res.json({
success: true, 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 * @openapi
* /rbac/roles/{id}: * /rbac/roles/{id}:
@@ -836,37 +1087,23 @@ router.post(
grantedBy: currentUserId, grantedBy: currentUserId,
}); });
const hostsSharedWithRole = try {
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources( const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().snapshotForRoleMember(
roleId, 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); permissionManager.invalidateUserPermissionCache(targetUserId);
@@ -959,6 +1196,28 @@ router.delete(
roleId, 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); permissionManager.invalidateUserPermissionCache(targetUserId);
databaseLogger.info("Role removed from user", { databaseLogger.info("Role removed from user", {
operation: "rbac_role_remove", operation: "rbac_role_remove",
+4 -5
View File
@@ -58,12 +58,11 @@ async function syncSharedCredentialsForUserRoles(
operation: string, operation: string,
) { ) {
try { try {
const { SharedCredentialManager } = const { SharedHostSecretsManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-host-secrets-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
await sharedCredManager.createSharedCredentialsForUserRoles(userId);
} catch (error) { } catch (error) {
authLogger.warn("Failed to sync role shared credentials", { authLogger.warn("Failed to sync role shared host secrets", {
operation, operation,
userId, userId,
error, error,
+5 -5
View File
@@ -182,7 +182,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
const accessInfo = await permissionManager.canAccessHost( const accessInfo = await permissionManager.canAccessHost(
userId, userId,
hostId, hostId,
"execute", "connect",
); );
if (!accessInfo.hasAccess) { if (!accessInfo.hasAccess) {
@@ -294,13 +294,13 @@ export function registerDockerSshRoutes(app: express.Express): void {
if (userId !== ownerId) { if (userId !== ownerId) {
try { try {
const { SharedCredentialManager } = const { SharedHostSecretsManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-host-secrets-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = const sharedCred =
await sharedCredManager.getSharedCredentialForUser( await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id, host.id,
userId, userId,
"ssh",
); );
if (sharedCred) { if (sharedCred) {
+104 -63
View File
@@ -196,10 +196,13 @@ router.post(
return res.status(400).json({ error: "Invalid host ID" }); return res.status(400).json({ error: "Invalid host ID" });
} }
const host = await createCurrentHostResolutionRepository().findHostById( const hostResolutionRepository = createCurrentHostResolutionRepository();
hostId, // Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields.
userId, const hostOwnerId =
); await hostResolutionRepository.findHostOwnerId(hostId);
const host = hostOwnerId
? await hostResolutionRepository.findHostById(hostId, hostOwnerId)
: null;
if (!host) { if (!host) {
return res.status(404).json({ error: "Host not found" }); return res.status(404).json({ error: "Host not found" });
@@ -210,7 +213,7 @@ router.post(
const accessInfo = await permissionManager.canAccessHost( const accessInfo = await permissionManager.canAccessHost(
userId, userId,
hostId, hostId,
"read", "connect",
); );
if (!accessInfo.hasAccess) { if (!accessInfo.hasAccess) {
@@ -294,83 +297,121 @@ router.post(
} }
const hostRecord = host as Record<string, unknown>; const hostRecord = host as Record<string, unknown>;
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 if (isSharedConnection) {
const rdpEffectiveAuthType = // Recipients never read the owner's raw secrets; wipe them and use
(host.rdpAuthType as string) || // the per-recipient snapshot for the requested protocol instead.
(host.rdpCredentialId ? "credential" : "direct"); host.password = null;
const vncEffectiveAuthType = host.rdpUser = null;
(host.vncAuthType as string) || host.rdpPassword = null;
(host.vncCredentialId ? "credential" : "direct"); host.vncUser = null;
const telnetEffectiveAuthType = host.vncPassword = null;
(host.telnetAuthType as string) || host.telnetUser = null;
(hostRecord.telnetCredentialId ? "credential" : "direct"); host.telnetPassword = null;
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
try { try {
const cred = const { SharedHostSecretsManager } =
await hostRepository.findCredentialByIdForOwnerDecryptedAs( 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.rdpCredentialId as number,
host.userId as string, host.userId as string,
userId,
); );
if (cred) { if (cred) {
if (cred.username) host.rdpUser = cred.username; if (cred.username) host.rdpUser = cred.username;
if (cred.password) host.rdpPassword = cred.password; if (cred.password) host.rdpPassword = cred.password;
// domain is never sourced from credential // 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) { if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
try { try {
const cred = const cred = await hostRepository.findCredentialByIdForUser(
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
host.vncCredentialId as number, host.vncCredentialId as number,
host.userId as string, host.userId as string,
userId,
); );
if (cred) { if (cred) {
if (cred.password) host.vncPassword = cred.password; if (cred.password) host.vncPassword = cred.password;
if (cred.username) host.vncUser = cred.username; 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 ( if (
telnetEffectiveAuthType === "credential" && telnetEffectiveAuthType === "credential" &&
hostRecord.telnetCredentialId hostRecord.telnetCredentialId
) { ) {
try { try {
const cred = const cred = await hostRepository.findCredentialByIdForUser(
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
hostRecord.telnetCredentialId as number, hostRecord.telnetCredentialId as number,
host.userId as string, host.userId as string,
userId,
); );
if (cred) { if (cred) {
if (cred.username) host.telnetUser = cred.username; if (cred.username) host.telnetUser = cred.username;
if (cred.password) host.telnetPassword = cred.password; 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",
});
} }
} }
+109 -84
View File
@@ -9,6 +9,7 @@ import {
expandOidcUsername, expandOidcUsername,
} from "./credential-username.js"; } from "./credential-username.js";
import type { SSHHost } from "../../types/index.js"; import type { SSHHost } from "../../types/index.js";
import type { HostAction } from "../utils/permission-manager.js";
const sshLogger = logger; const sshLogger = logger;
@@ -24,16 +25,28 @@ export async function resolveHostById(
const access = await PermissionManager.getInstance().canAccessHost( const access = await PermissionManager.getInstance().canAccessHost(
userId, userId,
hostId, hostId,
"read", "connect",
); );
if (!access.hasAccess) return null; if (!access.hasAccess) return null;
const repository = createCurrentHostResolutionRepository(); 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; if (!resolvedHost) return null;
const host = resolvedHost as Record<string, unknown>; const host = resolvedHost as Record<string, unknown>;
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 // Parse JSON fields
if (typeof host.jumpHosts === "string" && host.jumpHosts) { if (typeof host.jumpHosts === "string" && host.jumpHosts) {
try { try {
@@ -78,88 +91,16 @@ export async function resolveHostById(
} }
} }
// Resolve credential if using credential-based auth if (userId !== ownerId) {
if (host.credentialId) { const resolved = await resolveSharedSshSecrets(
const ownerId = (host.userId || userId) as string; host,
hostId,
userId,
repository,
);
if (!resolved) return null;
} else if (host.credentialId) {
try { 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<string, unknown> | 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( const cred = (await repository.findCredentialByIdForUser(
host.credentialId as number, host.credentialId as number,
ownerId, ownerId,
@@ -218,6 +159,90 @@ export async function resolveHostById(
return host as unknown as SSHHost; 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<string, unknown>,
hostId: number,
userId: string,
repository: ReturnType<typeof createCurrentHostResolutionRepository>,
): Promise<boolean> {
try {
const overrideCredId = await repository.findOverrideCredentialId(
hostId,
userId,
);
if (overrideCredId) {
const cred = (await repository.findCredentialByIdForUser(
overrideCredId,
userId,
)) as Record<string, unknown> | 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). * Check if a user has access to a host (owner or shared access).
*/ */
@@ -225,7 +250,7 @@ export async function checkHostAccess(
hostId: number, hostId: number,
userId: string, userId: string,
hostUserId: string, hostUserId: string,
requiredPermission: "read" | "execute" = "execute", requiredPermission: HostAction = "connect",
): Promise<boolean> { ): Promise<boolean> {
if (userId === hostUserId) return true; if (userId === hostUserId) return true;
+17 -16
View File
@@ -36,35 +36,36 @@ async function resolveJumpHost(
): Promise<JumpHostConfig | null> { ): Promise<JumpHostConfig | null> {
try { try {
const repository = createCurrentHostResolutionRepository(); 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) { if (!resolvedHost) {
return null; return null;
} }
const host = resolvedHost as Record<string, unknown>; const host = resolvedHost as Record<string, unknown>;
const ownerId = (host.userId || userId) as string;
if (host.credentialId) { if (host.credentialId) {
if (userId !== ownerId) { if (userId !== ownerId) {
try { try {
const { SharedCredentialManager } = const { SharedHostSecretsManager } =
await import("../utils/shared-credential-manager.js"); await import("../utils/shared-host-secrets-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const secret =
const sharedCred = await sharedCredManager.getSharedCredentialForUser( await SharedHostSecretsManager.getInstance().getSecretForUser(
hostId, hostId,
userId, userId,
); "ssh",
if (sharedCred) { );
if (secret) {
return { return {
...host, ...host,
password: sharedCred.password, password: secret.password,
key: sharedCred.key, key: secret.key,
keyPassword: sharedCred.keyPassword, keyPassword: secret.keyPassword,
keyType: sharedCred.keyType, keyType: secret.keyType,
authType: sharedCred.key authType: secret.key
? "key" ? "key"
: sharedCred.password : secret.password
? "password" ? "password"
: "none", : "none",
} as JumpHostConfig; } as JumpHostConfig;
+3 -2
View File
@@ -2,13 +2,14 @@ import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js"; import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js";
import { statsLogger } from "../../utils/logger.js"; import { statsLogger } from "../../utils/logger.js";
import type { HostAction } from "../../utils/permission-manager.js";
type HistoryRoutesDeps = { type HistoryRoutesDeps = {
validateHostId: RequestHandler; validateHostId: RequestHandler;
canAccessHost: ( canAccessHost: (
userId: string, userId: string,
hostId: number, hostId: number,
level: "read" | "write" | "execute" | "delete" | "share", level: HostAction,
) => Promise<boolean>; ) => Promise<boolean>;
}; };
@@ -65,7 +66,7 @@ export function registerHostMetricsHistoryRoutes(
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
try { try {
const hasAccess = await canAccessHost(userId, hostId, "read"); const hasAccess = await canAccessHost(userId, hostId, "connect");
if (!hasAccess) { if (!hasAccess) {
return res.status(403).json({ error: "Access denied" }); return res.status(403).json({ error: "Access denied" });
} }
+11 -10
View File
@@ -898,7 +898,7 @@ async function fetchHostById(
const accessInfo = await permissionManager.canAccessHost( const accessInfo = await permissionManager.canAccessHost(
userId, userId,
id, id,
"read", "connect",
); );
if (!accessInfo.hasAccess) { if (!accessInfo.hasAccess) {
@@ -991,13 +991,14 @@ async function resolveHostCredentials(
const isSharedHost = userId !== ownerId; const isSharedHost = userId !== ownerId;
if (isSharedHost) { if (isSharedHost) {
const { SharedCredentialManager } = const { SharedHostSecretsManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-host-secrets-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred =
const sharedCred = await sharedCredManager.getSharedCredentialForUser( await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id as number, host.id as number,
userId, userId,
); "ssh",
);
if (sharedCred) { if (sharedCred) {
baseHost.credentialId = host.credentialId; baseHost.credentialId = host.credentialId;
@@ -1730,7 +1731,7 @@ app.get("/status", async (req, res) => {
const result: Record<number, StatusEntry> = {}; const result: Record<number, StatusEntry> = {};
for (const [id, entry] of pollingManager.getAllStatuses().entries()) { 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) { if (access.hasAccess) {
result[id] = entry; 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) { if (!access.hasAccess) {
return res.status(404).json({ error: "Status not available" }); return res.status(404).json({ error: "Status not available" });
} }
+2 -2
View File
@@ -85,7 +85,7 @@ export function registerCronRoutes(
app.get( app.get(
"/host-metrics/managers/cron/:id", "/host-metrics/managers/cron/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "cron_list", async (client) => { managerHandler(runOnHost, "connect", "cron_list", async (client) => {
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000); const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
return { entries: parseCrontab(stdout) }; return { entries: parseCrontab(stdout) };
}), }),
@@ -96,7 +96,7 @@ export function registerCronRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"cron_replace", "cron_replace",
async (client, _host, req) => { async (client, _host, req) => {
const { entries } = req.body as { entries?: CronEntry[] }; const { entries } = req.body as { entries?: CronEntry[] };
@@ -51,7 +51,7 @@ export function registerFirewallRoutes(
app.get( app.get(
"/host-metrics/managers/firewall/:id", "/host-metrics/managers/firewall/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "firewall_read", async (client) => { managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
return await collectFirewallMetrics(client); return await collectFirewallMetrics(client);
}), }),
); );
@@ -61,7 +61,7 @@ export function registerFirewallRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"firewall_rule", "firewall_rule",
async (client, host, req) => { async (client, host, req) => {
const { op, protocol, port, target } = req.body as { const { op, protocol, port, target } = req.body as {
@@ -106,7 +106,7 @@ export function registerFirewallRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"firewall_persist", "firewall_persist",
async (client, host) => { async (client, host) => {
const platform = await detectPlatform(client); const platform = await detectPlatform(client);
+3 -3
View File
@@ -147,7 +147,7 @@ export function registerHealthRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"read", "connect",
"health_get", "health_get",
async (client, host, req) => { async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
@@ -191,7 +191,7 @@ export function registerHealthRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"read", "connect",
"health_config", "health_config",
async (_client, host, req) => { async (_client, host, req) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
@@ -226,7 +226,7 @@ export function registerHealthRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"read", "connect",
"health_run", "health_run",
async (client, host, req) => { async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
+1 -1
View File
@@ -45,7 +45,7 @@ export function registerManagerRoutes(
app.get( app.get(
"/host-metrics/platform/:id", "/host-metrics/platform/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "platform_detect", (client) => managerHandler(runOnHost, "connect", "platform_detect", (client) =>
detectPlatform(client), detectPlatform(client),
), ),
); );
+2 -2
View File
@@ -46,7 +46,7 @@ export function registerLogRoutes(
app.get( app.get(
"/host-metrics/managers/logs/:id/files", "/host-metrics/managers/logs/:id/files",
validateHostId, 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 { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
const found = stdout const found = stdout
.split("\n") .split("\n")
@@ -62,7 +62,7 @@ export function registerLogRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"read", "connect",
"logs_tail", "logs_tail",
async (client, host, req) => { async (client, host, req) => {
const path = req.query.path as string | undefined; const path = req.query.path as string | undefined;
@@ -96,7 +96,7 @@ export function registerPackageRoutes(
app.get( app.get(
"/host-metrics/managers/packages/:id", "/host-metrics/managers/packages/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "packages_list", async (client) => { managerHandler(runOnHost, "connect", "packages_list", async (client) => {
const platform = await detectPlatform(client); const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg); const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] }; if (!cmd) return { pkg: platform.pkg, upgradable: [] };
@@ -113,7 +113,7 @@ export function registerPackageRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"packages_action", "packages_action",
async (client, host, req) => { async (client, host, req) => {
const { action, pkg: name } = req.body as { const { action, pkg: name } = req.body as {
@@ -70,7 +70,7 @@ export function registerProcessRoutes(
app.get( app.get(
"/host-metrics/managers/processes/:id", "/host-metrics/managers/processes/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "processes_list", async (client) => { managerHandler(runOnHost, "connect", "processes_list", async (client) => {
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000); const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
return { processes: parseProcessList(stdout) }; return { processes: parseProcessList(stdout) };
}), }),
@@ -106,7 +106,7 @@ export function registerProcessRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"processes_signal", "processes_signal",
async (client, host, req) => { async (client, host, req) => {
const { pid, signal } = req.body as { const { pid, signal } = req.body as {
@@ -4,6 +4,7 @@ import type { AuthenticatedRequest } from "../../../../types/index.js";
import { statsLogger } from "../../../utils/logger.js"; import { statsLogger } from "../../../utils/logger.js";
import { ElevationError } from "./exec-elevated.js"; import { ElevationError } from "./exec-elevated.js";
import type { ManagerHost, RunOnHost } from "./types.js"; import type { ManagerHost, RunOnHost } from "./types.js";
import type { HostAction } from "../../../utils/permission-manager.js";
export class AccessDeniedError extends Error { export class AccessDeniedError extends Error {
constructor(message = "No access to this host") { constructor(message = "No access to this host") {
@@ -25,7 +26,7 @@ export class ManagerInputError extends Error {
*/ */
export function managerHandler( export function managerHandler(
runOnHost: RunOnHost, runOnHost: RunOnHost,
level: "read" | "execute", level: HostAction,
operation: string, operation: string,
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>, fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
) { ) {
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
app.get( app.get(
"/host-metrics/managers/services/:id", "/host-metrics/managers/services/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "services_list", async (client) => { managerHandler(runOnHost, "connect", "services_list", async (client) => {
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000); const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
return { services: parseServiceList(stdout) }; return { services: parseServiceList(stdout) };
}), }),
@@ -106,7 +106,7 @@ export function registerServiceRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"services_action", "services_action",
async (client, host, req) => { async (client, host, req) => {
const { unit, action } = req.body as { const { unit, action } = req.body as {
@@ -112,7 +112,7 @@ export function registerSimpleReadRoutes(
app.get( app.get(
"/host-metrics/managers/top-memory/:id", "/host-metrics/managers/top-memory/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "top_memory", async (client) => { managerHandler(runOnHost, "connect", "top_memory", async (client) => {
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000); const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
return { processes: parseTopMemory(stdout) }; return { processes: parseTopMemory(stdout) };
}), }),
@@ -121,7 +121,7 @@ export function registerSimpleReadRoutes(
app.get( app.get(
"/host-metrics/managers/timers/:id", "/host-metrics/managers/timers/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "systemd_timers", async (client) => { managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
const { stdout } = await execCommand(client, TIMERS_CMD, 15000); const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
return { timers: parseTimers(stdout) }; return { timers: parseTimers(stdout) };
}), }),
@@ -130,7 +130,7 @@ export function registerSimpleReadRoutes(
app.get( app.get(
"/host-metrics/managers/disk-breakdown/:id", "/host-metrics/managers/disk-breakdown/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "disk_breakdown", async (client) => { managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
const { stdout } = await execCommand(client, DF_CMD, 15000); const { stdout } = await execCommand(client, DF_CMD, 15000);
return { mounts: parseDfMounts(stdout) }; return { mounts: parseDfMounts(stdout) };
}), }),
+4 -4
View File
@@ -153,7 +153,7 @@ export function registerSslRoutes(
app.get( app.get(
"/host-metrics/managers/ssl/:id", "/host-metrics/managers/ssl/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "ssl_list", async (client, host) => { managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
const platform = await detectPlatform(client); const platform = await detectPlatform(client);
const certs: CertInfo[] = []; const certs: CertInfo[] = [];
if (platform.hasCertbot) { if (platform.hasCertbot) {
@@ -187,7 +187,7 @@ export function registerSslRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"ssl_issue", "ssl_issue",
async (client, host, req) => { async (client, host, req) => {
const body = req.body as Partial<IssueRequest>; const body = req.body as Partial<IssueRequest>;
@@ -236,7 +236,7 @@ export function registerSslRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"ssl_renew", "ssl_renew",
async (client, host, req) => { async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as { const { client: acmeClient, dryRun } = req.body as {
@@ -289,7 +289,7 @@ export function registerSslRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"ssl_revoke", "ssl_revoke",
async (client, host, req) => { async (client, host, req) => {
const { client: acmeClient, name } = req.body as { const { client: acmeClient, name } = req.body as {
@@ -116,7 +116,7 @@ export function registerTailscaleRoutes(
app.get( app.get(
"/host-metrics/managers/tailscale/:id", "/host-metrics/managers/tailscale/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "tailscale_read", async (client) => { managerHandler(runOnHost, "connect", "tailscale_read", async (client) => {
const { stdout } = await execCommand(client, PROBE_CMD, 15000); const { stdout } = await execCommand(client, PROBE_CMD, 15000);
return parseTailscaleData(stdout); return parseTailscaleData(stdout);
}), }),
@@ -154,7 +154,7 @@ export function registerTailscaleRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"tailscale_action", "tailscale_action",
async (client, host, req) => { async (client, host, req) => {
const { action } = req.body as { action: unknown }; const { action } = req.body as { action: unknown };
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Client } from "ssh2"; import type { Client } from "ssh2";
import type { RequestHandler } from "express"; import type { RequestHandler } from "express";
import type { HostAction } from "../../../utils/permission-manager.js";
/** Minimal host shape managers need (includes the decrypted sudo password). */ /** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost { export interface ManagerHost {
@@ -17,7 +18,7 @@ export interface ManagerHost {
export type RunOnHost = <T>( export type RunOnHost = <T>(
hostId: number, hostId: number,
userId: string, userId: string,
level: "read" | "execute", level: HostAction,
fn: (client: Client, host: ManagerHost) => Promise<T>, fn: (client: Client, host: ManagerHost) => Promise<T>,
) => Promise<T>; ) => Promise<T>;
+2 -2
View File
@@ -79,7 +79,7 @@ export function registerUserRoutes(
app.get( app.get(
"/host-metrics/managers/users/:id", "/host-metrics/managers/users/:id",
validateHostId, validateHostId,
managerHandler(runOnHost, "read", "users_list", async (client) => { managerHandler(runOnHost, "connect", "users_list", async (client) => {
const [passwd, groups, sudoers] = await Promise.all([ const [passwd, groups, sudoers] = await Promise.all([
execCommand(client, READ_USERS_CMD, 15000), execCommand(client, READ_USERS_CMD, 15000),
execCommand(client, READ_GROUPS_CMD, 15000), execCommand(client, READ_GROUPS_CMD, 15000),
@@ -98,7 +98,7 @@ export function registerUserRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"users_action", "users_action",
async (client, host, req) => { async (client, host, req) => {
const { action, username, group } = req.body as { const { action, username, group } = req.body as {
@@ -143,7 +143,7 @@ export function registerWireGuardRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"read", "connect",
"wireguard_read", "wireguard_read",
async (client, host) => { async (client, host) => {
const result = await execElevated( const result = await execElevated(
@@ -194,7 +194,7 @@ export function registerWireGuardRoutes(
validateHostId, validateHostId,
managerHandler( managerHandler(
runOnHost, runOnHost,
"execute", "connect",
"wireguard_action", "wireguard_action",
async (client, host, req) => { async (client, host, req) => {
const { interface: iface, action } = req.body as { const { interface: iface, action } = req.body as {
@@ -7,6 +7,7 @@ import {
defaultLayoutFromWidgets, defaultLayoutFromWidgets,
type HostMetricsLayout, type HostMetricsLayout,
} from "../../../types/host-metrics.js"; } from "../../../types/host-metrics.js";
import type { HostAction } from "../../utils/permission-manager.js";
interface PrefStatsConfig { interface PrefStatsConfig {
enabledWidgets?: string[]; enabledWidgets?: string[];
@@ -25,7 +26,7 @@ type HostMetricsPreferencesRoutesDeps = {
canAccessHost: ( canAccessHost: (
userId: string, userId: string,
hostId: number, hostId: number,
level: "read" | "execute", level: HostAction,
) => Promise<boolean>; ) => Promise<boolean>;
}; };
@@ -88,7 +89,7 @@ export function registerHostMetricsPreferencesRoutes(
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10); const hostId = parseInt(String(req.params.id), 10);
try { try {
if (!(await canAccessHost(userId, hostId, "read"))) { if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" }); return res.status(403).json({ error: "No access to this host" });
} }
const host = await fetchHostById(hostId, userId); const host = await fetchHostById(hostId, userId);
@@ -166,7 +167,7 @@ export function registerHostMetricsPreferencesRoutes(
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10); const hostId = parseInt(String(req.params.id), 10);
try { try {
if (!(await canAccessHost(userId, hostId, "read"))) { if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" }); return res.status(403).json({ error: "No access to this host" });
} }
const host = await fetchHostById(hostId, userId); const host = await fetchHostById(hostId, userId);
+10 -9
View File
@@ -14,6 +14,7 @@ import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
import { SSHHostKeyVerifier } from "../host-key-verifier.js"; import { SSHHostKeyVerifier } from "../host-key-verifier.js";
import { resolveHostById, checkHostAccess } from "../host-resolver.js"; import { resolveHostById, checkHostAccess } from "../host-resolver.js";
import type { HostAction } from "../../utils/permission-manager.js";
import { createJumpHostChain } from "../jump-host-chain.js"; import { createJumpHostChain } from "../jump-host-chain.js";
import { applyAgentAuth } from "../terminal-auth-helpers.js"; import { applyAgentAuth } from "../terminal-auth-helpers.js";
import { import {
@@ -343,7 +344,7 @@ app.use(authManager.createAuthMiddleware());
async function requireHost( async function requireHost(
req: express.Request, req: express.Request,
res: express.Response, res: express.Response,
permission: "read" | "execute" = "read", permission: HostAction = "connect",
): Promise<SSHHost | null> { ): Promise<SSHHost | null> {
const userId = (req as unknown as AuthenticatedRequest).userId; const userId = (req as unknown as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.hostId), 10); 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 // Focus a pane: select its window and pane on the server so every attached
// client (including the monitor's embedded terminal) switches to it. // client (including the monitor's embedded terminal) switches to it.
app.post("/tmux_monitor/:hostId/focus", async (req, res) => { 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; if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || ""); 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. // Create a detached session. Starts the tmux server if none is running.
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => { 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; if (!host) return;
const name = String((req.body as { name?: string })?.name || "").trim(); 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); // target's meaning (":" and "." are window/pane separators in tmux targets);
// "=" prefixes the target for an exact-name match. // "=" prefixes the target for an exact-name match.
app.post("/tmux_monitor/:hostId/windows", async (req, res) => { 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; if (!host) return;
const sessionName = String( 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 // Rename a session. Saved tags follow the session to its new name (for every
// user — the session itself is shared on the host). // user — the session itself is shared on the host).
app.post("/tmux_monitor/:hostId/rename", async (req, res) => { 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; if (!host) return;
const body = req.body as { sessionName?: string; newName?: string }; 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. // Kill a session and drop its saved tags.
app.post("/tmux_monitor/:hostId/kill", async (req, res) => { 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; if (!host) return;
const sessionName = String( 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 // Kill a window (and every pane in it). Killing the last window of a session
// ends the session — tmux semantics. // ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => { 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; if (!host) return;
const body = req.body as { sessionName?: string; windowIndex?: number }; 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, // 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. // and the last window of a session ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => { 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; if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || ""); 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 // "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
// source pane's working directory. // source pane's working directory.
app.post("/tmux_monitor/:hostId/split", async (req, res) => { 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; if (!host) return;
const body = req.body as { paneId?: string; direction?: string }; const body = req.body as { paneId?: string; direction?: string };
+1 -1
View File
@@ -36,7 +36,7 @@ async function resolveC2STunnelSource(
const accessInfo = await permissionManager.canAccessHost( const accessInfo = await permissionManager.canAccessHost(
userId, userId,
tunnelConfig.sourceHostId, tunnelConfig.sourceHostId,
"read", "connect",
); );
if (!accessInfo.hasAccess) { if (!accessInfo.hasAccess) {
throw new Error("Access denied to this host"); throw new Error("Access denied to this host");
+4 -4
View File
@@ -199,7 +199,7 @@ export function registerTunnelRoutes(app: express.Express): void {
const accessInfo = await permissionManager.canAccessHost( const accessInfo = await permissionManager.canAccessHost(
userId, userId,
tunnelConfig.sourceHostId, tunnelConfig.sourceHostId,
"read", "connect",
); );
if (!accessInfo.hasAccess) { if (!accessInfo.hasAccess) {
@@ -285,7 +285,7 @@ export function registerTunnelRoutes(app: express.Express): void {
const endpointAccess = await permissionManager.canAccessHost( const endpointAccess = await permissionManager.canAccessHost(
userId, userId,
endpointHost.id, endpointHost.id,
"read", "connect",
); );
if (!endpointAccess.hasAccess) { if (!endpointAccess.hasAccess) {
tunnelLogger.warn( tunnelLogger.warn(
@@ -442,7 +442,7 @@ export function registerTunnelRoutes(app: express.Express): void {
const accessInfo = await permissionManager.canAccessHost( const accessInfo = await permissionManager.canAccessHost(
userId, userId,
config.sourceHostId, config.sourceHostId,
"read", "connect",
); );
if (!accessInfo.hasAccess) { if (!accessInfo.hasAccess) {
return res.status(403).json({ error: "Access denied" }); return res.status(403).json({ error: "Access denied" });
@@ -547,7 +547,7 @@ export function registerTunnelRoutes(app: express.Express): void {
const accessInfo = await permissionManager.canAccessHost( const accessInfo = await permissionManager.canAccessHost(
userId, userId,
config.sourceHostId, config.sourceHostId,
"read", "connect",
); );
if (!accessInfo.hasAccess) { if (!accessInfo.hasAccess) {
return res.status(403).json({ error: "Access denied" }); return res.status(403).json({ error: "Access denied" });
+4
View File
@@ -101,6 +101,10 @@ import {
await authManager.initialize(); await authManager.initialize();
DataCrypto.initialize(); DataCrypto.initialize();
const { runSharedHostSecretsMigration } =
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
await runSharedHostSecretsMigration();
import("./utils/opkssh-binary-manager.js").then( import("./utils/opkssh-binary-manager.js").then(
({ OPKSSHBinaryManager }) => { ({ OPKSSHBinaryManager }) => {
OPKSSHBinaryManager.ensureBinary().catch((error) => { OPKSSHBinaryManager.ensureBinary().catch((error) => {
@@ -322,6 +322,7 @@ describe("HostResolutionRepository", () => {
rdpCredentialId: null, rdpCredentialId: null,
vncCredentialId: null, vncCredentialId: null,
telnetCredentialId: null, telnetCredentialId: null,
vaultProfileId: null,
authType: "password", authType: "password",
}); });
await expect(repository.findHostUpdateState(999)).resolves.toBeNull(); await expect(repository.findHostUpdateState(999)).resolves.toBeNull();
@@ -52,19 +52,23 @@ describe("RbacAccessRepository", () => {
override_credential_id INTEGER override_credential_id INTEGER
); );
CREATE TABLE shared_credentials ( CREATE TABLE shared_host_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
host_access_id INTEGER NOT NULL, host_access_id INTEGER NOT NULL,
original_credential_id INTEGER NOT NULL,
target_user_id TEXT NOT NULL, target_user_id TEXT NOT NULL,
encrypted_username TEXT NOT NULL, protocol TEXT NOT NULL DEFAULT 'ssh',
encrypted_auth_type TEXT NOT NULL, source_type TEXT NOT NULL DEFAULT 'credential',
original_credential_id INTEGER,
encrypted_username TEXT,
encrypted_auth_type TEXT,
encrypted_password TEXT, encrypted_password TEXT,
encrypted_key TEXT, encrypted_key TEXT,
encrypted_key_password TEXT, encrypted_key_password TEXT,
encrypted_key_type TEXT, encrypted_key_type TEXT,
encrypted_domain TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 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 ( 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'), (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'); (5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
INSERT INTO shared_credentials ( INSERT INTO shared_host_secrets (
id, host_access_id, original_credential_id, target_user_id, encrypted_username, encrypted_auth_type 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) INSERT INTO snippets (id, user_id, name, content)
VALUES (99, 'owner-1', 'deploy', 'echo deploy'); 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(); const repo = await createRepository();
await expect( await expect(
repo.findSharedCredentialForHostAndUser(42, "user-1"), repo.findSharedSecretForHostUserProtocol(42, "user-1", "ssh"),
).resolves.toMatchObject({ ).resolves.toMatchObject({
id: 8, id: 8,
hostAccessId: 2, hostAccessId: 2,
protocol: "ssh",
sourceType: "credential",
originalCredentialId: 123, originalCredentialId: 123,
targetUserId: "user-1", targetUserId: "user-1",
encryptedUsername: "enc-user", encryptedUsername: "enc-user",
@@ -276,12 +284,62 @@ describe("RbacAccessRepository", () => {
}); });
await expect( 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(); ).resolves.toBeNull();
await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1"); await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1");
await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull(); 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 () => { it("lists shared snippets and preserves route-level direct-over-role behavior", async () => {
const repo = await createRepository(); const repo = await createRepository();
@@ -92,6 +92,8 @@ describe("SessionRecordingRepository", () => {
expect(rows[0]).toMatchObject({ expect(rows[0]).toMatchObject({
hostIp: "10.0.0.2", hostIp: "10.0.0.2",
recordingPath: "/tmp/two.log", recordingPath: "/tmp/two.log",
protocol: "ssh",
format: "text",
}); });
}); });
@@ -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<void>,
): Promise<{
repository: SharedCredentialRepository;
sqlite: NonNullable<
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["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);
});
});
@@ -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<void>,
): Promise<{
repository: SharedHostSecretsRepository;
sqlite: NonNullable<
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["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]);
});
});
@@ -4,6 +4,7 @@ import {
isValidPort, isValidPort,
normalizeImportedHost, normalizeImportedHost,
renameFolderPath, renameFolderPath,
sanitizeHostForRecipient,
stripSensitiveFields, stripSensitiveFields,
transformHostResponse, transformHostResponse,
} from "../../../database/routes/host-normalizers.js"; } from "../../../database/routes/host-normalizers.js";
@@ -219,3 +220,58 @@ describe("transformHostResponse", () => {
expect(result.proxmoxConfig).toBeUndefined(); 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();
});
});
@@ -0,0 +1,179 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
host: null as Record<string, unknown> | null,
hasAccess: true,
overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(),
sharedSecret: null as Record<string, unknown> | 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<string, unknown> = {}) {
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();
});
});
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
settings: new Map<string, string>(),
grants: [] as Array<Record<string, unknown>>,
roleMembers: new Map<number, string[]>(),
executedSql: [] as string[],
snapshots: [] as Array<[number, number, string, string]>,
usersWithKeys: new Set<string>(),
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([]);
});
});
@@ -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);
});
});
@@ -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"); const { PermissionManager } = await import("../../utils/permission-manager.js");
type PermissionManagerInstance = ReturnType< type PermissionManagerInstance = ReturnType<
@@ -71,3 +104,65 @@ describe("PermissionManager.hasPermission wildcard matching", () => {
expect(await manager.hasPermission("u1", "hosts.write")).toBe(false); 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]);
});
});
@@ -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<Record<string, unknown>>,
ownerCredential: null as Record<string, unknown> | 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<string, unknown>) => {
const created = { id: state.sharedRows.length + 1, ...row };
state.sharedRows.push(created);
return created;
},
listByOriginalCredentialId: async () => state.sharedRows,
updateById: async (id: number, update: Record<string, unknown>) => {
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");
});
});
@@ -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<string, unknown> & {
id: number;
hostAccessId: number;
targetUserId: string;
protocol: string;
};
const state = vi.hoisted(() => ({
hosts: new Map<number, Record<string, unknown>>(),
credentials: new Map<number, Record<string, unknown>>(),
secretRows: [] as Array<Record<string, unknown>>,
// hostAccessId -> hostId, used by findForHostUserProtocol
accessToHost: new Map<number, number>(),
grants: [] as Array<Record<string, unknown>>,
roleMembers: new Map<number, string[]>(),
}));
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<string, unknown>) => {
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<string, unknown> = {}) {
// 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",
});
});
});
@@ -1,8 +1,6 @@
import { databaseLogger } from "../logger.js"; import { databaseLogger } from "../logger.js";
import { DataCrypto } from "../data-crypto.js";
import { DatabaseSaveTrigger } from "../database-save-trigger.js"; import { DatabaseSaveTrigger } from "../database-save-trigger.js";
import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js"; import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js";
import { SharedCredentialManager } from "../shared-credential-manager.js";
interface SqliteLike { interface SqliteLike {
prepare(sql: string): { prepare(sql: string): {
@@ -34,83 +32,17 @@ function dropColumnIfExists(
return true; return true;
} }
interface PendingShareRow { // One-time cleanup of the pre-2.5 sharing machinery: the
id: number; // CREDENTIAL_SHARING_KEY shadow columns are dropped. Pending share copies are
host_access_id: number; // no longer re-created here; the shared_host_secrets migration rebuilds every
original_credential_id: number; // active share from the live host_access grants instead.
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.
export async function runLegacySharedCredentialCleanup(): Promise<{ export async function runLegacySharedCredentialCleanup(): Promise<{
resolved: number;
dropped: number;
columnsDropped: number; columnsDropped: number;
}> { }> {
const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike; const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike;
const result = { resolved: 0, dropped: 0, columnsDropped: 0 }; const result = { 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,
},
);
}
}
for (const [table, column] of [ for (const [table, column] of [
["shared_credentials", "needs_re_encryption"],
["ssh_credentials", "system_password"], ["ssh_credentials", "system_password"],
["ssh_credentials", "system_key"], ["ssh_credentials", "system_key"],
["ssh_credentials", "system_key_password"], ["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"); await DatabaseSaveTrigger.forceSave("legacy_share_cleanup");
databaseLogger.info("Legacy shared-credential cleanup finished", { databaseLogger.info("Legacy shared-credential cleanup finished", {
operation: "legacy_share_cleanup", operation: "legacy_share_cleanup",
@@ -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;
}
}
+60
View File
@@ -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 ("*", "<group>.*").
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<string>(
PERMISSION_CATALOG.flatMap((entry) => [
...entry.permissions,
`${entry.group}.*`,
]).concat("*"),
);
export function isValidPermission(permission: string): boolean {
return VALID_PERMISSIONS.has(permission);
}
+53 -15
View File
@@ -12,11 +12,32 @@ interface AuthenticatedRequest extends Request {
dataKey?: Buffer; 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<SharePermissionLevel, number> = {
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 { interface HostAccessInfo {
hasAccess: boolean; hasAccess: boolean;
isOwner: boolean; isOwner: boolean;
isShared: boolean; isShared: boolean;
permissionLevel?: "view"; permissionLevel?: SharePermissionLevel;
expiresAt?: string | null; expiresAt?: string | null;
} }
@@ -146,7 +167,7 @@ class PermissionManager {
async canAccessHost( async canAccessHost(
userId: string, userId: string,
hostId: number, hostId: number,
action: "read" | "write" | "execute" | "delete" | "share" = "read", action: HostAction = "connect",
): Promise<HostAccessInfo> { ): Promise<HostAccessInfo> {
try { try {
const hostResolutionRepository = createCurrentHostResolutionRepository(); 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 { return {
hasAccess: false, hasAccess: false,
isOwner: false, isOwner: false,
isShared: true, isShared: true,
permissionLevel: access.permissionLevel as "view", permissionLevel: grantedLevel,
expiresAt: access.expiresAt, expiresAt: access.expiresAt,
}; };
} }
try { if (action === "connect") {
await createCurrentRbacAccessRepository().touchHostAccess(access.id); try {
} catch (error) { await createCurrentRbacAccessRepository().touchHostAccess(
databaseLogger.warn("Failed to update host access timestamp", { access.id,
operation: "update_host_access_timestamp", );
error, } catch (error) {
}); databaseLogger.warn("Failed to update host access timestamp", {
operation: "update_host_access_timestamp",
error,
});
}
} }
return { return {
hasAccess: true, hasAccess: true,
isOwner: false, isOwner: false,
isShared: true, isShared: true,
permissionLevel: access.permissionLevel as "view", permissionLevel: grantedLevel,
expiresAt: access.expiresAt, expiresAt: access.expiresAt,
}; };
} }
@@ -283,7 +315,7 @@ class PermissionManager {
requireHostAccess( requireHostAccess(
hostIdParam: string = "id", hostIdParam: string = "id",
action: "read" | "write" | "execute" | "delete" | "share" = "read", action: HostAction = "connect",
) { ) {
return async ( return async (
req: AuthenticatedRequest, req: AuthenticatedRequest,
@@ -358,5 +390,11 @@ class PermissionManager {
} }
} }
export { PermissionManager }; export { PermissionManager, SHARE_PERMISSION_LEVELS, LEVEL_RANK };
export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult }; export type {
AuthenticatedRequest,
HostAccessInfo,
PermissionCheckResult,
SharePermissionLevel,
HostAction,
};
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<CredentialData | null> {
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<void> {
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<void> {
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<CredentialData> {
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 };
@@ -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<ShareProtocol, boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<SharedSecretData | null> {
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<void> {
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<void> {
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<void> {
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<ProtocolSnapshot[]> {
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 };
+2 -1
View File
@@ -217,8 +217,9 @@ export interface Host {
hasKeyPassword?: boolean; hasKeyPassword?: boolean;
isShared?: boolean; isShared?: boolean;
permissionLevel?: "view"; permissionLevel?: "connect" | "view" | "edit" | "manage";
sharedExpiresAt?: string; sharedExpiresAt?: string;
ownerUsername?: string;
} }
export interface JumpHostData { export interface JumpHostData {
+7
View File
@@ -168,8 +168,15 @@ export type Host = {
guacamoleConfig?: Record<string, unknown>; guacamoleConfig?: Record<string, unknown>;
forceKeyboardInteractive?: boolean; forceKeyboardInteractive?: boolean;
isShared?: boolean;
permissionLevel?: SharePermissionLevel;
sharedExpiresAt?: string;
ownerUsername?: string;
}; };
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
export type Credential = { export type Credential = {
id: string; id: string;
name: string; name: string;
+79 -6
View File
@@ -28,6 +28,7 @@ export async function updateRole(
roleData: { roleData: {
displayName?: string; displayName?: string;
description?: string | null; description?: string | null;
permissions?: string[];
}, },
): Promise<{ role: Role }> { ): Promise<{ role: Role }> {
try { 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( export async function shareHost(
hostId: number, hostId: number,
shareData: { shareData: {
targetType: "user" | "role"; targets: ShareTarget[];
targetUserId?: string; permissionLevel: SharePermissionLevel;
targetRoleId?: number;
permissionLevel: "view";
durationHours?: number; durationHours?: number;
}, },
): Promise<{ success: boolean }> { ): Promise<{
success: boolean;
expiresAt: string | null;
results: Array<{
type: "user" | "role";
id: string | number;
accessId: number;
created: boolean;
}>;
}> {
try { try {
const response = await rbacApi.post( const response = await rbacApi.post(
`/rbac/host/${hostId}/share`, `/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( export async function getHostAccess(
hostId: number, hostId: number,
): Promise<{ accessList: AccessRecord[] }> { ): Promise<{ accessList: AccessRecord[]; isOwner?: boolean }> {
try { try {
const response = await rbacApi.get(`/rbac/host/${hostId}/access`); const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
return response.data; 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( export async function revokeHostAccess(
hostId: number, hostId: number,
accessId: number, accessId: number,
+321 -286
View File
@@ -106,8 +106,265 @@
"filterTagsGroup": "Tags" "filterTagsGroup": "Tags"
}, },
"homepage": { "homepage": {
"failedToLoadAlerts": "Failed to load alerts", "title": "Homepage",
"failedToDismissAlert": "Failed to dismiss alert" "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": { "serverConfig": {
"title": "Server Configuration", "title": "Server Configuration",
@@ -317,7 +574,7 @@
"status": "Status", "status": "Status",
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
"failedToRenameFolder": "Failed to rename folder", "failedToRenameFolder": "Failed to rename folder",
"movedToFolder": "Moved to \"{{folder}}\"", "movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
"editHostTooltip": "Edit host", "editHostTooltip": "Edit host",
"statusChecks": "Status Checks", "statusChecks": "Status Checks",
"metricsCollection": "Metrics Collection", "metricsCollection": "Metrics Collection",
@@ -756,7 +1013,6 @@
"failedToSaveFolder": "Failed to save folder", "failedToSaveFolder": "Failed to save folder",
"folderDeleted": "Deleted folder \"{{name}}\"", "folderDeleted": "Deleted folder \"{{name}}\"",
"deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.", "deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.",
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
"failedToMoveHosts": "Failed to move hosts", "failedToMoveHosts": "Failed to move hosts",
"expandAll": "Expand all folders", "expandAll": "Expand all folders",
"collapseAll": "Collapse all folders", "collapseAll": "Collapse all folders",
@@ -806,7 +1062,6 @@
"failedToDeployKey": "Failed to deploy key", "failedToDeployKey": "Failed to deploy key",
"deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.", "deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.",
"movedToRoot": "Moved to root", "movedToRoot": "Moved to root",
"failedToMoveHosts": "Failed to move hosts",
"enableTerminalFeature": "Enable Terminal", "enableTerminalFeature": "Enable Terminal",
"disableTerminalFeature": "Disable Terminal", "disableTerminalFeature": "Disable Terminal",
"enableFilesFeature": "Enable Files", "enableFilesFeature": "Enable Files",
@@ -927,7 +1182,57 @@
"shareHost": "Share Host", "shareHost": "Share Host",
"shareHostTitle": "Share: {{name}}", "shareHostTitle": "Share: {{name}}",
"sharing": { "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": { "guac": {
"connection": "Connection", "connection": "Connection",
@@ -1062,28 +1367,10 @@
"backspaceKey": "Backspace Key", "backspaceKey": "Backspace Key",
"saveHostFirst": "Save the host first.", "saveHostFirst": "Save the host first.",
"sharingOptionsAfterSave": "Sharing options are available after the host has been saved.", "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", "permissionLevel": "Permission Level",
"expiresInHours": "Expires in (hours)",
"noExpiryPlaceholder": "Leave empty for no expiry",
"shareBtn": "Share",
"currentAccess": "Current Access",
"typeHeader": "Type", "typeHeader": "Type",
"targetHeader": "Target", "targetHeader": "Target",
"permissionHeader": "Permission", "permissionHeader": "Permission",
"grantedByHeader": "Granted By",
"expiresHeader": "Expires",
"noAccessEntries": "No access entries yet.",
"expiredLabel": "Expired",
"neverLabel": "Never",
"revokeBtn": "Revoke",
"cancelBtn": "Cancel", "cancelBtn": "Cancel",
"savingBtn": "Saving...", "savingBtn": "Saving...",
"updateHostBtn": "Update Host", "updateHostBtn": "Update Host",
@@ -2583,7 +2870,16 @@
"hostDefaultsCommandHistory": "Command History", "hostDefaultsCommandHistory": "Command History",
"hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default", "hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default",
"hostDefaultsSaved": "Host defaults saved", "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": { "newUi": {
"sidebar": { "sidebar": {
@@ -3092,266 +3388,5 @@
"channels": "Notification Channels", "channels": "Notification Channels",
"noChannelsHint": "Add channels in the Channels tab first", "noChannelsHint": "Add channels in the Channels tab first",
"ruleSaveFailed": "Failed to save rule" "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"
} }
} }
+10 -2
View File
@@ -15,7 +15,7 @@ export interface Role {
displayName: string; displayName: string;
description: string | null; description: string | null;
isSystem: boolean; isSystem: boolean;
permissions: string | null; permissions: string[] | string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -40,7 +40,7 @@ export interface AccessRecord {
roleDisplayName: string | null; roleDisplayName: string | null;
grantedBy: string; grantedBy: string;
grantedByUsername: string; grantedByUsername: string;
permissionLevel: "view"; permissionLevel: "connect" | "view" | "edit" | "manage";
expiresAt: string | null; expiresAt: string | null;
createdAt: string; createdAt: string;
} }
@@ -2087,13 +2087,21 @@ export {
assignRoleToUser, assignRoleToUser,
removeRoleFromUser, removeRoleFromUser,
shareHost, shareHost,
updateHostAccess,
getHostAccess, getHostAccess,
revokeHostAccess, revokeHostAccess,
getPermissionsCatalog,
getSharedHosts,
shareSnippet, shareSnippet,
getSnippetAccess, getSnippetAccess,
revokeSnippetAccess, revokeSnippetAccess,
getSharedSnippets, getSharedSnippets,
} from "@/api/rbac-api"; } from "@/api/rbac-api";
export type {
SharePermissionLevel,
ShareTarget,
PermissionCatalogEntry,
} from "@/api/rbac-api";
// ============================================================================ // ============================================================================
// DOCKER MANAGEMENT API // DOCKER MANAGEMENT API
+32 -19
View File
@@ -34,6 +34,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { getRecentActivity, type RecentActivityItem } from "@/main-axios"; import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types"; import type { Host, TabType } from "@/types/ui-types";
import { canEditHost } from "@/sidebar/host-permissions";
interface CommandPaletteProps { interface CommandPaletteProps {
isOpen: boolean; isOpen: boolean;
@@ -403,6 +404,11 @@ export function CommandPalette({
<span className="text-sm font-semibold truncate"> <span className="text-sm font-semibold truncate">
{host.name} {host.name}
</span> </span>
{host.isShared && (
<span className="text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
{t("hosts.sharing.sharedBadge")}
</span>
)}
{host.online && ( {host.online && (
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" /> <span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
)} )}
@@ -481,25 +487,32 @@ export function CommandPalette({
Telnet Telnet
</button> </button>
)} )}
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" /> {canEditHost(host) && (
<button <>
title="Edit Host" <div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
onClick={(e) => { <button
e.stopPropagation(); title="Edit Host"
setIsOpen(false); onClick={(e) => {
onOpenTab("host-manager"); e.stopPropagation();
setTimeout(() => { setIsOpen(false);
window.dispatchEvent( onOpenTab("host-manager");
new CustomEvent("host-manager:edit-host", { setTimeout(() => {
detail: host.id, window.dispatchEvent(
}), new CustomEvent(
); "host-manager:edit-host",
}, 100); {
}} detail: host.id,
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors" },
> ),
<Pencil className="size-3.5" /> );
</button> }, 100);
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
</>
)}
</div> </div>
</CommandItem> </CommandItem>
))} ))}
+212 -44
View File
@@ -1,16 +1,19 @@
import type { Dispatch, SetStateAction } from "react"; import { useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
deleteRole, deleteRole,
deleteUser, deleteUser,
getPermissionsCatalog,
revokeAllUserSessions, revokeAllUserSessions,
revokeSession, revokeSession,
updateRole,
} from "@/main-axios"; } from "@/main-axios";
import type { Role } from "@/main-axios"; import type { PermissionCatalogEntry, Role } from "@/main-axios";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
import { import {
Activity, Activity,
Check,
KeyRound, KeyRound,
Pencil, Pencil,
Plus, Plus,
@@ -375,6 +378,69 @@ export function AdminRolesSection({
createRoleLoading, createRoleLoading,
}: RolesSectionProps) { }: RolesSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
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 ( return (
<AccordionSection <AccordionSection
@@ -467,52 +533,154 @@ export function AdminRolesSection({
</div> </div>
</div> </div>
)} )}
{roles.map((role) => ( {roles.map((role) => {
<div const permissionCount = rolePermissions(role).length;
key={role.id} return (
className="flex items-center justify-between py-2.5 border-b border-border last:border-0" <div
> key={role.id}
<div className="flex flex-col gap-0.5 min-w-0"> className="flex flex-col py-2.5 border-b border-border last:border-0"
<div className="flex items-center gap-1.5 flex-wrap"> >
<span className="text-xs font-semibold truncate"> <div className="flex items-center justify-between">
{role.displayName} <div className="flex flex-col gap-0.5 min-w-0">
</span> <div className="flex items-center gap-1.5 flex-wrap">
{role.isSystem ? ( <span className="text-xs font-semibold truncate">
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground"> {role.displayName}
{t("admin.systemBadge")} </span>
</span> {role.isSystem ? (
) : ( <span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"> {t("admin.systemBadge")}
{t("admin.customBadge")} </span>
) : (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.customBadge")}
</span>
)}
{permissionCount > 0 && (
<span className="text-[9px] px-1 py-px border border-border/60 text-muted-foreground">
{t("admin.rolePermissions.count", {
count: permissionCount,
})}
</span>
)}
</div>
<span className="text-[10px] font-mono text-muted-foreground">
{role.name}
</span> </span>
</div>
{!role.isSystem && (
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className={`size-6 ${editingRoleId === role.id ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("admin.rolePermissions.editAction")}
onClick={() => openPermissionsEditor(role)}
>
<KeyRound className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
await deleteRole(role.id);
setRoles((prev) =>
prev.filter((r) => r.id !== role.id),
);
toast.success(
t("admin.deleteRoleSuccess", {
name: role.displayName,
}),
);
}}
>
<Trash2 className="size-3" />
</Button>
</div>
)} )}
</div> </div>
<span className="text-[10px] font-mono text-muted-foreground"> {editingRoleId === role.id && (
{role.name} <div className="flex flex-col gap-2.5 mt-2.5 p-2.5 border border-border bg-muted/20">
</span> {catalog.map((entry) => {
</div> const wildcard = `${entry.group}.*`;
{!role.isSystem && ( const wildcardOn = editingPermissions.has(wildcard);
<div className="flex items-center gap-0.5 shrink-0"> return (
<Button <div key={entry.group} className="flex flex-col gap-1">
variant="ghost" <button
size="icon" onClick={() => togglePermission(wildcard)}
className="size-6 text-muted-foreground hover:text-destructive" className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
onClick={async () => { >
await deleteRole(role.id); <span
setRoles((prev) => prev.filter((r) => r.id !== role.id)); className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${wildcardOn ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
toast.success( >
t("admin.deleteRoleSuccess", { {wildcardOn && (
name: role.displayName, <Check className="size-2 text-background" />
}), )}
</span>
<span
className={
wildcardOn
? "text-accent-brand"
: "text-muted-foreground"
}
>
{entry.group}.*
</span>
</button>
<div className="flex flex-col gap-0.5 pl-4">
{entry.permissions.map((permission) => {
const checked =
wildcardOn || editingPermissions.has(permission);
return (
<button
key={permission}
disabled={wildcardOn}
onClick={() => togglePermission(permission)}
className="flex items-center gap-1.5 text-[10px] text-left disabled:opacity-60"
>
<span
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${checked ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{checked && (
<Check className="size-2 text-background" />
)}
</span>
<span className="font-mono text-muted-foreground">
{permission}
</span>
</button>
);
})}
</div>
</div>
); );
}} })}
> <div className="flex justify-end gap-2 pt-1">
<Trash2 className="size-3" /> <Button
</Button> variant="ghost"
</div> size="sm"
)} className="h-6 text-[10px]"
</div> onClick={() => setEditingRoleId(null)}
))} >
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={savingPermissions}
onClick={() => savePermissions(role)}
>
{savingPermissions
? t("admin.rolePermissions.saving")
: t("admin.rolePermissions.save")}
</Button>
</div>
</div>
)}
</div>
);
})}
</div> </div>
</AccordionSection> </AccordionSection>
); );
+1694 -1613
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -113,6 +113,10 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
socks5Password: h.socks5Password, socks5Password: h.socks5Password,
socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [], socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [],
overrideCredentialUsername: h.overrideCredentialUsername ?? false, overrideCredentialUsername: h.overrideCredentialUsername ?? false,
isShared: h.isShared ?? false,
permissionLevel: h.permissionLevel,
sharedExpiresAt: h.sharedExpiresAt,
ownerUsername: h.ownerUsername,
}; };
} }
+461 -228
View File
@@ -1,9 +1,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ArrowLeft, ArrowLeft,
Check,
ListChecks, ListChecks,
Plus, Search,
Share2,
Shield, Shield,
User, User,
Users, Users,
@@ -12,17 +14,43 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
import { SectionCard } from "@/components/section-card"; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { import {
getHostAccess, getHostAccess,
shareHost, shareHost,
updateHostAccess,
revokeHostAccess, revokeHostAccess,
getUserList, getUserList,
getRoles, getRoles,
type AccessRecord, type AccessRecord,
type SharePermissionLevel,
type ShareTarget,
} from "@/main-axios"; } from "@/main-axios";
import type { Host } from "@/types/ui-types"; import type { Host } from "@/types/ui-types";
const PERMISSION_LEVELS: SharePermissionLevel[] = [
"connect",
"view",
"edit",
"manage",
];
const EXPIRY_PRESETS = [
{ key: "never", hours: null },
{ key: "oneHour", hours: 1 },
{ key: "oneDay", hours: 24 },
{ key: "sevenDays", hours: 24 * 7 },
{ key: "thirtyDays", hours: 24 * 30 },
{ key: "custom", hours: undefined },
] as const;
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
export function HostShareModal({ export function HostShareModal({
open, open,
onClose, onClose,
@@ -33,18 +61,28 @@ export function HostShareModal({
host: Host | null; host: Host | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [shareType, setShareType] = useState<"user" | "role">("user"); const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [shareGranteeId, setShareGranteeId] = useState(""); const [search, setSearch] = useState("");
const [shareExpiryHours, setShareExpiryHours] = useState(""); const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
new Set(),
);
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<number>>(
new Set(),
);
const [permissionLevel, setPermissionLevel] =
useState<SharePermissionLevel>("connect");
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("never");
const [customHours, setCustomHours] = useState("");
const [accessList, setAccessList] = useState<AccessRecord[]>([]); const [accessList, setAccessList] = useState<AccessRecord[]>([]);
const [shareUsers, setShareUsers] = useState< const [shareUsers, setShareUsers] = useState<
{ id: string; username: string }[] { id: string; username: string }[]
>([]); >([]);
const [shareRoles, setShareRoles] = useState<{ id: string; name: string }[]>( const [shareRoles, setShareRoles] = useState<
[], { id: number; name: string; displayName?: string }[]
); >([]);
const [sharingLoaded, setSharingLoaded] = useState(false); const [sharingLoaded, setSharingLoaded] = useState(false);
const [sharingLoadError, setSharingLoadError] = useState(false); const [sharingLoadError, setSharingLoadError] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
if (!open || !host) return; if (!open || !host) return;
@@ -64,10 +102,13 @@ export function HostShareModal({
})), })),
); );
setShareRoles( setShareRoles(
(rolesRes.roles ?? []).map((r) => ({ (rolesRes.roles ?? [])
id: String(r.id), .filter((r) => !r.isSystem)
name: r.name, .map((r) => ({
})), id: Number(r.id),
name: r.name,
displayName: r.displayName,
})),
); );
}) })
.catch(() => setSharingLoadError(true)); .catch(() => setSharingLoadError(true));
@@ -77,21 +118,102 @@ export function HostShareModal({
setSharingLoaded(false); setSharingLoaded(false);
setSharingLoadError(false); setSharingLoadError(false);
setAccessList([]); setAccessList([]);
setShareGranteeId(""); setSearch("");
setShareExpiryHours(""); setSelectedUserIds(new Set());
setShareType("user"); setSelectedRoleIds(new Set());
setPermissionLevel("connect");
setExpiryPreset("never");
setCustomHours("");
setTargetTab("user");
}, [host?.id]); }, [host?.id]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? shareUsers.filter((u) => u.username.toLowerCase().includes(q))
: shareUsers;
}, [shareUsers, search]);
const filteredRoles = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? shareRoles.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
(r.displayName ?? "").toLowerCase().includes(q),
)
: shareRoles;
}, [shareRoles, search]);
const selectedCount = selectedUserIds.size + selectedRoleIds.size;
const durationHours = (() => {
if (expiryPreset === "never") return undefined;
if (expiryPreset === "custom") {
const hours = Number(customHours);
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
}
return (
EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours ?? undefined
);
})();
async function refreshAccessList() { async function refreshAccessList() {
if (!host) return; if (!host) return;
const res = await getHostAccess(Number(host.id)); const res = await getHostAccess(Number(host.id));
setAccessList(res.accessList ?? []); setAccessList(res.accessList ?? []);
} }
if (!open) return null; async function handleShare() {
if (!host || selectedCount === 0) return;
const targets: ShareTarget[] = [
...[...selectedUserIds].map(
(id) => ({ type: "user", id }) as ShareTarget,
),
...[...selectedRoleIds].map(
(id) => ({ type: "role", id }) as ShareTarget,
),
];
const hasCredential = setSubmitting(true);
!!host?.credentialId || !!host?.rdpCredentialId || !!host?.vncCredentialId; try {
await shareHost(Number(host.id), {
targets,
permissionLevel,
...(durationHours ? { durationHours } : {}),
});
await refreshAccessList();
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
toast.success(t("hosts.hostSharedSuccessfully"));
} catch {
toast.error(t("hosts.failedToShareHost"));
} finally {
setSubmitting(false);
}
}
async function handleLevelChange(
record: AccessRecord,
level: SharePermissionLevel,
) {
if (!host || record.permissionLevel === level) return;
try {
await updateHostAccess(Number(host.id), record.id, {
permissionLevel: level,
});
setAccessList((prev) =>
prev.map((entry) =>
entry.id === record.id ? { ...entry, permissionLevel: level } : entry,
),
);
toast.success(t("hosts.sharing.accessUpdated"));
} catch {
toast.error(t("hosts.sharing.accessUpdateFailed"));
}
}
if (!open) return null;
return ( return (
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar"> <div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
@@ -101,228 +223,339 @@ export function HostShareModal({
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left" className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
> >
<ArrowLeft className="size-3.5 shrink-0" /> <ArrowLeft className="size-3.5 shrink-0" />
<span>{t("hosts.shareHostTitle", { name: host?.name ?? "" })}</span> <span className="truncate">
{t("hosts.shareHostTitle", { name: host?.name ?? "" })}
</span>
</button> </button>
{/* Scrollable content */} {sharingLoadError && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-3 gap-3"> <div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
{!hasCredential && host !== null && ( <Shield className="size-3.5 shrink-0 mt-0.5" />
<div className="flex items-start gap-3 p-3 border border-yellow-500/30 bg-yellow-500/5 text-xs text-yellow-500"> <div>{t("hosts.sharing.loadError")}</div>
<Shield className="size-3.5 shrink-0 mt-0.5" /> </div>
<div>{t("hosts.sharing.requiresCredential")}</div> )}
</div>
)}
{sharingLoadError && hasCredential && ( {/* Share form: fixed, non-scrolling */}
<div className="flex items-start gap-3 p-3 border border-destructive/30 bg-destructive/5 text-xs text-destructive"> <div className="flex flex-col gap-2 p-3 shrink-0 border-b border-border">
<Shield className="size-3.5 shrink-0 mt-0.5" /> <div className="flex items-center justify-between gap-2">
<div>{t("hosts.guac.sharingLoadError")}</div> <div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<Users className="size-3.5" />
{t("hosts.sharing.shareWithSection")}
</div> </div>
)} <a
href="https://docs.termix.site/features/authentication/rbac"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline shrink-0"
>
{t("hosts.docsLink")}
</a>
</div>
{hasCredential && ( <div className="flex gap-1.5">
<> {(["user", "role"] as const).map((tab) => (
<SectionCard <button
title={t("hosts.guac.shareHostSection")} key={tab}
icon={<Users className="size-3.5" />} onClick={() => setTargetTab(tab)}
action={ className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${targetTab === tab ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
<a
href="https://docs.termix.site/features/authentication/rbac"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline normal-case tracking-normal font-normal"
>
{t("hosts.docsLink")}
</a>
}
> >
<div className="flex flex-col gap-4 py-3"> {tab === "user" ? (
<div className="flex gap-2"> <User className="size-3 shrink-0" />
{(["user", "role"] as const).map((shareTypeOpt) => ( ) : (
<button <Shield className="size-3 shrink-0" />
key={shareTypeOpt} )}
onClick={() => { {tab === "user"
setShareType(shareTypeOpt); ? t("hosts.sharing.usersTab")
setShareGranteeId(""); : t("hosts.sharing.rolesTab")}
}} {tab === "user" && selectedUserIds.size > 0 && (
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${shareType === shareTypeOpt ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`} <span>({selectedUserIds.size})</span>
> )}
{shareTypeOpt === "user" ? ( {tab === "role" && selectedRoleIds.size > 0 && (
<> <span>({selectedRoleIds.size})</span>
<User className="size-3 inline mr-1" /> )}
{t("hosts.guac.shareWithUser")} </button>
</> ))}
) : ( </div>
<>
<Shield className="size-3 inline mr-1" /> <div className="relative">
{t("hosts.guac.shareWithRole")} <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
</> <Input
)} placeholder={t("hosts.sharing.searchPlaceholder")}
</button> value={search}
))} onChange={(e) => setSearch(e.target.value)}
</div> className="pl-8"
<div className="flex flex-col gap-1.5"> />
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> </div>
{shareType === "user"
? t("hosts.guac.selectUser") <div className="flex flex-col border border-border h-28 overflow-y-auto">
: t("hosts.guac.selectRole")} {targetTab === "user" &&
</label> (filteredUsers.length === 0 ? (
<select <div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" {t("hosts.sharing.noMatches")}
value={shareGranteeId}
onChange={(e) => setShareGranteeId(e.target.value)}
>
<option value="">
{shareType === "user"
? t("hosts.guac.selectUserOption")
: t("hosts.guac.selectRoleOption")}
</option>
{shareType === "user"
? shareUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))
: shareRoles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.expiresInHours")}
</label>
<Input
type="number"
placeholder={t("hosts.guac.noExpiryPlaceholder")}
value={shareExpiryHours}
onChange={(e) => setShareExpiryHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
<div className="flex justify-end">
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={!shareGranteeId}
onClick={async () => {
try {
await shareHost(Number(host!.id), {
targetType: shareType,
...(shareType === "user"
? { targetUserId: shareGranteeId }
: { targetRoleId: Number(shareGranteeId) }),
permissionLevel: "view",
...(shareExpiryHours
? { durationHours: Number(shareExpiryHours) }
: {}),
});
await refreshAccessList();
setShareGranteeId("");
setShareExpiryHours("");
toast.success(t("hosts.hostSharedSuccessfully"));
} catch {
toast.error(t("hosts.failedToShareHost"));
}
}}
>
<Plus className="size-3.5 mr-1.5" />
{t("hosts.guac.shareBtn")}
</Button>
</div>
</div> </div>
</SectionCard> ) : (
filteredUsers.map((user) => {
<SectionCard const isSelected = selectedUserIds.has(user.id);
title={t("hosts.guac.currentAccess")} return (
icon={<ListChecks className="size-3.5" />} <button
> key={user.id}
<div className="py-2"> onClick={() =>
{accessList.length === 0 && ( setSelectedUserIds((prev) => {
<div className="px-2 py-4 text-xs text-muted-foreground/50 text-center"> const next = new Set(prev);
{t("hosts.guac.noAccessEntries")} if (next.has(user.id)) next.delete(user.id);
</div> else next.add(user.id);
)} return next;
{accessList.map((r, i) => { })
const expired = }
r.expiresAt && new Date(r.expiresAt) < new Date(); className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
return ( >
<div <div
key={i} className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
className="flex flex-col gap-1 px-2 py-2.5 border-b border-border last:border-0 text-xs"
> >
<div className="flex items-center justify-between gap-2"> {isSelected && (
<div className="flex items-center gap-1.5 min-w-0"> <Check className="size-2.5 text-background" />
{r.targetType === "user" ? ( )}
<User className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<span className="font-semibold truncate">
{r.username ??
r.roleName ??
r.roleDisplayName ??
r.userId ??
r.roleId}
</span>
<span className="text-muted-foreground capitalize shrink-0">
({r.targetType})
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
onClick={async () => {
try {
await revokeHostAccess(Number(host!.id), r.id);
setAccessList((prev) =>
prev.filter((_, idx) => idx !== i),
);
toast.success(t("hosts.accessRevoked"));
} catch {
toast.error(t("hosts.failedToRevokeAccess"));
}
}}
>
{t("hosts.guac.revokeBtn")}
</Button>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
<span>
{t("hosts.guac.grantedByHeader")}:{" "}
<span className="text-foreground/70">
{r.grantedByUsername ?? "—"}
</span>
</span>
<span className={expired ? "text-destructive" : ""}>
{t("hosts.guac.expiresHeader")}:{" "}
{expired ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<X className="size-3" />
{t("hosts.guac.expiredLabel")}
</span>
) : r.expiresAt ? (
<span className="text-foreground/70">
{new Date(r.expiresAt).toLocaleDateString()}
</span>
) : (
<span className="text-foreground/70">
{t("hosts.guac.neverLabel")}
</span>
)}
</span>
</div>
</div> </div>
); <User className="size-3 text-muted-foreground shrink-0" />
})} <span className="truncate">{user.username}</span>
</button>
);
})
))}
{targetTab === "role" &&
(filteredRoles.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noMatches")}
</div> </div>
</SectionCard> ) : (
</> filteredRoles.map((role) => {
const isSelected = selectedRoleIds.has(role.id);
return (
<button
key={role.id}
onClick={() =>
setSelectedRoleIds((prev) => {
const next = new Set(prev);
if (next.has(role.id)) next.delete(role.id);
else next.add(role.id);
return next;
})
}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{role.displayName || role.name}
</span>
</button>
);
})
))}
</div>
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hosts.sharing.permissionLevelLabel")}
</span>
<select
value={permissionLevel}
onChange={(e) =>
setPermissionLevel(e.target.value as SharePermissionLevel)
}
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
>
{PERMISSION_LEVELS.map((level) => (
<option key={level} value={level}>
{t(`hosts.sharing.levels.${level}.label`)}
</option>
))}
</select>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex flex-col gap-1 shrink-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
{t("hosts.sharing.expiryLabel")}
</span>
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
{t(`hosts.sharing.expiry.${expiryPreset}`)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{EXPIRY_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.key}
onClick={() => setExpiryPreset(preset.key)}
>
{expiryPreset === preset.key ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.expiry.${preset.key}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={
selectedCount === 0 ||
submitting ||
(expiryPreset === "custom" && !durationHours)
}
onClick={handleShare}
>
<Share2 className="size-3.5 mr-1.5" />
{selectedCount > 0
? t("hosts.sharing.shareWithCount", { count: selectedCount })
: t("hosts.sharing.shareButton")}
</Button>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{t(`hosts.sharing.levels.${permissionLevel}.description`)}
</p>
{expiryPreset === "custom" && (
<Input
type="number"
autoFocus
placeholder={t("hosts.sharing.customHoursPlaceholder")}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
)} )}
</div> </div>
{/* Current access: takes remaining space, scrolls independently */}
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<ListChecks className="size-3.5" />
{t("hosts.sharing.currentAccess")}
{accessList.length > 0 && (
<span className="text-muted-foreground/40">
({accessList.length})
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{accessList.length === 0 && (
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noAccessEntries")}
</div>
)}
{accessList.map((record) => {
const expired =
record.expiresAt && new Date(record.expiresAt) < new Date();
return (
<div
key={record.id}
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{record.targetType === "user" ? (
<User className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<span className="font-semibold truncate">
{record.username ??
record.roleDisplayName ??
record.roleName ??
record.userId ??
record.roleId}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
{t(
`hosts.sharing.levels.${record.permissionLevel ?? "connect"}.label`,
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{PERMISSION_LEVELS.map((level) => (
<DropdownMenuItem
key={level}
onClick={() => handleLevelChange(record, level)}
>
{record.permissionLevel === level ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.levels.${level}.label`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
try {
await revokeHostAccess(Number(host!.id), record.id);
setAccessList((prev) =>
prev.filter((entry) => entry.id !== record.id),
);
toast.success(t("hosts.accessRevoked"));
} catch {
toast.error(t("hosts.failedToRevokeAccess"));
}
}}
>
{t("hosts.sharing.revoke")}
</Button>
</div>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
<span>
{t("hosts.sharing.grantedBy")}:{" "}
<span className="text-foreground/70">
{record.grantedByUsername ?? "?"}
</span>
</span>
<span className={expired ? "text-destructive" : ""}>
{t("hosts.sharing.expires")}:{" "}
{expired ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<X className="size-3" />
{t("hosts.sharing.expired")}
</span>
) : record.expiresAt ? (
<span className="text-foreground/70">
{new Date(record.expiresAt).toLocaleString()}
</span>
) : (
<span className="text-foreground/70">
{t("hosts.sharing.never")}
</span>
)}
</span>
</div>
</div>
);
})}
</div>
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -117,7 +117,7 @@ function LogRow({
<span className="text-[10px] text-muted-foreground/60 truncate"> <span className="text-[10px] text-muted-foreground/60 truncate">
{formatDate(log.startedAt)} {formatDate(log.startedAt)}
{" · "} {" · "}
{log.protocol.toUpperCase()} {(log.protocol ?? "ssh").toUpperCase()}
{log.username ? ` · ${log.username}` : ""} {log.username ? ` · ${log.username}` : ""}
{" · "} {" · "}
{formatDuration(log.duration)} {formatDuration(log.duration)}
+129 -67
View File
@@ -36,6 +36,7 @@ import {
Share2, Share2,
Terminal, Terminal,
Trash2, Trash2,
Users,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -64,6 +65,11 @@ import type { SSHHostData } from "@/types/index";
import { FolderIconEl } from "@/components/folder-style"; import { FolderIconEl } from "@/components/folder-style";
import { resolveHostTabType } from "@/lib/host-connection-tabs"; import { resolveHostTabType } from "@/lib/host-connection-tabs";
import { copyToClipboard } from "@/lib/clipboard"; import { copyToClipboard } from "@/lib/clipboard";
import {
canDeleteHost,
canEditHost,
canShareHost,
} from "@/sidebar/host-permissions";
import { FolderMetadataDialog } from "./FolderMetadataDialog"; import { FolderMetadataDialog } from "./FolderMetadataDialog";
import { import {
useStatusColorScheme, useStatusColorScheme,
@@ -266,8 +272,8 @@ function folderHostCount(folder: HostFolder): {
export function HostItem({ export function HostItem({
host, host,
onOpenTab, onOpenTab,
onEditHost, onEditHost: onEditHostProp,
onShareHost, onShareHost: onShareHostProp,
onProxmoxDiscover, onProxmoxDiscover,
onDelete, onDelete,
onDuplicate, onDuplicate,
@@ -306,6 +312,10 @@ export function HostItem({
depth?: number; depth?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Shared hosts expose actions matching the recipient's permission level.
const onEditHost = canEditHost(host) ? onEditHostProp : undefined;
const onShareHost = canShareHost(host) ? onShareHostProp : undefined;
const allowDelete = canDeleteHost(host);
const metricsEnabled = const metricsEnabled =
host.enableSsh && host.statsConfig?.metricsEnabled !== false; host.enableSsh && host.statsConfig?.metricsEnabled !== false;
const [trayOnClick, setTrayOnClick] = useState( const [trayOnClick, setTrayOnClick] = useState(
@@ -328,8 +338,8 @@ export function HostItem({
const isTouchOnly = const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
const shouldUseClickTray = trayOnClick || isTouchOnly; const shouldUseClickTray = trayOnClick || isTouchOnly;
const showPasswordCopy = canCopyHostPassword(host); const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
const showSudoPasswordCopy = canCopyHostSudoPassword(host); const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
async function handleCopyPassword( async function handleCopyPassword(
e: MouseEvent, e: MouseEvent,
@@ -465,6 +475,26 @@ export function HostItem({
<span className="text-[13px] font-medium truncate text-foreground leading-none"> <span className="text-[13px] font-medium truncate text-foreground leading-none">
{host.name} {host.name}
</span> </span>
{host.isShared && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
<Users className="size-2.5" />
{t("hosts.sharing.sharedBadge")}
</span>
</TooltipTrigger>
<TooltipContent side="right">
{t("hosts.sharing.sharedBadgeTooltip", {
owner: host.ownerUsername || "?",
level: t(
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
),
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!selectionMode && shouldUseClickTray && ( {!selectionMode && shouldUseClickTray && (
<button <button
title={ title={
@@ -666,27 +696,31 @@ export function HostItem({
{t("nav.copySudoPassword")} {t("nav.copySudoPassword")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> {allowDelete && (
<DropdownMenuItem <>
onClick={(e) => { <DropdownMenuSeparator />
e.stopPropagation(); <DropdownMenuItem
onDuplicate(); onClick={(e) => {
}} e.stopPropagation();
> onDuplicate();
<CopyPlus className="size-3.5 mr-2" /> }}
{t("hosts.cloneHostAction")} >
</DropdownMenuItem> <CopyPlus className="size-3.5 mr-2" />
<DropdownMenuSeparator /> {t("hosts.cloneHostAction")}
<DropdownMenuItem </DropdownMenuItem>
className="text-destructive focus:text-destructive" <DropdownMenuSeparator />
onClick={(e) => { <DropdownMenuItem
e.stopPropagation(); className="text-destructive focus:text-destructive"
onDelete(); onClick={(e) => {
}} e.stopPropagation();
> onDelete();
<Trash2 className="size-3.5 mr-2" /> }}
{t("common.delete")} >
</DropdownMenuItem> <Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -862,27 +896,31 @@ export function HostItem({
{t("nav.copySudoPassword")} {t("nav.copySudoPassword")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> {allowDelete && (
<DropdownMenuItem <>
onClick={(e) => { <DropdownMenuSeparator />
e.stopPropagation(); <DropdownMenuItem
onDuplicate(); onClick={(e) => {
}} e.stopPropagation();
> onDuplicate();
<CopyPlus className="size-3.5 mr-2" /> }}
{t("hosts.cloneHostAction")} >
</DropdownMenuItem> <CopyPlus className="size-3.5 mr-2" />
<DropdownMenuSeparator /> {t("hosts.cloneHostAction")}
<DropdownMenuItem </DropdownMenuItem>
className="text-destructive focus:text-destructive" <DropdownMenuSeparator />
onClick={(e) => { <DropdownMenuItem
e.stopPropagation(); className="text-destructive focus:text-destructive"
onDelete(); onClick={(e) => {
}} e.stopPropagation();
> onDelete();
<Trash2 className="size-3.5 mr-2" /> }}
{t("common.delete")} >
</DropdownMenuItem> <Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -974,6 +1012,26 @@ export function HostItem({
{host.pin && ( {host.pin && (
<Pin className="size-2.5 text-accent-brand/50 shrink-0" /> <Pin className="size-2.5 text-accent-brand/50 shrink-0" />
)} )}
{host.isShared && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
<Users className="size-2.5" />
{t("hosts.sharing.sharedBadge")}
</span>
</TooltipTrigger>
<TooltipContent side="right">
{t("hosts.sharing.sharedBadgeTooltip", {
owner: host.ownerUsername || "?",
level: t(
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
),
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!selectionMode && shouldUseClickTray && ( {!selectionMode && shouldUseClickTray && (
<button <button
title={ title={
@@ -1445,27 +1503,31 @@ export function HostItem({
)} )}
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> {allowDelete && (
<DropdownMenuItem <>
onClick={(e) => { <DropdownMenuSeparator />
e.stopPropagation(); <DropdownMenuItem
onDuplicate(); onClick={(e) => {
}} e.stopPropagation();
> onDuplicate();
<CopyPlus className="size-3.5 mr-2" /> }}
{t("hosts.cloneHostAction")} >
</DropdownMenuItem> <CopyPlus className="size-3.5 mr-2" />
<DropdownMenuSeparator /> {t("hosts.cloneHostAction")}
<DropdownMenuItem </DropdownMenuItem>
className="text-destructive focus:text-destructive" <DropdownMenuSeparator />
onClick={(e) => { <DropdownMenuItem
e.stopPropagation(); className="text-destructive focus:text-destructive"
onDelete(); onClick={(e) => {
}} e.stopPropagation();
> onDelete();
<Trash2 className="size-3.5 mr-2" /> }}
{t("common.delete")} >
</DropdownMenuItem> <Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
+28
View File
@@ -0,0 +1,28 @@
import type { Host, SharePermissionLevel } from "@/types/ui-types";
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
connect: 1,
view: 2,
edit: 3,
manage: 4,
};
function sharedLevelRank(host: Host): number {
return LEVEL_RANK[host.permissionLevel ?? "connect"] ?? 1;
}
export function canViewHostConfig(host: Host): boolean {
return !host.isShared || sharedLevelRank(host) >= LEVEL_RANK.view;
}
export function canEditHost(host: Host): boolean {
return !host.isShared || sharedLevelRank(host) >= LEVEL_RANK.edit;
}
export function canShareHost(host: Host): boolean {
return !host.isShared || sharedLevelRank(host) >= LEVEL_RANK.manage;
}
export function canDeleteHost(host: Host): boolean {
return !host.isShared;
}