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 {
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS shared_credentials (
CREATE TABLE IF NOT EXISTS shared_host_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_access_id INTEGER NOT NULL,
original_credential_id INTEGER NOT NULL,
target_user_id TEXT NOT NULL,
encrypted_username TEXT NOT NULL,
encrypted_auth_type TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'ssh',
source_type TEXT NOT NULL DEFAULT 'credential',
original_credential_id INTEGER,
encrypted_username TEXT,
encrypted_auth_type TEXT,
encrypted_password TEXT,
encrypted_key TEXT,
encrypted_key_password TEXT,
encrypted_key_type TEXT,
encrypted_domain TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(host_access_id, target_user_id, protocol),
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create shared_credentials table", {
databaseLogger.warn("Failed to create shared_host_secrets table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
sqlite.exec(
"UPDATE host_access SET permission_level = 'connect' WHERE permission_level = 'view'",
);
setRawSettingValue("rbac_permission_levels_v2", "done");
}
} catch (migrateError) {
databaseLogger.warn("Failed to migrate legacy view permission level", {
operation: "schema_migration",
error: migrateError,
});
}
try {
sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
} catch {
+13 -8
View File
@@ -523,7 +523,7 @@ export const hostAccess = sqliteTable("host_access", {
permissionLevel: text("permission_level")
.notNull()
.default("view"),
.default("connect"),
expiresAt: text("expires_at"),
@@ -538,27 +538,32 @@ export const hostAccess = sqliteTable("host_access", {
),
});
export const sharedCredentials = sqliteTable("shared_credentials", {
export const sharedHostSecrets = sqliteTable("shared_host_secrets", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostAccessId: integer("host_access_id")
.notNull()
.references(() => hostAccess.id, { onDelete: "cascade" }),
originalCredentialId: integer("original_credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username").notNull(),
encryptedAuthType: text("encrypted_auth_type").notNull(),
protocol: text("protocol").notNull().default("ssh"),
sourceType: text("source_type").notNull().default("credential"),
originalCredentialId: integer("original_credential_id").references(
() => sshCredentials.id,
{ onDelete: "cascade" },
),
encryptedUsername: text("encrypted_username"),
encryptedAuthType: text("encrypted_auth_type"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key", { length: 16384 }),
encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"),
encryptedDomain: text("encrypted_domain"),
createdAt: text("created_at")
.notNull()
+4 -4
View File
@@ -28,7 +28,7 @@ import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedCredentialRepository } from "./shared-credential-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SnippetRepository } from "./snippet-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js";
@@ -260,10 +260,10 @@ export function createCurrentSettingsRepository(): SettingsRepository {
);
}
export function createCurrentSharedCredentialRepository(): SharedCredentialRepository {
return new SharedCredentialRepository(
export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRepository {
return new SharedHostSecretsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("shared_credential_repository_write"),
createCurrentRepositoryWriteHook("shared_host_secrets_repository_write"),
);
}
@@ -18,6 +18,7 @@ export interface HostUpdateStateRecord {
rdpCredentialId: number | null;
vncCredentialId: number | null;
telnetCredentialId: number | null;
vaultProfileId: number | null;
authType: string;
}
export interface HostListAccessEntry {
@@ -74,6 +75,7 @@ export class HostResolutionRepository {
rdpCredentialId: hosts.rdpCredentialId,
vncCredentialId: hosts.vncCredentialId,
telnetCredentialId: hosts.telnetCredentialId,
vaultProfileId: hosts.vaultProfileId,
authType: hosts.authType,
})
.from(hosts)
@@ -3,7 +3,7 @@ import {
hostAccess,
hosts,
roles,
sharedCredentials,
sharedHostSecrets,
snippetAccess,
snippets,
users,
@@ -152,10 +152,6 @@ export class RbacAccessRepository {
})
.where(eq(hostAccess.id, existing.id));
await this.context.drizzle
.delete(sharedCredentials)
.where(eq(sharedCredentials.hostAccessId, existing.id));
await this.afterWrite();
return { id: existing.id, created: false };
}
@@ -583,25 +579,73 @@ export class RbacAccessRepository {
.where(eq(hostAccess.roleId, roleId));
}
async findSharedCredentialForHostAndUser(
async findSharedSecretForHostUserProtocol(
hostId: number,
userId: string,
): Promise<typeof sharedCredentials.$inferSelect | null> {
protocol: string,
): Promise<typeof sharedHostSecrets.$inferSelect | null> {
const rows = await this.context.drizzle
.select({
sharedCredential: sharedCredentials,
secret: sharedHostSecrets,
})
.from(sharedCredentials)
.innerJoin(hostAccess, eq(sharedCredentials.hostAccessId, hostAccess.id))
.from(sharedHostSecrets)
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
.where(
and(
eq(hostAccess.hostId, hostId),
eq(sharedCredentials.targetUserId, userId),
eq(sharedHostSecrets.targetUserId, userId),
eq(sharedHostSecrets.protocol, protocol),
),
)
.limit(1);
return rows[0]?.sharedCredential ?? null;
return rows[0]?.secret ?? null;
}
async listActiveHostAccessGrants(
hostId: number,
now = new Date().toISOString(),
): Promise<(typeof hostAccess.$inferSelect)[]> {
return this.context.drizzle
.select()
.from(hostAccess)
.where(
and(
eq(hostAccess.hostId, hostId),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
),
);
}
async findHostAccessById(
accessId: number,
hostId: number,
): Promise<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> {
@@ -27,6 +27,8 @@ export interface SessionRecordingListRecord {
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
protocol: string;
format: string | null;
hostName: string | null;
hostIp: string | null;
}
@@ -106,6 +108,8 @@ export class SessionRecordingRepository {
endedAt: sessionRecordings.endedAt,
duration: sessionRecordings.duration,
recordingPath: sessionRecordings.recordingPath,
protocol: sessionRecordings.protocol,
format: sessionRecordings.format,
hostName: hosts.name,
hostIp: hosts.ip,
})
@@ -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 { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentRbacAccessRepository,
createCurrentCredentialRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
@@ -523,10 +522,9 @@ router.put(
credentialId,
));
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.updateSharedCredentialsForOriginal(
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
credentialId,
userId,
);
@@ -633,38 +631,24 @@ router.delete(
authType: "password",
},
);
for (const host of hostsUsingCredential) {
const revokedCount =
await createCurrentRbacAccessRepository().deleteHostAccessForHost(
host.id,
);
if (revokedCount > 0) {
authLogger.info(
"Auto-revoked host shares due to credential deletion",
{
operation: "auto_revoke_shares",
hostId: host.id,
credentialId,
revokedCount,
reason: "credential_deleted",
},
);
}
}
}
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.deleteSharedCredentialsForOriginal(credentialId);
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
await sharedSecretsManager.deleteForCredential(credentialId);
await createCurrentCredentialRepository().deleteForUser(
userId,
credentialId,
);
// Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy).
for (const host of hostsUsingCredential) {
await sharedSecretsManager.resyncHost(host.id);
}
authLogger.success("SSH credential deleted", {
operation: "credential_delete_success",
userId,
@@ -24,7 +24,7 @@ import {
createCurrentSessionRepository,
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
createCurrentSharedCredentialRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
@@ -40,7 +40,7 @@ import {
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
try {
await createCurrentSharedCredentialRepository().deleteByTargetUserId(
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
userId,
);
@@ -231,6 +231,76 @@ export function stripSensitiveFields(
return result;
}
// Connection essentials a connect-level recipient is allowed to see.
const CONNECT_LEVEL_FIELDS = new Set([
"id",
"userId",
"ownerId",
"ownerUsername",
"isShared",
"permissionLevel",
"sharedExpiresAt",
"name",
"ip",
"port",
"username",
"folder",
"tags",
"pin",
"authType",
"connectionType",
"credentialId",
"enableTerminal",
"enableTunnel",
"enableFileManager",
"enableDocker",
"enableProxmox",
"enableTmuxMonitor",
"showTerminalInSidebar",
"showFileManagerInSidebar",
"showTunnelInSidebar",
"showDockerInSidebar",
"showServerStatsInSidebar",
"enableSsh",
"enableRdp",
"enableVnc",
"enableTelnet",
"sshPort",
"rdpPort",
"vncPort",
"telnetPort",
"defaultPath",
"scpLegacy",
"tunnelConnections",
"jumpHosts",
"createdAt",
"updatedAt",
]);
/**
* Shapes a shared host row for its recipient. Secrets are always stripped
* (all levels); connect-level recipients are additionally reduced to
* connection essentials since they may not view the host's configuration.
*/
export function sanitizeHostForRecipient(
host: Record<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(
host: Record<string, unknown>,
): Record<string, unknown> {
+130 -65
View File
@@ -29,6 +29,7 @@ import {
import {
isNonEmptyString,
isValidPort,
sanitizeHostForRecipient,
stripSensitiveFields,
transformHostResponse,
} from "./host-normalizers.js";
@@ -1070,7 +1071,7 @@ router.put(
const accessInfo = await permissionManager.canAccessHost(
userId,
Number(hostId),
"write",
"edit",
);
if (!accessInfo.hasAccess) {
@@ -1082,17 +1083,6 @@ router.put(
return res.status(403).json({ error: "Access denied" });
}
if (!accessInfo.isOwner) {
sshLogger.warn("Shared user attempted to update host (view-only)", {
operation: "host_update",
hostId: parseInt(hostId),
userId,
});
return res.status(403).json({
error: "Only the host owner can modify host configuration",
});
}
const hostRecord =
await createCurrentHostResolutionRepository().findHostUpdateState(
Number(hostId),
@@ -1109,18 +1099,43 @@ router.put(
const ownerId = hostRecord.userId;
if (
!accessInfo.isOwner &&
sshDataObj.credentialId !== undefined &&
sshDataObj.credentialId !== hostRecord.credentialId
) {
if (!accessInfo.isOwner) {
// Shared editors work on the owner's real record but may never
// repoint it at credential/vault references (those live in the
// owner's personal vault) or switch the authentication type.
const referenceViolations: Array<[unknown, number | null, string]> = [
[sshDataObj.credentialId, hostRecord.credentialId, "credential"],
[
sshDataObj.rdpCredentialId,
hostRecord.rdpCredentialId,
"RDP credential",
],
[
sshDataObj.vncCredentialId,
hostRecord.vncCredentialId,
"VNC credential",
],
[
sshDataObj.telnetCredentialId,
hostRecord.telnetCredentialId,
"Telnet credential",
],
[
sshDataObj.vaultProfileId,
hostRecord.vaultProfileId,
"Vault profile",
],
];
for (const [incoming, current, label] of referenceViolations) {
if (incoming !== undefined && (incoming ?? null) !== current) {
return res.status(403).json({
error: "Only the host owner can change the credential",
error: `Only the host owner can change the ${label}`,
});
}
}
if (
!accessInfo.isOwner &&
sshDataObj.authType !== undefined &&
sshDataObj.authType !== hostRecord.authType
) {
@@ -1128,39 +1143,6 @@ router.put(
error: "Only the host owner can change the authentication type",
});
}
{
const newCredId =
sshDataObj.credentialId !== undefined
? sshDataObj.credentialId
: hostRecord.credentialId;
const newRdpCredId =
sshDataObj.rdpCredentialId !== undefined
? sshDataObj.rdpCredentialId
: hostRecord.rdpCredentialId;
const newVncCredId =
sshDataObj.vncCredentialId !== undefined
? sshDataObj.vncCredentialId
: hostRecord.vncCredentialId;
const newTelnetCredId =
sshDataObj.telnetCredentialId !== undefined
? sshDataObj.telnetCredentialId
: hostRecord.telnetCredentialId;
const hadCredential =
hostRecord.credentialId !== null ||
hostRecord.rdpCredentialId !== null ||
hostRecord.vncCredentialId !== null ||
hostRecord.telnetCredentialId !== null;
const willHaveCredential =
newCredId !== null ||
newRdpCredId !== null ||
newVncCredId !== null ||
newTelnetCredId !== null;
if (hadCredential && !willHaveCredential) {
await createCurrentRbacAccessRepository().deleteHostAccessForHost(
Number(hostId),
);
}
}
await createCurrentHostRepository().updateEncryptedForUser(
@@ -1169,6 +1151,23 @@ router.put(
sshDataObj,
);
// Keep every recipient's re-encrypted secret snapshots in sync with
// the updated host record.
try {
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHost(Number(hostId));
} catch (resyncError) {
sshLogger.warn("Failed to resync shared host secrets after update", {
operation: "host_update_resync",
hostId: parseInt(hostId),
error:
resyncError instanceof Error
? resyncError.message
: "Unknown error",
});
}
const updatedHost =
await createCurrentHostResolutionRepository().findHostById(
Number(hostId),
@@ -1296,9 +1295,21 @@ router.get(
}
}
const sanitizedSharedHosts = sharedHosts;
const ownerUsernames = new Map<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(
data.map(async (row: Record<string, unknown>) => {
@@ -1307,6 +1318,9 @@ router.get(
isShared: !!row.isShared,
permissionLevel: row.permissionLevel || undefined,
sharedExpiresAt: row.expiresAt || undefined,
ownerUsername: row.isShared
? ownerUsernames.get(row.userId as string) || undefined
: undefined,
};
const resolved =
@@ -1315,7 +1329,14 @@ router.get(
}),
);
const sanitized = result.map((host) => stripSensitiveFields(host));
const sanitized = result.map((host) =>
host.isShared
? sanitizeHostForRecipient(
host,
host.permissionLevel as string | undefined,
)
: stripSensitiveFields(host),
);
res.json(sanitized);
} catch (err) {
sshLogger.error("Failed to fetch SSH hosts from database", err, {
@@ -1370,13 +1391,28 @@ router.get(
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const host =
await createCurrentHostResolutionRepository().findHostByIdForUser(
const hostResolutionRepository = createCurrentHostResolutionRepository();
const host = await hostResolutionRepository.findHostByIdForUser(
Number(hostId),
userId,
);
if (!host) {
if (host) {
const result = transformHostResponse(host);
const resolved =
(await resolveHostCredentials(result, userId)) || result;
return res.json(stripSensitiveFields(resolved));
}
// Not the owner: shared recipients get a sanitized view of the host.
const accessInfo = await permissionManager.canAccessHost(
userId,
Number(hostId),
"connect",
);
if (!accessInfo.hasAccess) {
sshLogger.warn("SSH host not found", {
operation: "host_fetch_by_id",
hostId: parseInt(hostId),
@@ -1385,10 +1421,38 @@ router.get(
return res.status(404).json({ error: "SSH host not found" });
}
const result = transformHostResponse(host);
const resolved = (await resolveHostCredentials(result, userId)) || result;
const ownerId = await hostResolutionRepository.findHostOwnerId(
Number(hostId),
);
const sharedHost = ownerId
? await hostResolutionRepository.findHostById(Number(hostId), ownerId)
: null;
res.json(stripSensitiveFields(resolved));
if (!sharedHost) {
return res.status(404).json({ error: "SSH host not found" });
}
let ownerUsername: string | undefined;
try {
const owner = ownerId
? await createCurrentUserRepository().findById(ownerId)
: null;
ownerUsername = owner?.username ?? undefined;
} catch {
ownerUsername = undefined;
}
const sharedResult = {
...transformHostResponse(sharedHost),
isShared: true,
permissionLevel: accessInfo.permissionLevel,
sharedExpiresAt: accessInfo.expiresAt || undefined,
ownerUsername,
};
res.json(
sanitizeHostForRecipient(sharedResult, accessInfo.permissionLevel),
);
} catch (err) {
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
operation: "host_fetch_by_id",
@@ -2033,12 +2097,13 @@ async function resolveHostCredentials(
if (requestingUserId && requestingUserId !== ownerId) {
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedCred =
await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id as number,
requestingUserId,
"ssh",
);
if (sharedCred) {
+5 -4
View File
@@ -312,7 +312,7 @@ async function discoverProxmoxGuestsForHost(
const { PermissionManager } =
await import("../../utils/permission-manager.js");
const pm = PermissionManager.getInstance();
const access = await pm.canAccessHost(userId, parsedHostId, "execute");
const access = await pm.canAccessHost(userId, parsedHostId, "connect");
if (!access.hasAccess) {
const error = new Error("Access denied");
(error as Error & { status?: number }).status = 403;
@@ -337,12 +337,13 @@ async function discoverProxmoxGuestsForHost(
if (host.credentialId) {
if (userId !== host.userId) {
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedCred =
await SharedCredentialManager.getInstance().getSharedCredentialForUser(
await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id,
userId,
"ssh",
);
if (sharedCred) {
resolvedCredentials = {
+444 -185
View File
@@ -3,7 +3,15 @@ import express from "express";
import type { Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
PermissionManager,
SHARE_PERMISSION_LEVELS,
type SharePermissionLevel,
} from "../../utils/permission-manager.js";
import {
PERMISSION_CATALOG,
isValidPermission,
} from "../../utils/permission-catalog.js";
import {
createCurrentCredentialRepository,
createCurrentHostResolutionRepository,
@@ -24,12 +32,69 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isSharePermissionLevel(value: unknown): value is SharePermissionLevel {
return SHARE_PERMISSION_LEVELS.includes(value as SharePermissionLevel);
}
function expiryFromDuration(durationHours: unknown): string | null {
if (durationHours && typeof durationHours === "number" && durationHours > 0) {
const expiryDate = new Date();
expiryDate.setTime(expiryDate.getTime() + durationHours * 60 * 60 * 1000);
return expiryDate.toISOString();
}
return null;
}
// Sharing is controlled by the owner or any recipient holding "manage".
async function canManageHostSharing(
userId: string,
hostId: number,
): Promise<{ allowed: boolean; isOwner: boolean }> {
const access = await permissionManager.canAccessHost(
userId,
hostId,
"manage",
);
return { allowed: access.hasAccess, isOwner: access.isOwner };
}
interface ShareTarget {
type: "user" | "role";
id: string | number;
}
function parseShareTargets(
body: Record<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
* /rbac/host/{id}/share:
* post:
* summary: Share a host
* description: Shares a host with a user or a role.
* description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). Allowed for the host owner or recipients holding the manage level. Every auth type is shareable; per-recipient secret snapshots are created automatically.
* tags:
* - RBAC
* parameters:
@@ -44,26 +109,32 @@ function isNonEmptyString(value: unknown): value is string {
* application/json:
* schema:
* type: object
* required: [targets]
* properties:
* targetType:
* targets:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* enum: [user, role]
* targetUserId:
* type: string
* targetRoleId:
* type: integer
* durationHours:
* type: number
* id:
* oneOf:
* - type: string
* - type: integer
* permissionLevel:
* type: string
* enum: [view]
* enum: [connect, view, edit, manage]
* durationHours:
* type: number
* responses:
* 200:
* description: Host shared successfully.
* 400:
* description: Invalid request body.
* 403:
* description: Not host owner.
* description: Caller may not share this host.
* 404:
* description: Target user or role not found.
* 500:
@@ -82,37 +153,25 @@ router.post(
}
try {
const {
targetType = "user",
targetUserId,
targetRoleId,
durationHours,
permissionLevel = "view",
} = req.body;
if (!["user", "role"].includes(targetType)) {
return res
.status(400)
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
const targets = parseShareTargets(req.body ?? {});
if (!targets) {
return res.status(400).json({
error:
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
});
}
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
return res
.status(400)
.json({ error: "Target user ID is required when sharing with user" });
}
if (targetType === "role" && !targetRoleId) {
return res
.status(400)
.json({ error: "Target role ID is required when sharing with role" });
const { durationHours, permissionLevel = "connect" } = req.body;
if (!isSharePermissionLevel(permissionLevel)) {
return res.status(400).json({
error: "Invalid permission level",
validLevels: SHARE_PERMISSION_LEVELS,
});
}
const host =
await createCurrentHostResolutionRepository().findHostUpdateState(
hostId,
);
if (!host || host.userId !== userId) {
const sharing = await canManageHostSharing(userId, hostId);
if (!sharing.allowed) {
databaseLogger.warn("Permission denied", {
operation: "rbac_permission_denied",
userId,
@@ -120,141 +179,126 @@ router.post(
resourceId: hostId,
action: "share",
});
return res.status(403).json({ error: "Not host owner" });
return res.status(403).json({ error: "You may not share this host" });
}
if (
!host.credentialId &&
!host.rdpCredentialId &&
!host.vncCredentialId &&
host.authType !== "opkssh"
) {
return res.status(400).json({
error:
"Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.",
code: "CREDENTIAL_REQUIRED_FOR_SHARING",
});
const host =
await createCurrentHostResolutionRepository().findHostUpdateState(
hostId,
);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const ownerId = host.userId;
if (targetType === "user") {
const targetUser =
await createCurrentUserRepository().findById(targetUserId);
const userRepository = createCurrentUserRepository();
const roleRepository = createCurrentRoleRepository();
for (const target of targets) {
if (target.type === "user") {
if (target.id === ownerId) {
return res
.status(400)
.json({ error: "Cannot share a host with its owner" });
}
const targetUser = await userRepository.findById(target.id as string);
if (!targetUser) {
return res.status(404).json({ error: "Target user not found" });
return res.status(404).json({
error: "Target user not found",
targetId: target.id,
});
}
} else {
const targetRole =
await createCurrentRoleRepository().findRoleById(targetRoleId);
const targetRole = await roleRepository.findRoleById(
target.id as number,
);
if (!targetRole) {
return res.status(404).json({ error: "Target role not found" });
}
}
let expiresAt: string | null = null;
if (
durationHours &&
typeof durationHours === "number" &&
durationHours > 0
) {
const expiryDate = new Date();
expiryDate.setHours(expiryDate.getHours() + durationHours);
expiresAt = expiryDate.toISOString();
}
const validLevels = ["view"];
if (!validLevels.includes(permissionLevel)) {
return res.status(400).json({
error: "Invalid permission level. Only 'view' is supported.",
validLevels,
return res.status(404).json({
error: "Target role not found",
targetId: target.id,
});
}
}
}
const accessGrant =
await createCurrentRbacAccessRepository().upsertHostAccess({
const expiresAt = expiryFromDuration(durationHours);
const rbacAccessRepository = createCurrentRbacAccessRepository();
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
const results: Array<{
type: "user" | "role";
id: string | number;
accessId: number;
created: boolean;
}> = [];
for (const target of targets) {
const accessGrant = await rbacAccessRepository.upsertHostAccess({
hostId,
grantedBy: userId,
permissionLevel,
expiresAt,
...(targetType === "user"
? { targetType: "user" as const, targetUserId: targetUserId! }
: { targetType: "role" as const, targetRoleId: targetRoleId! }),
...(target.type === "user"
? { targetType: "user" as const, targetUserId: target.id as string }
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
if (!accessGrant.created) {
const activeCredentialId =
host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId;
if (activeCredentialId) {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
await sharedCredManager.createSharedCredentialForUser(
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
accessGrant.id,
activeCredentialId,
targetUserId!,
userId,
);
} else {
await sharedCredManager.createSharedCredentialsForRole(
accessGrant.id,
activeCredentialId,
targetRoleId!,
userId,
);
}
}
databaseLogger.info("Permission granted", {
operation: "rbac_permission_grant",
adminId: userId,
hostId,
resource: "host",
action: "view",
});
return res.json({
success: true,
message: "Host access updated",
expiresAt,
});
}
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,
target.id as string,
ownerId,
);
} else {
await sharedCredManager.createSharedCredentialsForRole(
await secretsManager.snapshotForRole(
accessGrant.id,
activeCredentialId,
targetRoleId!,
userId,
hostId,
target.id as number,
ownerId,
);
}
} catch (snapshotError) {
databaseLogger.warn("Share created but secret snapshot failed", {
operation: "rbac_host_share_snapshot_failed",
hostId,
accessId: accessGrant.id,
error:
snapshotError instanceof Error
? snapshotError.message
: "Unknown error",
});
}
results.push({
type: target.type,
id: target.id,
accessId: accessGrant.id,
created: accessGrant.created,
});
}
databaseLogger.success("Host shared successfully", {
operation: "rbac_host_share_success",
userId,
hostId,
targetUserId: targetType === "user" ? targetUserId : undefined,
targets: results.length,
permissionLevel,
});
res.json({
success: true,
message: `Host shared successfully with ${targetType}`,
message: "Host shared successfully",
permissionLevel,
expiresAt,
results,
});
} catch (error) {
databaseLogger.error("Failed to share host", error, {
@@ -267,6 +311,143 @@ router.post(
},
);
/**
* @openapi
* /rbac/host/{id}/access/{accessId}:
* patch:
* summary: Update a host access grant
* description: Changes the permission level and/or expiry of an existing host access grant. Allowed for the host owner or recipients holding the manage level.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: path
* name: accessId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* permissionLevel:
* type: string
* enum: [connect, view, edit, manage]
* durationHours:
* type: number
* nullable: true
* description: Hours from now until the grant expires; null clears the expiry.
* responses:
* 200:
* description: Grant updated successfully.
* 400:
* description: Invalid request.
* 403:
* description: Caller may not manage sharing on this host.
* 404:
* description: Grant not found.
* 500:
* description: Failed to update grant.
*/
router.patch(
"/host/:id/access/:accessId",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId)
? req.params.accessId[0]
: req.params.accessId;
const hostId = parseInt(id, 10);
const accessId = parseInt(accessIdParam, 10);
const userId = req.userId!;
if (isNaN(hostId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
const sharing = await canManageHostSharing(userId, hostId);
if (!sharing.allowed) {
return res
.status(403)
.json({ error: "You may not manage sharing on this host" });
}
const { permissionLevel, durationHours } = req.body ?? {};
if (permissionLevel === undefined && durationHours === undefined) {
return res.status(400).json({
error: "At least one of permissionLevel or durationHours is required",
});
}
if (
permissionLevel !== undefined &&
!isSharePermissionLevel(permissionLevel)
) {
return res.status(400).json({
error: "Invalid permission level",
validLevels: SHARE_PERMISSION_LEVELS,
});
}
const rbacAccessRepository = createCurrentRbacAccessRepository();
const grant = await rbacAccessRepository.findHostAccessById(
accessId,
hostId,
);
if (!grant) {
return res.status(404).json({ error: "Access grant not found" });
}
const update: { permissionLevel?: string; expiresAt?: string | null } =
{};
if (permissionLevel !== undefined) {
update.permissionLevel = permissionLevel;
}
if (durationHours !== undefined) {
update.expiresAt =
durationHours === null ? null : expiryFromDuration(durationHours);
}
await rbacAccessRepository.updateHostAccessGrant(
accessId,
hostId,
update,
);
databaseLogger.info("Host access grant updated", {
operation: "rbac_host_access_update",
userId,
hostId,
accessId,
permissionLevel,
});
res.json({
success: true,
message: "Access updated",
expiresAt: update.expiresAt ?? grant.expiresAt,
});
} catch (error) {
databaseLogger.error("Failed to update host access", error, {
operation: "update_host_access",
hostId,
accessId,
userId,
});
res.status(500).json({ error: "Failed to update access" });
}
},
);
/**
* @openapi
* /rbac/host/{id}/access/{accessId}:
@@ -292,7 +473,7 @@ router.post(
* 400:
* description: Invalid ID.
* 403:
* description: Not host owner.
* description: Caller may not manage sharing on this host.
* 500:
* description: Failed to revoke access.
*/
@@ -313,14 +494,11 @@ router.delete(
}
try {
const isHostOwner =
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
);
if (!isHostOwner) {
return res.status(403).json({ error: "Not host owner" });
const sharing = await canManageHostSharing(userId, hostId);
if (!sharing.allowed) {
return res
.status(403)
.json({ error: "You may not manage sharing on this host" });
}
await createCurrentRbacAccessRepository().revokeHostAccess(
@@ -363,11 +541,11 @@ router.delete(
* type: integer
* responses:
* 200:
* description: The access list for the host.
* description: The access list for the host, including each grant's permission level.
* 400:
* description: Invalid host ID.
* 403:
* description: Not host owner.
* description: Caller may not manage sharing on this host.
* 500:
* description: Failed to get access list.
*/
@@ -384,20 +562,17 @@ router.get(
}
try {
const isHostOwner =
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
);
if (!isHostOwner) {
return res.status(403).json({ error: "Not host owner" });
const sharing = await canManageHostSharing(userId, hostId);
if (!sharing.allowed) {
return res
.status(403)
.json({ error: "You may not manage sharing on this host" });
}
const accessList =
await createCurrentRbacAccessRepository().listHostAccess(hostId);
res.json({ accessList });
res.json({ accessList, isOwner: sharing.isOwner });
} catch (error) {
databaseLogger.error("Failed to get host access list", error, {
operation: "get_host_access_list",
@@ -475,17 +650,30 @@ router.get(
displayName,
description,
isSystem,
permissions,
createdAt,
updatedAt,
}) => ({
}) => {
let parsedPermissions: string[] = [];
try {
parsedPermissions = permissions
? (JSON.parse(permissions) as string[])
: [];
} catch {
parsedPermissions = [];
}
return {
id,
name,
displayName,
description,
isSystem,
permissions: parsedPermissions,
createdAt,
updatedAt,
}),
};
},
);
res.json({ roles: rolesList });
@@ -606,6 +794,11 @@ router.post(
* type: string
* description:
* type: string
* permissions:
* type: array
* items:
* type: string
* description: Permission strings validated against the permissions catalog (wildcards like hosts.* and * allowed).
* responses:
* 200:
* description: Role updated successfully.
@@ -623,21 +816,47 @@ router.put(
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const roleId = parseInt(id, 10);
const { displayName, description } = req.body;
const { displayName, description, permissions } = req.body;
if (isNaN(roleId)) {
return res.status(400).json({ error: "Invalid role ID" });
}
if (!displayName && description === undefined) {
if (
!displayName &&
description === undefined &&
permissions === undefined
) {
return res.status(400).json({
error: "At least one field (displayName or description) is required",
error:
"At least one field (displayName, description or permissions) is required",
});
}
if (permissions !== undefined) {
if (
!Array.isArray(permissions) ||
permissions.some((perm) => typeof perm !== "string")
) {
return res
.status(400)
.json({ error: "permissions must be an array of strings" });
}
const invalid = (permissions as string[]).filter(
(perm) => !isValidPermission(perm),
);
if (invalid.length > 0) {
return res.status(400).json({
error: "Unknown permissions",
invalid,
});
}
}
try {
const existingRole =
await createCurrentRoleRepository().findRoleById(roleId);
const roleRepository = createCurrentRoleRepository();
const existingRole = await roleRepository.findRoleById(roleId);
if (!existingRole) {
return res.status(404).json({ error: "Role not found" });
@@ -646,6 +865,7 @@ router.put(
const updates: {
displayName?: string;
description?: string | null;
permissions?: string | null;
updatedAt: string;
} = {
updatedAt: new Date().toISOString(),
@@ -659,7 +879,18 @@ router.put(
updates.description = description || null;
}
await createCurrentRoleRepository().updateRole(roleId, updates);
if (permissions !== undefined) {
updates.permissions = JSON.stringify(permissions);
}
await roleRepository.updateRole(roleId, updates);
if (permissions !== undefined) {
const memberIds = await roleRepository.listRoleUserIds(roleId);
for (const memberId of memberIds) {
permissionManager.invalidateUserPermissionCache(memberId);
}
}
res.json({
success: true,
@@ -675,6 +906,26 @@ router.put(
},
);
/**
* @openapi
* /rbac/permissions/catalog:
* get:
* summary: Get the role permissions catalog
* description: Returns the grouped catalog of role permission strings used by the role permissions editor.
* tags:
* - RBAC
* responses:
* 200:
* description: The permissions catalog.
*/
router.get(
"/permissions/catalog",
authenticateJWT,
async (_req: AuthenticatedRequest, res: Response) => {
res.json({ catalog: PERMISSION_CATALOG });
},
);
/**
* @openapi
* /rbac/roles/{id}:
@@ -836,38 +1087,24 @@ router.post(
grantedBy: currentUserId,
});
const hostsSharedWithRole =
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources(
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,
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().snapshotForRoleMember(
roleId,
targetUserId,
sharedHost.hostOwnerId,
);
} catch (error) {
databaseLogger.error(
"Failed to create shared credential for new role member",
"Failed to snapshot shared host secrets for new role member",
error,
{
operation: "assign_role_create_credentials",
operation: "assign_role_snapshot_secrets",
targetUserId,
roleId,
hostId: sharedHost.hostId,
},
);
}
}
}
permissionManager.invalidateUserPermissionCache(targetUserId);
databaseLogger.info("Role assigned to user", {
@@ -959,6 +1196,28 @@ router.delete(
roleId,
);
try {
const { createCurrentSharedHostSecretsRepository } =
await import("../repositories/factory.js");
await createCurrentSharedHostSecretsRepository().deleteForRoleMember(
roleId,
targetUserId,
);
} catch (cleanupError) {
databaseLogger.warn(
"Failed to clean shared host secrets after role removal",
{
operation: "remove_role_secret_cleanup",
targetUserId,
roleId,
error:
cleanupError instanceof Error
? cleanupError.message
: "Unknown error",
},
);
}
permissionManager.invalidateUserPermissionCache(targetUserId);
databaseLogger.info("Role removed from user", {
operation: "rbac_role_remove",
+4 -5
View File
@@ -58,12 +58,11 @@ async function syncSharedCredentialsForUserRoles(
operation: string,
) {
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.createSharedCredentialsForUserRoles(userId);
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
} catch (error) {
authLogger.warn("Failed to sync role shared credentials", {
authLogger.warn("Failed to sync role shared host secrets", {
operation,
userId,
error,
+5 -5
View File
@@ -182,7 +182,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
"execute",
"connect",
);
if (!accessInfo.hasAccess) {
@@ -294,13 +294,13 @@ export function registerDockerSshRoutes(app: express.Express): void {
if (userId !== ownerId) {
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedCred =
await sharedCredManager.getSharedCredentialForUser(
await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id,
userId,
"ssh",
);
if (sharedCred) {
+56 -15
View File
@@ -196,10 +196,13 @@ router.post(
return res.status(400).json({ error: "Invalid host ID" });
}
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
const hostResolutionRepository = createCurrentHostResolutionRepository();
// Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields.
const hostOwnerId =
await hostResolutionRepository.findHostOwnerId(hostId);
const host = hostOwnerId
? await hostResolutionRepository.findHostById(hostId, hostOwnerId)
: null;
if (!host) {
return res.status(404).json({ error: "Host not found" });
@@ -210,7 +213,7 @@ router.post(
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
"read",
"connect",
);
if (!accessInfo.hasAccess) {
@@ -294,8 +297,51 @@ router.post(
}
const hostRecord = host as Record<string, unknown>;
const hostRepository = createCurrentHostResolutionRepository();
const hostRepository = hostResolutionRepository;
const isSharedConnection = host.userId !== userId;
if (isSharedConnection) {
// Recipients never read the owner's raw secrets; wipe them and use
// the per-recipient snapshot for the requested protocol instead.
host.password = null;
host.rdpUser = null;
host.rdpPassword = null;
host.vncUser = null;
host.vncPassword = null;
host.telnetUser = null;
host.telnetPassword = null;
try {
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const secret =
await SharedHostSecretsManager.getInstance().getSecretForUser(
hostId,
userId,
connectionType as "rdp" | "vnc" | "telnet",
);
if (secret) {
if (connectionType === "rdp") {
host.rdpUser = secret.username ?? null;
host.rdpPassword = secret.password ?? null;
if (secret.domain) host.rdpDomain = secret.domain;
} else if (connectionType === "vnc") {
host.vncUser = secret.username ?? null;
host.vncPassword = secret.password ?? null;
} else if (connectionType === "telnet") {
host.telnetUser = secret.username ?? null;
host.telnetPassword = secret.password ?? null;
}
}
} catch (e) {
guacLogger.warn("Failed to resolve shared host secret", {
operation: "guac_shared_secret_resolve",
hostId,
protocol: connectionType,
error: e instanceof Error ? e.message : "Unknown",
});
}
} else {
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
const rdpEffectiveAuthType =
(host.rdpAuthType as string) ||
@@ -309,11 +355,9 @@ router.post(
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
try {
const cred =
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
const cred = await hostRepository.findCredentialByIdForUser(
host.rdpCredentialId as number,
host.userId as string,
userId,
);
if (cred) {
if (cred.username) host.rdpUser = cred.username;
@@ -331,11 +375,9 @@ router.post(
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
try {
const cred =
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
const cred = await hostRepository.findCredentialByIdForUser(
host.vncCredentialId as number,
host.userId as string,
userId,
);
if (cred) {
if (cred.password) host.vncPassword = cred.password;
@@ -355,11 +397,9 @@ router.post(
hostRecord.telnetCredentialId
) {
try {
const cred =
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
const cred = await hostRepository.findCredentialByIdForUser(
hostRecord.telnetCredentialId as number,
host.userId as string,
userId,
);
if (cred) {
if (cred.username) host.telnetUser = cred.username;
@@ -373,6 +413,7 @@ router.post(
});
}
}
}
let token: string;
let hostname = host.ip as string;
+108 -83
View File
@@ -9,6 +9,7 @@ import {
expandOidcUsername,
} from "./credential-username.js";
import type { SSHHost } from "../../types/index.js";
import type { HostAction } from "../utils/permission-manager.js";
const sshLogger = logger;
@@ -24,16 +25,28 @@ export async function resolveHostById(
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"read",
"connect",
);
if (!access.hasAccess) return null;
const repository = createCurrentHostResolutionRepository();
const resolvedHost = await repository.findHostById(hostId, userId);
// Decrypt under the owner's DEK: shared hosts carry owner-encrypted fields
// (socks5Password, inline auth, ...) that the requester's key cannot open.
const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId;
const resolvedHost = await repository.findHostById(hostId, ownerId);
if (!resolvedHost) return null;
const host = resolvedHost as Record<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
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
try {
@@ -78,88 +91,16 @@ export async function resolveHostById(
}
}
// Resolve credential if using credential-based auth
if (host.credentialId) {
const ownerId = (host.userId || userId) as string;
try {
// Try user's own override credential first
if (userId !== ownerId) {
const resolved = await resolveSharedSshSecrets(
host,
hostId,
userId,
repository,
);
if (!resolved) return null;
} else if (host.credentialId) {
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(
host.credentialId as number,
ownerId,
@@ -218,6 +159,90 @@ export async function resolveHostById(
return host as unknown as SSHHost;
}
/**
* Fill in SSH auth secrets for a shared (non-owner) requester. Order:
* the recipient's own override credential, then their re-encrypted share
* snapshot. Secret-less auth types (opkssh, vault, agent, none) pass through
* untouched. Returns false when a secret-bearing host has no usable source.
*/
async function resolveSharedSshSecrets(
host: Record<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).
*/
@@ -225,7 +250,7 @@ export async function checkHostAccess(
hostId: number,
userId: string,
hostUserId: string,
requiredPermission: "read" | "execute" = "execute",
requiredPermission: HostAction = "connect",
): Promise<boolean> {
if (userId === hostUserId) return true;
+14 -13
View File
@@ -36,35 +36,36 @@ async function resolveJumpHost(
): Promise<JumpHostConfig | null> {
try {
const repository = createCurrentHostResolutionRepository();
const resolvedHost = await repository.findHostById(hostId, userId);
const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId;
const resolvedHost = await repository.findHostById(hostId, ownerId);
if (!resolvedHost) {
return null;
}
const host = resolvedHost as Record<string, unknown>;
const ownerId = (host.userId || userId) as string;
if (host.credentialId) {
if (userId !== ownerId) {
try {
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
const secret =
await SharedHostSecretsManager.getInstance().getSecretForUser(
hostId,
userId,
"ssh",
);
if (sharedCred) {
if (secret) {
return {
...host,
password: sharedCred.password,
key: sharedCred.key,
keyPassword: sharedCred.keyPassword,
keyType: sharedCred.keyType,
authType: sharedCred.key
password: secret.password,
key: secret.key,
keyPassword: secret.keyPassword,
keyType: secret.keyType,
authType: secret.key
? "key"
: sharedCred.password
: secret.password
? "password"
: "none",
} as JumpHostConfig;
+3 -2
View File
@@ -2,13 +2,14 @@ import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js";
import { statsLogger } from "../../utils/logger.js";
import type { HostAction } from "../../utils/permission-manager.js";
type HistoryRoutesDeps = {
validateHostId: RequestHandler;
canAccessHost: (
userId: string,
hostId: number,
level: "read" | "write" | "execute" | "delete" | "share",
level: HostAction,
) => Promise<boolean>;
};
@@ -65,7 +66,7 @@ export function registerHostMetricsHistoryRoutes(
const userId = (req as AuthenticatedRequest).userId;
try {
const hasAccess = await canAccessHost(userId, hostId, "read");
const hasAccess = await canAccessHost(userId, hostId, "connect");
if (!hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
+8 -7
View File
@@ -898,7 +898,7 @@ async function fetchHostById(
const accessInfo = await permissionManager.canAccessHost(
userId,
id,
"read",
"connect",
);
if (!accessInfo.hasAccess) {
@@ -991,12 +991,13 @@ async function resolveHostCredentials(
const isSharedHost = userId !== ownerId;
if (isSharedHost) {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedCred =
await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id as number,
userId,
"ssh",
);
if (sharedCred) {
@@ -1730,7 +1731,7 @@ app.get("/status", async (req, res) => {
const result: Record<number, StatusEntry> = {};
for (const [id, entry] of pollingManager.getAllStatuses().entries()) {
const access = await permissionManager.canAccessHost(userId, id, "read");
const access = await permissionManager.canAccessHost(userId, id, "connect");
if (access.hasAccess) {
result[id] = entry;
}
@@ -1771,7 +1772,7 @@ app.get("/status/:id", validateHostId, async (req, res) => {
});
}
const access = await permissionManager.canAccessHost(userId, id, "read");
const access = await permissionManager.canAccessHost(userId, id, "connect");
if (!access.hasAccess) {
return res.status(404).json({ error: "Status not available" });
}
+2 -2
View File
@@ -85,7 +85,7 @@ export function registerCronRoutes(
app.get(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(runOnHost, "read", "cron_list", async (client) => {
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
return { entries: parseCrontab(stdout) };
}),
@@ -96,7 +96,7 @@ export function registerCronRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"cron_replace",
async (client, _host, req) => {
const { entries } = req.body as { entries?: CronEntry[] };
@@ -51,7 +51,7 @@ export function registerFirewallRoutes(
app.get(
"/host-metrics/managers/firewall/:id",
validateHostId,
managerHandler(runOnHost, "read", "firewall_read", async (client) => {
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
return await collectFirewallMetrics(client);
}),
);
@@ -61,7 +61,7 @@ export function registerFirewallRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"firewall_rule",
async (client, host, req) => {
const { op, protocol, port, target } = req.body as {
@@ -106,7 +106,7 @@ export function registerFirewallRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"firewall_persist",
async (client, host) => {
const platform = await detectPlatform(client);
+3 -3
View File
@@ -147,7 +147,7 @@ export function registerHealthRoutes(
validateHostId,
managerHandler(
runOnHost,
"read",
"connect",
"health_get",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -191,7 +191,7 @@ export function registerHealthRoutes(
validateHostId,
managerHandler(
runOnHost,
"read",
"connect",
"health_config",
async (_client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -226,7 +226,7 @@ export function registerHealthRoutes(
validateHostId,
managerHandler(
runOnHost,
"read",
"connect",
"health_run",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
+1 -1
View File
@@ -45,7 +45,7 @@ export function registerManagerRoutes(
app.get(
"/host-metrics/platform/:id",
validateHostId,
managerHandler(runOnHost, "read", "platform_detect", (client) =>
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
detectPlatform(client),
),
);
+2 -2
View File
@@ -46,7 +46,7 @@ export function registerLogRoutes(
app.get(
"/host-metrics/managers/logs/:id/files",
validateHostId,
managerHandler(runOnHost, "read", "logs_list", async (client) => {
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
const found = stdout
.split("\n")
@@ -62,7 +62,7 @@ export function registerLogRoutes(
validateHostId,
managerHandler(
runOnHost,
"read",
"connect",
"logs_tail",
async (client, host, req) => {
const path = req.query.path as string | undefined;
@@ -96,7 +96,7 @@ export function registerPackageRoutes(
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(runOnHost, "read", "packages_list", async (client) => {
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
@@ -113,7 +113,7 @@ export function registerPackageRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"packages_action",
async (client, host, req) => {
const { action, pkg: name } = req.body as {
@@ -70,7 +70,7 @@ export function registerProcessRoutes(
app.get(
"/host-metrics/managers/processes/:id",
validateHostId,
managerHandler(runOnHost, "read", "processes_list", async (client) => {
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
return { processes: parseProcessList(stdout) };
}),
@@ -106,7 +106,7 @@ export function registerProcessRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"processes_signal",
async (client, host, req) => {
const { pid, signal } = req.body as {
@@ -4,6 +4,7 @@ import type { AuthenticatedRequest } from "../../../../types/index.js";
import { statsLogger } from "../../../utils/logger.js";
import { ElevationError } from "./exec-elevated.js";
import type { ManagerHost, RunOnHost } from "./types.js";
import type { HostAction } from "../../../utils/permission-manager.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
@@ -25,7 +26,7 @@ export class ManagerInputError extends Error {
*/
export function managerHandler(
runOnHost: RunOnHost,
level: "read" | "execute",
level: HostAction,
operation: string,
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
) {
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
app.get(
"/host-metrics/managers/services/:id",
validateHostId,
managerHandler(runOnHost, "read", "services_list", async (client) => {
managerHandler(runOnHost, "connect", "services_list", async (client) => {
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
return { services: parseServiceList(stdout) };
}),
@@ -106,7 +106,7 @@ export function registerServiceRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"services_action",
async (client, host, req) => {
const { unit, action } = req.body as {
@@ -112,7 +112,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/top-memory/:id",
validateHostId,
managerHandler(runOnHost, "read", "top_memory", async (client) => {
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
return { processes: parseTopMemory(stdout) };
}),
@@ -121,7 +121,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/timers/:id",
validateHostId,
managerHandler(runOnHost, "read", "systemd_timers", async (client) => {
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
return { timers: parseTimers(stdout) };
}),
@@ -130,7 +130,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/disk-breakdown/:id",
validateHostId,
managerHandler(runOnHost, "read", "disk_breakdown", async (client) => {
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
const { stdout } = await execCommand(client, DF_CMD, 15000);
return { mounts: parseDfMounts(stdout) };
}),
+4 -4
View File
@@ -153,7 +153,7 @@ export function registerSslRoutes(
app.get(
"/host-metrics/managers/ssl/:id",
validateHostId,
managerHandler(runOnHost, "read", "ssl_list", async (client, host) => {
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
const platform = await detectPlatform(client);
const certs: CertInfo[] = [];
if (platform.hasCertbot) {
@@ -187,7 +187,7 @@ export function registerSslRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"ssl_issue",
async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
@@ -236,7 +236,7 @@ export function registerSslRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"ssl_renew",
async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
@@ -289,7 +289,7 @@ export function registerSslRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"ssl_revoke",
async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
@@ -116,7 +116,7 @@ export function registerTailscaleRoutes(
app.get(
"/host-metrics/managers/tailscale/:id",
validateHostId,
managerHandler(runOnHost, "read", "tailscale_read", async (client) => {
managerHandler(runOnHost, "connect", "tailscale_read", async (client) => {
const { stdout } = await execCommand(client, PROBE_CMD, 15000);
return parseTailscaleData(stdout);
}),
@@ -154,7 +154,7 @@ export function registerTailscaleRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"tailscale_action",
async (client, host, req) => {
const { action } = req.body as { action: unknown };
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
import type { HostAction } from "../../../utils/permission-manager.js";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
@@ -17,7 +18,7 @@ export interface ManagerHost {
export type RunOnHost = <T>(
hostId: number,
userId: string,
level: "read" | "execute",
level: HostAction,
fn: (client: Client, host: ManagerHost) => Promise<T>,
) => Promise<T>;
+2 -2
View File
@@ -79,7 +79,7 @@ export function registerUserRoutes(
app.get(
"/host-metrics/managers/users/:id",
validateHostId,
managerHandler(runOnHost, "read", "users_list", async (client) => {
managerHandler(runOnHost, "connect", "users_list", async (client) => {
const [passwd, groups, sudoers] = await Promise.all([
execCommand(client, READ_USERS_CMD, 15000),
execCommand(client, READ_GROUPS_CMD, 15000),
@@ -98,7 +98,7 @@ export function registerUserRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"users_action",
async (client, host, req) => {
const { action, username, group } = req.body as {
@@ -143,7 +143,7 @@ export function registerWireGuardRoutes(
validateHostId,
managerHandler(
runOnHost,
"read",
"connect",
"wireguard_read",
async (client, host) => {
const result = await execElevated(
@@ -194,7 +194,7 @@ export function registerWireGuardRoutes(
validateHostId,
managerHandler(
runOnHost,
"execute",
"connect",
"wireguard_action",
async (client, host, req) => {
const { interface: iface, action } = req.body as {
@@ -7,6 +7,7 @@ import {
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../../../types/host-metrics.js";
import type { HostAction } from "../../utils/permission-manager.js";
interface PrefStatsConfig {
enabledWidgets?: string[];
@@ -25,7 +26,7 @@ type HostMetricsPreferencesRoutesDeps = {
canAccessHost: (
userId: string,
hostId: number,
level: "read" | "execute",
level: HostAction,
) => Promise<boolean>;
};
@@ -88,7 +89,7 @@ export function registerHostMetricsPreferencesRoutes(
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "read"))) {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
@@ -166,7 +167,7 @@ export function registerHostMetricsPreferencesRoutes(
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "read"))) {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
+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 { SSHHostKeyVerifier } from "../host-key-verifier.js";
import { resolveHostById, checkHostAccess } from "../host-resolver.js";
import type { HostAction } from "../../utils/permission-manager.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { applyAgentAuth } from "../terminal-auth-helpers.js";
import {
@@ -343,7 +344,7 @@ app.use(authManager.createAuthMiddleware());
async function requireHost(
req: express.Request,
res: express.Response,
permission: "read" | "execute" = "read",
permission: HostAction = "connect",
): Promise<SSHHost | null> {
const userId = (req as unknown as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.hostId), 10);
@@ -503,7 +504,7 @@ app.get("/tmux_monitor/:hostId/overview", async (req, res) => {
// Focus a pane: select its window and pane on the server so every attached
// client (including the monitor's embedded terminal) switches to it.
app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
const host = await requireHost(req, res, "execute");
const host = await requireHost(req, res, "connect");
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
@@ -530,7 +531,7 @@ app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
// Create a detached session. Starts the tmux server if none is running.
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
const host = await requireHost(req, res, "execute");
const host = await requireHost(req, res, "connect");
if (!host) return;
const name = String((req.body as { name?: string })?.name || "").trim();
@@ -563,7 +564,7 @@ app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
// target's meaning (":" and "." are window/pane separators in tmux targets);
// "=" prefixes the target for an exact-name match.
app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
const host = await requireHost(req, res, "execute");
const host = await requireHost(req, res, "connect");
if (!host) return;
const sessionName = String(
@@ -597,7 +598,7 @@ app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
// Rename a session. Saved tags follow the session to its new name (for every
// user — the session itself is shared on the host).
app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
const host = await requireHost(req, res, "execute");
const host = await requireHost(req, res, "connect");
if (!host) return;
const body = req.body as { sessionName?: string; newName?: string };
@@ -649,7 +650,7 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
// Kill a session and drop its saved tags.
app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
const host = await requireHost(req, res, "execute");
const host = await requireHost(req, res, "connect");
if (!host) return;
const sessionName = String(
@@ -688,7 +689,7 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
// Kill a window (and every pane in it). Killing the last window of a session
// ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
const host = await requireHost(req, res, "execute");
const host = await requireHost(req, res, "connect");
if (!host) return;
const body = req.body as { sessionName?: string; windowIndex?: number };
@@ -735,7 +736,7 @@ app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
// Kill a single pane. Killing the last pane of a window closes the window,
// and the last window of a session ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
const host = await requireHost(req, res, "execute");
const host = await requireHost(req, res, "connect");
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
@@ -766,7 +767,7 @@ app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
// "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
// source pane's working directory.
app.post("/tmux_monitor/:hostId/split", async (req, res) => {
const host = await requireHost(req, res, "execute");
const host = await requireHost(req, res, "connect");
if (!host) return;
const body = req.body as { paneId?: string; direction?: string };
+1 -1
View File
@@ -36,7 +36,7 @@ async function resolveC2STunnelSource(
const accessInfo = await permissionManager.canAccessHost(
userId,
tunnelConfig.sourceHostId,
"read",
"connect",
);
if (!accessInfo.hasAccess) {
throw new Error("Access denied to this host");
+4 -4
View File
@@ -199,7 +199,7 @@ export function registerTunnelRoutes(app: express.Express): void {
const accessInfo = await permissionManager.canAccessHost(
userId,
tunnelConfig.sourceHostId,
"read",
"connect",
);
if (!accessInfo.hasAccess) {
@@ -285,7 +285,7 @@ export function registerTunnelRoutes(app: express.Express): void {
const endpointAccess = await permissionManager.canAccessHost(
userId,
endpointHost.id,
"read",
"connect",
);
if (!endpointAccess.hasAccess) {
tunnelLogger.warn(
@@ -442,7 +442,7 @@ export function registerTunnelRoutes(app: express.Express): void {
const accessInfo = await permissionManager.canAccessHost(
userId,
config.sourceHostId,
"read",
"connect",
);
if (!accessInfo.hasAccess) {
return res.status(403).json({ error: "Access denied" });
@@ -547,7 +547,7 @@ export function registerTunnelRoutes(app: express.Express): void {
const accessInfo = await permissionManager.canAccessHost(
userId,
config.sourceHostId,
"read",
"connect",
);
if (!accessInfo.hasAccess) {
return res.status(403).json({ error: "Access denied" });
+4
View File
@@ -101,6 +101,10 @@ import {
await authManager.initialize();
DataCrypto.initialize();
const { runSharedHostSecretsMigration } =
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
await runSharedHostSecretsMigration();
import("./utils/opkssh-binary-manager.js").then(
({ OPKSSHBinaryManager }) => {
OPKSSHBinaryManager.ensureBinary().catch((error) => {
@@ -322,6 +322,7 @@ describe("HostResolutionRepository", () => {
rdpCredentialId: null,
vncCredentialId: null,
telnetCredentialId: null,
vaultProfileId: null,
authType: "password",
});
await expect(repository.findHostUpdateState(999)).resolves.toBeNull();
@@ -52,19 +52,23 @@ describe("RbacAccessRepository", () => {
override_credential_id INTEGER
);
CREATE TABLE shared_credentials (
CREATE TABLE shared_host_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_access_id INTEGER NOT NULL,
original_credential_id INTEGER NOT NULL,
target_user_id TEXT NOT NULL,
encrypted_username TEXT NOT NULL,
encrypted_auth_type TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'ssh',
source_type TEXT NOT NULL DEFAULT 'credential',
original_credential_id INTEGER,
encrypted_username TEXT,
encrypted_auth_type TEXT,
encrypted_password TEXT,
encrypted_key TEXT,
encrypted_key_password TEXT,
encrypted_key_type TEXT,
encrypted_domain TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(host_access_id, target_user_id, protocol)
);
CREATE TABLE ssh_data (
@@ -128,10 +132,12 @@ describe("RbacAccessRepository", () => {
(2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'),
(5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
INSERT INTO shared_credentials (
id, host_access_id, original_credential_id, target_user_id, encrypted_username, encrypted_auth_type
INSERT INTO shared_host_secrets (
id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type
)
VALUES (8, 2, 123, 'user-1', 'enc-user', 'enc-auth');
VALUES
(8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'),
(9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct');
INSERT INTO snippets (id, user_id, name, content)
VALUES (99, 'owner-1', 'deploy', 'echo deploy');
@@ -261,14 +267,16 @@ describe("RbacAccessRepository", () => {
]);
});
it("finds shared credential and host access owner for shared credential reads", async () => {
it("finds shared secrets per protocol and host access owner", async () => {
const repo = await createRepository();
await expect(
repo.findSharedCredentialForHostAndUser(42, "user-1"),
repo.findSharedSecretForHostUserProtocol(42, "user-1", "ssh"),
).resolves.toMatchObject({
id: 8,
hostAccessId: 2,
protocol: "ssh",
sourceType: "credential",
originalCredentialId: 123,
targetUserId: "user-1",
encryptedUsername: "enc-user",
@@ -276,12 +284,62 @@ describe("RbacAccessRepository", () => {
});
await expect(
repo.findSharedCredentialForHostAndUser(99, "user-1"),
repo.findSharedSecretForHostUserProtocol(42, "user-1", "rdp"),
).resolves.toMatchObject({
id: 9,
protocol: "rdp",
sourceType: "inline",
originalCredentialId: null,
});
await expect(
repo.findSharedSecretForHostUserProtocol(42, "user-1", "vnc"),
).resolves.toBeNull();
await expect(
repo.findSharedSecretForHostUserProtocol(99, "user-1", "ssh"),
).resolves.toBeNull();
await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1");
await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull();
});
it("lists active grants, finds grants by id and updates grant level/expiry", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const grants = await repo.listActiveHostAccessGrants(42, activeAccessTime);
expect(grants.map((grant) => grant.id).sort()).toEqual([1, 2]);
// Host 44's only grant expired before activeAccessTime.
await expect(
repo.listActiveHostAccessGrants(44, activeAccessTime),
).resolves.toEqual([]);
await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({
id: 1,
hostId: 42,
permissionLevel: "view",
});
await expect(repo.findHostAccessById(1, 99)).resolves.toBeNull();
await expect(
repo.updateHostAccessGrant(1, 42, {
permissionLevel: "manage",
expiresAt: "2026-07-01T00:00:00.000Z",
}),
).resolves.toBe(true);
await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({
permissionLevel: "manage",
expiresAt: "2026-07-01T00:00:00.000Z",
});
await expect(
repo.updateHostAccessGrant(999, 42, { permissionLevel: "view" }),
).resolves.toBe(false);
expect(writeCount).toBe(1);
});
it("lists shared snippets and preserves route-level direct-over-role behavior", async () => {
const repo = await createRepository();
@@ -92,6 +92,8 @@ describe("SessionRecordingRepository", () => {
expect(rows[0]).toMatchObject({
hostIp: "10.0.0.2",
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,
normalizeImportedHost,
renameFolderPath,
sanitizeHostForRecipient,
stripSensitiveFields,
transformHostResponse,
} from "../../../database/routes/host-normalizers.js";
@@ -219,3 +220,58 @@ describe("transformHostResponse", () => {
expect(result.proxmoxConfig).toBeUndefined();
});
});
describe("sanitizeHostForRecipient", () => {
const sharedHost = {
id: 42,
userId: "owner",
ownerUsername: "owner",
isShared: true,
permissionLevel: "view",
name: "prod",
ip: "10.0.0.42",
port: 22,
username: "root",
folder: "servers",
tags: ["linux"],
notes: "secret runbook",
quickActions: [{ name: "restart", snippetId: "1" }],
password: "hunter2",
key: "PRIVATE",
sudoPassword: "sudo",
rdpPassword: "rdp",
socks5Password: "socks",
enableSsh: true,
enableRdp: true,
sshPort: 22,
rdpPort: 3389,
defaultPath: "/srv",
};
it("always strips secrets for recipients", () => {
const result = sanitizeHostForRecipient({ ...sharedHost }, "view");
expect(result.password).toBeUndefined();
expect(result.key).toBeUndefined();
expect(result.sudoPassword).toBeUndefined();
expect(result.rdpPassword).toBeUndefined();
expect(result.socks5Password).toBeUndefined();
// view keeps configuration fields
expect(result.notes).toBe("secret runbook");
expect(result.quickActions).toEqual(sharedHost.quickActions);
});
it("reduces connect-level hosts to connection essentials", () => {
const result = sanitizeHostForRecipient(
{ ...sharedHost, permissionLevel: "connect" },
"connect",
);
expect(result.name).toBe("prod");
expect(result.ip).toBe("10.0.0.42");
expect(result.enableRdp).toBe(true);
expect(result.rdpPort).toBe(3389);
expect(result.permissionLevel).toBe("connect");
expect(result.notes).toBeUndefined();
expect(result.quickActions).toBeUndefined();
expect(result.password).toBeUndefined();
});
});
@@ -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");
type PermissionManagerInstance = ReturnType<
@@ -71,3 +104,65 @@ describe("PermissionManager.hasPermission wildcard matching", () => {
expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
});
});
describe("PermissionManager.canAccessHost level hierarchy", () => {
const manager = PermissionManager.getInstance();
const actions = ["connect", "view", "edit", "manage"] as const;
const levels = ["connect", "view", "edit", "manage"] as const;
const rank = { connect: 1, view: 2, edit: 3, manage: 4 } as const;
beforeEach(() => {
vi.restoreAllMocks();
accessState.ownerId = "owner";
accessState.grant = null;
accessState.touched = [];
});
it("grants the owner every action including delete", async () => {
for (const action of [...actions, "delete"] as const) {
const info = await manager.canAccessHost("owner", 42, action);
expect(info).toMatchObject({ hasAccess: true, isOwner: true });
}
});
it("denies everything without a grant", async () => {
const info = await manager.canAccessHost("stranger", 42, "connect");
expect(info).toMatchObject({ hasAccess: false, isShared: false });
});
it("enforces the connect < view < edit < manage hierarchy", async () => {
for (const level of levels) {
accessState.grant = { id: 5, permissionLevel: level, expiresAt: null };
for (const action of actions) {
const info = await manager.canAccessHost("recipient", 42, action);
expect(info.hasAccess).toBe(rank[level] >= rank[action]);
expect(info.permissionLevel).toBe(level);
expect(info.isShared).toBe(true);
}
}
});
it("never grants delete to a shared recipient", async () => {
accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
const info = await manager.canAccessHost("recipient", 42, "delete");
expect(info.hasAccess).toBe(false);
});
it("normalizes the legacy 'view' string mapping and unknown levels to connect", async () => {
accessState.grant = { id: 5, permissionLevel: "bogus", expiresAt: null };
const connect = await manager.canAccessHost("recipient", 42, "connect");
expect(connect.hasAccess).toBe(true);
expect(connect.permissionLevel).toBe("connect");
const view = await manager.canAccessHost("recipient", 42, "view");
expect(view.hasAccess).toBe(false);
});
it("only touches the grant timestamp on connect", async () => {
accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
await manager.canAccessHost("recipient", 42, "manage");
expect(accessState.touched).toEqual([]);
await manager.canAccessHost("recipient", 42, "connect");
expect(accessState.touched).toEqual([5]);
});
});
@@ -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 { DataCrypto } from "../data-crypto.js";
import { DatabaseSaveTrigger } from "../database-save-trigger.js";
import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js";
import { SharedCredentialManager } from "../shared-credential-manager.js";
interface SqliteLike {
prepare(sql: string): {
@@ -34,83 +32,17 @@ function dropColumnIfExists(
return true;
}
interface PendingShareRow {
id: number;
host_access_id: number;
original_credential_id: number;
target_user_id: string;
}
// One-time cleanup of the pre-2.5 sharing machinery: pending share copies
// (rows that were waiting for an offline user's DEK) are re-created now that
// every migrated user's DEK is server-side, then the queue flag and the
// CREDENTIAL_SHARING_KEY shadow columns are dropped.
// One-time cleanup of the pre-2.5 sharing machinery: the
// CREDENTIAL_SHARING_KEY shadow columns are dropped. Pending share copies are
// no longer re-created here; the shared_host_secrets migration rebuilds every
// active share from the live host_access grants instead.
export async function runLegacySharedCredentialCleanup(): Promise<{
resolved: number;
dropped: number;
columnsDropped: number;
}> {
const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike;
const result = { resolved: 0, dropped: 0, columnsDropped: 0 };
if (tableHasColumn(sqlite, "shared_credentials", "needs_re_encryption")) {
const pendingRows = sqlite
.prepare(
`SELECT id, host_access_id, original_credential_id, target_user_id
FROM shared_credentials
WHERE needs_re_encryption = 1 OR encrypted_username = ''`,
)
.all() as PendingShareRow[];
for (const row of pendingRows) {
const owner = sqlite
.prepare(
`SELECT h.user_id AS ownerId
FROM host_access ha JOIN ssh_data h ON h.id = ha.host_id
WHERE ha.id = ?`,
)
.get(row.host_access_id) as { ownerId?: string } | undefined;
sqlite.prepare(`DELETE FROM shared_credentials WHERE id = ?`).run(row.id);
const ownerId = owner?.ownerId;
if (
ownerId &&
DataCrypto.canUserAccessData(ownerId) &&
DataCrypto.canUserAccessData(row.target_user_id)
) {
try {
await SharedCredentialManager.getInstance().createSharedCredentialForUser(
row.host_access_id,
row.original_credential_id,
row.target_user_id,
ownerId,
);
result.resolved++;
continue;
} catch (error) {
databaseLogger.warn("Could not re-create pending shared credential", {
operation: "legacy_share_cleanup_recreate_failed",
sharedCredentialId: row.id,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
result.dropped++;
databaseLogger.warn(
"Dropped unresolvable pending shared credential; the owner can re-share the host",
{
operation: "legacy_share_cleanup_dropped",
hostAccessId: row.host_access_id,
targetUserId: row.target_user_id,
},
);
}
}
const result = { columnsDropped: 0 };
for (const [table, column] of [
["shared_credentials", "needs_re_encryption"],
["ssh_credentials", "system_password"],
["ssh_credentials", "system_key"],
["ssh_credentials", "system_key_password"],
@@ -120,7 +52,7 @@ export async function runLegacySharedCredentialCleanup(): Promise<{
}
}
if (result.resolved || result.dropped || result.columnsDropped) {
if (result.columnsDropped) {
await DatabaseSaveTrigger.forceSave("legacy_share_cleanup");
databaseLogger.info("Legacy shared-credential cleanup finished", {
operation: "legacy_share_cleanup",
@@ -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);
}
+47 -9
View File
@@ -12,11 +12,32 @@ interface AuthenticatedRequest extends Request {
dataKey?: Buffer;
}
const SHARE_PERMISSION_LEVELS = ["connect", "view", "edit", "manage"] as const;
type SharePermissionLevel = (typeof SHARE_PERMISSION_LEVELS)[number];
type HostAction = SharePermissionLevel | "delete";
const LEVEL_RANK: Record<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 {
hasAccess: boolean;
isOwner: boolean;
isShared: boolean;
permissionLevel?: "view";
permissionLevel?: SharePermissionLevel;
expiresAt?: string | null;
}
@@ -146,7 +167,7 @@ class PermissionManager {
async canAccessHost(
userId: string,
hostId: number,
action: "read" | "write" | "execute" | "delete" | "share" = "read",
action: HostAction = "connect",
): Promise<HostAccessInfo> {
try {
const hostResolutionRepository = createCurrentHostResolutionRepository();
@@ -180,30 +201,41 @@ class PermissionManager {
};
}
if (action === "write" || action === "delete") {
const grantedLevel = normalizeSharePermissionLevel(
access.permissionLevel,
);
if (
action === "delete" ||
LEVEL_RANK[grantedLevel] < LEVEL_RANK[action]
) {
return {
hasAccess: false,
isOwner: false,
isShared: true,
permissionLevel: access.permissionLevel as "view",
permissionLevel: grantedLevel,
expiresAt: access.expiresAt,
};
}
if (action === "connect") {
try {
await createCurrentRbacAccessRepository().touchHostAccess(access.id);
await createCurrentRbacAccessRepository().touchHostAccess(
access.id,
);
} catch (error) {
databaseLogger.warn("Failed to update host access timestamp", {
operation: "update_host_access_timestamp",
error,
});
}
}
return {
hasAccess: true,
isOwner: false,
isShared: true,
permissionLevel: access.permissionLevel as "view",
permissionLevel: grantedLevel,
expiresAt: access.expiresAt,
};
}
@@ -283,7 +315,7 @@ class PermissionManager {
requireHostAccess(
hostIdParam: string = "id",
action: "read" | "write" | "execute" | "delete" | "share" = "read",
action: HostAction = "connect",
) {
return async (
req: AuthenticatedRequest,
@@ -358,5 +390,11 @@ class PermissionManager {
}
}
export { PermissionManager };
export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult };
export { PermissionManager, SHARE_PERMISSION_LEVELS, LEVEL_RANK };
export type {
AuthenticatedRequest,
HostAccessInfo,
PermissionCheckResult,
SharePermissionLevel,
HostAction,
};
@@ -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;
isShared?: boolean;
permissionLevel?: "view";
permissionLevel?: "connect" | "view" | "edit" | "manage";
sharedExpiresAt?: string;
ownerUsername?: string;
}
export interface JumpHostData {
+7
View File
@@ -168,8 +168,15 @@ export type Host = {
guacamoleConfig?: Record<string, unknown>;
forceKeyboardInteractive?: boolean;
isShared?: boolean;
permissionLevel?: SharePermissionLevel;
sharedExpiresAt?: string;
ownerUsername?: string;
};
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
export type Credential = {
id: string;
name: string;
+79 -6
View File
@@ -28,6 +28,7 @@ export async function updateRole(
roleData: {
displayName?: string;
description?: string | null;
permissions?: string[];
},
): Promise<{ role: Role }> {
try {
@@ -88,16 +89,30 @@ export async function removeRoleFromUser(
}
}
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
export interface ShareTarget {
type: "user" | "role";
id: string | number;
}
export async function shareHost(
hostId: number,
shareData: {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
permissionLevel: "view";
targets: ShareTarget[];
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
): Promise<{ success: boolean }> {
): Promise<{
success: boolean;
expiresAt: string | null;
results: Array<{
type: "user" | "role";
id: string | number;
accessId: number;
created: boolean;
}>;
}> {
try {
const response = await rbacApi.post(
`/rbac/host/${hostId}/share`,
@@ -109,9 +124,28 @@ export async function shareHost(
}
}
export async function updateHostAccess(
hostId: number,
accessId: number,
update: {
permissionLevel?: SharePermissionLevel;
durationHours?: number | null;
},
): Promise<{ success: boolean; expiresAt: string | null }> {
try {
const response = await rbacApi.patch(
`/rbac/host/${hostId}/access/${accessId}`,
update,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update host access");
}
}
export async function getHostAccess(
hostId: number,
): Promise<{ accessList: AccessRecord[] }> {
): Promise<{ accessList: AccessRecord[]; isOwner?: boolean }> {
try {
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
return response.data;
@@ -120,6 +154,45 @@ export async function getHostAccess(
}
}
export interface PermissionCatalogEntry {
group: string;
permissions: string[];
}
export async function getPermissionsCatalog(): Promise<{
catalog: PermissionCatalogEntry[];
}> {
try {
const response = await rbacApi.get("/rbac/permissions/catalog");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch permissions catalog");
}
}
export async function getSharedHosts(): Promise<{
sharedHosts: Array<{
id: number;
name: string | null;
ip: string;
port: number;
username: string;
folder: string | null;
tags: string | null;
permissionLevel: SharePermissionLevel;
expiresAt: string | null;
grantedBy: string;
ownerUsername: string;
}>;
}> {
try {
const response = await rbacApi.get("/rbac/shared-hosts");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch shared hosts");
}
}
export async function revokeHostAccess(
hostId: number,
accessId: number,
+321 -286
View File
@@ -106,8 +106,265 @@
"filterTagsGroup": "Tags"
},
"homepage": {
"failedToLoadAlerts": "Failed to load alerts",
"failedToDismissAlert": "Failed to dismiss alert"
"title": "Homepage",
"addWidget": "Add Widget",
"editWidget": "Edit Widget",
"deleteWidget": "Delete",
"widgetTypes": "Widget Types",
"serviceLink": "Service Link",
"folder": "Folder",
"clock": "Clock",
"notes": "Notes",
"hostStatus": "Host Status",
"bookmarkList": "Bookmarks",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"resetView": "Reset View",
"lockLayout": "Lock Layout",
"unlockLayout": "Unlock Layout",
"noWidgets": "Right-click or click + to add your first widget",
"openFullView": "Open Full View",
"previewTitle": "Homepage Preview",
"cancel": "Cancel",
"save": "Save",
"title_label": "Title",
"widgetTitlePlaceholder": "Widget title (optional)",
"url": "URL",
"imageUrl": "Custom Image URL",
"imageUrlHint": "Leave blank to use the site favicon automatically",
"showImage": "Show Image",
"description": "Description",
"color": "Color",
"icon": "Icon",
"expanded": "Expanded by default",
"timezone": "Timezone",
"showSeconds": "Show seconds",
"format12h": "12-hour",
"format24h": "24-hour",
"content": "Content",
"backgroundColor": "Background Color",
"host": "Host",
"showMetrics": "Show Metrics",
"links": "Links",
"addLink": "Add Link",
"linkLabel": "Label",
"linkUrl": "URL",
"removeLink": "Remove",
"categoryLinks": "Links",
"categoryInfo": "Info",
"categorySystem": "System",
"widgetServiceLinkDesc": "A clickable tile linking to a service URL",
"widgetFolderDesc": "A container to group related widgets",
"widgetClockDesc": "A live clock with configurable timezone",
"widgetNotesDesc": "A markdown notes widget",
"widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host",
"widgetBookmarkListDesc": "A list of quick links",
"copyLink": "Copy Link",
"linkCopied": "Link copied!",
"location": "Location",
"temperatureUnit": "Temperature Unit",
"showForecast": "Show 3-day forecast",
"scrolling": "Allow Scrolling",
"feedUrl": "Feed URL",
"maxItems": "Max Items",
"showDescription": "Show Description",
"widgetWeatherName": "Weather",
"widgetWeatherDesc": "Live weather for any location",
"widgetIframeName": "iFrame Embed",
"widgetIframeDesc": "Embed any URL in an iframe",
"widgetRssName": "RSS Feed",
"widgetRssDesc": "Display items from an RSS or Atom feed",
"dragToFolder": "Drag widgets here or use + to add",
"showDisk": "Show Disk Usage",
"addToFolder": "Add widget to folder",
"noHostSelected": "No host selected",
"metricsNotAvailable": "Metrics not available",
"selectHost": "Select a host...",
"displayedMetrics": "Displayed Metrics",
"metricCpu": "CPU",
"metricMemory": "Memory",
"metricDisk": "Disk",
"metricUptime": "Uptime",
"metricSystem": "System",
"metricOs": "OS",
"metricKernel": "Kernel",
"metricHostname": "Hostname",
"metricNetwork": "Network",
"metricProcesses": "Processes",
"metricProcessesTotal": "total",
"metricProcessesRunning": "running",
"categoryMonitoring": "Monitoring",
"loading": "Loading...",
"noData": "No data available",
"allClear": "All clear",
"acknowledgeAlert": "Acknowledge",
"noPingUrls": "No URLs configured",
"pingLabel": "Label",
"addPingUrl": "Add URL",
"showLatency": "Show Latency",
"refreshInterval": "Refresh Interval",
"seconds": "seconds",
"filterActivityTypes": "Filter Types",
"filterTypesHint": "Leave empty to show all",
"showTimestamp": "Show Timestamp",
"uptimeUnavailable": "Uptime unavailable",
"uptimeLabel": "Uptime",
"overviewVersion": "Version",
"overviewUpdate": "Up to date",
"overviewUpdateAvailable": "Update available",
"overviewDatabase": "Database",
"overviewUptime": "Uptime",
"noHosts": "No hosts configured",
"hostGridHosts": "Hosts",
"hostGridAllHint": "Leave empty to show all hosts",
"columns": "Columns",
"showIp": "Show IP Address",
"connectionType": "Connection Type",
"layout": "Layout",
"showStatus": "Show Status",
"showHostName": "Show Host Name",
"noDockerActivity": "No Docker activity",
"noActivity": "No activity",
"showAcknowledged": "Show Acknowledged",
"showCurrentValue": "Show Current Value",
"chartMetric": "Metric",
"metricRange": "Range",
"widgetMetricsChartName": "Metrics Chart",
"widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host",
"widgetHostGridName": "Host Grid",
"widgetHostGridDesc": "Grid view of SSH host statuses",
"widgetAlertFeedName": "Alert Feed",
"widgetAlertFeedDesc": "Live alert firings with acknowledge support",
"widgetPingStatusName": "Ping Status",
"widgetPingStatusDesc": "HTTP ping status for one or more URLs",
"widgetRecentActivityName": "Recent Activity",
"widgetRecentActivityDesc": "Scrollable feed of recent Termix activity",
"widgetTermixUptimeName": "Termix Uptime",
"widgetTermixUptimeDesc": "Live uptime counter for the Termix server",
"widgetSystemOverviewName": "System Overview",
"widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance",
"widgetSshQuickConnectName": "SSH Quick Connect",
"widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions",
"widgetDockerActivityName": "Docker Activity",
"widgetDockerActivityDesc": "Recent Docker container events across all hosts",
"widgetCalendarName": "Calendar",
"widgetCalendarDesc": "A monthly calendar with today highlighted",
"widgetCountdownName": "Countdown",
"widgetCountdownDesc": "Countdown timer to a target date",
"widgetSearchBarName": "Search Bar",
"widgetSearchBarDesc": "Quick web search widget",
"widgetTextBannerName": "Text Banner",
"widgetTextBannerDesc": "A bold label or section header for the canvas",
"widgetImageWidgetName": "Image",
"widgetImageWidgetDesc": "Display an image from a URL",
"widgetMarkdownNotesName": "Markdown Notes",
"widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering",
"widgetCustomApiName": "Custom API",
"widgetCustomApiDesc": "Fetch and display data from any JSON API",
"widgetServiceGridName": "Service Grid",
"widgetServiceGridDesc": "A configurable grid of service tile links",
"widgetDashboardLinksName": "Dashboard Links",
"widgetDashboardLinksDesc": "Display your configured service links from the dashboard",
"widgetSearchLinksName": "Search Shortcuts",
"widgetSearchLinksDesc": "Quick search shortcut buttons with inline input",
"widgetLinkTreeName": "Link Tree",
"widgetLinkTreeDesc": "Grouped sections of links with headings",
"calMon": "Mo",
"calTue": "Tu",
"calWed": "We",
"calThu": "Th",
"calFri": "Fr",
"calSat": "Sa",
"calSun": "Su",
"startOnMonday": "Start week on Monday",
"countdownNoDate": "No target date set",
"countdownPast": "Event has passed",
"countdownDays": "days",
"countdownHours": "hrs",
"countdownMinutes": "min",
"countdownSeconds": "sec",
"countdownLabel": "Label",
"countdownLabelPlaceholder": "e.g. Launch Day",
"countdownShowDays": "Show Days",
"countdownShowHours": "Show Hours",
"targetDate": "Target Date",
"searchEngine": "Search Engine",
"customSearchUrl": "Custom Search URL",
"searchPlaceholder": "Search...",
"searchGo": "Go",
"searchPlaceholderLabel": "Placeholder Text",
"searchPlaceholderHint": "Text shown inside the search input",
"openInNewTab": "Open in New Tab",
"searchQueryPlaceholder": "Enter query...",
"noSearchShortcuts": "No shortcuts configured",
"addSearchShortcut": "Add Shortcut",
"fontSize": "Font Size",
"textAlign": "Text Align",
"fontWeight": "Font Weight",
"clearColor": "Clear Color",
"imageFit": "Image Fit",
"imageLinkUrl": "Link URL",
"noImage": "No image URL set",
"altText": "Alt Text",
"altTextPlaceholder": "Describe the image",
"renderMarkdown": "Render Markdown",
"displayMode": "Display Mode",
"displayField": "Display Field",
"jsonPath": "JSON Path",
"customApiLabel": "Label",
"customApiLabelPlaceholder": "e.g. Temperature",
"customApiUnit": "Unit",
"customApiNoUrl": "No API URL configured",
"customApiError": "Failed to fetch",
"customApiNotArray": "Response is not an array",
"addService": "Add Service",
"showLabels": "Show Labels",
"iconSize": "Icon Size",
"noDashboardLinks": "No dashboard links configured",
"noLimit": "No limit",
"sectionHeading": "Section Heading",
"addSection": "Add Section",
"compactMode": "Compact Mode",
"showDetailed": "Show Detailed",
"selectHosts": "Select Hosts",
"allHosts": "All hosts",
"listLayout": "List",
"gridLayout": "Grid",
"terminal": "Terminal",
"files": "Files",
"docker": "Docker",
"range15m": "15 minutes",
"range1h": "1 hour",
"range6h": "6 hours",
"range24h": "24 hours",
"metricNetRx": "Net Download",
"metricNetTx": "Net Upload",
"severityFilter": "Severity Filter",
"filterAll": "All",
"accentColor": "Accent Color",
"widgetSshTerminalName": "SSH Terminal",
"widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host",
"sshTerminalNoHost": "No host configured",
"sshTerminalConnect": "Connect",
"sshTerminalAutoConnect": "Auto-connect on load",
"widgetQuickConnectName": "Quick Connect",
"widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts",
"connectionTypes": "Connection Types",
"connType_terminal": "Terminal",
"connType_files": "File Manager",
"connType_docker": "Docker",
"connType_tunnel": "Tunnel",
"connType_host-metrics": "Host Metrics",
"connType_rdp": "RDP",
"connType_vnc": "VNC",
"connType_telnet": "Telnet",
"widgetFileManagerName": "File Manager",
"widgetFileManagerDesc": "Embedded SFTP file manager for a configured host",
"widgetDockerName": "Docker Manager",
"widgetDockerDesc": "Embedded Docker container manager for a configured host",
"widgetTunnelName": "Tunnel Manager",
"widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host",
"widgetNoHostSelected": "No host configured"
},
"serverConfig": {
"title": "Server Configuration",
@@ -317,7 +574,7 @@
"status": "Status",
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
"failedToRenameFolder": "Failed to rename folder",
"movedToFolder": "Moved to \"{{folder}}\"",
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
"editHostTooltip": "Edit host",
"statusChecks": "Status Checks",
"metricsCollection": "Metrics Collection",
@@ -756,7 +1013,6 @@
"failedToSaveFolder": "Failed to save folder",
"folderDeleted": "Deleted folder \"{{name}}\"",
"deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.",
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
"failedToMoveHosts": "Failed to move hosts",
"expandAll": "Expand all folders",
"collapseAll": "Collapse all folders",
@@ -806,7 +1062,6 @@
"failedToDeployKey": "Failed to deploy key",
"deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.",
"movedToRoot": "Moved to root",
"failedToMoveHosts": "Failed to move hosts",
"enableTerminalFeature": "Enable Terminal",
"disableTerminalFeature": "Disable Terminal",
"enableFilesFeature": "Enable Files",
@@ -927,7 +1182,57 @@
"shareHost": "Share Host",
"shareHostTitle": "Share: {{name}}",
"sharing": {
"requiresCredential": "This host must use a credential to enable sharing. Edit the host and assign a credential first."
"loadError": "Failed to load sharing data. Please try again.",
"shareWithSection": "Share with",
"usersTab": "Users",
"rolesTab": "Roles",
"searchPlaceholder": "Search users or roles...",
"noMatches": "No matches found",
"permissionLevelLabel": "Permission level",
"levels": {
"connect": {
"label": "Connect",
"description": "Open sessions only: terminal, remote desktop, file manager, tunnels and Docker. No access to the host configuration."
},
"view": {
"label": "View",
"description": "Connect, plus see the host configuration. Secrets are never shown."
},
"edit": {
"label": "Edit",
"description": "View, plus modify the host. Secrets can be replaced but never read; credential assignments stay owner-only."
},
"manage": {
"label": "Manage",
"description": "Edit, plus share the host with others, change permission levels and revoke access."
}
},
"expiryLabel": "Access expiry",
"expiry": {
"never": "Never",
"oneHour": "1 hour",
"oneDay": "24 hours",
"sevenDays": "7 days",
"thirtyDays": "30 days",
"custom": "Custom"
},
"customHoursPlaceholder": "Hours until access expires",
"shareButton": "Share",
"shareWithCount": "Share ({{count}})",
"currentAccess": "Current access",
"noAccessEntries": "This host has not been shared yet",
"grantedBy": "Granted by",
"expires": "Expires",
"expired": "Expired",
"never": "Never",
"revoke": "Revoke",
"accessUpdated": "Access updated",
"accessUpdateFailed": "Failed to update access",
"sharedBadge": "Shared",
"sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)",
"viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.",
"sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.",
"ownerOnlyControl": "Only the host owner can change this"
},
"guac": {
"connection": "Connection",
@@ -1062,28 +1367,10 @@
"backspaceKey": "Backspace Key",
"saveHostFirst": "Save the host first.",
"sharingOptionsAfterSave": "Sharing options are available after the host has been saved.",
"sharingLoadError": "Failed to load sharing data. Check your connection and try again.",
"shareHostSection": "Share Host",
"shareWithUser": "Share with User",
"shareWithRole": "Share with Role",
"selectUser": "Select User",
"selectRole": "Select Role",
"selectUserOption": "Select a user...",
"selectRoleOption": "Select a role...",
"permissionLevel": "Permission Level",
"expiresInHours": "Expires in (hours)",
"noExpiryPlaceholder": "Leave empty for no expiry",
"shareBtn": "Share",
"currentAccess": "Current Access",
"typeHeader": "Type",
"targetHeader": "Target",
"permissionHeader": "Permission",
"grantedByHeader": "Granted By",
"expiresHeader": "Expires",
"noAccessEntries": "No access entries yet.",
"expiredLabel": "Expired",
"neverLabel": "Never",
"revokeBtn": "Revoke",
"cancelBtn": "Cancel",
"savingBtn": "Saving...",
"updateHostBtn": "Update Host",
@@ -2583,7 +2870,16 @@
"hostDefaultsCommandHistory": "Command History",
"hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default",
"hostDefaultsSaved": "Host defaults saved",
"hostDefaultsSaveFailed": "Failed to save host defaults"
"hostDefaultsSaveFailed": "Failed to save host defaults",
"rolePermissions": {
"count": "{{count}} permissions",
"editAction": "Edit permissions",
"loadError": "Failed to load the permissions catalog",
"saved": "Role permissions saved",
"saveError": "Failed to save role permissions",
"save": "Save",
"saving": "Saving..."
}
},
"newUi": {
"sidebar": {
@@ -3092,266 +3388,5 @@
"channels": "Notification Channels",
"noChannelsHint": "Add channels in the Channels tab first",
"ruleSaveFailed": "Failed to save rule"
},
"homepage": {
"title": "Homepage",
"addWidget": "Add Widget",
"editWidget": "Edit Widget",
"deleteWidget": "Delete",
"widgetTypes": "Widget Types",
"serviceLink": "Service Link",
"folder": "Folder",
"clock": "Clock",
"notes": "Notes",
"hostStatus": "Host Status",
"bookmarkList": "Bookmarks",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"resetView": "Reset View",
"lockLayout": "Lock Layout",
"unlockLayout": "Unlock Layout",
"noWidgets": "Right-click or click + to add your first widget",
"openFullView": "Open Full View",
"previewTitle": "Homepage Preview",
"cancel": "Cancel",
"save": "Save",
"title_label": "Title",
"widgetTitlePlaceholder": "Widget title (optional)",
"url": "URL",
"imageUrl": "Custom Image URL",
"imageUrlHint": "Leave blank to use the site favicon automatically",
"showImage": "Show Image",
"description": "Description",
"color": "Color",
"icon": "Icon",
"expanded": "Expanded by default",
"timezone": "Timezone",
"showSeconds": "Show seconds",
"format12h": "12-hour",
"format24h": "24-hour",
"content": "Content",
"backgroundColor": "Background Color",
"host": "Host",
"showMetrics": "Show Metrics",
"links": "Links",
"addLink": "Add Link",
"linkLabel": "Label",
"linkUrl": "URL",
"removeLink": "Remove",
"categoryLinks": "Links",
"categoryInfo": "Info",
"categorySystem": "System",
"widgetServiceLinkDesc": "A clickable tile linking to a service URL",
"widgetFolderDesc": "A container to group related widgets",
"widgetClockDesc": "A live clock with configurable timezone",
"widgetNotesDesc": "A markdown notes widget",
"widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host",
"widgetBookmarkListDesc": "A list of quick links",
"copyLink": "Copy Link",
"linkCopied": "Link copied!",
"location": "Location",
"temperatureUnit": "Temperature Unit",
"showForecast": "Show 3-day forecast",
"scrolling": "Allow Scrolling",
"feedUrl": "Feed URL",
"maxItems": "Max Items",
"showDescription": "Show Description",
"widgetWeatherName": "Weather",
"widgetWeatherDesc": "Live weather for any location",
"widgetIframeName": "iFrame Embed",
"widgetIframeDesc": "Embed any URL in an iframe",
"widgetRssName": "RSS Feed",
"widgetRssDesc": "Display items from an RSS or Atom feed",
"dragToFolder": "Drag widgets here or use + to add",
"showDisk": "Show Disk Usage",
"addToFolder": "Add widget to folder",
"noHostSelected": "No host selected",
"metricsNotAvailable": "Metrics not available",
"selectHost": "Select a host...",
"displayedMetrics": "Displayed Metrics",
"metricCpu": "CPU",
"metricMemory": "Memory",
"metricDisk": "Disk",
"metricUptime": "Uptime",
"metricSystem": "System",
"metricOs": "OS",
"metricKernel": "Kernel",
"metricHostname": "Hostname",
"metricNetwork": "Network",
"metricProcesses": "Processes",
"metricProcessesTotal": "total",
"metricProcessesRunning": "running",
"categoryMonitoring": "Monitoring",
"loading": "Loading...",
"noData": "No data available",
"allClear": "All clear",
"acknowledgeAlert": "Acknowledge",
"noPingUrls": "No URLs configured",
"pingLabel": "Label",
"addPingUrl": "Add URL",
"showLatency": "Show Latency",
"refreshInterval": "Refresh Interval",
"seconds": "seconds",
"filterActivityTypes": "Filter Types",
"filterTypesHint": "Leave empty to show all",
"showTimestamp": "Show Timestamp",
"uptimeUnavailable": "Uptime unavailable",
"uptimeLabel": "Uptime",
"overviewVersion": "Version",
"overviewUpdate": "Up to date",
"overviewUpdateAvailable": "Update available",
"overviewDatabase": "Database",
"overviewUptime": "Uptime",
"noHosts": "No hosts configured",
"hostGridHosts": "Hosts",
"hostGridAllHint": "Leave empty to show all hosts",
"columns": "Columns",
"showIp": "Show IP Address",
"connectionType": "Connection Type",
"layout": "Layout",
"showStatus": "Show Status",
"showHostName": "Show Host Name",
"noDockerActivity": "No Docker activity",
"noActivity": "No activity",
"showAcknowledged": "Show Acknowledged",
"showCurrentValue": "Show Current Value",
"chartMetric": "Metric",
"metricRange": "Range",
"widgetMetricsChartName": "Metrics Chart",
"widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host",
"widgetHostGridName": "Host Grid",
"widgetHostGridDesc": "Grid view of SSH host statuses",
"widgetAlertFeedName": "Alert Feed",
"widgetAlertFeedDesc": "Live alert firings with acknowledge support",
"widgetPingStatusName": "Ping Status",
"widgetPingStatusDesc": "HTTP ping status for one or more URLs",
"widgetRecentActivityName": "Recent Activity",
"widgetRecentActivityDesc": "Scrollable feed of recent Termix activity",
"widgetTermixUptimeName": "Termix Uptime",
"widgetTermixUptimeDesc": "Live uptime counter for the Termix server",
"widgetSystemOverviewName": "System Overview",
"widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance",
"widgetSshQuickConnectName": "SSH Quick Connect",
"widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions",
"widgetDockerActivityName": "Docker Activity",
"widgetDockerActivityDesc": "Recent Docker container events across all hosts",
"widgetCalendarName": "Calendar",
"widgetCalendarDesc": "A monthly calendar with today highlighted",
"widgetCountdownName": "Countdown",
"widgetCountdownDesc": "Countdown timer to a target date",
"widgetSearchBarName": "Search Bar",
"widgetSearchBarDesc": "Quick web search widget",
"widgetTextBannerName": "Text Banner",
"widgetTextBannerDesc": "A bold label or section header for the canvas",
"widgetImageWidgetName": "Image",
"widgetImageWidgetDesc": "Display an image from a URL",
"widgetMarkdownNotesName": "Markdown Notes",
"widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering",
"widgetCustomApiName": "Custom API",
"widgetCustomApiDesc": "Fetch and display data from any JSON API",
"widgetServiceGridName": "Service Grid",
"widgetServiceGridDesc": "A configurable grid of service tile links",
"widgetDashboardLinksName": "Dashboard Links",
"widgetDashboardLinksDesc": "Display your configured service links from the dashboard",
"widgetSearchLinksName": "Search Shortcuts",
"widgetSearchLinksDesc": "Quick search shortcut buttons with inline input",
"widgetLinkTreeName": "Link Tree",
"widgetLinkTreeDesc": "Grouped sections of links with headings",
"calMon": "Mo",
"calTue": "Tu",
"calWed": "We",
"calThu": "Th",
"calFri": "Fr",
"calSat": "Sa",
"calSun": "Su",
"startOnMonday": "Start week on Monday",
"countdownNoDate": "No target date set",
"countdownPast": "Event has passed",
"countdownDays": "days",
"countdownHours": "hrs",
"countdownMinutes": "min",
"countdownSeconds": "sec",
"countdownLabel": "Label",
"countdownLabelPlaceholder": "e.g. Launch Day",
"countdownShowDays": "Show Days",
"countdownShowHours": "Show Hours",
"targetDate": "Target Date",
"searchEngine": "Search Engine",
"customSearchUrl": "Custom Search URL",
"searchPlaceholder": "Search...",
"searchGo": "Go",
"searchPlaceholderLabel": "Placeholder Text",
"searchPlaceholderHint": "Text shown inside the search input",
"openInNewTab": "Open in New Tab",
"searchQueryPlaceholder": "Enter query...",
"noSearchShortcuts": "No shortcuts configured",
"addSearchShortcut": "Add Shortcut",
"fontSize": "Font Size",
"textAlign": "Text Align",
"fontWeight": "Font Weight",
"clearColor": "Clear Color",
"imageFit": "Image Fit",
"imageLinkUrl": "Link URL",
"noImage": "No image URL set",
"altText": "Alt Text",
"altTextPlaceholder": "Describe the image",
"renderMarkdown": "Render Markdown",
"displayMode": "Display Mode",
"displayField": "Display Field",
"jsonPath": "JSON Path",
"customApiLabel": "Label",
"customApiLabelPlaceholder": "e.g. Temperature",
"customApiUnit": "Unit",
"customApiNoUrl": "No API URL configured",
"customApiError": "Failed to fetch",
"customApiNotArray": "Response is not an array",
"addService": "Add Service",
"showLabels": "Show Labels",
"iconSize": "Icon Size",
"noDashboardLinks": "No dashboard links configured",
"noLimit": "No limit",
"sectionHeading": "Section Heading",
"addSection": "Add Section",
"compactMode": "Compact Mode",
"showDetailed": "Show Detailed",
"selectHosts": "Select Hosts",
"allHosts": "All hosts",
"listLayout": "List",
"gridLayout": "Grid",
"terminal": "Terminal",
"files": "Files",
"docker": "Docker",
"range15m": "15 minutes",
"range1h": "1 hour",
"range6h": "6 hours",
"range24h": "24 hours",
"metricNetRx": "Net Download",
"metricNetTx": "Net Upload",
"severityFilter": "Severity Filter",
"filterAll": "All",
"accentColor": "Accent Color",
"widgetSshTerminalName": "SSH Terminal",
"widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host",
"sshTerminalNoHost": "No host configured",
"sshTerminalConnect": "Connect",
"sshTerminalAutoConnect": "Auto-connect on load",
"widgetQuickConnectName": "Quick Connect",
"widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts",
"connectionTypes": "Connection Types",
"connType_terminal": "Terminal",
"connType_files": "File Manager",
"connType_docker": "Docker",
"connType_tunnel": "Tunnel",
"connType_host-metrics": "Host Metrics",
"connType_rdp": "RDP",
"connType_vnc": "VNC",
"connType_telnet": "Telnet",
"widgetFileManagerName": "File Manager",
"widgetFileManagerDesc": "Embedded SFTP file manager for a configured host",
"widgetDockerName": "Docker Manager",
"widgetDockerDesc": "Embedded Docker container manager for a configured host",
"widgetTunnelName": "Tunnel Manager",
"widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host",
"widgetNoHostSelected": "No host configured"
}
}
+10 -2
View File
@@ -15,7 +15,7 @@ export interface Role {
displayName: string;
description: string | null;
isSystem: boolean;
permissions: string | null;
permissions: string[] | string | null;
createdAt: string;
updatedAt: string;
}
@@ -40,7 +40,7 @@ export interface AccessRecord {
roleDisplayName: string | null;
grantedBy: string;
grantedByUsername: string;
permissionLevel: "view";
permissionLevel: "connect" | "view" | "edit" | "manage";
expiresAt: string | null;
createdAt: string;
}
@@ -2087,13 +2087,21 @@ export {
assignRoleToUser,
removeRoleFromUser,
shareHost,
updateHostAccess,
getHostAccess,
revokeHostAccess,
getPermissionsCatalog,
getSharedHosts,
shareSnippet,
getSnippetAccess,
revokeSnippetAccess,
getSharedSnippets,
} from "@/api/rbac-api";
export type {
SharePermissionLevel,
ShareTarget,
PermissionCatalogEntry,
} from "@/api/rbac-api";
// ============================================================================
// DOCKER MANAGEMENT API
+15 -2
View File
@@ -34,6 +34,7 @@ import {
} from "lucide-react";
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types";
import { canEditHost } from "@/sidebar/host-permissions";
interface CommandPaletteProps {
isOpen: boolean;
@@ -403,6 +404,11 @@ export function CommandPalette({
<span className="text-sm font-semibold truncate">
{host.name}
</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 && (
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
)}
@@ -481,6 +487,8 @@ export function CommandPalette({
Telnet
</button>
)}
{canEditHost(host) && (
<>
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
<button
title="Edit Host"
@@ -490,9 +498,12 @@ export function CommandPalette({
onOpenTab("host-manager");
setTimeout(() => {
window.dispatchEvent(
new CustomEvent("host-manager:edit-host", {
new CustomEvent(
"host-manager:edit-host",
{
detail: host.id,
}),
},
),
);
}, 100);
}}
@@ -500,6 +511,8 @@ export function CommandPalette({
>
<Pencil className="size-3.5" />
</button>
</>
)}
</div>
</CommandItem>
))}
+174 -6
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 {
deleteRole,
deleteUser,
getPermissionsCatalog,
revokeAllUserSessions,
revokeSession,
updateRole,
} from "@/main-axios";
import type { Role } from "@/main-axios";
import type { PermissionCatalogEntry, Role } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Activity,
Check,
KeyRound,
Pencil,
Plus,
@@ -375,6 +378,69 @@ export function AdminRolesSection({
createRoleLoading,
}: RolesSectionProps) {
const { t } = useTranslation();
const [catalog, setCatalog] = useState<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 (
<AccordionSection
@@ -467,11 +533,14 @@ export function AdminRolesSection({
</div>
</div>
)}
{roles.map((role) => (
{roles.map((role) => {
const permissionCount = rolePermissions(role).length;
return (
<div
key={role.id}
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
className="flex flex-col py-2.5 border-b border-border last:border-0"
>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold truncate">
@@ -486,6 +555,13 @@ export function AdminRolesSection({
{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}
@@ -493,13 +569,24 @@ export function AdminRolesSection({
</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));
setRoles((prev) =>
prev.filter((r) => r.id !== role.id),
);
toast.success(
t("admin.deleteRoleSuccess", {
name: role.displayName,
@@ -512,7 +599,88 @@ export function AdminRolesSection({
</div>
)}
</div>
))}
{editingRoleId === role.id && (
<div className="flex flex-col gap-2.5 mt-2.5 p-2.5 border border-border bg-muted/20">
{catalog.map((entry) => {
const wildcard = `${entry.group}.*`;
const wildcardOn = editingPermissions.has(wildcard);
return (
<div key={entry.group} className="flex flex-col gap-1">
<button
onClick={() => togglePermission(wildcard)}
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
>
<span
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${wildcardOn ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{wildcardOn && (
<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">
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px]"
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>
</AccordionSection>
);
+101 -20
View File
@@ -57,6 +57,7 @@ import {
HostFilesTab,
} from "./HostEditorFeatureTabs";
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
import { canEditHost } from "./host-permissions";
import {
HostEditorRdpTab,
HostEditorTelnetTab,
@@ -201,6 +202,13 @@ export function HostEditor({
(c) => c.id === form.credentialId,
);
// Shared hosts: view-level recipients see a read-only editor; edit-level
// recipients may change the host but never its credential/vault references
// or auth type (owner-only, enforced server-side too).
const isSharedHost = !!host?.isShared;
const readOnly = isSharedHost && host !== null && !canEditHost(host);
const lockAuthReferences = isSharedHost && !readOnly;
const handleProtocolToggle = (
proto: keyof typeof protocols,
value: boolean,
@@ -232,6 +240,24 @@ export function HostEditor({
return (
<div className="flex flex-col gap-3">
{isSharedHost && (
<div className="flex items-start gap-2.5 p-3 border border-accent-brand/30 bg-accent-brand/5 text-xs text-muted-foreground">
<Shield className="size-3.5 shrink-0 mt-0.5 text-accent-brand" />
<div>
{readOnly
? t("hosts.sharing.viewOnlyBanner", {
owner: host?.ownerUsername || "?",
})
: t("hosts.sharing.sharedEditBanner", {
owner: host?.ownerUsername || "?",
})}
</div>
</div>
)}
<fieldset
disabled={readOnly}
className={`flex flex-col gap-3 min-w-0 ${readOnly ? "opacity-80" : ""}`}
>
<div className="flex flex-col gap-3">
{activeTab === "general" && (
<HostEditorGeneralTab
@@ -289,15 +315,26 @@ export function HostEditor({
].map((m) => (
<button
key={m}
disabled={lockAuthReferences}
title={
lockAuthReferences
? t("hosts.sharing.ownerOnlyControl")
: undefined
}
onClick={() => {
setField("authType", m as HostAuthType);
}}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${authMethod === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${authMethod === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m}
</button>
))}
</div>
{lockAuthReferences && (
<p className="text-[10px] text-muted-foreground/60">
{t("hosts.sharing.ownerOnlyControl")}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4 mt-1">
<div className="flex flex-col gap-1.5">
@@ -313,10 +350,12 @@ export function HostEditor({
!form.overrideCredentialUsername
}
onFocus={() => {
if (form.username === "root") setField("username", "");
if (form.username === "root")
setField("username", "");
}}
onBlur={() => {
if (form.username === "") setField("username", "root");
if (form.username === "")
setField("username", "root");
}}
onChange={(e) => setField("username", e.target.value)}
/>
@@ -375,7 +414,9 @@ export function HostEditor({
value={
form.key === "existing_key" ? "" : form.key
}
onChange={(e) => setField("key", e.target.value)}
onChange={(e) =>
setField("key", e.target.value)
}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
@@ -452,7 +493,9 @@ export function HostEditor({
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.password}
onChange={(e) => setField("password", e.target.value)}
onChange={(e) =>
setField("password", e.target.value)
}
/>
</div>
<div className="flex flex-col gap-1.5">
@@ -461,10 +504,14 @@ export function HostEditor({
</label>
<select
value={form.keyType}
onChange={(e) => setField("keyType", e.target.value)}
onChange={(e) =>
setField("keyType", e.target.value)
}
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"
>
<option value="auto">{t("hosts.keyTypeAuto")}</option>
<option value="auto">
{t("hosts.keyTypeAuto")}
</option>
<option value="ssh-rsa">RSA</option>
<option value="ssh-ed25519">Ed25519</option>
<option value="ecdsa-sha2-nistp256">
@@ -477,8 +524,12 @@ export function HostEditor({
ECDSA P-521
</option>
<option value="ssh-dss">DSA</option>
<option value="ssh-rsa-sha2-256">RSA SHA2-256</option>
<option value="ssh-rsa-sha2-512">RSA SHA2-512</option>
<option value="ssh-rsa-sha2-256">
RSA SHA2-256
</option>
<option value="ssh-rsa-sha2-512">
RSA SHA2-512
</option>
</select>
</div>
</>
@@ -543,6 +594,12 @@ export function HostEditor({
</label>
<select
value={form.credentialId}
disabled={lockAuthReferences}
title={
lockAuthReferences
? t("hosts.sharing.ownerOnlyControl")
: undefined
}
onChange={(e) => {
const newId = e.target.value;
setField("credentialId", newId);
@@ -554,7 +611,7 @@ export function HostEditor({
setField("username", cred.username);
}
}}
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"
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">
{t("hosts.selectACredential")}
@@ -600,7 +657,9 @@ export function HostEditor({
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.password}
onChange={(e) => setField("password", e.target.value)}
onChange={(e) =>
setField("password", e.target.value)
}
/>
</div>
</>
@@ -683,7 +742,9 @@ export function HostEditor({
{tailscaleDevices.map((d) => (
<option key={d.id} value={d.id}>
{d.hostname} (
{d.addresses.find((a) => a.startsWith("100.")) ??
{d.addresses.find((a) =>
a.startsWith("100."),
) ??
d.addresses[0] ??
""}
)
@@ -746,7 +807,9 @@ export function HostEditor({
<SettingRow
label={t("hosts.allowLegacyAlgorithmsLabel")}
badge={
form.allowLegacyAlgorithms ? t("hosts.insecure") : undefined
form.allowLegacyAlgorithms
? t("hosts.insecure")
: undefined
}
description={t("hosts.allowLegacyAlgorithmsDesc")}
>
@@ -773,7 +836,9 @@ export function HostEditor({
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.sudoPassword}
onChange={(e) => setField("sudoPassword", e.target.value)}
onChange={(e) =>
setField("sudoPassword", e.target.value)
}
/>
</div>
)}
@@ -815,7 +880,10 @@ export function HostEditor({
const newTheme = e.target.value;
setField("theme", newTheme);
setPreviewTerminalTheme(newTheme);
if (newTheme === "custom" && !form.customThemeColors) {
if (
newTheme === "custom" &&
!form.customThemeColors
) {
setField("customThemeColors", {
...TERMINAL_THEMES.termixDark.colors,
});
@@ -1374,7 +1442,10 @@ export function HostEditor({
value={ev.key}
onChange={(e) => {
const updated = [...form.environmentVariables];
updated[i] = { ...updated[i], key: e.target.value };
updated[i] = {
...updated[i],
key: e.target.value,
};
setField("environmentVariables", updated);
}}
/>
@@ -1458,7 +1529,9 @@ export function HostEditor({
<Input
placeholder="mosh"
value={form.moshCommand}
onChange={(e) => setField("moshCommand", e.target.value)}
onChange={(e) =>
setField("moshCommand", e.target.value)
}
/>
</div>
)}
@@ -1676,7 +1749,8 @@ export function HostEditor({
tunnelIndex: i,
hostName: host.name,
sourceIP: host.ip,
sourceSSHPort: host.sshPort ?? host.port,
sourceSSHPort:
host.sshPort ?? host.port,
sourceUsername: form.username,
sourcePassword:
form.password || undefined,
@@ -1721,7 +1795,9 @@ export function HostEditor({
autoStart: tun.autoStart ?? false,
isPinned: false,
});
toast.success(t("hosts.tunnelConnecting"));
toast.success(
t("hosts.tunnelConnecting"),
);
}
} catch {
toast.error(
@@ -1749,7 +1825,9 @@ export function HostEditor({
onClick={() =>
setField(
"serverTunnels",
form.serverTunnels.filter((_, idx) => idx !== i),
form.serverTunnels.filter(
(_, idx) => idx !== i,
),
)
}
>
@@ -1976,6 +2054,7 @@ export function HostEditor({
/>
)}
</div>
</fieldset>
<div className="flex justify-end gap-3 mt-3 mb-6">
<Button
@@ -1988,6 +2067,7 @@ export function HostEditor({
>
{t("hosts.guac.cancelBtn")}
</Button>
{!readOnly && (
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
@@ -2000,6 +2080,7 @@ export function HostEditor({
? t("hosts.guac.updateHostBtn")
: t("hosts.guac.addHostBtn")}
</Button>
)}
</div>
</div>
);
+4
View File
@@ -113,6 +113,10 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
socks5Password: h.socks5Password,
socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [],
overrideCredentialUsername: h.overrideCredentialUsername ?? false,
isShared: h.isShared ?? false,
permissionLevel: h.permissionLevel,
sharedExpiresAt: h.sharedExpiresAt,
ownerUsername: h.ownerUsername,
};
}
+385 -152
View File
@@ -1,9 +1,11 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Check,
ListChecks,
Plus,
Search,
Share2,
Shield,
User,
Users,
@@ -12,17 +14,43 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SectionCard } from "@/components/section-card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import {
getHostAccess,
shareHost,
updateHostAccess,
revokeHostAccess,
getUserList,
getRoles,
type AccessRecord,
type SharePermissionLevel,
type ShareTarget,
} from "@/main-axios";
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({
open,
onClose,
@@ -33,18 +61,28 @@ export function HostShareModal({
host: Host | null;
}) {
const { t } = useTranslation();
const [shareType, setShareType] = useState<"user" | "role">("user");
const [shareGranteeId, setShareGranteeId] = useState("");
const [shareExpiryHours, setShareExpiryHours] = useState("");
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = 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 [shareUsers, setShareUsers] = useState<
{ 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 [sharingLoadError, setSharingLoadError] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open || !host) return;
@@ -64,9 +102,12 @@ export function HostShareModal({
})),
);
setShareRoles(
(rolesRes.roles ?? []).map((r) => ({
id: String(r.id),
(rolesRes.roles ?? [])
.filter((r) => !r.isSystem)
.map((r) => ({
id: Number(r.id),
name: r.name,
displayName: r.displayName,
})),
);
})
@@ -77,21 +118,102 @@ export function HostShareModal({
setSharingLoaded(false);
setSharingLoadError(false);
setAccessList([]);
setShareGranteeId("");
setShareExpiryHours("");
setShareType("user");
setSearch("");
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
setPermissionLevel("connect");
setExpiryPreset("never");
setCustomHours("");
setTargetTab("user");
}, [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() {
if (!host) return;
const res = await getHostAccess(Number(host.id));
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 =
!!host?.credentialId || !!host?.rdpCredentialId || !!host?.vncCredentialId;
setSubmitting(true);
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 (
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
@@ -101,185 +223,298 @@ 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"
>
<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>
{/* Scrollable content */}
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-3 gap-3">
{!hasCredential && host !== null && (
<div className="flex items-start gap-3 p-3 border border-yellow-500/30 bg-yellow-500/5 text-xs text-yellow-500">
{sharingLoadError && (
<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">
<Shield className="size-3.5 shrink-0 mt-0.5" />
<div>{t("hosts.sharing.requiresCredential")}</div>
<div>{t("hosts.sharing.loadError")}</div>
</div>
)}
{sharingLoadError && hasCredential && (
<div className="flex items-start gap-3 p-3 border border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
<div>{t("hosts.guac.sharingLoadError")}</div>
{/* Share form: fixed, non-scrolling */}
<div className="flex flex-col gap-2 p-3 shrink-0 border-b border-border">
<div className="flex items-center justify-between gap-2">
<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>
)}
{hasCredential && (
<>
<SectionCard
title={t("hosts.guac.shareHostSection")}
icon={<Users className="size-3.5" />}
action={
<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"
className="text-[10px] text-accent-brand hover:underline shrink-0"
>
{t("hosts.docsLink")}
</a>
}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex gap-2">
{(["user", "role"] as const).map((shareTypeOpt) => (
</div>
<div className="flex gap-1.5">
{(["user", "role"] as const).map((tab) => (
<button
key={shareTypeOpt}
onClick={() => {
setShareType(shareTypeOpt);
setShareGranteeId("");
}}
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"}`}
key={tab}
onClick={() => setTargetTab(tab)}
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"}`}
>
{shareTypeOpt === "user" ? (
<>
<User className="size-3 inline mr-1" />
{t("hosts.guac.shareWithUser")}
</>
{tab === "user" ? (
<User className="size-3 shrink-0" />
) : (
<>
<Shield className="size-3 inline mr-1" />
{t("hosts.guac.shareWithRole")}
</>
<Shield className="size-3 shrink-0" />
)}
{tab === "user"
? t("hosts.sharing.usersTab")
: t("hosts.sharing.rolesTab")}
{tab === "user" && selectedUserIds.size > 0 && (
<span>({selectedUserIds.size})</span>
)}
{tab === "role" && selectedRoleIds.size > 0 && (
<span>({selectedRoleIds.size})</span>
)}
</button>
))}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{shareType === "user"
? t("hosts.guac.selectUser")
: t("hosts.guac.selectRole")}
</label>
<select
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"
value={shareGranteeId}
onChange={(e) => setShareGranteeId(e.target.value)}
<div className="relative">
<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")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-col border border-border h-28 overflow-y-auto">
{targetTab === "user" &&
(filteredUsers.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noMatches")}
</div>
) : (
filteredUsers.map((user) => {
const isSelected = selectedUserIds.has(user.id);
return (
<button
key={user.id}
onClick={() =>
setSelectedUserIds((prev) => {
const next = new Set(prev);
if (next.has(user.id)) next.delete(user.id);
else next.add(user.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"}`}
>
<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}
<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>
<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>
) : (
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>
<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">
<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="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"));
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}
>
<Plus className="size-3.5 mr-1.5" />
{t("hosts.guac.shareBtn")}
<Share2 className="size-3.5 mr-1.5" />
{selectedCount > 0
? t("hosts.sharing.shareWithCount", { count: selectedCount })
: t("hosts.sharing.shareButton")}
</Button>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.currentAccess")}
icon={<ListChecks className="size-3.5" />}
>
<div className="py-2">
<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>
{/* 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-2 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.guac.noAccessEntries")}
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noAccessEntries")}
</div>
)}
{accessList.map((r, i) => {
{accessList.map((record) => {
const expired =
r.expiresAt && new Date(r.expiresAt) < new Date();
record.expiresAt && new Date(record.expiresAt) < new Date();
return (
<div
key={i}
className="flex flex-col gap-1 px-2 py-2.5 border-b border-border last:border-0 text-xs"
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">
{r.targetType === "user" ? (
{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">
{r.username ??
r.roleName ??
r.roleDisplayName ??
r.userId ??
r.roleId}
</span>
<span className="text-muted-foreground capitalize shrink-0">
({r.targetType})
{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 shrink-0"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
try {
await revokeHostAccess(Number(host!.id), r.id);
await revokeHostAccess(Number(host!.id), record.id);
setAccessList((prev) =>
prev.filter((_, idx) => idx !== i),
prev.filter((entry) => entry.id !== record.id),
);
toast.success(t("hosts.accessRevoked"));
} catch {
@@ -287,30 +522,31 @@ export function HostShareModal({
}
}}
>
{t("hosts.guac.revokeBtn")}
{t("hosts.sharing.revoke")}
</Button>
</div>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
<span>
{t("hosts.guac.grantedByHeader")}:{" "}
{t("hosts.sharing.grantedBy")}:{" "}
<span className="text-foreground/70">
{r.grantedByUsername ?? ""}
{record.grantedByUsername ?? "?"}
</span>
</span>
<span className={expired ? "text-destructive" : ""}>
{t("hosts.guac.expiresHeader")}:{" "}
{t("hosts.sharing.expires")}:{" "}
{expired ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<X className="size-3" />
{t("hosts.guac.expiredLabel")}
{t("hosts.sharing.expired")}
</span>
) : r.expiresAt ? (
) : record.expiresAt ? (
<span className="text-foreground/70">
{new Date(r.expiresAt).toLocaleDateString()}
{new Date(record.expiresAt).toLocaleString()}
</span>
) : (
<span className="text-foreground/70">
{t("hosts.guac.neverLabel")}
{t("hosts.sharing.never")}
</span>
)}
</span>
@@ -319,9 +555,6 @@ export function HostShareModal({
);
})}
</div>
</SectionCard>
</>
)}
</div>
</div>
);
+1 -1
View File
@@ -117,7 +117,7 @@ function LogRow({
<span className="text-[10px] text-muted-foreground/60 truncate">
{formatDate(log.startedAt)}
{" · "}
{log.protocol.toUpperCase()}
{(log.protocol ?? "ssh").toUpperCase()}
{log.username ? ` · ${log.username}` : ""}
{" · "}
{formatDuration(log.duration)}
+66 -4
View File
@@ -36,6 +36,7 @@ import {
Share2,
Terminal,
Trash2,
Users,
Zap,
} from "lucide-react";
import {
@@ -64,6 +65,11 @@ import type { SSHHostData } from "@/types/index";
import { FolderIconEl } from "@/components/folder-style";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
import { copyToClipboard } from "@/lib/clipboard";
import {
canDeleteHost,
canEditHost,
canShareHost,
} from "@/sidebar/host-permissions";
import { FolderMetadataDialog } from "./FolderMetadataDialog";
import {
useStatusColorScheme,
@@ -266,8 +272,8 @@ function folderHostCount(folder: HostFolder): {
export function HostItem({
host,
onOpenTab,
onEditHost,
onShareHost,
onEditHost: onEditHostProp,
onShareHost: onShareHostProp,
onProxmoxDiscover,
onDelete,
onDuplicate,
@@ -306,6 +312,10 @@ export function HostItem({
depth?: number;
}) {
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 =
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
const [trayOnClick, setTrayOnClick] = useState(
@@ -328,8 +338,8 @@ export function HostItem({
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
const shouldUseClickTray = trayOnClick || isTouchOnly;
const showPasswordCopy = canCopyHostPassword(host);
const showSudoPasswordCopy = canCopyHostSudoPassword(host);
const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
async function handleCopyPassword(
e: MouseEvent,
@@ -465,6 +475,26 @@ export function HostItem({
<span className="text-[13px] font-medium truncate text-foreground leading-none">
{host.name}
</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 && (
<button
title={
@@ -666,6 +696,8 @@ export function HostItem({
{t("nav.copySudoPassword")}
</DropdownMenuItem>
)}
{allowDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
@@ -687,6 +719,8 @@ export function HostItem({
<Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -862,6 +896,8 @@ export function HostItem({
{t("nav.copySudoPassword")}
</DropdownMenuItem>
)}
{allowDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
@@ -883,6 +919,8 @@ export function HostItem({
<Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -974,6 +1012,26 @@ export function HostItem({
{host.pin && (
<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 && (
<button
title={
@@ -1445,6 +1503,8 @@ export function HostItem({
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
{allowDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
@@ -1466,6 +1526,8 @@ export function HostItem({
<Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</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;
}