mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
feat: refactor rbac/sharing to support new permissions and auth types
This commit is contained in:
@@ -1585,36 +1585,54 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
|
sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS shared_credentials (
|
CREATE TABLE IF NOT EXISTS shared_host_secrets (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
host_access_id INTEGER NOT NULL,
|
host_access_id INTEGER NOT NULL,
|
||||||
original_credential_id INTEGER NOT NULL,
|
|
||||||
target_user_id TEXT NOT NULL,
|
target_user_id TEXT NOT NULL,
|
||||||
encrypted_username TEXT NOT NULL,
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
encrypted_auth_type TEXT NOT NULL,
|
source_type TEXT NOT NULL DEFAULT 'credential',
|
||||||
|
original_credential_id INTEGER,
|
||||||
|
encrypted_username TEXT,
|
||||||
|
encrypted_auth_type TEXT,
|
||||||
encrypted_password TEXT,
|
encrypted_password TEXT,
|
||||||
encrypted_key TEXT,
|
encrypted_key TEXT,
|
||||||
encrypted_key_password TEXT,
|
encrypted_key_password TEXT,
|
||||||
encrypted_key_type TEXT,
|
encrypted_key_type TEXT,
|
||||||
|
encrypted_domain TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(host_access_id, target_user_id, protocol),
|
||||||
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
|
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
databaseLogger.warn("Failed to create shared_credentials table", {
|
databaseLogger.warn("Failed to create shared_host_secrets table", {
|
||||||
operation: "schema_migration",
|
operation: "schema_migration",
|
||||||
error: createError,
|
error: createError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
|
||||||
|
sqlite.exec(
|
||||||
|
"UPDATE host_access SET permission_level = 'connect' WHERE permission_level = 'view'",
|
||||||
|
);
|
||||||
|
setRawSettingValue("rbac_permission_levels_v2", "done");
|
||||||
|
}
|
||||||
|
} catch (migrateError) {
|
||||||
|
databaseLogger.warn("Failed to migrate legacy view permission level", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: migrateError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
|
sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -523,7 +523,7 @@ export const hostAccess = sqliteTable("host_access", {
|
|||||||
|
|
||||||
permissionLevel: text("permission_level")
|
permissionLevel: text("permission_level")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("view"),
|
.default("connect"),
|
||||||
|
|
||||||
expiresAt: text("expires_at"),
|
expiresAt: text("expires_at"),
|
||||||
|
|
||||||
@@ -538,27 +538,32 @@ export const hostAccess = sqliteTable("host_access", {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sharedCredentials = sqliteTable("shared_credentials", {
|
export const sharedHostSecrets = sqliteTable("shared_host_secrets", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
|
||||||
hostAccessId: integer("host_access_id")
|
hostAccessId: integer("host_access_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => hostAccess.id, { onDelete: "cascade" }),
|
.references(() => hostAccess.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
originalCredentialId: integer("original_credential_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
|
||||||
|
|
||||||
targetUserId: text("target_user_id")
|
targetUserId: text("target_user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
encryptedUsername: text("encrypted_username").notNull(),
|
protocol: text("protocol").notNull().default("ssh"),
|
||||||
encryptedAuthType: text("encrypted_auth_type").notNull(),
|
sourceType: text("source_type").notNull().default("credential"),
|
||||||
|
|
||||||
|
originalCredentialId: integer("original_credential_id").references(
|
||||||
|
() => sshCredentials.id,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
|
),
|
||||||
|
|
||||||
|
encryptedUsername: text("encrypted_username"),
|
||||||
|
encryptedAuthType: text("encrypted_auth_type"),
|
||||||
encryptedPassword: text("encrypted_password"),
|
encryptedPassword: text("encrypted_password"),
|
||||||
encryptedKey: text("encrypted_key", { length: 16384 }),
|
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||||
encryptedKeyPassword: text("encrypted_key_password"),
|
encryptedKeyPassword: text("encrypted_key_password"),
|
||||||
encryptedKeyType: text("encrypted_key_type"),
|
encryptedKeyType: text("encrypted_key_type"),
|
||||||
|
encryptedDomain: text("encrypted_domain"),
|
||||||
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { RoleRepository } from "./role-repository.js";
|
|||||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||||
import { SessionRepository } from "./session-repository.js";
|
import { SessionRepository } from "./session-repository.js";
|
||||||
import { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { SharedCredentialRepository } from "./shared-credential-repository.js";
|
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||||
import { SnippetRepository } from "./snippet-repository.js";
|
import { SnippetRepository } from "./snippet-repository.js";
|
||||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||||
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
||||||
@@ -260,10 +260,10 @@ export function createCurrentSettingsRepository(): SettingsRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCurrentSharedCredentialRepository(): SharedCredentialRepository {
|
export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRepository {
|
||||||
return new SharedCredentialRepository(
|
return new SharedHostSecretsRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
createCurrentRepositoryWriteHook("shared_credential_repository_write"),
|
createCurrentRepositoryWriteHook("shared_host_secrets_repository_write"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface HostUpdateStateRecord {
|
|||||||
rdpCredentialId: number | null;
|
rdpCredentialId: number | null;
|
||||||
vncCredentialId: number | null;
|
vncCredentialId: number | null;
|
||||||
telnetCredentialId: number | null;
|
telnetCredentialId: number | null;
|
||||||
|
vaultProfileId: number | null;
|
||||||
authType: string;
|
authType: string;
|
||||||
}
|
}
|
||||||
export interface HostListAccessEntry {
|
export interface HostListAccessEntry {
|
||||||
@@ -74,6 +75,7 @@ export class HostResolutionRepository {
|
|||||||
rdpCredentialId: hosts.rdpCredentialId,
|
rdpCredentialId: hosts.rdpCredentialId,
|
||||||
vncCredentialId: hosts.vncCredentialId,
|
vncCredentialId: hosts.vncCredentialId,
|
||||||
telnetCredentialId: hosts.telnetCredentialId,
|
telnetCredentialId: hosts.telnetCredentialId,
|
||||||
|
vaultProfileId: hosts.vaultProfileId,
|
||||||
authType: hosts.authType,
|
authType: hosts.authType,
|
||||||
})
|
})
|
||||||
.from(hosts)
|
.from(hosts)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
hostAccess,
|
hostAccess,
|
||||||
hosts,
|
hosts,
|
||||||
roles,
|
roles,
|
||||||
sharedCredentials,
|
sharedHostSecrets,
|
||||||
snippetAccess,
|
snippetAccess,
|
||||||
snippets,
|
snippets,
|
||||||
users,
|
users,
|
||||||
@@ -152,10 +152,6 @@ export class RbacAccessRepository {
|
|||||||
})
|
})
|
||||||
.where(eq(hostAccess.id, existing.id));
|
.where(eq(hostAccess.id, existing.id));
|
||||||
|
|
||||||
await this.context.drizzle
|
|
||||||
.delete(sharedCredentials)
|
|
||||||
.where(eq(sharedCredentials.hostAccessId, existing.id));
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return { id: existing.id, created: false };
|
return { id: existing.id, created: false };
|
||||||
}
|
}
|
||||||
@@ -583,25 +579,73 @@ export class RbacAccessRepository {
|
|||||||
.where(eq(hostAccess.roleId, roleId));
|
.where(eq(hostAccess.roleId, roleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findSharedCredentialForHostAndUser(
|
async findSharedSecretForHostUserProtocol(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<typeof sharedCredentials.$inferSelect | null> {
|
protocol: string,
|
||||||
|
): Promise<typeof sharedHostSecrets.$inferSelect | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.select({
|
.select({
|
||||||
sharedCredential: sharedCredentials,
|
secret: sharedHostSecrets,
|
||||||
})
|
})
|
||||||
.from(sharedCredentials)
|
.from(sharedHostSecrets)
|
||||||
.innerJoin(hostAccess, eq(sharedCredentials.hostAccessId, hostAccess.id))
|
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(hostAccess.hostId, hostId),
|
eq(hostAccess.hostId, hostId),
|
||||||
eq(sharedCredentials.targetUserId, userId),
|
eq(sharedHostSecrets.targetUserId, userId),
|
||||||
|
eq(sharedHostSecrets.protocol, protocol),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return rows[0]?.sharedCredential ?? null;
|
return rows[0]?.secret ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listActiveHostAccessGrants(
|
||||||
|
hostId: number,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<(typeof hostAccess.$inferSelect)[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hostAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(hostAccess.hostId, hostId),
|
||||||
|
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findHostAccessById(
|
||||||
|
accessId: number,
|
||||||
|
hostId: number,
|
||||||
|
): Promise<typeof hostAccess.$inferSelect | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hostAccess)
|
||||||
|
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateHostAccessGrant(
|
||||||
|
accessId: number,
|
||||||
|
hostId: number,
|
||||||
|
update: { permissionLevel?: string; expiresAt?: string | null },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.update(hostAccess)
|
||||||
|
.set(update)
|
||||||
|
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)))
|
||||||
|
.returning({ id: hostAccess.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
|
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface SessionRecordingListRecord {
|
|||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
recordingPath: string | null;
|
recordingPath: string | null;
|
||||||
|
protocol: string;
|
||||||
|
format: string | null;
|
||||||
hostName: string | null;
|
hostName: string | null;
|
||||||
hostIp: string | null;
|
hostIp: string | null;
|
||||||
}
|
}
|
||||||
@@ -106,6 +108,8 @@ export class SessionRecordingRepository {
|
|||||||
endedAt: sessionRecordings.endedAt,
|
endedAt: sessionRecordings.endedAt,
|
||||||
duration: sessionRecordings.duration,
|
duration: sessionRecordings.duration,
|
||||||
recordingPath: sessionRecordings.recordingPath,
|
recordingPath: sessionRecordings.recordingPath,
|
||||||
|
protocol: sessionRecordings.protocol,
|
||||||
|
format: sessionRecordings.format,
|
||||||
hostName: hosts.name,
|
hostName: hosts.name,
|
||||||
hostIp: hosts.ip,
|
hostIp: hosts.ip,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { sharedCredentials } from "../db/schema.js";
|
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
|
||||||
|
|
||||||
export type SharedCredentialRecord = typeof sharedCredentials.$inferSelect;
|
|
||||||
export type NewSharedCredentialRecord = typeof sharedCredentials.$inferInsert;
|
|
||||||
export type SharedCredentialUpdate = Partial<
|
|
||||||
Omit<NewSharedCredentialRecord, "id">
|
|
||||||
>;
|
|
||||||
|
|
||||||
export class SharedCredentialRepository {
|
|
||||||
constructor(
|
|
||||||
private readonly context: DatabaseContext,
|
|
||||||
private readonly onWrite?: () => void | Promise<void>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async existsForHostAccessAndTargetUser(
|
|
||||||
hostAccessId: number,
|
|
||||||
targetUserId: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.select({ id: sharedCredentials.id })
|
|
||||||
.from(sharedCredentials)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sharedCredentials.hostAccessId, hostAccessId),
|
|
||||||
eq(sharedCredentials.targetUserId, targetUserId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return rows.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(
|
|
||||||
sharedCredential: NewSharedCredentialRecord,
|
|
||||||
): Promise<SharedCredentialRecord> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.insert(sharedCredentials)
|
|
||||||
.values(sharedCredential)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: number): Promise<SharedCredentialRecord | null> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.select()
|
|
||||||
.from(sharedCredentials)
|
|
||||||
.where(eq(sharedCredentials.id, id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return rows[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listByOriginalCredentialId(
|
|
||||||
credentialId: number,
|
|
||||||
): Promise<SharedCredentialRecord[]> {
|
|
||||||
return this.context.drizzle
|
|
||||||
.select()
|
|
||||||
.from(sharedCredentials)
|
|
||||||
.where(eq(sharedCredentials.originalCredentialId, credentialId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateById(
|
|
||||||
id: number,
|
|
||||||
update: SharedCredentialUpdate,
|
|
||||||
): Promise<SharedCredentialRecord | null> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.update(sharedCredentials)
|
|
||||||
.set(update)
|
|
||||||
.where(eq(sharedCredentials.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteById(id: number): Promise<boolean> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.delete(sharedCredentials)
|
|
||||||
.where(eq(sharedCredentials.id, id))
|
|
||||||
.returning({ id: sharedCredentials.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.delete(sharedCredentials)
|
|
||||||
.where(eq(sharedCredentials.originalCredentialId, credentialId))
|
|
||||||
.returning({ id: sharedCredentials.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByTargetUserId(userId: string): Promise<number> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.delete(sharedCredentials)
|
|
||||||
.where(eq(sharedCredentials.targetUserId, userId))
|
|
||||||
.returning({ id: sharedCredentials.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
|
||||||
await this.onWrite?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { and, eq, inArray, or } from "drizzle-orm";
|
||||||
|
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
|
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
|
||||||
|
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
|
||||||
|
|
||||||
|
export type ShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
|
||||||
|
export class SharedHostSecretsRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upsert(record: NewSharedHostSecretRecord): Promise<void> {
|
||||||
|
const existing = await this.context.drizzle
|
||||||
|
.select({ id: sharedHostSecrets.id })
|
||||||
|
.from(sharedHostSecrets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedHostSecrets.hostAccessId, record.hostAccessId),
|
||||||
|
eq(sharedHostSecrets.targetUserId, record.targetUserId),
|
||||||
|
eq(sharedHostSecrets.protocol, record.protocol ?? "ssh"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sharedHostSecrets)
|
||||||
|
.set({ ...record, updatedAt: new Date().toISOString() })
|
||||||
|
.where(eq(sharedHostSecrets.id, existing[0].id));
|
||||||
|
} else {
|
||||||
|
await this.context.drizzle.insert(sharedHostSecrets).values(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findForHostUserProtocol(
|
||||||
|
hostId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
protocol: ShareProtocol,
|
||||||
|
): Promise<SharedHostSecretRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ secret: sharedHostSecrets })
|
||||||
|
.from(sharedHostSecrets)
|
||||||
|
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(hostAccess.hostId, hostId),
|
||||||
|
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||||
|
eq(sharedHostSecrets.protocol, protocol),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return rows[0]?.secret ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsForHostAccessAndTargetUser(
|
||||||
|
hostAccessId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ id: sharedHostSecrets.id })
|
||||||
|
.from(sharedHostSecrets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedHostSecrets.hostAccessId, hostAccessId),
|
||||||
|
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForHostAccessAndTarget(
|
||||||
|
hostAccessId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
keepProtocols: ShareProtocol[] = [],
|
||||||
|
): Promise<void> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
id: sharedHostSecrets.id,
|
||||||
|
protocol: sharedHostSecrets.protocol,
|
||||||
|
})
|
||||||
|
.from(sharedHostSecrets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedHostSecrets.hostAccessId, hostAccessId),
|
||||||
|
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const staleIds = rows
|
||||||
|
.filter((row) => !keepProtocols.includes(row.protocol as ShareProtocol))
|
||||||
|
.map((row) => row.id);
|
||||||
|
|
||||||
|
if (staleIds.length > 0) {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(sharedHostSecrets)
|
||||||
|
.where(inArray(sharedHostSecrets.id, staleIds));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByHostAccessId(hostAccessId: number): Promise<number> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.delete(sharedHostSecrets)
|
||||||
|
.where(eq(sharedHostSecrets.hostAccessId, hostAccessId))
|
||||||
|
.returning({ id: sharedHostSecrets.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForRoleMember(
|
||||||
|
roleId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ id: sharedHostSecrets.id })
|
||||||
|
.from(sharedHostSecrets)
|
||||||
|
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(hostAccess.roleId, roleId),
|
||||||
|
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return 0;
|
||||||
|
|
||||||
|
await this.context.drizzle.delete(sharedHostSecrets).where(
|
||||||
|
inArray(
|
||||||
|
sharedHostSecrets.id,
|
||||||
|
rows.map((row) => row.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.delete(sharedHostSecrets)
|
||||||
|
.where(eq(sharedHostSecrets.originalCredentialId, credentialId))
|
||||||
|
.returning({ id: sharedHostSecrets.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByTargetUserId(userId: string): Promise<number> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.delete(sharedHostSecrets)
|
||||||
|
.where(eq(sharedHostSecrets.targetUserId, userId))
|
||||||
|
.returning({ id: sharedHostSecrets.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findHostIdsReferencingCredential(
|
||||||
|
ownerId: string,
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ id: hosts.id })
|
||||||
|
.from(hosts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(hosts.userId, ownerId),
|
||||||
|
or(
|
||||||
|
eq(hosts.credentialId, credentialId),
|
||||||
|
eq(hosts.rdpCredentialId, credentialId),
|
||||||
|
eq(hosts.vncCredentialId, credentialId),
|
||||||
|
eq(hosts.telnetCredentialId, credentialId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
|||||||
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
|
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
|
||||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||||
import {
|
import {
|
||||||
createCurrentRbacAccessRepository,
|
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
@@ -523,10 +522,9 @@ router.put(
|
|||||||
credentialId,
|
credentialId,
|
||||||
));
|
));
|
||||||
|
|
||||||
const { SharedCredentialManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-credential-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
|
||||||
await sharedCredManager.updateSharedCredentialsForOriginal(
|
|
||||||
credentialId,
|
credentialId,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@@ -633,38 +631,24 @@ router.delete(
|
|||||||
authType: "password",
|
authType: "password",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const host of hostsUsingCredential) {
|
|
||||||
const revokedCount =
|
|
||||||
await createCurrentRbacAccessRepository().deleteHostAccessForHost(
|
|
||||||
host.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (revokedCount > 0) {
|
|
||||||
authLogger.info(
|
|
||||||
"Auto-revoked host shares due to credential deletion",
|
|
||||||
{
|
|
||||||
operation: "auto_revoke_shares",
|
|
||||||
hostId: host.id,
|
|
||||||
credentialId,
|
|
||||||
revokedCount,
|
|
||||||
reason: "credential_deleted",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { SharedCredentialManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-credential-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
|
||||||
await sharedCredManager.deleteSharedCredentialsForOriginal(credentialId);
|
await sharedSecretsManager.deleteForCredential(credentialId);
|
||||||
|
|
||||||
await createCurrentCredentialRepository().deleteForUser(
|
await createCurrentCredentialRepository().deleteForUser(
|
||||||
userId,
|
userId,
|
||||||
credentialId,
|
credentialId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Shares stay in place; re-snapshot so recipients fall back to whatever
|
||||||
|
// auth the host still has (or lose the stale credential copy).
|
||||||
|
for (const host of hostsUsingCredential) {
|
||||||
|
await sharedSecretsManager.resyncHost(host.id);
|
||||||
|
}
|
||||||
|
|
||||||
authLogger.success("SSH credential deleted", {
|
authLogger.success("SSH credential deleted", {
|
||||||
operation: "credential_delete_success",
|
operation: "credential_delete_success",
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
createCurrentSessionRepository,
|
createCurrentSessionRepository,
|
||||||
createCurrentSessionRecordingRepository,
|
createCurrentSessionRecordingRepository,
|
||||||
createCurrentSettingsRepository,
|
createCurrentSettingsRepository,
|
||||||
createCurrentSharedCredentialRepository,
|
createCurrentSharedHostSecretsRepository,
|
||||||
createCurrentSnippetRepository,
|
createCurrentSnippetRepository,
|
||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
createCurrentTermixIdentityCaRepository,
|
createCurrentTermixIdentityCaRepository,
|
||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
|
|
||||||
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await createCurrentSharedCredentialRepository().deleteByTargetUserId(
|
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,76 @@ export function stripSensitiveFields(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection essentials a connect-level recipient is allowed to see.
|
||||||
|
const CONNECT_LEVEL_FIELDS = new Set([
|
||||||
|
"id",
|
||||||
|
"userId",
|
||||||
|
"ownerId",
|
||||||
|
"ownerUsername",
|
||||||
|
"isShared",
|
||||||
|
"permissionLevel",
|
||||||
|
"sharedExpiresAt",
|
||||||
|
"name",
|
||||||
|
"ip",
|
||||||
|
"port",
|
||||||
|
"username",
|
||||||
|
"folder",
|
||||||
|
"tags",
|
||||||
|
"pin",
|
||||||
|
"authType",
|
||||||
|
"connectionType",
|
||||||
|
"credentialId",
|
||||||
|
"enableTerminal",
|
||||||
|
"enableTunnel",
|
||||||
|
"enableFileManager",
|
||||||
|
"enableDocker",
|
||||||
|
"enableProxmox",
|
||||||
|
"enableTmuxMonitor",
|
||||||
|
"showTerminalInSidebar",
|
||||||
|
"showFileManagerInSidebar",
|
||||||
|
"showTunnelInSidebar",
|
||||||
|
"showDockerInSidebar",
|
||||||
|
"showServerStatsInSidebar",
|
||||||
|
"enableSsh",
|
||||||
|
"enableRdp",
|
||||||
|
"enableVnc",
|
||||||
|
"enableTelnet",
|
||||||
|
"sshPort",
|
||||||
|
"rdpPort",
|
||||||
|
"vncPort",
|
||||||
|
"telnetPort",
|
||||||
|
"defaultPath",
|
||||||
|
"scpLegacy",
|
||||||
|
"tunnelConnections",
|
||||||
|
"jumpHosts",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shapes a shared host row for its recipient. Secrets are always stripped
|
||||||
|
* (all levels); connect-level recipients are additionally reduced to
|
||||||
|
* connection essentials since they may not view the host's configuration.
|
||||||
|
*/
|
||||||
|
export function sanitizeHostForRecipient(
|
||||||
|
host: Record<string, unknown>,
|
||||||
|
permissionLevel: string | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const stripped = stripSensitiveFields(host);
|
||||||
|
|
||||||
|
if (permissionLevel !== "connect") {
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reduced: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(stripped)) {
|
||||||
|
if (CONNECT_LEVEL_FIELDS.has(key)) {
|
||||||
|
reduced[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
|
||||||
export function transformHostResponse(
|
export function transformHostResponse(
|
||||||
host: Record<string, unknown>,
|
host: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
sanitizeHostForRecipient,
|
||||||
stripSensitiveFields,
|
stripSensitiveFields,
|
||||||
transformHostResponse,
|
transformHostResponse,
|
||||||
} from "./host-normalizers.js";
|
} from "./host-normalizers.js";
|
||||||
@@ -1070,7 +1071,7 @@ router.put(
|
|||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
Number(hostId),
|
Number(hostId),
|
||||||
"write",
|
"edit",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!accessInfo.hasAccess) {
|
if (!accessInfo.hasAccess) {
|
||||||
@@ -1082,17 +1083,6 @@ router.put(
|
|||||||
return res.status(403).json({ error: "Access denied" });
|
return res.status(403).json({ error: "Access denied" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessInfo.isOwner) {
|
|
||||||
sshLogger.warn("Shared user attempted to update host (view-only)", {
|
|
||||||
operation: "host_update",
|
|
||||||
hostId: parseInt(hostId),
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return res.status(403).json({
|
|
||||||
error: "Only the host owner can modify host configuration",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostRecord =
|
const hostRecord =
|
||||||
await createCurrentHostResolutionRepository().findHostUpdateState(
|
await createCurrentHostResolutionRepository().findHostUpdateState(
|
||||||
Number(hostId),
|
Number(hostId),
|
||||||
@@ -1109,57 +1099,49 @@ router.put(
|
|||||||
|
|
||||||
const ownerId = hostRecord.userId;
|
const ownerId = hostRecord.userId;
|
||||||
|
|
||||||
if (
|
if (!accessInfo.isOwner) {
|
||||||
!accessInfo.isOwner &&
|
// Shared editors work on the owner's real record but may never
|
||||||
sshDataObj.credentialId !== undefined &&
|
// repoint it at credential/vault references (those live in the
|
||||||
sshDataObj.credentialId !== hostRecord.credentialId
|
// owner's personal vault) or switch the authentication type.
|
||||||
) {
|
const referenceViolations: Array<[unknown, number | null, string]> = [
|
||||||
return res.status(403).json({
|
[sshDataObj.credentialId, hostRecord.credentialId, "credential"],
|
||||||
error: "Only the host owner can change the credential",
|
[
|
||||||
});
|
sshDataObj.rdpCredentialId,
|
||||||
}
|
hostRecord.rdpCredentialId,
|
||||||
|
"RDP credential",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
sshDataObj.vncCredentialId,
|
||||||
|
hostRecord.vncCredentialId,
|
||||||
|
"VNC credential",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
sshDataObj.telnetCredentialId,
|
||||||
|
hostRecord.telnetCredentialId,
|
||||||
|
"Telnet credential",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
sshDataObj.vaultProfileId,
|
||||||
|
hostRecord.vaultProfileId,
|
||||||
|
"Vault profile",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
if (
|
for (const [incoming, current, label] of referenceViolations) {
|
||||||
!accessInfo.isOwner &&
|
if (incoming !== undefined && (incoming ?? null) !== current) {
|
||||||
sshDataObj.authType !== undefined &&
|
return res.status(403).json({
|
||||||
sshDataObj.authType !== hostRecord.authType
|
error: `Only the host owner can change the ${label}`,
|
||||||
) {
|
});
|
||||||
return res.status(403).json({
|
}
|
||||||
error: "Only the host owner can change the authentication type",
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
if (
|
||||||
const newCredId =
|
sshDataObj.authType !== undefined &&
|
||||||
sshDataObj.credentialId !== undefined
|
sshDataObj.authType !== hostRecord.authType
|
||||||
? sshDataObj.credentialId
|
) {
|
||||||
: hostRecord.credentialId;
|
return res.status(403).json({
|
||||||
const newRdpCredId =
|
error: "Only the host owner can change the authentication type",
|
||||||
sshDataObj.rdpCredentialId !== undefined
|
});
|
||||||
? sshDataObj.rdpCredentialId
|
|
||||||
: hostRecord.rdpCredentialId;
|
|
||||||
const newVncCredId =
|
|
||||||
sshDataObj.vncCredentialId !== undefined
|
|
||||||
? sshDataObj.vncCredentialId
|
|
||||||
: hostRecord.vncCredentialId;
|
|
||||||
const newTelnetCredId =
|
|
||||||
sshDataObj.telnetCredentialId !== undefined
|
|
||||||
? sshDataObj.telnetCredentialId
|
|
||||||
: hostRecord.telnetCredentialId;
|
|
||||||
const hadCredential =
|
|
||||||
hostRecord.credentialId !== null ||
|
|
||||||
hostRecord.rdpCredentialId !== null ||
|
|
||||||
hostRecord.vncCredentialId !== null ||
|
|
||||||
hostRecord.telnetCredentialId !== null;
|
|
||||||
const willHaveCredential =
|
|
||||||
newCredId !== null ||
|
|
||||||
newRdpCredId !== null ||
|
|
||||||
newVncCredId !== null ||
|
|
||||||
newTelnetCredId !== null;
|
|
||||||
if (hadCredential && !willHaveCredential) {
|
|
||||||
await createCurrentRbacAccessRepository().deleteHostAccessForHost(
|
|
||||||
Number(hostId),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1169,6 +1151,23 @@ router.put(
|
|||||||
sshDataObj,
|
sshDataObj,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Keep every recipient's re-encrypted secret snapshots in sync with
|
||||||
|
// the updated host record.
|
||||||
|
try {
|
||||||
|
const { SharedHostSecretsManager } =
|
||||||
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
|
await SharedHostSecretsManager.getInstance().resyncHost(Number(hostId));
|
||||||
|
} catch (resyncError) {
|
||||||
|
sshLogger.warn("Failed to resync shared host secrets after update", {
|
||||||
|
operation: "host_update_resync",
|
||||||
|
hostId: parseInt(hostId),
|
||||||
|
error:
|
||||||
|
resyncError instanceof Error
|
||||||
|
? resyncError.message
|
||||||
|
: "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updatedHost =
|
const updatedHost =
|
||||||
await createCurrentHostResolutionRepository().findHostById(
|
await createCurrentHostResolutionRepository().findHostById(
|
||||||
Number(hostId),
|
Number(hostId),
|
||||||
@@ -1296,9 +1295,21 @@ router.get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedSharedHosts = sharedHosts;
|
const ownerUsernames = new Map<string, string>();
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
for (const sharedHost of sharedHosts) {
|
||||||
|
const ownerId = sharedHost.userId as string;
|
||||||
|
if (!ownerUsernames.has(ownerId)) {
|
||||||
|
try {
|
||||||
|
const owner = await userRepository.findById(ownerId);
|
||||||
|
ownerUsernames.set(ownerId, owner?.username ?? "");
|
||||||
|
} catch {
|
||||||
|
ownerUsernames.set(ownerId, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data = [...decryptedOwnHosts, ...sanitizedSharedHosts];
|
const data = [...decryptedOwnHosts, ...sharedHosts];
|
||||||
|
|
||||||
const result = await Promise.all(
|
const result = await Promise.all(
|
||||||
data.map(async (row: Record<string, unknown>) => {
|
data.map(async (row: Record<string, unknown>) => {
|
||||||
@@ -1307,6 +1318,9 @@ router.get(
|
|||||||
isShared: !!row.isShared,
|
isShared: !!row.isShared,
|
||||||
permissionLevel: row.permissionLevel || undefined,
|
permissionLevel: row.permissionLevel || undefined,
|
||||||
sharedExpiresAt: row.expiresAt || undefined,
|
sharedExpiresAt: row.expiresAt || undefined,
|
||||||
|
ownerUsername: row.isShared
|
||||||
|
? ownerUsernames.get(row.userId as string) || undefined
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolved =
|
const resolved =
|
||||||
@@ -1315,7 +1329,14 @@ router.get(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sanitized = result.map((host) => stripSensitiveFields(host));
|
const sanitized = result.map((host) =>
|
||||||
|
host.isShared
|
||||||
|
? sanitizeHostForRecipient(
|
||||||
|
host,
|
||||||
|
host.permissionLevel as string | undefined,
|
||||||
|
)
|
||||||
|
: stripSensitiveFields(host),
|
||||||
|
);
|
||||||
res.json(sanitized);
|
res.json(sanitized);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
||||||
@@ -1370,13 +1391,28 @@ router.get(
|
|||||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const host =
|
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||||
await createCurrentHostResolutionRepository().findHostByIdForUser(
|
const host = await hostResolutionRepository.findHostByIdForUser(
|
||||||
Number(hostId),
|
Number(hostId),
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!host) {
|
if (host) {
|
||||||
|
const result = transformHostResponse(host);
|
||||||
|
const resolved =
|
||||||
|
(await resolveHostCredentials(result, userId)) || result;
|
||||||
|
|
||||||
|
return res.json(stripSensitiveFields(resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not the owner: shared recipients get a sanitized view of the host.
|
||||||
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
|
userId,
|
||||||
|
Number(hostId),
|
||||||
|
"connect",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!accessInfo.hasAccess) {
|
||||||
sshLogger.warn("SSH host not found", {
|
sshLogger.warn("SSH host not found", {
|
||||||
operation: "host_fetch_by_id",
|
operation: "host_fetch_by_id",
|
||||||
hostId: parseInt(hostId),
|
hostId: parseInt(hostId),
|
||||||
@@ -1385,10 +1421,38 @@ router.get(
|
|||||||
return res.status(404).json({ error: "SSH host not found" });
|
return res.status(404).json({ error: "SSH host not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = transformHostResponse(host);
|
const ownerId = await hostResolutionRepository.findHostOwnerId(
|
||||||
const resolved = (await resolveHostCredentials(result, userId)) || result;
|
Number(hostId),
|
||||||
|
);
|
||||||
|
const sharedHost = ownerId
|
||||||
|
? await hostResolutionRepository.findHostById(Number(hostId), ownerId)
|
||||||
|
: null;
|
||||||
|
|
||||||
res.json(stripSensitiveFields(resolved));
|
if (!sharedHost) {
|
||||||
|
return res.status(404).json({ error: "SSH host not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let ownerUsername: string | undefined;
|
||||||
|
try {
|
||||||
|
const owner = ownerId
|
||||||
|
? await createCurrentUserRepository().findById(ownerId)
|
||||||
|
: null;
|
||||||
|
ownerUsername = owner?.username ?? undefined;
|
||||||
|
} catch {
|
||||||
|
ownerUsername = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedResult = {
|
||||||
|
...transformHostResponse(sharedHost),
|
||||||
|
isShared: true,
|
||||||
|
permissionLevel: accessInfo.permissionLevel,
|
||||||
|
sharedExpiresAt: accessInfo.expiresAt || undefined,
|
||||||
|
ownerUsername,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
sanitizeHostForRecipient(sharedResult, accessInfo.permissionLevel),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
|
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
|
||||||
operation: "host_fetch_by_id",
|
operation: "host_fetch_by_id",
|
||||||
@@ -2033,13 +2097,14 @@ async function resolveHostCredentials(
|
|||||||
|
|
||||||
if (requestingUserId && requestingUserId !== ownerId) {
|
if (requestingUserId && requestingUserId !== ownerId) {
|
||||||
try {
|
try {
|
||||||
const { SharedCredentialManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-credential-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCred =
|
||||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||||
host.id as number,
|
host.id as number,
|
||||||
requestingUserId,
|
requestingUserId,
|
||||||
);
|
"ssh",
|
||||||
|
);
|
||||||
|
|
||||||
if (sharedCred) {
|
if (sharedCred) {
|
||||||
const resolvedHost: Record<string, unknown> = {
|
const resolvedHost: Record<string, unknown> = {
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
const { PermissionManager } =
|
const { PermissionManager } =
|
||||||
await import("../../utils/permission-manager.js");
|
await import("../../utils/permission-manager.js");
|
||||||
const pm = PermissionManager.getInstance();
|
const pm = PermissionManager.getInstance();
|
||||||
const access = await pm.canAccessHost(userId, parsedHostId, "execute");
|
const access = await pm.canAccessHost(userId, parsedHostId, "connect");
|
||||||
if (!access.hasAccess) {
|
if (!access.hasAccess) {
|
||||||
const error = new Error("Access denied");
|
const error = new Error("Access denied");
|
||||||
(error as Error & { status?: number }).status = 403;
|
(error as Error & { status?: number }).status = 403;
|
||||||
@@ -337,12 +337,13 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
if (host.credentialId) {
|
if (host.credentialId) {
|
||||||
if (userId !== host.userId) {
|
if (userId !== host.userId) {
|
||||||
try {
|
try {
|
||||||
const { SharedCredentialManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-credential-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
const sharedCred =
|
const sharedCred =
|
||||||
await SharedCredentialManager.getInstance().getSharedCredentialForUser(
|
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||||
host.id,
|
host.id,
|
||||||
userId,
|
userId,
|
||||||
|
"ssh",
|
||||||
);
|
);
|
||||||
if (sharedCred) {
|
if (sharedCred) {
|
||||||
resolvedCredentials = {
|
resolvedCredentials = {
|
||||||
|
|||||||
+461
-202
@@ -3,7 +3,15 @@ import express from "express";
|
|||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import {
|
||||||
|
PermissionManager,
|
||||||
|
SHARE_PERMISSION_LEVELS,
|
||||||
|
type SharePermissionLevel,
|
||||||
|
} from "../../utils/permission-manager.js";
|
||||||
|
import {
|
||||||
|
PERMISSION_CATALOG,
|
||||||
|
isValidPermission,
|
||||||
|
} from "../../utils/permission-catalog.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
@@ -24,12 +32,69 @@ function isNonEmptyString(value: unknown): value is string {
|
|||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSharePermissionLevel(value: unknown): value is SharePermissionLevel {
|
||||||
|
return SHARE_PERMISSION_LEVELS.includes(value as SharePermissionLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expiryFromDuration(durationHours: unknown): string | null {
|
||||||
|
if (durationHours && typeof durationHours === "number" && durationHours > 0) {
|
||||||
|
const expiryDate = new Date();
|
||||||
|
expiryDate.setTime(expiryDate.getTime() + durationHours * 60 * 60 * 1000);
|
||||||
|
return expiryDate.toISOString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sharing is controlled by the owner or any recipient holding "manage".
|
||||||
|
async function canManageHostSharing(
|
||||||
|
userId: string,
|
||||||
|
hostId: number,
|
||||||
|
): Promise<{ allowed: boolean; isOwner: boolean }> {
|
||||||
|
const access = await permissionManager.canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"manage",
|
||||||
|
);
|
||||||
|
return { allowed: access.hasAccess, isOwner: access.isOwner };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareTarget {
|
||||||
|
type: "user" | "role";
|
||||||
|
id: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShareTargets(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): ShareTarget[] | null {
|
||||||
|
const rawTargets = body.targets;
|
||||||
|
if (!Array.isArray(rawTargets) || rawTargets.length === 0) return null;
|
||||||
|
|
||||||
|
const targets: ShareTarget[] = [];
|
||||||
|
for (const raw of rawTargets) {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const { type, id } = raw as { type?: unknown; id?: unknown };
|
||||||
|
if (type === "user" && isNonEmptyString(id)) {
|
||||||
|
targets.push({ type: "user", id });
|
||||||
|
} else if (
|
||||||
|
type === "role" &&
|
||||||
|
typeof id === "number" &&
|
||||||
|
Number.isInteger(id)
|
||||||
|
) {
|
||||||
|
targets.push({ type: "role", id });
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /rbac/host/{id}/share:
|
* /rbac/host/{id}/share:
|
||||||
* post:
|
* post:
|
||||||
* summary: Share a host
|
* summary: Share a host
|
||||||
* description: Shares a host with a user or a role.
|
* description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). Allowed for the host owner or recipients holding the manage level. Every auth type is shareable; per-recipient secret snapshots are created automatically.
|
||||||
* tags:
|
* tags:
|
||||||
* - RBAC
|
* - RBAC
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -44,26 +109,32 @@ function isNonEmptyString(value: unknown): value is string {
|
|||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* type: object
|
* type: object
|
||||||
|
* required: [targets]
|
||||||
* properties:
|
* properties:
|
||||||
* targetType:
|
* targets:
|
||||||
* type: string
|
* type: array
|
||||||
* enum: [user, role]
|
* items:
|
||||||
* targetUserId:
|
* type: object
|
||||||
* type: string
|
* properties:
|
||||||
* targetRoleId:
|
* type:
|
||||||
* type: integer
|
* type: string
|
||||||
* durationHours:
|
* enum: [user, role]
|
||||||
* type: number
|
* id:
|
||||||
|
* oneOf:
|
||||||
|
* - type: string
|
||||||
|
* - type: integer
|
||||||
* permissionLevel:
|
* permissionLevel:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [view]
|
* enum: [connect, view, edit, manage]
|
||||||
|
* durationHours:
|
||||||
|
* type: number
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Host shared successfully.
|
* description: Host shared successfully.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Invalid request body.
|
* description: Invalid request body.
|
||||||
* 403:
|
* 403:
|
||||||
* description: Not host owner.
|
* description: Caller may not share this host.
|
||||||
* 404:
|
* 404:
|
||||||
* description: Target user or role not found.
|
* description: Target user or role not found.
|
||||||
* 500:
|
* 500:
|
||||||
@@ -82,37 +153,25 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {
|
const targets = parseShareTargets(req.body ?? {});
|
||||||
targetType = "user",
|
if (!targets) {
|
||||||
targetUserId,
|
return res.status(400).json({
|
||||||
targetRoleId,
|
error:
|
||||||
durationHours,
|
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
|
||||||
permissionLevel = "view",
|
});
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
if (!["user", "role"].includes(targetType)) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
|
const { durationHours, permissionLevel = "connect" } = req.body;
|
||||||
return res
|
|
||||||
.status(400)
|
if (!isSharePermissionLevel(permissionLevel)) {
|
||||||
.json({ error: "Target user ID is required when sharing with user" });
|
return res.status(400).json({
|
||||||
}
|
error: "Invalid permission level",
|
||||||
if (targetType === "role" && !targetRoleId) {
|
validLevels: SHARE_PERMISSION_LEVELS,
|
||||||
return res
|
});
|
||||||
.status(400)
|
|
||||||
.json({ error: "Target role ID is required when sharing with role" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const host =
|
const sharing = await canManageHostSharing(userId, hostId);
|
||||||
await createCurrentHostResolutionRepository().findHostUpdateState(
|
if (!sharing.allowed) {
|
||||||
hostId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!host || host.userId !== userId) {
|
|
||||||
databaseLogger.warn("Permission denied", {
|
databaseLogger.warn("Permission denied", {
|
||||||
operation: "rbac_permission_denied",
|
operation: "rbac_permission_denied",
|
||||||
userId,
|
userId,
|
||||||
@@ -120,141 +179,126 @@ router.post(
|
|||||||
resourceId: hostId,
|
resourceId: hostId,
|
||||||
action: "share",
|
action: "share",
|
||||||
});
|
});
|
||||||
return res.status(403).json({ error: "Not host owner" });
|
return res.status(403).json({ error: "You may not share this host" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const host =
|
||||||
!host.credentialId &&
|
await createCurrentHostResolutionRepository().findHostUpdateState(
|
||||||
!host.rdpCredentialId &&
|
hostId,
|
||||||
!host.vncCredentialId &&
|
);
|
||||||
host.authType !== "opkssh"
|
if (!host) {
|
||||||
) {
|
return res.status(404).json({ error: "Host not found" });
|
||||||
return res.status(400).json({
|
|
||||||
error:
|
|
||||||
"Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.",
|
|
||||||
code: "CREDENTIAL_REQUIRED_FOR_SHARING",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const ownerId = host.userId;
|
||||||
|
|
||||||
if (targetType === "user") {
|
const userRepository = createCurrentUserRepository();
|
||||||
const targetUser =
|
const roleRepository = createCurrentRoleRepository();
|
||||||
await createCurrentUserRepository().findById(targetUserId);
|
|
||||||
|
|
||||||
if (!targetUser) {
|
for (const target of targets) {
|
||||||
return res.status(404).json({ error: "Target user not found" });
|
if (target.type === "user") {
|
||||||
}
|
if (target.id === ownerId) {
|
||||||
} else {
|
return res
|
||||||
const targetRole =
|
.status(400)
|
||||||
await createCurrentRoleRepository().findRoleById(targetRoleId);
|
.json({ error: "Cannot share a host with its owner" });
|
||||||
|
}
|
||||||
if (!targetRole) {
|
const targetUser = await userRepository.findById(target.id as string);
|
||||||
return res.status(404).json({ error: "Target role not found" });
|
if (!targetUser) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Target user not found",
|
||||||
|
targetId: target.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const targetRole = await roleRepository.findRoleById(
|
||||||
|
target.id as number,
|
||||||
|
);
|
||||||
|
if (!targetRole) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Target role not found",
|
||||||
|
targetId: target.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let expiresAt: string | null = null;
|
const expiresAt = expiryFromDuration(durationHours);
|
||||||
if (
|
|
||||||
durationHours &&
|
|
||||||
typeof durationHours === "number" &&
|
|
||||||
durationHours > 0
|
|
||||||
) {
|
|
||||||
const expiryDate = new Date();
|
|
||||||
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
|
||||||
expiresAt = expiryDate.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const validLevels = ["view"];
|
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||||
if (!validLevels.includes(permissionLevel)) {
|
const { SharedHostSecretsManager } =
|
||||||
return res.status(400).json({
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
error: "Invalid permission level. Only 'view' is supported.",
|
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||||
validLevels,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessGrant =
|
const results: Array<{
|
||||||
await createCurrentRbacAccessRepository().upsertHostAccess({
|
type: "user" | "role";
|
||||||
|
id: string | number;
|
||||||
|
accessId: number;
|
||||||
|
created: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const accessGrant = await rbacAccessRepository.upsertHostAccess({
|
||||||
hostId,
|
hostId,
|
||||||
grantedBy: userId,
|
grantedBy: userId,
|
||||||
permissionLevel,
|
permissionLevel,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
...(targetType === "user"
|
...(target.type === "user"
|
||||||
? { targetType: "user" as const, targetUserId: targetUserId! }
|
? { targetType: "user" as const, targetUserId: target.id as string }
|
||||||
: { targetType: "role" as const, targetRoleId: targetRoleId! }),
|
: {
|
||||||
|
targetType: "role" as const,
|
||||||
|
targetRoleId: target.id as number,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!accessGrant.created) {
|
try {
|
||||||
const activeCredentialId =
|
if (target.type === "user") {
|
||||||
host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId;
|
await secretsManager.snapshotForUser(
|
||||||
if (activeCredentialId) {
|
|
||||||
const { SharedCredentialManager } =
|
|
||||||
await import("../../utils/shared-credential-manager.js");
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
||||||
if (targetType === "user") {
|
|
||||||
await sharedCredManager.createSharedCredentialForUser(
|
|
||||||
accessGrant.id,
|
accessGrant.id,
|
||||||
activeCredentialId,
|
hostId,
|
||||||
targetUserId!,
|
target.id as string,
|
||||||
userId,
|
ownerId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await sharedCredManager.createSharedCredentialsForRole(
|
await secretsManager.snapshotForRole(
|
||||||
accessGrant.id,
|
accessGrant.id,
|
||||||
activeCredentialId,
|
hostId,
|
||||||
targetRoleId!,
|
target.id as number,
|
||||||
userId,
|
ownerId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (snapshotError) {
|
||||||
|
databaseLogger.warn("Share created but secret snapshot failed", {
|
||||||
|
operation: "rbac_host_share_snapshot_failed",
|
||||||
|
hostId,
|
||||||
|
accessId: accessGrant.id,
|
||||||
|
error:
|
||||||
|
snapshotError instanceof Error
|
||||||
|
? snapshotError.message
|
||||||
|
: "Unknown error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
databaseLogger.info("Permission granted", {
|
|
||||||
operation: "rbac_permission_grant",
|
|
||||||
adminId: userId,
|
|
||||||
hostId,
|
|
||||||
resource: "host",
|
|
||||||
action: "view",
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
results.push({
|
||||||
success: true,
|
type: target.type,
|
||||||
message: "Host access updated",
|
id: target.id,
|
||||||
expiresAt,
|
accessId: accessGrant.id,
|
||||||
|
created: accessGrant.created,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { SharedCredentialManager } =
|
|
||||||
await import("../../utils/shared-credential-manager.js");
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
||||||
|
|
||||||
const activeCredentialId =
|
|
||||||
host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId;
|
|
||||||
if (activeCredentialId) {
|
|
||||||
if (targetType === "user") {
|
|
||||||
await sharedCredManager.createSharedCredentialForUser(
|
|
||||||
accessGrant.id,
|
|
||||||
activeCredentialId,
|
|
||||||
targetUserId!,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await sharedCredManager.createSharedCredentialsForRole(
|
|
||||||
accessGrant.id,
|
|
||||||
activeCredentialId,
|
|
||||||
targetRoleId!,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
databaseLogger.success("Host shared successfully", {
|
databaseLogger.success("Host shared successfully", {
|
||||||
operation: "rbac_host_share_success",
|
operation: "rbac_host_share_success",
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
targetUserId: targetType === "user" ? targetUserId : undefined,
|
targets: results.length,
|
||||||
permissionLevel,
|
permissionLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Host shared successfully with ${targetType}`,
|
message: "Host shared successfully",
|
||||||
|
permissionLevel,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
results,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to share host", error, {
|
databaseLogger.error("Failed to share host", error, {
|
||||||
@@ -267,6 +311,143 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/host/{id}/access/{accessId}:
|
||||||
|
* patch:
|
||||||
|
* summary: Update a host access grant
|
||||||
|
* description: Changes the permission level and/or expiry of an existing host access grant. Allowed for the host owner or recipients holding the manage level.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: path
|
||||||
|
* name: accessId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* permissionLevel:
|
||||||
|
* type: string
|
||||||
|
* enum: [connect, view, edit, manage]
|
||||||
|
* durationHours:
|
||||||
|
* type: number
|
||||||
|
* nullable: true
|
||||||
|
* description: Hours from now until the grant expires; null clears the expiry.
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Grant updated successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 403:
|
||||||
|
* description: Caller may not manage sharing on this host.
|
||||||
|
* 404:
|
||||||
|
* description: Grant not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update grant.
|
||||||
|
*/
|
||||||
|
router.patch(
|
||||||
|
"/host/:id/access/:accessId",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
|
const accessIdParam = Array.isArray(req.params.accessId)
|
||||||
|
? req.params.accessId[0]
|
||||||
|
: req.params.accessId;
|
||||||
|
const hostId = parseInt(id, 10);
|
||||||
|
const accessId = parseInt(accessIdParam, 10);
|
||||||
|
const userId = req.userId!;
|
||||||
|
|
||||||
|
if (isNaN(hostId) || isNaN(accessId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sharing = await canManageHostSharing(userId, hostId);
|
||||||
|
if (!sharing.allowed) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "You may not manage sharing on this host" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { permissionLevel, durationHours } = req.body ?? {};
|
||||||
|
|
||||||
|
if (permissionLevel === undefined && durationHours === undefined) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "At least one of permissionLevel or durationHours is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
permissionLevel !== undefined &&
|
||||||
|
!isSharePermissionLevel(permissionLevel)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid permission level",
|
||||||
|
validLevels: SHARE_PERMISSION_LEVELS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||||
|
const grant = await rbacAccessRepository.findHostAccessById(
|
||||||
|
accessId,
|
||||||
|
hostId,
|
||||||
|
);
|
||||||
|
if (!grant) {
|
||||||
|
return res.status(404).json({ error: "Access grant not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const update: { permissionLevel?: string; expiresAt?: string | null } =
|
||||||
|
{};
|
||||||
|
if (permissionLevel !== undefined) {
|
||||||
|
update.permissionLevel = permissionLevel;
|
||||||
|
}
|
||||||
|
if (durationHours !== undefined) {
|
||||||
|
update.expiresAt =
|
||||||
|
durationHours === null ? null : expiryFromDuration(durationHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rbacAccessRepository.updateHostAccessGrant(
|
||||||
|
accessId,
|
||||||
|
hostId,
|
||||||
|
update,
|
||||||
|
);
|
||||||
|
|
||||||
|
databaseLogger.info("Host access grant updated", {
|
||||||
|
operation: "rbac_host_access_update",
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
accessId,
|
||||||
|
permissionLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Access updated",
|
||||||
|
expiresAt: update.expiresAt ?? grant.expiresAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to update host access", error, {
|
||||||
|
operation: "update_host_access",
|
||||||
|
hostId,
|
||||||
|
accessId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to update access" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /rbac/host/{id}/access/{accessId}:
|
* /rbac/host/{id}/access/{accessId}:
|
||||||
@@ -292,7 +473,7 @@ router.post(
|
|||||||
* 400:
|
* 400:
|
||||||
* description: Invalid ID.
|
* description: Invalid ID.
|
||||||
* 403:
|
* 403:
|
||||||
* description: Not host owner.
|
* description: Caller may not manage sharing on this host.
|
||||||
* 500:
|
* 500:
|
||||||
* description: Failed to revoke access.
|
* description: Failed to revoke access.
|
||||||
*/
|
*/
|
||||||
@@ -313,14 +494,11 @@ router.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isHostOwner =
|
const sharing = await canManageHostSharing(userId, hostId);
|
||||||
await createCurrentHostResolutionRepository().isHostOwnedByUser(
|
if (!sharing.allowed) {
|
||||||
hostId,
|
return res
|
||||||
userId,
|
.status(403)
|
||||||
);
|
.json({ error: "You may not manage sharing on this host" });
|
||||||
|
|
||||||
if (!isHostOwner) {
|
|
||||||
return res.status(403).json({ error: "Not host owner" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createCurrentRbacAccessRepository().revokeHostAccess(
|
await createCurrentRbacAccessRepository().revokeHostAccess(
|
||||||
@@ -363,11 +541,11 @@ router.delete(
|
|||||||
* type: integer
|
* type: integer
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: The access list for the host.
|
* description: The access list for the host, including each grant's permission level.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Invalid host ID.
|
* description: Invalid host ID.
|
||||||
* 403:
|
* 403:
|
||||||
* description: Not host owner.
|
* description: Caller may not manage sharing on this host.
|
||||||
* 500:
|
* 500:
|
||||||
* description: Failed to get access list.
|
* description: Failed to get access list.
|
||||||
*/
|
*/
|
||||||
@@ -384,20 +562,17 @@ router.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isHostOwner =
|
const sharing = await canManageHostSharing(userId, hostId);
|
||||||
await createCurrentHostResolutionRepository().isHostOwnedByUser(
|
if (!sharing.allowed) {
|
||||||
hostId,
|
return res
|
||||||
userId,
|
.status(403)
|
||||||
);
|
.json({ error: "You may not manage sharing on this host" });
|
||||||
|
|
||||||
if (!isHostOwner) {
|
|
||||||
return res.status(403).json({ error: "Not host owner" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessList =
|
const accessList =
|
||||||
await createCurrentRbacAccessRepository().listHostAccess(hostId);
|
await createCurrentRbacAccessRepository().listHostAccess(hostId);
|
||||||
|
|
||||||
res.json({ accessList });
|
res.json({ accessList, isOwner: sharing.isOwner });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to get host access list", error, {
|
databaseLogger.error("Failed to get host access list", error, {
|
||||||
operation: "get_host_access_list",
|
operation: "get_host_access_list",
|
||||||
@@ -475,17 +650,30 @@ router.get(
|
|||||||
displayName,
|
displayName,
|
||||||
description,
|
description,
|
||||||
isSystem,
|
isSystem,
|
||||||
|
permissions,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
}) => ({
|
}) => {
|
||||||
id,
|
let parsedPermissions: string[] = [];
|
||||||
name,
|
try {
|
||||||
displayName,
|
parsedPermissions = permissions
|
||||||
description,
|
? (JSON.parse(permissions) as string[])
|
||||||
isSystem,
|
: [];
|
||||||
createdAt,
|
} catch {
|
||||||
updatedAt,
|
parsedPermissions = [];
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
displayName,
|
||||||
|
description,
|
||||||
|
isSystem,
|
||||||
|
permissions: parsedPermissions,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ roles: rolesList });
|
res.json({ roles: rolesList });
|
||||||
@@ -606,6 +794,11 @@ router.post(
|
|||||||
* type: string
|
* type: string
|
||||||
* description:
|
* description:
|
||||||
* type: string
|
* type: string
|
||||||
|
* permissions:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Permission strings validated against the permissions catalog (wildcards like hosts.* and * allowed).
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Role updated successfully.
|
* description: Role updated successfully.
|
||||||
@@ -623,21 +816,47 @@ router.put(
|
|||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
const roleId = parseInt(id, 10);
|
const roleId = parseInt(id, 10);
|
||||||
const { displayName, description } = req.body;
|
const { displayName, description, permissions } = req.body;
|
||||||
|
|
||||||
if (isNaN(roleId)) {
|
if (isNaN(roleId)) {
|
||||||
return res.status(400).json({ error: "Invalid role ID" });
|
return res.status(400).json({ error: "Invalid role ID" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!displayName && description === undefined) {
|
if (
|
||||||
|
!displayName &&
|
||||||
|
description === undefined &&
|
||||||
|
permissions === undefined
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "At least one field (displayName or description) is required",
|
error:
|
||||||
|
"At least one field (displayName, description or permissions) is required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (permissions !== undefined) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(permissions) ||
|
||||||
|
permissions.some((perm) => typeof perm !== "string")
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "permissions must be an array of strings" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = (permissions as string[]).filter(
|
||||||
|
(perm) => !isValidPermission(perm),
|
||||||
|
);
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Unknown permissions",
|
||||||
|
invalid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingRole =
|
const roleRepository = createCurrentRoleRepository();
|
||||||
await createCurrentRoleRepository().findRoleById(roleId);
|
const existingRole = await roleRepository.findRoleById(roleId);
|
||||||
|
|
||||||
if (!existingRole) {
|
if (!existingRole) {
|
||||||
return res.status(404).json({ error: "Role not found" });
|
return res.status(404).json({ error: "Role not found" });
|
||||||
@@ -646,6 +865,7 @@ router.put(
|
|||||||
const updates: {
|
const updates: {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
permissions?: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
} = {
|
} = {
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@@ -659,7 +879,18 @@ router.put(
|
|||||||
updates.description = description || null;
|
updates.description = description || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await createCurrentRoleRepository().updateRole(roleId, updates);
|
if (permissions !== undefined) {
|
||||||
|
updates.permissions = JSON.stringify(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
await roleRepository.updateRole(roleId, updates);
|
||||||
|
|
||||||
|
if (permissions !== undefined) {
|
||||||
|
const memberIds = await roleRepository.listRoleUserIds(roleId);
|
||||||
|
for (const memberId of memberIds) {
|
||||||
|
permissionManager.invalidateUserPermissionCache(memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -675,6 +906,26 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/permissions/catalog:
|
||||||
|
* get:
|
||||||
|
* summary: Get the role permissions catalog
|
||||||
|
* description: Returns the grouped catalog of role permission strings used by the role permissions editor.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The permissions catalog.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/permissions/catalog",
|
||||||
|
authenticateJWT,
|
||||||
|
async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
res.json({ catalog: PERMISSION_CATALOG });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /rbac/roles/{id}:
|
* /rbac/roles/{id}:
|
||||||
@@ -836,37 +1087,23 @@ router.post(
|
|||||||
grantedBy: currentUserId,
|
grantedBy: currentUserId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hostsSharedWithRole =
|
try {
|
||||||
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources(
|
const { SharedHostSecretsManager } =
|
||||||
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
|
await SharedHostSecretsManager.getInstance().snapshotForRoleMember(
|
||||||
roleId,
|
roleId,
|
||||||
|
targetUserId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
"Failed to snapshot shared host secrets for new role member",
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "assign_role_snapshot_secrets",
|
||||||
|
targetUserId,
|
||||||
|
roleId,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { SharedCredentialManager } =
|
|
||||||
await import("../../utils/shared-credential-manager.js");
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
||||||
|
|
||||||
for (const sharedHost of hostsSharedWithRole) {
|
|
||||||
if (sharedHost.credentialId) {
|
|
||||||
try {
|
|
||||||
await sharedCredManager.createSharedCredentialForUser(
|
|
||||||
sharedHost.hostAccessId,
|
|
||||||
sharedHost.credentialId,
|
|
||||||
targetUserId,
|
|
||||||
sharedHost.hostOwnerId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error(
|
|
||||||
"Failed to create shared credential for new role member",
|
|
||||||
error,
|
|
||||||
{
|
|
||||||
operation: "assign_role_create_credentials",
|
|
||||||
targetUserId,
|
|
||||||
roleId,
|
|
||||||
hostId: sharedHost.hostId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
permissionManager.invalidateUserPermissionCache(targetUserId);
|
permissionManager.invalidateUserPermissionCache(targetUserId);
|
||||||
@@ -959,6 +1196,28 @@ router.delete(
|
|||||||
roleId,
|
roleId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createCurrentSharedHostSecretsRepository } =
|
||||||
|
await import("../repositories/factory.js");
|
||||||
|
await createCurrentSharedHostSecretsRepository().deleteForRoleMember(
|
||||||
|
roleId,
|
||||||
|
targetUserId,
|
||||||
|
);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
databaseLogger.warn(
|
||||||
|
"Failed to clean shared host secrets after role removal",
|
||||||
|
{
|
||||||
|
operation: "remove_role_secret_cleanup",
|
||||||
|
targetUserId,
|
||||||
|
roleId,
|
||||||
|
error:
|
||||||
|
cleanupError instanceof Error
|
||||||
|
? cleanupError.message
|
||||||
|
: "Unknown error",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
permissionManager.invalidateUserPermissionCache(targetUserId);
|
permissionManager.invalidateUserPermissionCache(targetUserId);
|
||||||
databaseLogger.info("Role removed from user", {
|
databaseLogger.info("Role removed from user", {
|
||||||
operation: "rbac_role_remove",
|
operation: "rbac_role_remove",
|
||||||
|
|||||||
@@ -58,12 +58,11 @@ async function syncSharedCredentialsForUserRoles(
|
|||||||
operation: string,
|
operation: string,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { SharedCredentialManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-credential-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
|
||||||
await sharedCredManager.createSharedCredentialsForUserRoles(userId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authLogger.warn("Failed to sync role shared credentials", {
|
authLogger.warn("Failed to sync role shared host secrets", {
|
||||||
operation,
|
operation,
|
||||||
userId,
|
userId,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
|||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
"execute",
|
"connect",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!accessInfo.hasAccess) {
|
if (!accessInfo.hasAccess) {
|
||||||
@@ -294,13 +294,13 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
|||||||
|
|
||||||
if (userId !== ownerId) {
|
if (userId !== ownerId) {
|
||||||
try {
|
try {
|
||||||
const { SharedCredentialManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-credential-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
||||||
const sharedCred =
|
const sharedCred =
|
||||||
await sharedCredManager.getSharedCredentialForUser(
|
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||||
host.id,
|
host.id,
|
||||||
userId,
|
userId,
|
||||||
|
"ssh",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sharedCred) {
|
if (sharedCred) {
|
||||||
|
|||||||
@@ -196,10 +196,13 @@ router.post(
|
|||||||
return res.status(400).json({ error: "Invalid host ID" });
|
return res.status(400).json({ error: "Invalid host ID" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||||
hostId,
|
// Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields.
|
||||||
userId,
|
const hostOwnerId =
|
||||||
);
|
await hostResolutionRepository.findHostOwnerId(hostId);
|
||||||
|
const host = hostOwnerId
|
||||||
|
? await hostResolutionRepository.findHostById(hostId, hostOwnerId)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return res.status(404).json({ error: "Host not found" });
|
return res.status(404).json({ error: "Host not found" });
|
||||||
@@ -210,7 +213,7 @@ router.post(
|
|||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
"read",
|
"connect",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!accessInfo.hasAccess) {
|
if (!accessInfo.hasAccess) {
|
||||||
@@ -294,83 +297,121 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hostRecord = host as Record<string, unknown>;
|
const hostRecord = host as Record<string, unknown>;
|
||||||
const hostRepository = createCurrentHostResolutionRepository();
|
const hostRepository = hostResolutionRepository;
|
||||||
|
const isSharedConnection = host.userId !== userId;
|
||||||
|
|
||||||
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
|
if (isSharedConnection) {
|
||||||
const rdpEffectiveAuthType =
|
// Recipients never read the owner's raw secrets; wipe them and use
|
||||||
(host.rdpAuthType as string) ||
|
// the per-recipient snapshot for the requested protocol instead.
|
||||||
(host.rdpCredentialId ? "credential" : "direct");
|
host.password = null;
|
||||||
const vncEffectiveAuthType =
|
host.rdpUser = null;
|
||||||
(host.vncAuthType as string) ||
|
host.rdpPassword = null;
|
||||||
(host.vncCredentialId ? "credential" : "direct");
|
host.vncUser = null;
|
||||||
const telnetEffectiveAuthType =
|
host.vncPassword = null;
|
||||||
(host.telnetAuthType as string) ||
|
host.telnetUser = null;
|
||||||
(hostRecord.telnetCredentialId ? "credential" : "direct");
|
host.telnetPassword = null;
|
||||||
|
|
||||||
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
|
|
||||||
try {
|
try {
|
||||||
const cred =
|
const { SharedHostSecretsManager } =
|
||||||
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
|
const secret =
|
||||||
|
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
connectionType as "rdp" | "vnc" | "telnet",
|
||||||
|
);
|
||||||
|
if (secret) {
|
||||||
|
if (connectionType === "rdp") {
|
||||||
|
host.rdpUser = secret.username ?? null;
|
||||||
|
host.rdpPassword = secret.password ?? null;
|
||||||
|
if (secret.domain) host.rdpDomain = secret.domain;
|
||||||
|
} else if (connectionType === "vnc") {
|
||||||
|
host.vncUser = secret.username ?? null;
|
||||||
|
host.vncPassword = secret.password ?? null;
|
||||||
|
} else if (connectionType === "telnet") {
|
||||||
|
host.telnetUser = secret.username ?? null;
|
||||||
|
host.telnetPassword = secret.password ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
guacLogger.warn("Failed to resolve shared host secret", {
|
||||||
|
operation: "guac_shared_secret_resolve",
|
||||||
|
hostId,
|
||||||
|
protocol: connectionType,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
|
||||||
|
const rdpEffectiveAuthType =
|
||||||
|
(host.rdpAuthType as string) ||
|
||||||
|
(host.rdpCredentialId ? "credential" : "direct");
|
||||||
|
const vncEffectiveAuthType =
|
||||||
|
(host.vncAuthType as string) ||
|
||||||
|
(host.vncCredentialId ? "credential" : "direct");
|
||||||
|
const telnetEffectiveAuthType =
|
||||||
|
(host.telnetAuthType as string) ||
|
||||||
|
(hostRecord.telnetCredentialId ? "credential" : "direct");
|
||||||
|
|
||||||
|
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
|
||||||
|
try {
|
||||||
|
const cred = await hostRepository.findCredentialByIdForUser(
|
||||||
host.rdpCredentialId as number,
|
host.rdpCredentialId as number,
|
||||||
host.userId as string,
|
host.userId as string,
|
||||||
userId,
|
|
||||||
);
|
);
|
||||||
if (cred) {
|
if (cred) {
|
||||||
if (cred.username) host.rdpUser = cred.username;
|
if (cred.username) host.rdpUser = cred.username;
|
||||||
if (cred.password) host.rdpPassword = cred.password;
|
if (cred.password) host.rdpPassword = cred.password;
|
||||||
// domain is never sourced from credential
|
// domain is never sourced from credential
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
guacLogger.warn("Failed to resolve RDP credential", {
|
||||||
|
operation: "guac_rdp_credential_resolve",
|
||||||
|
hostId,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
guacLogger.warn("Failed to resolve RDP credential", {
|
|
||||||
operation: "guac_rdp_credential_resolve",
|
|
||||||
hostId,
|
|
||||||
error: e instanceof Error ? e.message : "Unknown",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
|
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
|
||||||
try {
|
try {
|
||||||
const cred =
|
const cred = await hostRepository.findCredentialByIdForUser(
|
||||||
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
|
|
||||||
host.vncCredentialId as number,
|
host.vncCredentialId as number,
|
||||||
host.userId as string,
|
host.userId as string,
|
||||||
userId,
|
|
||||||
);
|
);
|
||||||
if (cred) {
|
if (cred) {
|
||||||
if (cred.password) host.vncPassword = cred.password;
|
if (cred.password) host.vncPassword = cred.password;
|
||||||
if (cred.username) host.vncUser = cred.username;
|
if (cred.username) host.vncUser = cred.username;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
guacLogger.warn("Failed to resolve VNC credential", {
|
||||||
|
operation: "guac_vnc_credential_resolve",
|
||||||
|
hostId,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
guacLogger.warn("Failed to resolve VNC credential", {
|
|
||||||
operation: "guac_vnc_credential_resolve",
|
|
||||||
hostId,
|
|
||||||
error: e instanceof Error ? e.message : "Unknown",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
telnetEffectiveAuthType === "credential" &&
|
telnetEffectiveAuthType === "credential" &&
|
||||||
hostRecord.telnetCredentialId
|
hostRecord.telnetCredentialId
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const cred =
|
const cred = await hostRepository.findCredentialByIdForUser(
|
||||||
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
|
|
||||||
hostRecord.telnetCredentialId as number,
|
hostRecord.telnetCredentialId as number,
|
||||||
host.userId as string,
|
host.userId as string,
|
||||||
userId,
|
|
||||||
);
|
);
|
||||||
if (cred) {
|
if (cred) {
|
||||||
if (cred.username) host.telnetUser = cred.username;
|
if (cred.username) host.telnetUser = cred.username;
|
||||||
if (cred.password) host.telnetPassword = cred.password;
|
if (cred.password) host.telnetPassword = cred.password;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
guacLogger.warn("Failed to resolve Telnet credential", {
|
||||||
|
operation: "guac_telnet_credential_resolve",
|
||||||
|
hostId,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
guacLogger.warn("Failed to resolve Telnet credential", {
|
|
||||||
operation: "guac_telnet_credential_resolve",
|
|
||||||
hostId,
|
|
||||||
error: e instanceof Error ? e.message : "Unknown",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
expandOidcUsername,
|
expandOidcUsername,
|
||||||
} from "./credential-username.js";
|
} from "./credential-username.js";
|
||||||
import type { SSHHost } from "../../types/index.js";
|
import type { SSHHost } from "../../types/index.js";
|
||||||
|
import type { HostAction } from "../utils/permission-manager.js";
|
||||||
|
|
||||||
const sshLogger = logger;
|
const sshLogger = logger;
|
||||||
|
|
||||||
@@ -24,16 +25,28 @@ export async function resolveHostById(
|
|||||||
const access = await PermissionManager.getInstance().canAccessHost(
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
"read",
|
"connect",
|
||||||
);
|
);
|
||||||
if (!access.hasAccess) return null;
|
if (!access.hasAccess) return null;
|
||||||
|
|
||||||
const repository = createCurrentHostResolutionRepository();
|
const repository = createCurrentHostResolutionRepository();
|
||||||
const resolvedHost = await repository.findHostById(hostId, userId);
|
|
||||||
|
// Decrypt under the owner's DEK: shared hosts carry owner-encrypted fields
|
||||||
|
// (socks5Password, inline auth, ...) that the requester's key cannot open.
|
||||||
|
const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId;
|
||||||
|
const resolvedHost = await repository.findHostById(hostId, ownerId);
|
||||||
if (!resolvedHost) return null;
|
if (!resolvedHost) return null;
|
||||||
|
|
||||||
const host = resolvedHost as Record<string, unknown>;
|
const host = resolvedHost as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (userId !== ownerId) {
|
||||||
|
// Owner-only operational secrets are never shared.
|
||||||
|
host.sudoPassword = null;
|
||||||
|
host.autostartPassword = null;
|
||||||
|
host.autostartKey = null;
|
||||||
|
host.autostartKeyPassword = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse JSON fields
|
// Parse JSON fields
|
||||||
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
|
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
|
||||||
try {
|
try {
|
||||||
@@ -78,88 +91,16 @@ export async function resolveHostById(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve credential if using credential-based auth
|
if (userId !== ownerId) {
|
||||||
if (host.credentialId) {
|
const resolved = await resolveSharedSshSecrets(
|
||||||
const ownerId = (host.userId || userId) as string;
|
host,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
repository,
|
||||||
|
);
|
||||||
|
if (!resolved) return null;
|
||||||
|
} else if (host.credentialId) {
|
||||||
try {
|
try {
|
||||||
// Try user's own override credential first
|
|
||||||
if (userId !== ownerId) {
|
|
||||||
try {
|
|
||||||
const overrideCredId = await repository.findOverrideCredentialId(
|
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
if (overrideCredId) {
|
|
||||||
const cred = (await repository.findCredentialByIdForUser(
|
|
||||||
overrideCredId,
|
|
||||||
userId,
|
|
||||||
)) as Record<string, unknown> | null;
|
|
||||||
if (cred) {
|
|
||||||
host.password = cred.password;
|
|
||||||
host.key = cred.key;
|
|
||||||
host.keyPassword = cred.keyPassword;
|
|
||||||
host.keyType = cred.keyType;
|
|
||||||
host.username = pickResolvedUsername(
|
|
||||||
host.username,
|
|
||||||
cred.username,
|
|
||||||
host.overrideCredentialUsername,
|
|
||||||
);
|
|
||||||
host.authType = cred.key
|
|
||||||
? "key"
|
|
||||||
: cred.password
|
|
||||||
? "password"
|
|
||||||
: "none";
|
|
||||||
host.username = await expandOidcUsername(
|
|
||||||
host.username as string | undefined,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
return host as unknown as SSHHost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to shared credential
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { SharedCredentialManager } =
|
|
||||||
await import("../utils/shared-credential-manager.js");
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
||||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
if (sharedCred) {
|
|
||||||
host.password = sharedCred.password;
|
|
||||||
host.key = sharedCred.key;
|
|
||||||
host.keyPassword = sharedCred.keyPassword;
|
|
||||||
host.keyType = sharedCred.keyType;
|
|
||||||
host.username = pickResolvedUsername(
|
|
||||||
host.username,
|
|
||||||
sharedCred.username,
|
|
||||||
host.overrideCredentialUsername,
|
|
||||||
);
|
|
||||||
host.authType = sharedCred.key
|
|
||||||
? "key"
|
|
||||||
: sharedCred.password
|
|
||||||
? "password"
|
|
||||||
: "none";
|
|
||||||
host.username = await expandOidcUsername(
|
|
||||||
host.username as string | undefined,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
return host as unknown as SSHHost;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
sshLogger.warn("Failed to get shared credential", {
|
|
||||||
operation: "host_resolver_shared_credential",
|
|
||||||
hostId,
|
|
||||||
error: e instanceof Error ? e.message : "Unknown",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cred = (await repository.findCredentialByIdForUser(
|
const cred = (await repository.findCredentialByIdForUser(
|
||||||
host.credentialId as number,
|
host.credentialId as number,
|
||||||
ownerId,
|
ownerId,
|
||||||
@@ -218,6 +159,90 @@ export async function resolveHostById(
|
|||||||
return host as unknown as SSHHost;
|
return host as unknown as SSHHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill in SSH auth secrets for a shared (non-owner) requester. Order:
|
||||||
|
* the recipient's own override credential, then their re-encrypted share
|
||||||
|
* snapshot. Secret-less auth types (opkssh, vault, agent, none) pass through
|
||||||
|
* untouched. Returns false when a secret-bearing host has no usable source.
|
||||||
|
*/
|
||||||
|
async function resolveSharedSshSecrets(
|
||||||
|
host: Record<string, unknown>,
|
||||||
|
hostId: number,
|
||||||
|
userId: string,
|
||||||
|
repository: ReturnType<typeof createCurrentHostResolutionRepository>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const overrideCredId = await repository.findOverrideCredentialId(
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (overrideCredId) {
|
||||||
|
const cred = (await repository.findCredentialByIdForUser(
|
||||||
|
overrideCredId,
|
||||||
|
userId,
|
||||||
|
)) as Record<string, unknown> | null;
|
||||||
|
if (cred) {
|
||||||
|
host.password = cred.password;
|
||||||
|
host.key = (cred.privateKey || cred.key) as string | null;
|
||||||
|
host.keyPassword = cred.keyPassword;
|
||||||
|
host.keyType = cred.keyType;
|
||||||
|
host.username = pickResolvedUsername(
|
||||||
|
host.username,
|
||||||
|
cred.username,
|
||||||
|
host.overrideCredentialUsername,
|
||||||
|
);
|
||||||
|
host.authType = host.key ? "key" : host.password ? "password" : "none";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to the share snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { SharedHostSecretsManager } =
|
||||||
|
await import("../utils/shared-host-secrets-manager.js");
|
||||||
|
const secret =
|
||||||
|
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
if (secret) {
|
||||||
|
host.password = secret.password;
|
||||||
|
host.key = secret.key;
|
||||||
|
host.keyPassword = secret.keyPassword;
|
||||||
|
host.keyType = secret.keyType;
|
||||||
|
host.username = pickResolvedUsername(
|
||||||
|
host.username,
|
||||||
|
secret.username,
|
||||||
|
host.overrideCredentialUsername,
|
||||||
|
);
|
||||||
|
host.authType = secret.key
|
||||||
|
? "key"
|
||||||
|
: secret.password
|
||||||
|
? "password"
|
||||||
|
: "none";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sshLogger.warn("Failed to get shared host secret", {
|
||||||
|
operation: "host_resolver_shared_secret",
|
||||||
|
hostId,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsSecrets =
|
||||||
|
!!host.credentialId ||
|
||||||
|
host.authType === "password" ||
|
||||||
|
host.authType === "key" ||
|
||||||
|
host.authType === "credential";
|
||||||
|
if (!needsSecrets) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a user has access to a host (owner or shared access).
|
* Check if a user has access to a host (owner or shared access).
|
||||||
*/
|
*/
|
||||||
@@ -225,7 +250,7 @@ export async function checkHostAccess(
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
hostUserId: string,
|
hostUserId: string,
|
||||||
requiredPermission: "read" | "execute" = "execute",
|
requiredPermission: HostAction = "connect",
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (userId === hostUserId) return true;
|
if (userId === hostUserId) return true;
|
||||||
|
|
||||||
|
|||||||
@@ -36,35 +36,36 @@ async function resolveJumpHost(
|
|||||||
): Promise<JumpHostConfig | null> {
|
): Promise<JumpHostConfig | null> {
|
||||||
try {
|
try {
|
||||||
const repository = createCurrentHostResolutionRepository();
|
const repository = createCurrentHostResolutionRepository();
|
||||||
const resolvedHost = await repository.findHostById(hostId, userId);
|
const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId;
|
||||||
|
const resolvedHost = await repository.findHostById(hostId, ownerId);
|
||||||
|
|
||||||
if (!resolvedHost) {
|
if (!resolvedHost) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = resolvedHost as Record<string, unknown>;
|
const host = resolvedHost as Record<string, unknown>;
|
||||||
const ownerId = (host.userId || userId) as string;
|
|
||||||
|
|
||||||
if (host.credentialId) {
|
if (host.credentialId) {
|
||||||
if (userId !== ownerId) {
|
if (userId !== ownerId) {
|
||||||
try {
|
try {
|
||||||
const { SharedCredentialManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../utils/shared-credential-manager.js");
|
await import("../utils/shared-host-secrets-manager.js");
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const secret =
|
||||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||||
hostId,
|
hostId,
|
||||||
userId,
|
userId,
|
||||||
);
|
"ssh",
|
||||||
if (sharedCred) {
|
);
|
||||||
|
if (secret) {
|
||||||
return {
|
return {
|
||||||
...host,
|
...host,
|
||||||
password: sharedCred.password,
|
password: secret.password,
|
||||||
key: sharedCred.key,
|
key: secret.key,
|
||||||
keyPassword: sharedCred.keyPassword,
|
keyPassword: secret.keyPassword,
|
||||||
keyType: sharedCred.keyType,
|
keyType: secret.keyType,
|
||||||
authType: sharedCred.key
|
authType: secret.key
|
||||||
? "key"
|
? "key"
|
||||||
: sharedCred.password
|
: secret.password
|
||||||
? "password"
|
? "password"
|
||||||
: "none",
|
: "none",
|
||||||
} as JumpHostConfig;
|
} as JumpHostConfig;
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import type { Express, RequestHandler } from "express";
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js";
|
import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js";
|
||||||
import { statsLogger } from "../../utils/logger.js";
|
import { statsLogger } from "../../utils/logger.js";
|
||||||
|
import type { HostAction } from "../../utils/permission-manager.js";
|
||||||
|
|
||||||
type HistoryRoutesDeps = {
|
type HistoryRoutesDeps = {
|
||||||
validateHostId: RequestHandler;
|
validateHostId: RequestHandler;
|
||||||
canAccessHost: (
|
canAccessHost: (
|
||||||
userId: string,
|
userId: string,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
level: "read" | "write" | "execute" | "delete" | "share",
|
level: HostAction,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export function registerHostMetricsHistoryRoutes(
|
|||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hasAccess = await canAccessHost(userId, hostId, "read");
|
const hasAccess = await canAccessHost(userId, hostId, "connect");
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return res.status(403).json({ error: "Access denied" });
|
return res.status(403).json({ error: "Access denied" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -898,7 +898,7 @@ async function fetchHostById(
|
|||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
id,
|
id,
|
||||||
"read",
|
"connect",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!accessInfo.hasAccess) {
|
if (!accessInfo.hasAccess) {
|
||||||
@@ -991,13 +991,14 @@ async function resolveHostCredentials(
|
|||||||
const isSharedHost = userId !== ownerId;
|
const isSharedHost = userId !== ownerId;
|
||||||
|
|
||||||
if (isSharedHost) {
|
if (isSharedHost) {
|
||||||
const { SharedCredentialManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../../utils/shared-credential-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCred =
|
||||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||||
host.id as number,
|
host.id as number,
|
||||||
userId,
|
userId,
|
||||||
);
|
"ssh",
|
||||||
|
);
|
||||||
|
|
||||||
if (sharedCred) {
|
if (sharedCred) {
|
||||||
baseHost.credentialId = host.credentialId;
|
baseHost.credentialId = host.credentialId;
|
||||||
@@ -1730,7 +1731,7 @@ app.get("/status", async (req, res) => {
|
|||||||
|
|
||||||
const result: Record<number, StatusEntry> = {};
|
const result: Record<number, StatusEntry> = {};
|
||||||
for (const [id, entry] of pollingManager.getAllStatuses().entries()) {
|
for (const [id, entry] of pollingManager.getAllStatuses().entries()) {
|
||||||
const access = await permissionManager.canAccessHost(userId, id, "read");
|
const access = await permissionManager.canAccessHost(userId, id, "connect");
|
||||||
if (access.hasAccess) {
|
if (access.hasAccess) {
|
||||||
result[id] = entry;
|
result[id] = entry;
|
||||||
}
|
}
|
||||||
@@ -1771,7 +1772,7 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const access = await permissionManager.canAccessHost(userId, id, "read");
|
const access = await permissionManager.canAccessHost(userId, id, "connect");
|
||||||
if (!access.hasAccess) {
|
if (!access.hasAccess) {
|
||||||
return res.status(404).json({ error: "Status not available" });
|
return res.status(404).json({ error: "Status not available" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function registerCronRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/cron/:id",
|
"/host-metrics/managers/cron/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "cron_list", async (client) => {
|
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
|
||||||
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
||||||
return { entries: parseCrontab(stdout) };
|
return { entries: parseCrontab(stdout) };
|
||||||
}),
|
}),
|
||||||
@@ -96,7 +96,7 @@ export function registerCronRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"cron_replace",
|
"cron_replace",
|
||||||
async (client, _host, req) => {
|
async (client, _host, req) => {
|
||||||
const { entries } = req.body as { entries?: CronEntry[] };
|
const { entries } = req.body as { entries?: CronEntry[] };
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function registerFirewallRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/firewall/:id",
|
"/host-metrics/managers/firewall/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "firewall_read", async (client) => {
|
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
|
||||||
return await collectFirewallMetrics(client);
|
return await collectFirewallMetrics(client);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -61,7 +61,7 @@ export function registerFirewallRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"firewall_rule",
|
"firewall_rule",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const { op, protocol, port, target } = req.body as {
|
const { op, protocol, port, target } = req.body as {
|
||||||
@@ -106,7 +106,7 @@ export function registerFirewallRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"firewall_persist",
|
"firewall_persist",
|
||||||
async (client, host) => {
|
async (client, host) => {
|
||||||
const platform = await detectPlatform(client);
|
const platform = await detectPlatform(client);
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function registerHealthRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"read",
|
"connect",
|
||||||
"health_get",
|
"health_get",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -191,7 +191,7 @@ export function registerHealthRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"read",
|
"connect",
|
||||||
"health_config",
|
"health_config",
|
||||||
async (_client, host, req) => {
|
async (_client, host, req) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -226,7 +226,7 @@ export function registerHealthRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"read",
|
"connect",
|
||||||
"health_run",
|
"health_run",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function registerManagerRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/platform/:id",
|
"/host-metrics/platform/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "platform_detect", (client) =>
|
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
|
||||||
detectPlatform(client),
|
detectPlatform(client),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function registerLogRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/logs/:id/files",
|
"/host-metrics/managers/logs/:id/files",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "logs_list", async (client) => {
|
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
|
||||||
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
|
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
|
||||||
const found = stdout
|
const found = stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -62,7 +62,7 @@ export function registerLogRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"read",
|
"connect",
|
||||||
"logs_tail",
|
"logs_tail",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const path = req.query.path as string | undefined;
|
const path = req.query.path as string | undefined;
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function registerPackageRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/packages/:id",
|
"/host-metrics/managers/packages/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "packages_list", async (client) => {
|
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
|
||||||
const platform = await detectPlatform(client);
|
const platform = await detectPlatform(client);
|
||||||
const cmd = buildListUpgradableCommand(platform.pkg);
|
const cmd = buildListUpgradableCommand(platform.pkg);
|
||||||
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
|
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
|
||||||
@@ -113,7 +113,7 @@ export function registerPackageRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"packages_action",
|
"packages_action",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const { action, pkg: name } = req.body as {
|
const { action, pkg: name } = req.body as {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function registerProcessRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/processes/:id",
|
"/host-metrics/managers/processes/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "processes_list", async (client) => {
|
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
|
||||||
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
|
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
|
||||||
return { processes: parseProcessList(stdout) };
|
return { processes: parseProcessList(stdout) };
|
||||||
}),
|
}),
|
||||||
@@ -106,7 +106,7 @@ export function registerProcessRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"processes_signal",
|
"processes_signal",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const { pid, signal } = req.body as {
|
const { pid, signal } = req.body as {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { AuthenticatedRequest } from "../../../../types/index.js";
|
|||||||
import { statsLogger } from "../../../utils/logger.js";
|
import { statsLogger } from "../../../utils/logger.js";
|
||||||
import { ElevationError } from "./exec-elevated.js";
|
import { ElevationError } from "./exec-elevated.js";
|
||||||
import type { ManagerHost, RunOnHost } from "./types.js";
|
import type { ManagerHost, RunOnHost } from "./types.js";
|
||||||
|
import type { HostAction } from "../../../utils/permission-manager.js";
|
||||||
|
|
||||||
export class AccessDeniedError extends Error {
|
export class AccessDeniedError extends Error {
|
||||||
constructor(message = "No access to this host") {
|
constructor(message = "No access to this host") {
|
||||||
@@ -25,7 +26,7 @@ export class ManagerInputError extends Error {
|
|||||||
*/
|
*/
|
||||||
export function managerHandler(
|
export function managerHandler(
|
||||||
runOnHost: RunOnHost,
|
runOnHost: RunOnHost,
|
||||||
level: "read" | "execute",
|
level: HostAction,
|
||||||
operation: string,
|
operation: string,
|
||||||
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
|
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/services/:id",
|
"/host-metrics/managers/services/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "services_list", async (client) => {
|
managerHandler(runOnHost, "connect", "services_list", async (client) => {
|
||||||
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
|
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
|
||||||
return { services: parseServiceList(stdout) };
|
return { services: parseServiceList(stdout) };
|
||||||
}),
|
}),
|
||||||
@@ -106,7 +106,7 @@ export function registerServiceRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"services_action",
|
"services_action",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const { unit, action } = req.body as {
|
const { unit, action } = req.body as {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function registerSimpleReadRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/top-memory/:id",
|
"/host-metrics/managers/top-memory/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "top_memory", async (client) => {
|
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
|
||||||
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
|
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
|
||||||
return { processes: parseTopMemory(stdout) };
|
return { processes: parseTopMemory(stdout) };
|
||||||
}),
|
}),
|
||||||
@@ -121,7 +121,7 @@ export function registerSimpleReadRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/timers/:id",
|
"/host-metrics/managers/timers/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "systemd_timers", async (client) => {
|
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
|
||||||
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
|
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
|
||||||
return { timers: parseTimers(stdout) };
|
return { timers: parseTimers(stdout) };
|
||||||
}),
|
}),
|
||||||
@@ -130,7 +130,7 @@ export function registerSimpleReadRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/disk-breakdown/:id",
|
"/host-metrics/managers/disk-breakdown/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "disk_breakdown", async (client) => {
|
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
|
||||||
const { stdout } = await execCommand(client, DF_CMD, 15000);
|
const { stdout } = await execCommand(client, DF_CMD, 15000);
|
||||||
return { mounts: parseDfMounts(stdout) };
|
return { mounts: parseDfMounts(stdout) };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function registerSslRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/ssl/:id",
|
"/host-metrics/managers/ssl/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "ssl_list", async (client, host) => {
|
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
|
||||||
const platform = await detectPlatform(client);
|
const platform = await detectPlatform(client);
|
||||||
const certs: CertInfo[] = [];
|
const certs: CertInfo[] = [];
|
||||||
if (platform.hasCertbot) {
|
if (platform.hasCertbot) {
|
||||||
@@ -187,7 +187,7 @@ export function registerSslRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"ssl_issue",
|
"ssl_issue",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const body = req.body as Partial<IssueRequest>;
|
const body = req.body as Partial<IssueRequest>;
|
||||||
@@ -236,7 +236,7 @@ export function registerSslRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"ssl_renew",
|
"ssl_renew",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const { client: acmeClient, dryRun } = req.body as {
|
const { client: acmeClient, dryRun } = req.body as {
|
||||||
@@ -289,7 +289,7 @@ export function registerSslRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"ssl_revoke",
|
"ssl_revoke",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const { client: acmeClient, name } = req.body as {
|
const { client: acmeClient, name } = req.body as {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function registerTailscaleRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/tailscale/:id",
|
"/host-metrics/managers/tailscale/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "tailscale_read", async (client) => {
|
managerHandler(runOnHost, "connect", "tailscale_read", async (client) => {
|
||||||
const { stdout } = await execCommand(client, PROBE_CMD, 15000);
|
const { stdout } = await execCommand(client, PROBE_CMD, 15000);
|
||||||
return parseTailscaleData(stdout);
|
return parseTailscaleData(stdout);
|
||||||
}),
|
}),
|
||||||
@@ -154,7 +154,7 @@ export function registerTailscaleRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"tailscale_action",
|
"tailscale_action",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const { action } = req.body as { action: unknown };
|
const { action } = req.body as { action: unknown };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Client } from "ssh2";
|
import type { Client } from "ssh2";
|
||||||
import type { RequestHandler } from "express";
|
import type { RequestHandler } from "express";
|
||||||
|
import type { HostAction } from "../../../utils/permission-manager.js";
|
||||||
|
|
||||||
/** Minimal host shape managers need (includes the decrypted sudo password). */
|
/** Minimal host shape managers need (includes the decrypted sudo password). */
|
||||||
export interface ManagerHost {
|
export interface ManagerHost {
|
||||||
@@ -17,7 +18,7 @@ export interface ManagerHost {
|
|||||||
export type RunOnHost = <T>(
|
export type RunOnHost = <T>(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
level: "read" | "execute",
|
level: HostAction,
|
||||||
fn: (client: Client, host: ManagerHost) => Promise<T>,
|
fn: (client: Client, host: ManagerHost) => Promise<T>,
|
||||||
) => Promise<T>;
|
) => Promise<T>;
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function registerUserRoutes(
|
|||||||
app.get(
|
app.get(
|
||||||
"/host-metrics/managers/users/:id",
|
"/host-metrics/managers/users/:id",
|
||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(runOnHost, "read", "users_list", async (client) => {
|
managerHandler(runOnHost, "connect", "users_list", async (client) => {
|
||||||
const [passwd, groups, sudoers] = await Promise.all([
|
const [passwd, groups, sudoers] = await Promise.all([
|
||||||
execCommand(client, READ_USERS_CMD, 15000),
|
execCommand(client, READ_USERS_CMD, 15000),
|
||||||
execCommand(client, READ_GROUPS_CMD, 15000),
|
execCommand(client, READ_GROUPS_CMD, 15000),
|
||||||
@@ -98,7 +98,7 @@ export function registerUserRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"users_action",
|
"users_action",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const { action, username, group } = req.body as {
|
const { action, username, group } = req.body as {
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function registerWireGuardRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"read",
|
"connect",
|
||||||
"wireguard_read",
|
"wireguard_read",
|
||||||
async (client, host) => {
|
async (client, host) => {
|
||||||
const result = await execElevated(
|
const result = await execElevated(
|
||||||
@@ -194,7 +194,7 @@ export function registerWireGuardRoutes(
|
|||||||
validateHostId,
|
validateHostId,
|
||||||
managerHandler(
|
managerHandler(
|
||||||
runOnHost,
|
runOnHost,
|
||||||
"execute",
|
"connect",
|
||||||
"wireguard_action",
|
"wireguard_action",
|
||||||
async (client, host, req) => {
|
async (client, host, req) => {
|
||||||
const { interface: iface, action } = req.body as {
|
const { interface: iface, action } = req.body as {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
defaultLayoutFromWidgets,
|
defaultLayoutFromWidgets,
|
||||||
type HostMetricsLayout,
|
type HostMetricsLayout,
|
||||||
} from "../../../types/host-metrics.js";
|
} from "../../../types/host-metrics.js";
|
||||||
|
import type { HostAction } from "../../utils/permission-manager.js";
|
||||||
|
|
||||||
interface PrefStatsConfig {
|
interface PrefStatsConfig {
|
||||||
enabledWidgets?: string[];
|
enabledWidgets?: string[];
|
||||||
@@ -25,7 +26,7 @@ type HostMetricsPreferencesRoutesDeps = {
|
|||||||
canAccessHost: (
|
canAccessHost: (
|
||||||
userId: string,
|
userId: string,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
level: "read" | "execute",
|
level: HostAction,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ export function registerHostMetricsPreferencesRoutes(
|
|||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostId = parseInt(String(req.params.id), 10);
|
const hostId = parseInt(String(req.params.id), 10);
|
||||||
try {
|
try {
|
||||||
if (!(await canAccessHost(userId, hostId, "read"))) {
|
if (!(await canAccessHost(userId, hostId, "connect"))) {
|
||||||
return res.status(403).json({ error: "No access to this host" });
|
return res.status(403).json({ error: "No access to this host" });
|
||||||
}
|
}
|
||||||
const host = await fetchHostById(hostId, userId);
|
const host = await fetchHostById(hostId, userId);
|
||||||
@@ -166,7 +167,7 @@ export function registerHostMetricsPreferencesRoutes(
|
|||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostId = parseInt(String(req.params.id), 10);
|
const hostId = parseInt(String(req.params.id), 10);
|
||||||
try {
|
try {
|
||||||
if (!(await canAccessHost(userId, hostId, "read"))) {
|
if (!(await canAccessHost(userId, hostId, "connect"))) {
|
||||||
return res.status(403).json({ error: "No access to this host" });
|
return res.status(403).json({ error: "No access to this host" });
|
||||||
}
|
}
|
||||||
const host = await fetchHostById(hostId, userId);
|
const host = await fetchHostById(hostId, userId);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
|||||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||||
import { resolveHostById, checkHostAccess } from "../host-resolver.js";
|
import { resolveHostById, checkHostAccess } from "../host-resolver.js";
|
||||||
|
import type { HostAction } from "../../utils/permission-manager.js";
|
||||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
||||||
import {
|
import {
|
||||||
@@ -343,7 +344,7 @@ app.use(authManager.createAuthMiddleware());
|
|||||||
async function requireHost(
|
async function requireHost(
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
permission: "read" | "execute" = "read",
|
permission: HostAction = "connect",
|
||||||
): Promise<SSHHost | null> {
|
): Promise<SSHHost | null> {
|
||||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||||
const hostId = parseInt(String(req.params.hostId), 10);
|
const hostId = parseInt(String(req.params.hostId), 10);
|
||||||
@@ -503,7 +504,7 @@ app.get("/tmux_monitor/:hostId/overview", async (req, res) => {
|
|||||||
// Focus a pane: select its window and pane on the server so every attached
|
// Focus a pane: select its window and pane on the server so every attached
|
||||||
// client (including the monitor's embedded terminal) switches to it.
|
// client (including the monitor's embedded terminal) switches to it.
|
||||||
app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
|
app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
|
||||||
const host = await requireHost(req, res, "execute");
|
const host = await requireHost(req, res, "connect");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
||||||
@@ -530,7 +531,7 @@ app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
|
|||||||
|
|
||||||
// Create a detached session. Starts the tmux server if none is running.
|
// Create a detached session. Starts the tmux server if none is running.
|
||||||
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
|
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
|
||||||
const host = await requireHost(req, res, "execute");
|
const host = await requireHost(req, res, "connect");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
const name = String((req.body as { name?: string })?.name || "").trim();
|
const name = String((req.body as { name?: string })?.name || "").trim();
|
||||||
@@ -563,7 +564,7 @@ app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
|
|||||||
// target's meaning (":" and "." are window/pane separators in tmux targets);
|
// target's meaning (":" and "." are window/pane separators in tmux targets);
|
||||||
// "=" prefixes the target for an exact-name match.
|
// "=" prefixes the target for an exact-name match.
|
||||||
app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
|
app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
|
||||||
const host = await requireHost(req, res, "execute");
|
const host = await requireHost(req, res, "connect");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
const sessionName = String(
|
const sessionName = String(
|
||||||
@@ -597,7 +598,7 @@ app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
|
|||||||
// Rename a session. Saved tags follow the session to its new name (for every
|
// Rename a session. Saved tags follow the session to its new name (for every
|
||||||
// user — the session itself is shared on the host).
|
// user — the session itself is shared on the host).
|
||||||
app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
|
app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
|
||||||
const host = await requireHost(req, res, "execute");
|
const host = await requireHost(req, res, "connect");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
const body = req.body as { sessionName?: string; newName?: string };
|
const body = req.body as { sessionName?: string; newName?: string };
|
||||||
@@ -649,7 +650,7 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
|
|||||||
|
|
||||||
// Kill a session and drop its saved tags.
|
// Kill a session and drop its saved tags.
|
||||||
app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
|
app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
|
||||||
const host = await requireHost(req, res, "execute");
|
const host = await requireHost(req, res, "connect");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
const sessionName = String(
|
const sessionName = String(
|
||||||
@@ -688,7 +689,7 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
|
|||||||
// Kill a window (and every pane in it). Killing the last window of a session
|
// Kill a window (and every pane in it). Killing the last window of a session
|
||||||
// ends the session — tmux semantics.
|
// ends the session — tmux semantics.
|
||||||
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
|
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
|
||||||
const host = await requireHost(req, res, "execute");
|
const host = await requireHost(req, res, "connect");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
const body = req.body as { sessionName?: string; windowIndex?: number };
|
const body = req.body as { sessionName?: string; windowIndex?: number };
|
||||||
@@ -735,7 +736,7 @@ app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
|
|||||||
// Kill a single pane. Killing the last pane of a window closes the window,
|
// Kill a single pane. Killing the last pane of a window closes the window,
|
||||||
// and the last window of a session ends the session — tmux semantics.
|
// and the last window of a session ends the session — tmux semantics.
|
||||||
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
|
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
|
||||||
const host = await requireHost(req, res, "execute");
|
const host = await requireHost(req, res, "connect");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
||||||
@@ -766,7 +767,7 @@ app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
|
|||||||
// "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
|
// "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
|
||||||
// source pane's working directory.
|
// source pane's working directory.
|
||||||
app.post("/tmux_monitor/:hostId/split", async (req, res) => {
|
app.post("/tmux_monitor/:hostId/split", async (req, res) => {
|
||||||
const host = await requireHost(req, res, "execute");
|
const host = await requireHost(req, res, "connect");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
const body = req.body as { paneId?: string; direction?: string };
|
const body = req.body as { paneId?: string; direction?: string };
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function resolveC2STunnelSource(
|
|||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
tunnelConfig.sourceHostId,
|
tunnelConfig.sourceHostId,
|
||||||
"read",
|
"connect",
|
||||||
);
|
);
|
||||||
if (!accessInfo.hasAccess) {
|
if (!accessInfo.hasAccess) {
|
||||||
throw new Error("Access denied to this host");
|
throw new Error("Access denied to this host");
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function registerTunnelRoutes(app: express.Express): void {
|
|||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
tunnelConfig.sourceHostId,
|
tunnelConfig.sourceHostId,
|
||||||
"read",
|
"connect",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!accessInfo.hasAccess) {
|
if (!accessInfo.hasAccess) {
|
||||||
@@ -285,7 +285,7 @@ export function registerTunnelRoutes(app: express.Express): void {
|
|||||||
const endpointAccess = await permissionManager.canAccessHost(
|
const endpointAccess = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
endpointHost.id,
|
endpointHost.id,
|
||||||
"read",
|
"connect",
|
||||||
);
|
);
|
||||||
if (!endpointAccess.hasAccess) {
|
if (!endpointAccess.hasAccess) {
|
||||||
tunnelLogger.warn(
|
tunnelLogger.warn(
|
||||||
@@ -442,7 +442,7 @@ export function registerTunnelRoutes(app: express.Express): void {
|
|||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
config.sourceHostId,
|
config.sourceHostId,
|
||||||
"read",
|
"connect",
|
||||||
);
|
);
|
||||||
if (!accessInfo.hasAccess) {
|
if (!accessInfo.hasAccess) {
|
||||||
return res.status(403).json({ error: "Access denied" });
|
return res.status(403).json({ error: "Access denied" });
|
||||||
@@ -547,7 +547,7 @@ export function registerTunnelRoutes(app: express.Express): void {
|
|||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
config.sourceHostId,
|
config.sourceHostId,
|
||||||
"read",
|
"connect",
|
||||||
);
|
);
|
||||||
if (!accessInfo.hasAccess) {
|
if (!accessInfo.hasAccess) {
|
||||||
return res.status(403).json({ error: "Access denied" });
|
return res.status(403).json({ error: "Access denied" });
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ import {
|
|||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
DataCrypto.initialize();
|
DataCrypto.initialize();
|
||||||
|
|
||||||
|
const { runSharedHostSecretsMigration } =
|
||||||
|
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
|
||||||
|
await runSharedHostSecretsMigration();
|
||||||
|
|
||||||
import("./utils/opkssh-binary-manager.js").then(
|
import("./utils/opkssh-binary-manager.js").then(
|
||||||
({ OPKSSHBinaryManager }) => {
|
({ OPKSSHBinaryManager }) => {
|
||||||
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ describe("HostResolutionRepository", () => {
|
|||||||
rdpCredentialId: null,
|
rdpCredentialId: null,
|
||||||
vncCredentialId: null,
|
vncCredentialId: null,
|
||||||
telnetCredentialId: null,
|
telnetCredentialId: null,
|
||||||
|
vaultProfileId: null,
|
||||||
authType: "password",
|
authType: "password",
|
||||||
});
|
});
|
||||||
await expect(repository.findHostUpdateState(999)).resolves.toBeNull();
|
await expect(repository.findHostUpdateState(999)).resolves.toBeNull();
|
||||||
|
|||||||
@@ -52,19 +52,23 @@ describe("RbacAccessRepository", () => {
|
|||||||
override_credential_id INTEGER
|
override_credential_id INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE shared_credentials (
|
CREATE TABLE shared_host_secrets (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
host_access_id INTEGER NOT NULL,
|
host_access_id INTEGER NOT NULL,
|
||||||
original_credential_id INTEGER NOT NULL,
|
|
||||||
target_user_id TEXT NOT NULL,
|
target_user_id TEXT NOT NULL,
|
||||||
encrypted_username TEXT NOT NULL,
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
encrypted_auth_type TEXT NOT NULL,
|
source_type TEXT NOT NULL DEFAULT 'credential',
|
||||||
|
original_credential_id INTEGER,
|
||||||
|
encrypted_username TEXT,
|
||||||
|
encrypted_auth_type TEXT,
|
||||||
encrypted_password TEXT,
|
encrypted_password TEXT,
|
||||||
encrypted_key TEXT,
|
encrypted_key TEXT,
|
||||||
encrypted_key_password TEXT,
|
encrypted_key_password TEXT,
|
||||||
encrypted_key_type TEXT,
|
encrypted_key_type TEXT,
|
||||||
|
encrypted_domain TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(host_access_id, target_user_id, protocol)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE ssh_data (
|
CREATE TABLE ssh_data (
|
||||||
@@ -128,10 +132,12 @@ describe("RbacAccessRepository", () => {
|
|||||||
(2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'),
|
(2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'),
|
||||||
(5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
|
(5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
|
||||||
|
|
||||||
INSERT INTO shared_credentials (
|
INSERT INTO shared_host_secrets (
|
||||||
id, host_access_id, original_credential_id, target_user_id, encrypted_username, encrypted_auth_type
|
id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type
|
||||||
)
|
)
|
||||||
VALUES (8, 2, 123, 'user-1', 'enc-user', 'enc-auth');
|
VALUES
|
||||||
|
(8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'),
|
||||||
|
(9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct');
|
||||||
|
|
||||||
INSERT INTO snippets (id, user_id, name, content)
|
INSERT INTO snippets (id, user_id, name, content)
|
||||||
VALUES (99, 'owner-1', 'deploy', 'echo deploy');
|
VALUES (99, 'owner-1', 'deploy', 'echo deploy');
|
||||||
@@ -261,14 +267,16 @@ describe("RbacAccessRepository", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("finds shared credential and host access owner for shared credential reads", async () => {
|
it("finds shared secrets per protocol and host access owner", async () => {
|
||||||
const repo = await createRepository();
|
const repo = await createRepository();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
repo.findSharedCredentialForHostAndUser(42, "user-1"),
|
repo.findSharedSecretForHostUserProtocol(42, "user-1", "ssh"),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
id: 8,
|
id: 8,
|
||||||
hostAccessId: 2,
|
hostAccessId: 2,
|
||||||
|
protocol: "ssh",
|
||||||
|
sourceType: "credential",
|
||||||
originalCredentialId: 123,
|
originalCredentialId: 123,
|
||||||
targetUserId: "user-1",
|
targetUserId: "user-1",
|
||||||
encryptedUsername: "enc-user",
|
encryptedUsername: "enc-user",
|
||||||
@@ -276,12 +284,62 @@ describe("RbacAccessRepository", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
repo.findSharedCredentialForHostAndUser(99, "user-1"),
|
repo.findSharedSecretForHostUserProtocol(42, "user-1", "rdp"),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
id: 9,
|
||||||
|
protocol: "rdp",
|
||||||
|
sourceType: "inline",
|
||||||
|
originalCredentialId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.findSharedSecretForHostUserProtocol(42, "user-1", "vnc"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repo.findSharedSecretForHostUserProtocol(99, "user-1", "ssh"),
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1");
|
await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1");
|
||||||
await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull();
|
await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists active grants, finds grants by id and updates grant level/expiry", async () => {
|
||||||
|
let writeCount = 0;
|
||||||
|
const repo = await createRepository(() => {
|
||||||
|
writeCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const grants = await repo.listActiveHostAccessGrants(42, activeAccessTime);
|
||||||
|
expect(grants.map((grant) => grant.id).sort()).toEqual([1, 2]);
|
||||||
|
|
||||||
|
// Host 44's only grant expired before activeAccessTime.
|
||||||
|
await expect(
|
||||||
|
repo.listActiveHostAccessGrants(44, activeAccessTime),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
|
||||||
|
await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({
|
||||||
|
id: 1,
|
||||||
|
hostId: 42,
|
||||||
|
permissionLevel: "view",
|
||||||
|
});
|
||||||
|
await expect(repo.findHostAccessById(1, 99)).resolves.toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.updateHostAccessGrant(1, 42, {
|
||||||
|
permissionLevel: "manage",
|
||||||
|
expiresAt: "2026-07-01T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({
|
||||||
|
permissionLevel: "manage",
|
||||||
|
expiresAt: "2026-07-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.updateHostAccessGrant(999, 42, { permissionLevel: "view" }),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("lists shared snippets and preserves route-level direct-over-role behavior", async () => {
|
it("lists shared snippets and preserves route-level direct-over-role behavior", async () => {
|
||||||
const repo = await createRepository();
|
const repo = await createRepository();
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ describe("SessionRecordingRepository", () => {
|
|||||||
expect(rows[0]).toMatchObject({
|
expect(rows[0]).toMatchObject({
|
||||||
hostIp: "10.0.0.2",
|
hostIp: "10.0.0.2",
|
||||||
recordingPath: "/tmp/two.log",
|
recordingPath: "/tmp/two.log",
|
||||||
|
protocol: "ssh",
|
||||||
|
format: "text",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { TestSqliteDatabase } from "./test-support.js";
|
|
||||||
import { SharedCredentialRepository } from "../../../database/repositories/shared-credential-repository.js";
|
|
||||||
|
|
||||||
describe("SharedCredentialRepository", () => {
|
|
||||||
let adapter: TestSqliteDatabase | null = null;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (adapter) {
|
|
||||||
await adapter.close();
|
|
||||||
adapter = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createRepository(
|
|
||||||
onWrite?: () => void | Promise<void>,
|
|
||||||
): Promise<{
|
|
||||||
repository: SharedCredentialRepository;
|
|
||||||
sqlite: NonNullable<
|
|
||||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
|
||||||
>;
|
|
||||||
}> {
|
|
||||||
adapter = new TestSqliteDatabase();
|
|
||||||
const context = await adapter.connect();
|
|
||||||
context.sqlite?.exec(`
|
|
||||||
CREATE TABLE shared_credentials (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
host_access_id INTEGER NOT NULL,
|
|
||||||
original_credential_id INTEGER NOT NULL,
|
|
||||||
target_user_id TEXT NOT NULL,
|
|
||||||
encrypted_username TEXT NOT NULL,
|
|
||||||
encrypted_auth_type TEXT NOT NULL,
|
|
||||||
encrypted_password TEXT,
|
|
||||||
encrypted_key TEXT,
|
|
||||||
encrypted_key_password TEXT,
|
|
||||||
encrypted_key_type TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO shared_credentials (
|
|
||||||
id,
|
|
||||||
host_access_id,
|
|
||||||
original_credential_id,
|
|
||||||
target_user_id,
|
|
||||||
encrypted_username,
|
|
||||||
encrypted_auth_type,
|
|
||||||
encrypted_password
|
|
||||||
)
|
|
||||||
VALUES
|
|
||||||
(1, 10, 100, 'user-1', 'u1', 'password', 'p1'),
|
|
||||||
(2, 10, 100, 'user-2', 'u2', 'password', 'p2'),
|
|
||||||
(3, 11, 101, 'user-2', 'u3', 'key', NULL);
|
|
||||||
`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
repository: new SharedCredentialRepository(context, onWrite),
|
|
||||||
sqlite: context.sqlite!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it("checks existence and creates shared credentials", async () => {
|
|
||||||
let writes = 0;
|
|
||||||
const { repository } = await createRepository(() => {
|
|
||||||
writes += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
repository.existsForHostAccessAndTargetUser(10, "user-1"),
|
|
||||||
).resolves.toBe(true);
|
|
||||||
await expect(
|
|
||||||
repository.existsForHostAccessAndTargetUser(10, "missing"),
|
|
||||||
).resolves.toBe(false);
|
|
||||||
|
|
||||||
const created = await repository.create({
|
|
||||||
hostAccessId: 12,
|
|
||||||
originalCredentialId: 102,
|
|
||||||
targetUserId: "user-3",
|
|
||||||
encryptedUsername: "u4",
|
|
||||||
encryptedAuthType: "password",
|
|
||||||
encryptedPassword: "p4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(created).toMatchObject({
|
|
||||||
hostAccessId: 12,
|
|
||||||
originalCredentialId: 102,
|
|
||||||
targetUserId: "user-3",
|
|
||||||
encryptedUsername: "u4",
|
|
||||||
});
|
|
||||||
expect(writes).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds, lists, and updates shared credentials", async () => {
|
|
||||||
let writes = 0;
|
|
||||||
const { repository } = await createRepository(() => {
|
|
||||||
writes += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(repository.findById(1)).resolves.toMatchObject({
|
|
||||||
targetUserId: "user-1",
|
|
||||||
});
|
|
||||||
await expect(repository.findById(999)).resolves.toBeNull();
|
|
||||||
await expect(
|
|
||||||
repository.listByOriginalCredentialId(100),
|
|
||||||
).resolves.toHaveLength(2);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
repository.updateById(2, {
|
|
||||||
encryptedPassword: "updated",
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
encryptedPassword: "updated",
|
|
||||||
});
|
|
||||||
expect(writes).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes shared credentials", async () => {
|
|
||||||
let writes = 0;
|
|
||||||
const { repository, sqlite } = await createRepository(() => {
|
|
||||||
writes += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(repository.deleteById(1)).resolves.toBe(true);
|
|
||||||
await expect(repository.deleteById(999)).resolves.toBe(false);
|
|
||||||
await expect(repository.deleteByTargetUserId("user-2")).resolves.toBe(2);
|
|
||||||
await expect(repository.deleteByOriginalCredentialId(100)).resolves.toBe(0);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
sqlite.prepare("SELECT id FROM shared_credentials ORDER BY id").all(),
|
|
||||||
).toEqual([]);
|
|
||||||
expect(writes).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
|
import { SharedHostSecretsRepository } from "../../../database/repositories/shared-host-secrets-repository.js";
|
||||||
|
|
||||||
|
describe("SharedHostSecretsRepository", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (adapter) {
|
||||||
|
await adapter.close();
|
||||||
|
adapter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createRepository(
|
||||||
|
onWrite?: () => void | Promise<void>,
|
||||||
|
): Promise<{
|
||||||
|
repository: SharedHostSecretsRepository;
|
||||||
|
sqlite: NonNullable<
|
||||||
|
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
||||||
|
>;
|
||||||
|
}> {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const context = await adapter.connect();
|
||||||
|
context.sqlite!.exec(`
|
||||||
|
CREATE TABLE host_access (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
role_id INTEGER,
|
||||||
|
granted_by TEXT NOT NULL,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'connect',
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_accessed_at TEXT,
|
||||||
|
access_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
override_credential_id INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ssh_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
credential_id INTEGER,
|
||||||
|
rdp_credential_id INTEGER,
|
||||||
|
vnc_credential_id INTEGER,
|
||||||
|
telnet_credential_id INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE shared_host_secrets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
host_access_id INTEGER NOT NULL,
|
||||||
|
target_user_id TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
|
source_type TEXT NOT NULL DEFAULT 'credential',
|
||||||
|
original_credential_id INTEGER,
|
||||||
|
encrypted_username TEXT,
|
||||||
|
encrypted_auth_type TEXT,
|
||||||
|
encrypted_password TEXT,
|
||||||
|
encrypted_key TEXT,
|
||||||
|
encrypted_key_password TEXT,
|
||||||
|
encrypted_key_type TEXT,
|
||||||
|
encrypted_domain TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(host_access_id, target_user_id, protocol)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id)
|
||||||
|
VALUES
|
||||||
|
(42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124),
|
||||||
|
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL),
|
||||||
|
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL);
|
||||||
|
|
||||||
|
INSERT INTO host_access (id, host_id, user_id, role_id, granted_by)
|
||||||
|
VALUES
|
||||||
|
(1, 42, 'user-1', NULL, 'owner-1'),
|
||||||
|
(2, 42, NULL, 7, 'owner-1'),
|
||||||
|
(3, 43, 'user-2', NULL, 'owner-1');
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository: new SharedHostSecretsRepository(context, onWrite),
|
||||||
|
sqlite: context.sqlite!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("upserts snapshots per protocol and finds them by host/user/protocol", async () => {
|
||||||
|
let writeCount = 0;
|
||||||
|
const { repository } = await createRepository(() => {
|
||||||
|
writeCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.upsert({
|
||||||
|
hostAccessId: 1,
|
||||||
|
targetUserId: "user-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sourceType: "credential",
|
||||||
|
originalCredentialId: 123,
|
||||||
|
encryptedUsername: "enc-user",
|
||||||
|
encryptedAuthType: "password",
|
||||||
|
encryptedPassword: "enc-pass",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.findForHostUserProtocol(42, "user-1", "ssh"),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
hostAccessId: 1,
|
||||||
|
protocol: "ssh",
|
||||||
|
encryptedPassword: "enc-pass",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert on the same (grant, user, protocol) updates in place.
|
||||||
|
await repository.upsert({
|
||||||
|
hostAccessId: 1,
|
||||||
|
targetUserId: "user-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sourceType: "inline",
|
||||||
|
originalCredentialId: null,
|
||||||
|
encryptedUsername: "enc-user-2",
|
||||||
|
encryptedAuthType: "key",
|
||||||
|
encryptedKey: "enc-key",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await repository.findForHostUserProtocol(
|
||||||
|
42,
|
||||||
|
"user-1",
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
expect(updated).toMatchObject({
|
||||||
|
sourceType: "inline",
|
||||||
|
encryptedUsername: "enc-user-2",
|
||||||
|
encryptedKey: "enc-key",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.findForHostUserProtocol(42, "user-1", "rdp"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.findForHostUserProtocol(43, "user-1", "ssh"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(writeCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes stale protocols while keeping the listed ones", async () => {
|
||||||
|
const { repository } = await createRepository();
|
||||||
|
|
||||||
|
for (const protocol of ["ssh", "rdp", "vnc"] as const) {
|
||||||
|
await repository.upsert({
|
||||||
|
hostAccessId: 1,
|
||||||
|
targetUserId: "user-1",
|
||||||
|
protocol,
|
||||||
|
sourceType: "inline",
|
||||||
|
encryptedAuthType: "direct",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.deleteForHostAccessAndTarget(1, "user-1", ["ssh"]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.findForHostUserProtocol(42, "user-1", "ssh"),
|
||||||
|
).resolves.not.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.findForHostUserProtocol(42, "user-1", "rdp"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.findForHostUserProtocol(42, "user-1", "vnc"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes by host access, target user, credential and role membership", async () => {
|
||||||
|
const { repository } = await createRepository();
|
||||||
|
|
||||||
|
await repository.upsert({
|
||||||
|
hostAccessId: 1,
|
||||||
|
targetUserId: "user-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sourceType: "credential",
|
||||||
|
originalCredentialId: 123,
|
||||||
|
encryptedAuthType: "password",
|
||||||
|
});
|
||||||
|
await repository.upsert({
|
||||||
|
hostAccessId: 2,
|
||||||
|
targetUserId: "user-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sourceType: "credential",
|
||||||
|
originalCredentialId: 123,
|
||||||
|
encryptedAuthType: "password",
|
||||||
|
});
|
||||||
|
await repository.upsert({
|
||||||
|
hostAccessId: 3,
|
||||||
|
targetUserId: "user-2",
|
||||||
|
protocol: "ssh",
|
||||||
|
sourceType: "inline",
|
||||||
|
encryptedAuthType: "password",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Role-membership cleanup: only grant 2 targets role 7.
|
||||||
|
expect(await repository.deleteForRoleMember(7, "user-1")).toBe(1);
|
||||||
|
await expect(
|
||||||
|
repository.existsForHostAccessAndTargetUser(2, "user-1"),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
await expect(
|
||||||
|
repository.existsForHostAccessAndTargetUser(1, "user-1"),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(await repository.deleteByHostAccessId(1)).toBe(1);
|
||||||
|
expect(await repository.deleteByTargetUserId("user-2")).toBe(1);
|
||||||
|
expect(await repository.deleteByOriginalCredentialId(123)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds host ids referencing a credential for the owner", async () => {
|
||||||
|
const { repository } = await createRepository();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.findHostIdsReferencingCredential("owner-1", 123),
|
||||||
|
).resolves.toEqual([42]);
|
||||||
|
await expect(
|
||||||
|
repository.findHostIdsReferencingCredential("owner-1", 124),
|
||||||
|
).resolves.toEqual([42]);
|
||||||
|
await expect(
|
||||||
|
repository.findHostIdsReferencingCredential("owner-1", 999),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
await expect(
|
||||||
|
repository.findHostIdsReferencingCredential("owner-2", 123),
|
||||||
|
).resolves.toEqual([44]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
isValidPort,
|
isValidPort,
|
||||||
normalizeImportedHost,
|
normalizeImportedHost,
|
||||||
renameFolderPath,
|
renameFolderPath,
|
||||||
|
sanitizeHostForRecipient,
|
||||||
stripSensitiveFields,
|
stripSensitiveFields,
|
||||||
transformHostResponse,
|
transformHostResponse,
|
||||||
} from "../../../database/routes/host-normalizers.js";
|
} from "../../../database/routes/host-normalizers.js";
|
||||||
@@ -219,3 +220,58 @@ describe("transformHostResponse", () => {
|
|||||||
expect(result.proxmoxConfig).toBeUndefined();
|
expect(result.proxmoxConfig).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sanitizeHostForRecipient", () => {
|
||||||
|
const sharedHost = {
|
||||||
|
id: 42,
|
||||||
|
userId: "owner",
|
||||||
|
ownerUsername: "owner",
|
||||||
|
isShared: true,
|
||||||
|
permissionLevel: "view",
|
||||||
|
name: "prod",
|
||||||
|
ip: "10.0.0.42",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
folder: "servers",
|
||||||
|
tags: ["linux"],
|
||||||
|
notes: "secret runbook",
|
||||||
|
quickActions: [{ name: "restart", snippetId: "1" }],
|
||||||
|
password: "hunter2",
|
||||||
|
key: "PRIVATE",
|
||||||
|
sudoPassword: "sudo",
|
||||||
|
rdpPassword: "rdp",
|
||||||
|
socks5Password: "socks",
|
||||||
|
enableSsh: true,
|
||||||
|
enableRdp: true,
|
||||||
|
sshPort: 22,
|
||||||
|
rdpPort: 3389,
|
||||||
|
defaultPath: "/srv",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("always strips secrets for recipients", () => {
|
||||||
|
const result = sanitizeHostForRecipient({ ...sharedHost }, "view");
|
||||||
|
expect(result.password).toBeUndefined();
|
||||||
|
expect(result.key).toBeUndefined();
|
||||||
|
expect(result.sudoPassword).toBeUndefined();
|
||||||
|
expect(result.rdpPassword).toBeUndefined();
|
||||||
|
expect(result.socks5Password).toBeUndefined();
|
||||||
|
// view keeps configuration fields
|
||||||
|
expect(result.notes).toBe("secret runbook");
|
||||||
|
expect(result.quickActions).toEqual(sharedHost.quickActions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reduces connect-level hosts to connection essentials", () => {
|
||||||
|
const result = sanitizeHostForRecipient(
|
||||||
|
{ ...sharedHost, permissionLevel: "connect" },
|
||||||
|
"connect",
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("prod");
|
||||||
|
expect(result.ip).toBe("10.0.0.42");
|
||||||
|
expect(result.enableRdp).toBe(true);
|
||||||
|
expect(result.rdpPort).toBe(3389);
|
||||||
|
expect(result.permissionLevel).toBe("connect");
|
||||||
|
expect(result.notes).toBeUndefined();
|
||||||
|
expect(result.quickActions).toBeUndefined();
|
||||||
|
expect(result.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
host: null as Record<string, unknown> | null,
|
||||||
|
hasAccess: true,
|
||||||
|
overrideCredentialId: null as number | null,
|
||||||
|
credentials: new Map<string, Record<string, unknown>>(),
|
||||||
|
sharedSecret: null as Record<string, unknown> | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostResolutionRepository: () => ({
|
||||||
|
findHostOwnerId: async () => (state.host?.userId as string) ?? null,
|
||||||
|
findHostById: async () => (state.host ? { ...state.host } : null),
|
||||||
|
findOverrideCredentialId: async () => state.overrideCredentialId,
|
||||||
|
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
||||||
|
state.credentials.get(`${credentialId}:${userId}`) ?? null,
|
||||||
|
}),
|
||||||
|
createCurrentVaultProfileRepository: () => ({
|
||||||
|
findById: async () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
canAccessHost: async () => ({ hasAccess: state.hasAccess }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||||
|
SharedHostSecretsManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getSecretForUser: async () => state.sharedSecret,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { resolveHostById } from "../../hosts/host-resolver.js";
|
||||||
|
|
||||||
|
function baseHost(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
userId: "owner",
|
||||||
|
name: "prod",
|
||||||
|
ip: "10.0.0.42",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
password: "owner-secret",
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
credentialId: null,
|
||||||
|
vaultProfileId: null,
|
||||||
|
sudoPassword: "owner-sudo",
|
||||||
|
autostartPassword: "auto-pass",
|
||||||
|
autostartKey: null,
|
||||||
|
autostartKeyPassword: null,
|
||||||
|
jumpHosts: null,
|
||||||
|
tunnelConnections: null,
|
||||||
|
statsConfig: null,
|
||||||
|
terminalConfig: null,
|
||||||
|
socks5ProxyChain: null,
|
||||||
|
quickActions: null,
|
||||||
|
overrideCredentialUsername: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.host = baseHost();
|
||||||
|
state.hasAccess = true;
|
||||||
|
state.overrideCredentialId = null;
|
||||||
|
state.credentials.clear();
|
||||||
|
state.sharedSecret = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHostById", () => {
|
||||||
|
it("returns null when access is denied", async () => {
|
||||||
|
state.hasAccess = false;
|
||||||
|
expect(await resolveHostById(42, "stranger")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the owner's credential on the owner path", async () => {
|
||||||
|
// Empty host username so the credential's username is used as fallback.
|
||||||
|
state.host = baseHost({
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: 9,
|
||||||
|
username: "",
|
||||||
|
});
|
||||||
|
state.credentials.set("9:owner", {
|
||||||
|
id: 9,
|
||||||
|
username: "cred-user",
|
||||||
|
authType: "key",
|
||||||
|
password: null,
|
||||||
|
privateKey: "PRIVATE-KEY",
|
||||||
|
key: null,
|
||||||
|
keyPassword: "kp",
|
||||||
|
keyType: "ssh-ed25519",
|
||||||
|
certPublicKey: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = (await resolveHostById(42, "owner")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(host.key).toBe("PRIVATE-KEY");
|
||||||
|
expect(host.username).toBe("cred-user");
|
||||||
|
expect(host.authType).toBe("key");
|
||||||
|
expect(host.sudoPassword).toBe("owner-sudo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => {
|
||||||
|
state.host = baseHost({ username: "" });
|
||||||
|
state.sharedSecret = {
|
||||||
|
username: "shared-user",
|
||||||
|
authType: "password",
|
||||||
|
password: "shared-pass",
|
||||||
|
};
|
||||||
|
|
||||||
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(host.password).toBe("shared-pass");
|
||||||
|
expect(host.username).toBe("shared-user");
|
||||||
|
expect(host.sudoPassword).toBeNull();
|
||||||
|
expect(host.autostartPassword).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the recipient's override credential over the snapshot", async () => {
|
||||||
|
state.host = baseHost({ username: "" });
|
||||||
|
state.overrideCredentialId = 5;
|
||||||
|
state.credentials.set("5:recipient", {
|
||||||
|
id: 5,
|
||||||
|
username: "my-user",
|
||||||
|
authType: "password",
|
||||||
|
password: "my-pass",
|
||||||
|
privateKey: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
});
|
||||||
|
state.sharedSecret = {
|
||||||
|
username: "shared-user",
|
||||||
|
authType: "password",
|
||||||
|
password: "shared-pass",
|
||||||
|
};
|
||||||
|
|
||||||
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(host.password).toBe("my-pass");
|
||||||
|
expect(host.username).toBe("my-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies a non-owner when a secret-bearing host has no snapshot", async () => {
|
||||||
|
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a non-owner through on secret-less auth types without a snapshot", async () => {
|
||||||
|
state.host = baseHost({ authType: "none", password: null });
|
||||||
|
const host = await resolveHostById(42, "recipient");
|
||||||
|
expect(host).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
settings: new Map<string, string>(),
|
||||||
|
grants: [] as Array<Record<string, unknown>>,
|
||||||
|
roleMembers: new Map<number, string[]>(),
|
||||||
|
executedSql: [] as string[],
|
||||||
|
snapshots: [] as Array<[number, number, string, string]>,
|
||||||
|
usersWithKeys: new Set<string>(),
|
||||||
|
saves: [] as string[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentSettingsRepository: () => ({
|
||||||
|
get: async (key: string) => state.settings.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => {
|
||||||
|
state.settings.set(key, value);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getCurrentRepositorySqlite: () => ({
|
||||||
|
prepare: (sql: string) => ({
|
||||||
|
all: (..._params: unknown[]) => {
|
||||||
|
if (sql.includes("FROM host_access")) return state.grants;
|
||||||
|
if (sql.includes("FROM user_roles")) {
|
||||||
|
const roleId = _params[0] as number;
|
||||||
|
return (state.roleMembers.get(roleId) ?? []).map((id) => ({
|
||||||
|
user_id: id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
exec: (sql: string) => {
|
||||||
|
state.executedSql.push(sql);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||||
|
SharedHostSecretsManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
snapshotForUser: async (
|
||||||
|
hostAccessId: number,
|
||||||
|
hostId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
ownerId: string,
|
||||||
|
) => {
|
||||||
|
if (targetUserId === "broken") throw new Error("boom");
|
||||||
|
state.snapshots.push([hostAccessId, hostId, targetUserId, ownerId]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||||
|
DataCrypto: {
|
||||||
|
canUserAccessData: (userId: string) => state.usersWithKeys.has(userId),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||||
|
DatabaseSaveTrigger: {
|
||||||
|
forceSave: async (reason: string) => {
|
||||||
|
state.saves.push(reason);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
databaseLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { runSharedHostSecretsMigration } from "../../../utils/crypto-migration/shared-host-secrets-migration.js";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.settings.clear();
|
||||||
|
state.grants = [];
|
||||||
|
state.roleMembers.clear();
|
||||||
|
state.executedSql = [];
|
||||||
|
state.snapshots = [];
|
||||||
|
state.usersWithKeys = new Set(["owner", "alice", "bob"]);
|
||||||
|
state.saves = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runSharedHostSecretsMigration", () => {
|
||||||
|
it("re-snapshots direct and role grants, drops the legacy table and sets the flag", async () => {
|
||||||
|
state.grants = [
|
||||||
|
{
|
||||||
|
hostAccessId: 1,
|
||||||
|
hostId: 42,
|
||||||
|
userId: "alice",
|
||||||
|
roleId: null,
|
||||||
|
ownerId: "owner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hostAccessId: 2,
|
||||||
|
hostId: 42,
|
||||||
|
userId: null,
|
||||||
|
roleId: 9,
|
||||||
|
ownerId: "owner",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.roleMembers.set(9, ["bob", "owner"]);
|
||||||
|
|
||||||
|
const result = await runSharedHostSecretsMigration();
|
||||||
|
|
||||||
|
expect(result).toEqual({ snapshotted: 2, skipped: 0 });
|
||||||
|
expect(state.snapshots).toEqual([
|
||||||
|
[1, 42, "alice", "owner"],
|
||||||
|
[2, 42, "bob", "owner"],
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
state.executedSql.some((sql) =>
|
||||||
|
sql.includes("DROP TABLE IF EXISTS shared_credentials"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(state.settings.get("shared_host_secrets_migrated_v1")).toBe("done");
|
||||||
|
expect(state.saves).toContain("shared_host_secrets_migration");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips grants with missing DEKs and failed snapshots without crashing", async () => {
|
||||||
|
state.usersWithKeys = new Set(["owner", "alice", "broken"]);
|
||||||
|
state.grants = [
|
||||||
|
{
|
||||||
|
hostAccessId: 1,
|
||||||
|
hostId: 42,
|
||||||
|
userId: "alice",
|
||||||
|
roleId: null,
|
||||||
|
ownerId: "owner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hostAccessId: 2,
|
||||||
|
hostId: 42,
|
||||||
|
userId: "no-key",
|
||||||
|
roleId: null,
|
||||||
|
ownerId: "owner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hostAccessId: 3,
|
||||||
|
hostId: 42,
|
||||||
|
userId: "broken",
|
||||||
|
roleId: null,
|
||||||
|
ownerId: "owner",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await runSharedHostSecretsMigration();
|
||||||
|
|
||||||
|
expect(result).toEqual({ snapshotted: 1, skipped: 2 });
|
||||||
|
expect(state.settings.get("shared_host_secrets_migrated_v1")).toBe("done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the migration flag is already set", async () => {
|
||||||
|
state.settings.set("shared_host_secrets_migrated_v1", "done");
|
||||||
|
state.grants = [
|
||||||
|
{
|
||||||
|
hostAccessId: 1,
|
||||||
|
hostId: 42,
|
||||||
|
userId: "alice",
|
||||||
|
roleId: null,
|
||||||
|
ownerId: "owner",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await runSharedHostSecretsMigration();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(state.snapshots).toEqual([]);
|
||||||
|
expect(state.executedSql).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
PERMISSION_CATALOG,
|
||||||
|
isValidPermission,
|
||||||
|
} from "../../utils/permission-catalog.js";
|
||||||
|
|
||||||
|
describe("permission catalog", () => {
|
||||||
|
it("accepts every cataloged permission and group wildcard", () => {
|
||||||
|
for (const entry of PERMISSION_CATALOG) {
|
||||||
|
expect(isValidPermission(`${entry.group}.*`)).toBe(true);
|
||||||
|
for (const permission of entry.permissions) {
|
||||||
|
expect(isValidPermission(permission)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the global wildcard", () => {
|
||||||
|
expect(isValidPermission("*")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown permissions and malformed wildcards", () => {
|
||||||
|
expect(isValidPermission("hosts.hack")).toBe(false);
|
||||||
|
expect(isValidPermission("unknown.*")).toBe(false);
|
||||||
|
expect(isValidPermission("")).toBe(false);
|
||||||
|
expect(isValidPermission("hosts")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,39 @@ vi.mock("../../utils/logger.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const accessState = vi.hoisted(() => ({
|
||||||
|
ownerId: "owner" as string,
|
||||||
|
grant: null as {
|
||||||
|
id: number;
|
||||||
|
permissionLevel: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
} | null,
|
||||||
|
touched: [] as number[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostResolutionRepository: () => ({
|
||||||
|
isHostOwnedByUser: async (_hostId: number, userId: string) =>
|
||||||
|
userId === accessState.ownerId,
|
||||||
|
findHostOwnerId: async () => accessState.ownerId,
|
||||||
|
}),
|
||||||
|
createCurrentRbacAccessRepository: () => ({
|
||||||
|
findActiveHostAccess: async () => accessState.grant,
|
||||||
|
touchHostAccess: async (id: number) => {
|
||||||
|
accessState.touched.push(id);
|
||||||
|
},
|
||||||
|
deleteExpiredHostAccess: async () => 0,
|
||||||
|
}),
|
||||||
|
createCurrentRoleRepository: () => ({
|
||||||
|
listUserRoleIds: async () => [],
|
||||||
|
listUserRolePermissions: async () => [],
|
||||||
|
userHasAnyRoleName: async () => false,
|
||||||
|
}),
|
||||||
|
createCurrentUserRepository: () => ({
|
||||||
|
findById: async () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const { PermissionManager } = await import("../../utils/permission-manager.js");
|
const { PermissionManager } = await import("../../utils/permission-manager.js");
|
||||||
|
|
||||||
type PermissionManagerInstance = ReturnType<
|
type PermissionManagerInstance = ReturnType<
|
||||||
@@ -71,3 +104,65 @@ describe("PermissionManager.hasPermission wildcard matching", () => {
|
|||||||
expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
|
expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PermissionManager.canAccessHost level hierarchy", () => {
|
||||||
|
const manager = PermissionManager.getInstance();
|
||||||
|
const actions = ["connect", "view", "edit", "manage"] as const;
|
||||||
|
const levels = ["connect", "view", "edit", "manage"] as const;
|
||||||
|
const rank = { connect: 1, view: 2, edit: 3, manage: 4 } as const;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
accessState.ownerId = "owner";
|
||||||
|
accessState.grant = null;
|
||||||
|
accessState.touched = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants the owner every action including delete", async () => {
|
||||||
|
for (const action of [...actions, "delete"] as const) {
|
||||||
|
const info = await manager.canAccessHost("owner", 42, action);
|
||||||
|
expect(info).toMatchObject({ hasAccess: true, isOwner: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies everything without a grant", async () => {
|
||||||
|
const info = await manager.canAccessHost("stranger", 42, "connect");
|
||||||
|
expect(info).toMatchObject({ hasAccess: false, isShared: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the connect < view < edit < manage hierarchy", async () => {
|
||||||
|
for (const level of levels) {
|
||||||
|
accessState.grant = { id: 5, permissionLevel: level, expiresAt: null };
|
||||||
|
for (const action of actions) {
|
||||||
|
const info = await manager.canAccessHost("recipient", 42, action);
|
||||||
|
expect(info.hasAccess).toBe(rank[level] >= rank[action]);
|
||||||
|
expect(info.permissionLevel).toBe(level);
|
||||||
|
expect(info.isShared).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never grants delete to a shared recipient", async () => {
|
||||||
|
accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
|
||||||
|
const info = await manager.canAccessHost("recipient", 42, "delete");
|
||||||
|
expect(info.hasAccess).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes the legacy 'view' string mapping and unknown levels to connect", async () => {
|
||||||
|
accessState.grant = { id: 5, permissionLevel: "bogus", expiresAt: null };
|
||||||
|
const connect = await manager.canAccessHost("recipient", 42, "connect");
|
||||||
|
expect(connect.hasAccess).toBe(true);
|
||||||
|
expect(connect.permissionLevel).toBe("connect");
|
||||||
|
|
||||||
|
const view = await manager.canAccessHost("recipient", 42, "view");
|
||||||
|
expect(view.hasAccess).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only touches the grant timestamp on connect", async () => {
|
||||||
|
accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
|
||||||
|
await manager.canAccessHost("recipient", 42, "manage");
|
||||||
|
expect(accessState.touched).toEqual([]);
|
||||||
|
await manager.canAccessHost("recipient", 42, "connect");
|
||||||
|
expect(accessState.touched).toEqual([5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const ownerDEK = crypto.randomBytes(32);
|
|
||||||
const targetDEK = crypto.randomBytes(32);
|
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
|
||||||
sharedRows: [] as Array<Record<string, unknown>>,
|
|
||||||
ownerCredential: null as Record<string, unknown> | null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../database/repositories/factory.js", () => ({
|
|
||||||
createCurrentCredentialRepository: () => ({
|
|
||||||
findByIdForUser: async (_userId: string, _id: number) =>
|
|
||||||
state.ownerCredential,
|
|
||||||
}),
|
|
||||||
createCurrentSharedCredentialRepository: () => ({
|
|
||||||
existsForHostAccessAndTargetUser: async () => state.sharedRows.length > 0,
|
|
||||||
create: async (row: Record<string, unknown>) => {
|
|
||||||
const created = { id: state.sharedRows.length + 1, ...row };
|
|
||||||
state.sharedRows.push(created);
|
|
||||||
return created;
|
|
||||||
},
|
|
||||||
listByOriginalCredentialId: async () => state.sharedRows,
|
|
||||||
updateById: async (id: number, update: Record<string, unknown>) => {
|
|
||||||
const row = state.sharedRows.find((r) => r.id === id);
|
|
||||||
if (row) Object.assign(row, update);
|
|
||||||
return row ?? null;
|
|
||||||
},
|
|
||||||
deleteByOriginalCredentialId: async () => 0,
|
|
||||||
}),
|
|
||||||
createCurrentRbacAccessRepository: () => ({
|
|
||||||
findSharedCredentialForHostAndUser: async () => state.sharedRows[0] ?? null,
|
|
||||||
}),
|
|
||||||
createCurrentRoleRepository: () => ({
|
|
||||||
listRoleUserIds: async () => [],
|
|
||||||
listUserRoleIds: async () => [],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../utils/data-crypto.js", () => ({
|
|
||||||
DataCrypto: {
|
|
||||||
validateUserAccess: (userId: string) => {
|
|
||||||
if (userId === "owner") return ownerDEK;
|
|
||||||
if (userId === "target") return targetDEK;
|
|
||||||
throw new Error(`User ${userId} has no data encryption key`);
|
|
||||||
},
|
|
||||||
getUserDataKey: (userId: string) =>
|
|
||||||
userId === "owner" ? ownerDEK : userId === "target" ? targetDEK : null,
|
|
||||||
canUserAccessData: () => true,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
|
||||||
import { SharedCredentialManager } from "../../utils/shared-credential-manager.js";
|
|
||||||
|
|
||||||
const manager = SharedCredentialManager.getInstance();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.sharedRows = [];
|
|
||||||
state.ownerCredential = {
|
|
||||||
id: 42,
|
|
||||||
userId: "owner",
|
|
||||||
username: "root",
|
|
||||||
authType: "password",
|
|
||||||
password: FieldCrypto.encryptField("hunter2", ownerDEK, "42", "password"),
|
|
||||||
key: null,
|
|
||||||
keyPassword: null,
|
|
||||||
keyType: null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SharedCredentialManager", () => {
|
|
||||||
it("shares a credential and the target can decrypt it", async () => {
|
|
||||||
await manager.createSharedCredentialForUser(7, 42, "target", "owner");
|
|
||||||
|
|
||||||
expect(state.sharedRows).toHaveLength(1);
|
|
||||||
const row = state.sharedRows[0];
|
|
||||||
expect(row.encryptedPassword).not.toBe("hunter2");
|
|
||||||
expect(row.targetUserId).toBe("target");
|
|
||||||
|
|
||||||
const resolved = await manager.getSharedCredentialForUser(1, "target");
|
|
||||||
expect(resolved).toMatchObject({
|
|
||||||
username: "root",
|
|
||||||
authType: "password",
|
|
||||||
password: "hunter2",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails the share when a participant has no key instead of queueing", async () => {
|
|
||||||
await expect(
|
|
||||||
manager.createSharedCredentialForUser(7, 42, "locked-user", "owner"),
|
|
||||||
).rejects.toThrow(/no data encryption key/);
|
|
||||||
expect(state.sharedRows).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-encrypts existing share copies when the original changes", async () => {
|
|
||||||
await manager.createSharedCredentialForUser(7, 42, "target", "owner");
|
|
||||||
state.ownerCredential!.password = FieldCrypto.encryptField(
|
|
||||||
"new-password",
|
|
||||||
ownerDEK,
|
|
||||||
"42",
|
|
||||||
"password",
|
|
||||||
);
|
|
||||||
|
|
||||||
await manager.updateSharedCredentialsForOriginal(42, "owner");
|
|
||||||
|
|
||||||
const resolved = await manager.getSharedCredentialForUser(1, "target");
|
|
||||||
expect(resolved?.password).toBe("new-password");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const ownerDEK = crypto.randomBytes(32);
|
||||||
|
const targetDEK = crypto.randomBytes(32);
|
||||||
|
|
||||||
|
type SecretRow = Record<string, unknown> & {
|
||||||
|
id: number;
|
||||||
|
hostAccessId: number;
|
||||||
|
targetUserId: string;
|
||||||
|
protocol: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
hosts: new Map<number, Record<string, unknown>>(),
|
||||||
|
credentials: new Map<number, Record<string, unknown>>(),
|
||||||
|
secretRows: [] as Array<Record<string, unknown>>,
|
||||||
|
// hostAccessId -> hostId, used by findForHostUserProtocol
|
||||||
|
accessToHost: new Map<number, number>(),
|
||||||
|
grants: [] as Array<Record<string, unknown>>,
|
||||||
|
roleMembers: new Map<number, string[]>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostResolutionRepository: () => ({
|
||||||
|
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||||
|
findHostOwnerId: async (hostId: number) =>
|
||||||
|
(state.hosts.get(hostId)?.userId as string) ?? null,
|
||||||
|
findCredentialByIdForUser: async (credentialId: number) =>
|
||||||
|
state.credentials.get(credentialId) ?? null,
|
||||||
|
}),
|
||||||
|
createCurrentSharedHostSecretsRepository: () => ({
|
||||||
|
upsert: async (row: Record<string, unknown>) => {
|
||||||
|
const existing = state.secretRows.find(
|
||||||
|
(r) =>
|
||||||
|
r.hostAccessId === row.hostAccessId &&
|
||||||
|
r.targetUserId === row.targetUserId &&
|
||||||
|
r.protocol === row.protocol,
|
||||||
|
);
|
||||||
|
if (existing) Object.assign(existing, row);
|
||||||
|
else state.secretRows.push({ id: state.secretRows.length + 1, ...row });
|
||||||
|
},
|
||||||
|
deleteForHostAccessAndTarget: async (
|
||||||
|
hostAccessId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
keepProtocols: string[],
|
||||||
|
) => {
|
||||||
|
state.secretRows = state.secretRows.filter(
|
||||||
|
(r) =>
|
||||||
|
!(
|
||||||
|
r.hostAccessId === hostAccessId &&
|
||||||
|
r.targetUserId === targetUserId &&
|
||||||
|
!keepProtocols.includes(r.protocol as string)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
findForHostUserProtocol: async (
|
||||||
|
hostId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
protocol: string,
|
||||||
|
) =>
|
||||||
|
state.secretRows.find(
|
||||||
|
(r) =>
|
||||||
|
state.accessToHost.get(r.hostAccessId as number) === hostId &&
|
||||||
|
r.targetUserId === targetUserId &&
|
||||||
|
r.protocol === protocol,
|
||||||
|
) ?? null,
|
||||||
|
deleteByHostAccessId: async () => 0,
|
||||||
|
deleteByTargetUserId: async () => 0,
|
||||||
|
deleteByOriginalCredentialId: async () => 0,
|
||||||
|
findHostIdsReferencingCredential: async (
|
||||||
|
_ownerId: string,
|
||||||
|
credentialId: number,
|
||||||
|
) =>
|
||||||
|
[...state.hosts.values()]
|
||||||
|
.filter((h) => h.credentialId === credentialId)
|
||||||
|
.map((h) => h.id as number),
|
||||||
|
}),
|
||||||
|
createCurrentRbacAccessRepository: () => ({
|
||||||
|
listActiveHostAccessGrants: async (hostId: number) =>
|
||||||
|
state.grants.filter((g) => g.hostId === hostId),
|
||||||
|
listRoleHostAccessCredentialSources: async (roleId: number) =>
|
||||||
|
state.grants
|
||||||
|
.filter((g) => g.roleId === roleId)
|
||||||
|
.map((g) => ({
|
||||||
|
hostAccessId: g.id,
|
||||||
|
hostId: g.hostId,
|
||||||
|
hostOwnerId: state.hosts.get(g.hostId as number)?.userId,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
createCurrentRoleRepository: () => ({
|
||||||
|
listRoleUserIds: async (roleId: number) =>
|
||||||
|
state.roleMembers.get(roleId) ?? [],
|
||||||
|
listUserRoleIds: async () => [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/data-crypto.js", () => ({
|
||||||
|
DataCrypto: {
|
||||||
|
validateUserAccess: (userId: string) => {
|
||||||
|
if (userId === "owner") return ownerDEK;
|
||||||
|
if (userId === "target" || userId === "member-1") return targetDEK;
|
||||||
|
throw new Error(`User ${userId} has no data encryption key`);
|
||||||
|
},
|
||||||
|
getUserDataKey: (userId: string) =>
|
||||||
|
userId === "owner"
|
||||||
|
? ownerDEK
|
||||||
|
: userId === "target" || userId === "member-1"
|
||||||
|
? targetDEK
|
||||||
|
: null,
|
||||||
|
canUserAccessData: () => true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
databaseLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||||
|
import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js";
|
||||||
|
|
||||||
|
const manager = SharedHostSecretsManager.getInstance();
|
||||||
|
|
||||||
|
function baseHost(overrides: Record<string, unknown> = {}) {
|
||||||
|
// Records come back from the resolution repository already decrypted.
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
userId: "owner",
|
||||||
|
connectionType: "ssh",
|
||||||
|
name: "prod",
|
||||||
|
ip: "10.0.0.42",
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
password: "hunter2",
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
credentialId: null,
|
||||||
|
enableSsh: true,
|
||||||
|
enableRdp: false,
|
||||||
|
enableVnc: false,
|
||||||
|
enableTelnet: false,
|
||||||
|
rdpAuthType: null,
|
||||||
|
vncAuthType: null,
|
||||||
|
telnetAuthType: null,
|
||||||
|
rdpCredentialId: null,
|
||||||
|
vncCredentialId: null,
|
||||||
|
telnetCredentialId: null,
|
||||||
|
rdpUser: null,
|
||||||
|
rdpPassword: null,
|
||||||
|
rdpDomain: null,
|
||||||
|
vncUser: null,
|
||||||
|
vncPassword: null,
|
||||||
|
telnetUser: null,
|
||||||
|
telnetPassword: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.hosts.clear();
|
||||||
|
state.credentials.clear();
|
||||||
|
state.secretRows = [];
|
||||||
|
state.accessToHost = new Map([[7, 42]]);
|
||||||
|
state.grants = [];
|
||||||
|
state.roleMembers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SharedHostSecretsManager", () => {
|
||||||
|
it("snapshots an inline-password SSH host and the target can decrypt it", async () => {
|
||||||
|
state.hosts.set(42, baseHost());
|
||||||
|
|
||||||
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||||
|
|
||||||
|
expect(state.secretRows).toHaveLength(1);
|
||||||
|
const row = state.secretRows[0];
|
||||||
|
expect(row.protocol).toBe("ssh");
|
||||||
|
expect(row.sourceType).toBe("inline");
|
||||||
|
expect(row.encryptedPassword).not.toBe("hunter2");
|
||||||
|
|
||||||
|
const secret = await manager.getSecretForUser(42, "target", "ssh");
|
||||||
|
expect(secret).toMatchObject({
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
password: "hunter2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshots every enabled protocol from credential and inline sources", async () => {
|
||||||
|
state.credentials.set(123, {
|
||||||
|
id: 123,
|
||||||
|
userId: "owner",
|
||||||
|
username: "cred-user",
|
||||||
|
authType: "key",
|
||||||
|
password: null,
|
||||||
|
privateKey: "PRIVATE-KEY",
|
||||||
|
key: null,
|
||||||
|
keyPassword: "kp",
|
||||||
|
keyType: "ssh-ed25519",
|
||||||
|
});
|
||||||
|
state.hosts.set(
|
||||||
|
42,
|
||||||
|
baseHost({
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: 123,
|
||||||
|
password: null,
|
||||||
|
enableRdp: true,
|
||||||
|
rdpUser: "rdp-admin",
|
||||||
|
rdpPassword: "rdp-pass",
|
||||||
|
rdpDomain: "CORP",
|
||||||
|
enableTelnet: true,
|
||||||
|
telnetUser: "tel-user",
|
||||||
|
telnetPassword: "tel-pass",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||||
|
|
||||||
|
expect(state.secretRows.map((row) => row.protocol).sort()).toEqual([
|
||||||
|
"rdp",
|
||||||
|
"ssh",
|
||||||
|
"telnet",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ssh = await manager.getSecretForUser(42, "target", "ssh");
|
||||||
|
expect(ssh).toMatchObject({
|
||||||
|
username: "cred-user",
|
||||||
|
authType: "key",
|
||||||
|
key: "PRIVATE-KEY",
|
||||||
|
keyPassword: "kp",
|
||||||
|
keyType: "ssh-ed25519",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rdp = await manager.getSecretForUser(42, "target", "rdp");
|
||||||
|
expect(rdp).toMatchObject({
|
||||||
|
username: "rdp-admin",
|
||||||
|
password: "rdp-pass",
|
||||||
|
domain: "CORP",
|
||||||
|
authType: "direct",
|
||||||
|
});
|
||||||
|
|
||||||
|
const telnet = await manager.getSecretForUser(42, "target", "telnet");
|
||||||
|
expect(telnet).toMatchObject({
|
||||||
|
username: "tel-user",
|
||||||
|
password: "tel-pass",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces no snapshot rows for secret-less auth types", async () => {
|
||||||
|
state.hosts.set(42, baseHost({ authType: "opkssh", password: null }));
|
||||||
|
|
||||||
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||||
|
expect(state.secretRows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes stale protocol rows on re-snapshot", async () => {
|
||||||
|
state.hosts.set(
|
||||||
|
42,
|
||||||
|
baseHost({
|
||||||
|
enableRdp: true,
|
||||||
|
rdpUser: "rdp-admin",
|
||||||
|
rdpPassword: "rdp-pass",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||||
|
expect(state.secretRows).toHaveLength(2);
|
||||||
|
|
||||||
|
// Owner turns RDP off; the RDP snapshot must disappear.
|
||||||
|
state.hosts.set(42, baseHost());
|
||||||
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||||
|
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails fast when a participant has no DEK", async () => {
|
||||||
|
state.hosts.set(42, baseHost());
|
||||||
|
await expect(
|
||||||
|
manager.snapshotForUser(7, 42, "locked-user", "owner"),
|
||||||
|
).rejects.toThrow(/no data encryption key/);
|
||||||
|
expect(state.secretRows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be decrypted with the wrong DEK", async () => {
|
||||||
|
state.hosts.set(42, baseHost());
|
||||||
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||||
|
|
||||||
|
const row = state.secretRows[0];
|
||||||
|
expect(() =>
|
||||||
|
FieldCrypto.decryptField(
|
||||||
|
row.encryptedPassword as string,
|
||||||
|
ownerDEK,
|
||||||
|
"shared-7-target-ssh",
|
||||||
|
"password",
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resyncHost re-snapshots direct grants and role members", async () => {
|
||||||
|
state.hosts.set(42, baseHost());
|
||||||
|
state.accessToHost = new Map([
|
||||||
|
[1, 42],
|
||||||
|
[2, 42],
|
||||||
|
]);
|
||||||
|
state.grants = [
|
||||||
|
{ id: 1, hostId: 42, userId: "target", roleId: null },
|
||||||
|
{ id: 2, hostId: 42, userId: null, roleId: 9 },
|
||||||
|
];
|
||||||
|
state.roleMembers.set(9, ["member-1", "owner"]);
|
||||||
|
|
||||||
|
await manager.resyncHost(42);
|
||||||
|
|
||||||
|
// target via grant 1, member-1 via grant 2; owner skipped.
|
||||||
|
expect(
|
||||||
|
state.secretRows.map((row) => [row.hostAccessId, row.targetUserId]),
|
||||||
|
).toEqual([
|
||||||
|
[1, "target"],
|
||||||
|
[2, "member-1"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Owner rotates the inline password; resync updates the copies.
|
||||||
|
state.hosts.set(42, baseHost({ password: "rotated" }));
|
||||||
|
await manager.resyncHost(42);
|
||||||
|
|
||||||
|
const secret = await manager.getSecretForUser(42, "target", "ssh");
|
||||||
|
expect(secret?.password).toBe("rotated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshotForRoleMember fans out from role grants", async () => {
|
||||||
|
state.hosts.set(42, baseHost());
|
||||||
|
state.accessToHost = new Map([[2, 42]]);
|
||||||
|
state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }];
|
||||||
|
|
||||||
|
await manager.snapshotForRoleMember(9, "member-1");
|
||||||
|
|
||||||
|
expect(state.secretRows).toHaveLength(1);
|
||||||
|
expect(state.secretRows[0]).toMatchObject({
|
||||||
|
hostAccessId: 2,
|
||||||
|
targetUserId: "member-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { databaseLogger } from "../logger.js";
|
import { databaseLogger } from "../logger.js";
|
||||||
import { DataCrypto } from "../data-crypto.js";
|
|
||||||
import { DatabaseSaveTrigger } from "../database-save-trigger.js";
|
import { DatabaseSaveTrigger } from "../database-save-trigger.js";
|
||||||
import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js";
|
import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js";
|
||||||
import { SharedCredentialManager } from "../shared-credential-manager.js";
|
|
||||||
|
|
||||||
interface SqliteLike {
|
interface SqliteLike {
|
||||||
prepare(sql: string): {
|
prepare(sql: string): {
|
||||||
@@ -34,83 +32,17 @@ function dropColumnIfExists(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingShareRow {
|
// One-time cleanup of the pre-2.5 sharing machinery: the
|
||||||
id: number;
|
// CREDENTIAL_SHARING_KEY shadow columns are dropped. Pending share copies are
|
||||||
host_access_id: number;
|
// no longer re-created here; the shared_host_secrets migration rebuilds every
|
||||||
original_credential_id: number;
|
// active share from the live host_access grants instead.
|
||||||
target_user_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One-time cleanup of the pre-2.5 sharing machinery: pending share copies
|
|
||||||
// (rows that were waiting for an offline user's DEK) are re-created now that
|
|
||||||
// every migrated user's DEK is server-side, then the queue flag and the
|
|
||||||
// CREDENTIAL_SHARING_KEY shadow columns are dropped.
|
|
||||||
export async function runLegacySharedCredentialCleanup(): Promise<{
|
export async function runLegacySharedCredentialCleanup(): Promise<{
|
||||||
resolved: number;
|
|
||||||
dropped: number;
|
|
||||||
columnsDropped: number;
|
columnsDropped: number;
|
||||||
}> {
|
}> {
|
||||||
const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike;
|
const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike;
|
||||||
const result = { resolved: 0, dropped: 0, columnsDropped: 0 };
|
const result = { columnsDropped: 0 };
|
||||||
|
|
||||||
if (tableHasColumn(sqlite, "shared_credentials", "needs_re_encryption")) {
|
|
||||||
const pendingRows = sqlite
|
|
||||||
.prepare(
|
|
||||||
`SELECT id, host_access_id, original_credential_id, target_user_id
|
|
||||||
FROM shared_credentials
|
|
||||||
WHERE needs_re_encryption = 1 OR encrypted_username = ''`,
|
|
||||||
)
|
|
||||||
.all() as PendingShareRow[];
|
|
||||||
|
|
||||||
for (const row of pendingRows) {
|
|
||||||
const owner = sqlite
|
|
||||||
.prepare(
|
|
||||||
`SELECT h.user_id AS ownerId
|
|
||||||
FROM host_access ha JOIN ssh_data h ON h.id = ha.host_id
|
|
||||||
WHERE ha.id = ?`,
|
|
||||||
)
|
|
||||||
.get(row.host_access_id) as { ownerId?: string } | undefined;
|
|
||||||
|
|
||||||
sqlite.prepare(`DELETE FROM shared_credentials WHERE id = ?`).run(row.id);
|
|
||||||
|
|
||||||
const ownerId = owner?.ownerId;
|
|
||||||
if (
|
|
||||||
ownerId &&
|
|
||||||
DataCrypto.canUserAccessData(ownerId) &&
|
|
||||||
DataCrypto.canUserAccessData(row.target_user_id)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await SharedCredentialManager.getInstance().createSharedCredentialForUser(
|
|
||||||
row.host_access_id,
|
|
||||||
row.original_credential_id,
|
|
||||||
row.target_user_id,
|
|
||||||
ownerId,
|
|
||||||
);
|
|
||||||
result.resolved++;
|
|
||||||
continue;
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.warn("Could not re-create pending shared credential", {
|
|
||||||
operation: "legacy_share_cleanup_recreate_failed",
|
|
||||||
sharedCredentialId: row.id,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.dropped++;
|
|
||||||
databaseLogger.warn(
|
|
||||||
"Dropped unresolvable pending shared credential; the owner can re-share the host",
|
|
||||||
{
|
|
||||||
operation: "legacy_share_cleanup_dropped",
|
|
||||||
hostAccessId: row.host_access_id,
|
|
||||||
targetUserId: row.target_user_id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [table, column] of [
|
for (const [table, column] of [
|
||||||
["shared_credentials", "needs_re_encryption"],
|
|
||||||
["ssh_credentials", "system_password"],
|
["ssh_credentials", "system_password"],
|
||||||
["ssh_credentials", "system_key"],
|
["ssh_credentials", "system_key"],
|
||||||
["ssh_credentials", "system_key_password"],
|
["ssh_credentials", "system_key_password"],
|
||||||
@@ -120,7 +52,7 @@ export async function runLegacySharedCredentialCleanup(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.resolved || result.dropped || result.columnsDropped) {
|
if (result.columnsDropped) {
|
||||||
await DatabaseSaveTrigger.forceSave("legacy_share_cleanup");
|
await DatabaseSaveTrigger.forceSave("legacy_share_cleanup");
|
||||||
databaseLogger.info("Legacy shared-credential cleanup finished", {
|
databaseLogger.info("Legacy shared-credential cleanup finished", {
|
||||||
operation: "legacy_share_cleanup",
|
operation: "legacy_share_cleanup",
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { databaseLogger } from "../logger.js";
|
||||||
|
import { DataCrypto } from "../data-crypto.js";
|
||||||
|
import { DatabaseSaveTrigger } from "../database-save-trigger.js";
|
||||||
|
import {
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
getCurrentRepositorySqlite,
|
||||||
|
} from "../../database/repositories/factory.js";
|
||||||
|
import { SharedHostSecretsManager } from "../shared-host-secrets-manager.js";
|
||||||
|
|
||||||
|
const MIGRATION_FLAG = "shared_host_secrets_migrated_v1";
|
||||||
|
|
||||||
|
interface GrantRow {
|
||||||
|
hostAccessId: number;
|
||||||
|
hostId: number;
|
||||||
|
userId: string | null;
|
||||||
|
roleId: number | null;
|
||||||
|
ownerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time 2.5.x migration: rebuild every active share as per-protocol
|
||||||
|
// secret snapshots in shared_host_secrets, then drop the SSH-only
|
||||||
|
// shared_credentials table it replaces. Grants whose owner or recipient DEK
|
||||||
|
// is unavailable are skipped (logged), never fatal to boot.
|
||||||
|
export async function runSharedHostSecretsMigration(): Promise<{
|
||||||
|
snapshotted: number;
|
||||||
|
skipped: number;
|
||||||
|
} | null> {
|
||||||
|
const settingsRepository = createCurrentSettingsRepository();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = getCurrentRepositorySqlite();
|
||||||
|
const result = { snapshotted: 0, skipped: 0 };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const grants = sqlite
|
||||||
|
.prepare(
|
||||||
|
`SELECT ha.id AS hostAccessId, ha.host_id AS hostId,
|
||||||
|
ha.user_id AS userId, ha.role_id AS roleId,
|
||||||
|
h.user_id AS ownerId
|
||||||
|
FROM host_access ha
|
||||||
|
JOIN ssh_data h ON h.id = ha.host_id
|
||||||
|
WHERE ha.expires_at IS NULL OR ha.expires_at >= ?`,
|
||||||
|
)
|
||||||
|
.all(new Date().toISOString()) as GrantRow[];
|
||||||
|
|
||||||
|
const manager = SharedHostSecretsManager.getInstance();
|
||||||
|
|
||||||
|
for (const grant of grants) {
|
||||||
|
const targetUserIds = grant.userId
|
||||||
|
? [grant.userId]
|
||||||
|
: grant.roleId
|
||||||
|
? (
|
||||||
|
sqlite
|
||||||
|
.prepare(`SELECT user_id FROM user_roles WHERE role_id = ?`)
|
||||||
|
.all(grant.roleId) as Array<{ user_id: string }>
|
||||||
|
).map((row) => row.user_id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const targetUserId of targetUserIds) {
|
||||||
|
if (targetUserId === grant.ownerId) continue;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!DataCrypto.canUserAccessData(grant.ownerId) ||
|
||||||
|
!DataCrypto.canUserAccessData(targetUserId)
|
||||||
|
) {
|
||||||
|
result.skipped++;
|
||||||
|
databaseLogger.warn(
|
||||||
|
"Skipping share snapshot migration: missing DEK",
|
||||||
|
{
|
||||||
|
operation: "shared_host_secrets_migration_skip",
|
||||||
|
hostAccessId: grant.hostAccessId,
|
||||||
|
targetUserId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.snapshotForUser(
|
||||||
|
grant.hostAccessId,
|
||||||
|
grant.hostId,
|
||||||
|
targetUserId,
|
||||||
|
grant.ownerId,
|
||||||
|
);
|
||||||
|
result.snapshotted++;
|
||||||
|
} catch (error) {
|
||||||
|
result.skipped++;
|
||||||
|
databaseLogger.warn("Failed to migrate share snapshot", {
|
||||||
|
operation: "shared_host_secrets_migration_failed",
|
||||||
|
hostAccessId: grant.hostAccessId,
|
||||||
|
targetUserId,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite.exec("DROP TABLE IF EXISTS shared_credentials");
|
||||||
|
await settingsRepository.set(MIGRATION_FLAG, "done");
|
||||||
|
await DatabaseSaveTrigger.forceSave("shared_host_secrets_migration");
|
||||||
|
|
||||||
|
databaseLogger.info("Shared host secrets migration finished", {
|
||||||
|
operation: "shared_host_secrets_migration",
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Shared host secrets migration failed", error, {
|
||||||
|
operation: "shared_host_secrets_migration",
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -12,11 +12,32 @@ interface AuthenticatedRequest extends Request {
|
|||||||
dataKey?: Buffer;
|
dataKey?: Buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHARE_PERMISSION_LEVELS = ["connect", "view", "edit", "manage"] as const;
|
||||||
|
|
||||||
|
type SharePermissionLevel = (typeof SHARE_PERMISSION_LEVELS)[number];
|
||||||
|
|
||||||
|
type HostAction = SharePermissionLevel | "delete";
|
||||||
|
|
||||||
|
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
|
||||||
|
connect: 1,
|
||||||
|
view: 2,
|
||||||
|
edit: 3,
|
||||||
|
manage: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSharePermissionLevel(
|
||||||
|
level: string | null | undefined,
|
||||||
|
): SharePermissionLevel {
|
||||||
|
return SHARE_PERMISSION_LEVELS.includes(level as SharePermissionLevel)
|
||||||
|
? (level as SharePermissionLevel)
|
||||||
|
: "connect";
|
||||||
|
}
|
||||||
|
|
||||||
interface HostAccessInfo {
|
interface HostAccessInfo {
|
||||||
hasAccess: boolean;
|
hasAccess: boolean;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
isShared: boolean;
|
isShared: boolean;
|
||||||
permissionLevel?: "view";
|
permissionLevel?: SharePermissionLevel;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +167,7 @@ class PermissionManager {
|
|||||||
async canAccessHost(
|
async canAccessHost(
|
||||||
userId: string,
|
userId: string,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
action: "read" | "write" | "execute" | "delete" | "share" = "read",
|
action: HostAction = "connect",
|
||||||
): Promise<HostAccessInfo> {
|
): Promise<HostAccessInfo> {
|
||||||
try {
|
try {
|
||||||
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||||
@@ -180,30 +201,41 @@ class PermissionManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "write" || action === "delete") {
|
const grantedLevel = normalizeSharePermissionLevel(
|
||||||
|
access.permissionLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
action === "delete" ||
|
||||||
|
LEVEL_RANK[grantedLevel] < LEVEL_RANK[action]
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
hasAccess: false,
|
hasAccess: false,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
isShared: true,
|
isShared: true,
|
||||||
permissionLevel: access.permissionLevel as "view",
|
permissionLevel: grantedLevel,
|
||||||
expiresAt: access.expiresAt,
|
expiresAt: access.expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (action === "connect") {
|
||||||
await createCurrentRbacAccessRepository().touchHostAccess(access.id);
|
try {
|
||||||
} catch (error) {
|
await createCurrentRbacAccessRepository().touchHostAccess(
|
||||||
databaseLogger.warn("Failed to update host access timestamp", {
|
access.id,
|
||||||
operation: "update_host_access_timestamp",
|
);
|
||||||
error,
|
} catch (error) {
|
||||||
});
|
databaseLogger.warn("Failed to update host access timestamp", {
|
||||||
|
operation: "update_host_access_timestamp",
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasAccess: true,
|
hasAccess: true,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
isShared: true,
|
isShared: true,
|
||||||
permissionLevel: access.permissionLevel as "view",
|
permissionLevel: grantedLevel,
|
||||||
expiresAt: access.expiresAt,
|
expiresAt: access.expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -283,7 +315,7 @@ class PermissionManager {
|
|||||||
|
|
||||||
requireHostAccess(
|
requireHostAccess(
|
||||||
hostIdParam: string = "id",
|
hostIdParam: string = "id",
|
||||||
action: "read" | "write" | "execute" | "delete" | "share" = "read",
|
action: HostAction = "connect",
|
||||||
) {
|
) {
|
||||||
return async (
|
return async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
@@ -358,5 +390,11 @@ class PermissionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { PermissionManager };
|
export { PermissionManager, SHARE_PERMISSION_LEVELS, LEVEL_RANK };
|
||||||
export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult };
|
export type {
|
||||||
|
AuthenticatedRequest,
|
||||||
|
HostAccessInfo,
|
||||||
|
PermissionCheckResult,
|
||||||
|
SharePermissionLevel,
|
||||||
|
HostAction,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,444 +0,0 @@
|
|||||||
import {
|
|
||||||
createCurrentCredentialRepository,
|
|
||||||
createCurrentRbacAccessRepository,
|
|
||||||
createCurrentRoleRepository,
|
|
||||||
createCurrentSharedCredentialRepository,
|
|
||||||
} from "../database/repositories/factory.js";
|
|
||||||
import type { SharedCredentialRecord } from "../database/repositories/shared-credential-repository.js";
|
|
||||||
import { DataCrypto } from "./data-crypto.js";
|
|
||||||
import { FieldCrypto } from "./field-crypto.js";
|
|
||||||
import { databaseLogger } from "./logger.js";
|
|
||||||
|
|
||||||
interface CredentialData {
|
|
||||||
username: string;
|
|
||||||
authType: string;
|
|
||||||
password?: string;
|
|
||||||
key?: string;
|
|
||||||
keyPassword?: string;
|
|
||||||
keyType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared credentials are per-target copies of the owner's credential,
|
|
||||||
// re-encrypted under the target user's DEK at share time.
|
|
||||||
class SharedCredentialManager {
|
|
||||||
private static instance: SharedCredentialManager;
|
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
static getInstance(): SharedCredentialManager {
|
|
||||||
if (!this.instance) {
|
|
||||||
this.instance = new SharedCredentialManager();
|
|
||||||
}
|
|
||||||
return this.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSharedCredentialForUser(
|
|
||||||
hostAccessId: number,
|
|
||||||
originalCredentialId: number,
|
|
||||||
targetUserId: string,
|
|
||||||
ownerId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const sharedCredentialRepository =
|
|
||||||
createCurrentSharedCredentialRepository();
|
|
||||||
const existing =
|
|
||||||
await sharedCredentialRepository.existsForHostAccessAndTargetUser(
|
|
||||||
hostAccessId,
|
|
||||||
targetUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
const ownerDEK = DataCrypto.validateUserAccess(ownerId);
|
|
||||||
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
|
|
||||||
|
|
||||||
const credentialData = await this.getDecryptedCredential(
|
|
||||||
originalCredentialId,
|
|
||||||
ownerId,
|
|
||||||
ownerDEK,
|
|
||||||
);
|
|
||||||
|
|
||||||
const encryptedForTarget = this.encryptCredentialForUser(
|
|
||||||
credentialData,
|
|
||||||
targetUserId,
|
|
||||||
targetDEK,
|
|
||||||
hostAccessId,
|
|
||||||
);
|
|
||||||
|
|
||||||
await sharedCredentialRepository.create({
|
|
||||||
hostAccessId,
|
|
||||||
originalCredentialId,
|
|
||||||
targetUserId,
|
|
||||||
...encryptedForTarget,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error("Failed to create shared credential", error, {
|
|
||||||
operation: "create_shared_credential",
|
|
||||||
hostAccessId,
|
|
||||||
targetUserId,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSharedCredentialsForRole(
|
|
||||||
hostAccessId: number,
|
|
||||||
originalCredentialId: number,
|
|
||||||
roleId: number,
|
|
||||||
ownerId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const roleUserIds =
|
|
||||||
await createCurrentRoleRepository().listRoleUserIds(roleId);
|
|
||||||
|
|
||||||
for (const userId of roleUserIds) {
|
|
||||||
try {
|
|
||||||
await this.createSharedCredentialForUser(
|
|
||||||
hostAccessId,
|
|
||||||
originalCredentialId,
|
|
||||||
userId,
|
|
||||||
ownerId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error(
|
|
||||||
"Failed to create shared credential for role member",
|
|
||||||
error,
|
|
||||||
{
|
|
||||||
operation: "create_shared_credentials_role",
|
|
||||||
hostAccessId,
|
|
||||||
roleId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error(
|
|
||||||
"Failed to create shared credentials for role",
|
|
||||||
error,
|
|
||||||
{
|
|
||||||
operation: "create_shared_credentials_role",
|
|
||||||
hostAccessId,
|
|
||||||
roleId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSharedCredentialsForRoleMember(
|
|
||||||
roleId: number,
|
|
||||||
targetUserId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const hostsSharedWithRole =
|
|
||||||
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources(
|
|
||||||
roleId,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const sharedHost of hostsSharedWithRole) {
|
|
||||||
const activeCredentialId =
|
|
||||||
sharedHost.credentialId ??
|
|
||||||
sharedHost.rdpCredentialId ??
|
|
||||||
sharedHost.vncCredentialId ??
|
|
||||||
sharedHost.telnetCredentialId;
|
|
||||||
|
|
||||||
if (!activeCredentialId) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.createSharedCredentialForUser(
|
|
||||||
sharedHost.hostAccessId,
|
|
||||||
activeCredentialId,
|
|
||||||
targetUserId,
|
|
||||||
sharedHost.hostOwnerId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error(
|
|
||||||
"Failed to create shared credential for role member",
|
|
||||||
error,
|
|
||||||
{
|
|
||||||
operation: "create_shared_credentials_role_member",
|
|
||||||
roleId,
|
|
||||||
targetUserId,
|
|
||||||
hostId: sharedHost.hostId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error(
|
|
||||||
"Failed to create shared credentials for role member",
|
|
||||||
error,
|
|
||||||
{
|
|
||||||
operation: "create_shared_credentials_role_member",
|
|
||||||
roleId,
|
|
||||||
targetUserId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSharedCredentialsForUserRoles(userId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const roleIds =
|
|
||||||
await createCurrentRoleRepository().listUserRoleIds(userId);
|
|
||||||
|
|
||||||
for (const roleId of roleIds) {
|
|
||||||
await this.createSharedCredentialsForRoleMember(roleId, userId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error(
|
|
||||||
"Failed to create shared credentials for user roles",
|
|
||||||
error,
|
|
||||||
{
|
|
||||||
operation: "create_shared_credentials_user_roles",
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSharedCredentialForUser(
|
|
||||||
hostId: number,
|
|
||||||
userId: string,
|
|
||||||
): Promise<CredentialData | null> {
|
|
||||||
try {
|
|
||||||
const userDEK = DataCrypto.validateUserAccess(userId);
|
|
||||||
|
|
||||||
const cred =
|
|
||||||
await createCurrentRbacAccessRepository().findSharedCredentialForHostAndUser(
|
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!cred) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.decryptSharedCredential(cred, userDEK);
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error("Failed to get shared credential", error, {
|
|
||||||
operation: "get_shared_credential",
|
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSharedCredentialsForOriginal(
|
|
||||||
credentialId: number,
|
|
||||||
ownerId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const sharedCredentialRepository =
|
|
||||||
createCurrentSharedCredentialRepository();
|
|
||||||
const sharedCreds =
|
|
||||||
await sharedCredentialRepository.listByOriginalCredentialId(
|
|
||||||
credentialId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sharedCreds.length === 0) return;
|
|
||||||
|
|
||||||
const ownerDEK = DataCrypto.validateUserAccess(ownerId);
|
|
||||||
const credentialData = await this.getDecryptedCredential(
|
|
||||||
credentialId,
|
|
||||||
ownerId,
|
|
||||||
ownerDEK,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const sharedCred of sharedCreds) {
|
|
||||||
const targetDEK = DataCrypto.validateUserAccess(
|
|
||||||
sharedCred.targetUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const encryptedForTarget = this.encryptCredentialForUser(
|
|
||||||
credentialData,
|
|
||||||
sharedCred.targetUserId,
|
|
||||||
targetDEK,
|
|
||||||
sharedCred.hostAccessId,
|
|
||||||
);
|
|
||||||
|
|
||||||
await sharedCredentialRepository.updateById(sharedCred.id, {
|
|
||||||
...encryptedForTarget,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error("Failed to update shared credentials", error, {
|
|
||||||
operation: "update_shared_credentials",
|
|
||||||
credentialId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteSharedCredentialsForOriginal(
|
|
||||||
credentialId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await createCurrentSharedCredentialRepository().deleteByOriginalCredentialId(
|
|
||||||
credentialId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error("Failed to delete shared credentials", error, {
|
|
||||||
operation: "delete_shared_credentials",
|
|
||||||
credentialId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getDecryptedCredential(
|
|
||||||
credentialId: number,
|
|
||||||
ownerId: string,
|
|
||||||
ownerDEK: Buffer,
|
|
||||||
): Promise<CredentialData> {
|
|
||||||
const cred = await createCurrentCredentialRepository().findByIdForUser(
|
|
||||||
ownerId,
|
|
||||||
credentialId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!cred) {
|
|
||||||
throw new Error(`Credential ${credentialId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
username: cred.username,
|
|
||||||
authType: cred.authType,
|
|
||||||
password: cred.password
|
|
||||||
? this.decryptField(cred.password, ownerDEK, credentialId, "password")
|
|
||||||
: undefined,
|
|
||||||
key: cred.key
|
|
||||||
? this.decryptField(cred.key, ownerDEK, credentialId, "key")
|
|
||||||
: undefined,
|
|
||||||
keyPassword: cred.keyPassword
|
|
||||||
? this.decryptField(
|
|
||||||
cred.keyPassword,
|
|
||||||
ownerDEK,
|
|
||||||
credentialId,
|
|
||||||
"keyPassword",
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
keyType: cred.keyType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private encryptCredentialForUser(
|
|
||||||
credentialData: CredentialData,
|
|
||||||
targetUserId: string,
|
|
||||||
targetDEK: Buffer,
|
|
||||||
hostAccessId: number,
|
|
||||||
): {
|
|
||||||
encryptedUsername: string;
|
|
||||||
encryptedAuthType: string;
|
|
||||||
encryptedPassword: string | null;
|
|
||||||
encryptedKey: string | null;
|
|
||||||
encryptedKeyPassword: string | null;
|
|
||||||
encryptedKeyType: string | null;
|
|
||||||
} {
|
|
||||||
const recordId = `shared-${hostAccessId}-${targetUserId}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
encryptedUsername: FieldCrypto.encryptField(
|
|
||||||
credentialData.username,
|
|
||||||
targetDEK,
|
|
||||||
recordId,
|
|
||||||
"username",
|
|
||||||
),
|
|
||||||
encryptedAuthType: credentialData.authType,
|
|
||||||
encryptedPassword: credentialData.password
|
|
||||||
? FieldCrypto.encryptField(
|
|
||||||
credentialData.password,
|
|
||||||
targetDEK,
|
|
||||||
recordId,
|
|
||||||
"password",
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
encryptedKey: credentialData.key
|
|
||||||
? FieldCrypto.encryptField(
|
|
||||||
credentialData.key,
|
|
||||||
targetDEK,
|
|
||||||
recordId,
|
|
||||||
"key",
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
encryptedKeyPassword: credentialData.keyPassword
|
|
||||||
? FieldCrypto.encryptField(
|
|
||||||
credentialData.keyPassword,
|
|
||||||
targetDEK,
|
|
||||||
recordId,
|
|
||||||
"key_password",
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
encryptedKeyType: credentialData.keyType || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private decryptSharedCredential(
|
|
||||||
sharedCred: SharedCredentialRecord,
|
|
||||||
userDEK: Buffer,
|
|
||||||
): CredentialData {
|
|
||||||
const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
username: FieldCrypto.decryptField(
|
|
||||||
sharedCred.encryptedUsername,
|
|
||||||
userDEK,
|
|
||||||
recordId,
|
|
||||||
"username",
|
|
||||||
),
|
|
||||||
authType: sharedCred.encryptedAuthType,
|
|
||||||
password: sharedCred.encryptedPassword
|
|
||||||
? FieldCrypto.decryptField(
|
|
||||||
sharedCred.encryptedPassword,
|
|
||||||
userDEK,
|
|
||||||
recordId,
|
|
||||||
"password",
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
key: sharedCred.encryptedKey
|
|
||||||
? FieldCrypto.decryptField(
|
|
||||||
sharedCred.encryptedKey,
|
|
||||||
userDEK,
|
|
||||||
recordId,
|
|
||||||
"key",
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
keyPassword: sharedCred.encryptedKeyPassword
|
|
||||||
? FieldCrypto.decryptField(
|
|
||||||
sharedCred.encryptedKeyPassword,
|
|
||||||
userDEK,
|
|
||||||
recordId,
|
|
||||||
"key_password",
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
keyType: sharedCred.encryptedKeyType || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private decryptField(
|
|
||||||
encryptedValue: string,
|
|
||||||
dek: Buffer,
|
|
||||||
recordId: number | string,
|
|
||||||
fieldName: string,
|
|
||||||
): string {
|
|
||||||
try {
|
|
||||||
return FieldCrypto.decryptField(
|
|
||||||
encryptedValue,
|
|
||||||
dek,
|
|
||||||
recordId.toString(),
|
|
||||||
fieldName,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
databaseLogger.warn("Field decryption failed, returning as-is", {
|
|
||||||
operation: "decrypt_field",
|
|
||||||
fieldName,
|
|
||||||
recordId,
|
|
||||||
});
|
|
||||||
return encryptedValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { SharedCredentialManager };
|
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
import {
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentRbacAccessRepository,
|
||||||
|
createCurrentRoleRepository,
|
||||||
|
createCurrentSharedHostSecretsRepository,
|
||||||
|
} from "../database/repositories/factory.js";
|
||||||
|
import type {
|
||||||
|
SharedHostSecretRecord,
|
||||||
|
ShareProtocol,
|
||||||
|
} from "../database/repositories/shared-host-secrets-repository.js";
|
||||||
|
import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js";
|
||||||
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
|
import { FieldCrypto } from "./field-crypto.js";
|
||||||
|
import { databaseLogger } from "./logger.js";
|
||||||
|
|
||||||
|
export interface SharedSecretData {
|
||||||
|
username?: string;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
domain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProtocolSnapshot {
|
||||||
|
protocol: ShareProtocol;
|
||||||
|
sourceType: "credential" | "inline";
|
||||||
|
originalCredentialId: number | null;
|
||||||
|
data: SharedSecretData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotRecordId(
|
||||||
|
hostAccessId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
protocol: ShareProtocol,
|
||||||
|
): string {
|
||||||
|
return `shared-${hostAccessId}-${targetUserId}-${protocol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the connection-type migration fallback in transformHostResponse():
|
||||||
|
// old hosts only set connectionType, the per-protocol enable flags came later.
|
||||||
|
function enabledProtocols(
|
||||||
|
host: HostResolutionHostRecord,
|
||||||
|
): Record<ShareProtocol, boolean> {
|
||||||
|
const ct = host.connectionType;
|
||||||
|
const rdp = !!host.enableRdp;
|
||||||
|
const vnc = !!host.enableVnc;
|
||||||
|
const telnet = !!host.enableTelnet;
|
||||||
|
const isMigratedNonSsh = !rdp && !vnc && !telnet && !!ct && ct !== "ssh";
|
||||||
|
|
||||||
|
return {
|
||||||
|
ssh: isMigratedNonSsh ? false : host.enableSsh !== false,
|
||||||
|
rdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||||
|
vnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||||
|
telnet: isMigratedNonSsh ? ct === "telnet" : telnet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-recipient copies of a shared host's connection secrets, re-encrypted
|
||||||
|
// under the recipient's DEK. Every enabled protocol gets its own snapshot;
|
||||||
|
// secret-less auth types (opkssh, vault, agent, none, ...) produce none.
|
||||||
|
class SharedHostSecretsManager {
|
||||||
|
private static instance: SharedHostSecretsManager;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): SharedHostSecretsManager {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new SharedHostSecretsManager();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotForUser(
|
||||||
|
hostAccessId: number,
|
||||||
|
hostId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
ownerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (targetUserId === ownerId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
|
||||||
|
DataCrypto.validateUserAccess(ownerId);
|
||||||
|
|
||||||
|
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||||
|
hostId,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
if (!host) {
|
||||||
|
throw new Error(`Host ${hostId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshots = await this.collectProtocolSnapshots(host, ownerId);
|
||||||
|
const repository = createCurrentSharedHostSecretsRepository();
|
||||||
|
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
const recordId = snapshotRecordId(
|
||||||
|
hostAccessId,
|
||||||
|
targetUserId,
|
||||||
|
snapshot.protocol,
|
||||||
|
);
|
||||||
|
const encrypt = (value: string | undefined, fieldName: string) =>
|
||||||
|
value
|
||||||
|
? FieldCrypto.encryptField(value, targetDEK, recordId, fieldName)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await repository.upsert({
|
||||||
|
hostAccessId,
|
||||||
|
targetUserId,
|
||||||
|
protocol: snapshot.protocol,
|
||||||
|
sourceType: snapshot.sourceType,
|
||||||
|
originalCredentialId: snapshot.originalCredentialId,
|
||||||
|
encryptedUsername: encrypt(snapshot.data.username, "username"),
|
||||||
|
encryptedAuthType: snapshot.data.authType,
|
||||||
|
encryptedPassword: encrypt(snapshot.data.password, "password"),
|
||||||
|
encryptedKey: encrypt(snapshot.data.key, "key"),
|
||||||
|
encryptedKeyPassword: encrypt(
|
||||||
|
snapshot.data.keyPassword,
|
||||||
|
"key_password",
|
||||||
|
),
|
||||||
|
encryptedKeyType: snapshot.data.keyType || null,
|
||||||
|
encryptedDomain: encrypt(snapshot.data.domain, "domain"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.deleteForHostAccessAndTarget(
|
||||||
|
hostAccessId,
|
||||||
|
targetUserId,
|
||||||
|
snapshots.map((snapshot) => snapshot.protocol),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to snapshot shared host secrets", error, {
|
||||||
|
operation: "shared_host_secrets_snapshot",
|
||||||
|
hostAccessId,
|
||||||
|
hostId,
|
||||||
|
targetUserId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotForRole(
|
||||||
|
hostAccessId: number,
|
||||||
|
hostId: number,
|
||||||
|
roleId: number,
|
||||||
|
ownerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const roleUserIds =
|
||||||
|
await createCurrentRoleRepository().listRoleUserIds(roleId);
|
||||||
|
|
||||||
|
for (const userId of roleUserIds) {
|
||||||
|
try {
|
||||||
|
await this.snapshotForUser(hostAccessId, hostId, userId, ownerId);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
"Failed to snapshot shared host secrets for role member",
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "shared_host_secrets_snapshot_role",
|
||||||
|
hostAccessId,
|
||||||
|
roleId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotForRoleMember(
|
||||||
|
roleId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const hostsSharedWithRole =
|
||||||
|
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources(
|
||||||
|
roleId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const sharedHost of hostsSharedWithRole) {
|
||||||
|
try {
|
||||||
|
await this.snapshotForUser(
|
||||||
|
sharedHost.hostAccessId,
|
||||||
|
sharedHost.hostId,
|
||||||
|
targetUserId,
|
||||||
|
sharedHost.hostOwnerId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
"Failed to snapshot shared host secrets for role member",
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "shared_host_secrets_snapshot_role_member",
|
||||||
|
roleId,
|
||||||
|
targetUserId,
|
||||||
|
hostId: sharedHost.hostId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotForUserRoles(userId: string): Promise<void> {
|
||||||
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
|
||||||
|
for (const roleId of roleIds) {
|
||||||
|
await this.snapshotForRoleMember(roleId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-snapshot every active grant on a host. Called after the owner (or an
|
||||||
|
// editor) changes the host so recipients keep working secrets.
|
||||||
|
async resyncHost(hostId: number): Promise<void> {
|
||||||
|
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||||
|
const ownerId = await hostResolutionRepository.findHostOwnerId(hostId);
|
||||||
|
if (!ownerId) return;
|
||||||
|
|
||||||
|
const grants =
|
||||||
|
await createCurrentRbacAccessRepository().listActiveHostAccessGrants(
|
||||||
|
hostId,
|
||||||
|
);
|
||||||
|
if (grants.length === 0) return;
|
||||||
|
|
||||||
|
const roleRepository = createCurrentRoleRepository();
|
||||||
|
|
||||||
|
for (const grant of grants) {
|
||||||
|
const targetUserIds = grant.userId
|
||||||
|
? [grant.userId]
|
||||||
|
: grant.roleId
|
||||||
|
? await roleRepository.listRoleUserIds(grant.roleId)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const targetUserId of targetUserIds) {
|
||||||
|
if (targetUserId === ownerId) continue;
|
||||||
|
try {
|
||||||
|
await this.snapshotForUser(grant.id, hostId, targetUserId, ownerId);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn(
|
||||||
|
"Skipping shared host secret resync for recipient",
|
||||||
|
{
|
||||||
|
operation: "shared_host_secrets_resync_skip",
|
||||||
|
hostId,
|
||||||
|
hostAccessId: grant.id,
|
||||||
|
targetUserId,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-snapshot every shared host that references a credential after the
|
||||||
|
// owner edits that credential.
|
||||||
|
async resyncHostsForCredential(
|
||||||
|
credentialId: number,
|
||||||
|
ownerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const hostIds =
|
||||||
|
await createCurrentSharedHostSecretsRepository().findHostIdsReferencingCredential(
|
||||||
|
ownerId,
|
||||||
|
credentialId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const hostId of hostIds) {
|
||||||
|
await this.resyncHost(hostId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
"Failed to resync shared host secrets for credential",
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "shared_host_secrets_resync_credential",
|
||||||
|
credentialId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSecretForUser(
|
||||||
|
hostId: number,
|
||||||
|
userId: string,
|
||||||
|
protocol: ShareProtocol,
|
||||||
|
): Promise<SharedSecretData | null> {
|
||||||
|
try {
|
||||||
|
const userDEK = DataCrypto.validateUserAccess(userId);
|
||||||
|
|
||||||
|
const secret =
|
||||||
|
await createCurrentSharedHostSecretsRepository().findForHostUserProtocol(
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
protocol,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secret) return null;
|
||||||
|
|
||||||
|
return this.decryptSnapshot(secret, userDEK);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to get shared host secret", error, {
|
||||||
|
operation: "shared_host_secrets_get",
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
protocol,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForHostAccess(hostAccessId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await createCurrentSharedHostSecretsRepository().deleteByHostAccessId(
|
||||||
|
hostAccessId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
"Failed to delete shared host secrets for access grant",
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "shared_host_secrets_delete_access",
|
||||||
|
hostAccessId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForUser(userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
"Failed to delete shared host secrets for user",
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "shared_host_secrets_delete_user",
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForCredential(credentialId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await createCurrentSharedHostSecretsRepository().deleteByOriginalCredentialId(
|
||||||
|
credentialId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
"Failed to delete shared host secrets for credential",
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "shared_host_secrets_delete_credential",
|
||||||
|
credentialId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectProtocolSnapshots(
|
||||||
|
host: HostResolutionHostRecord,
|
||||||
|
ownerId: string,
|
||||||
|
): Promise<ProtocolSnapshot[]> {
|
||||||
|
const repository = createCurrentHostResolutionRepository();
|
||||||
|
const enabled = enabledProtocols(host);
|
||||||
|
const snapshots: ProtocolSnapshot[] = [];
|
||||||
|
|
||||||
|
if (enabled.ssh) {
|
||||||
|
if (host.credentialId) {
|
||||||
|
const credential = await repository.findCredentialByIdForUser(
|
||||||
|
host.credentialId,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
if (credential) {
|
||||||
|
snapshots.push({
|
||||||
|
protocol: "ssh",
|
||||||
|
sourceType: "credential",
|
||||||
|
originalCredentialId: host.credentialId,
|
||||||
|
data: {
|
||||||
|
username: credential.username || undefined,
|
||||||
|
authType: credential.authType,
|
||||||
|
password: credential.password || undefined,
|
||||||
|
key: credential.privateKey || credential.key || undefined,
|
||||||
|
keyPassword: credential.keyPassword || undefined,
|
||||||
|
keyType: credential.keyType || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
(host.authType === "password" && host.password) ||
|
||||||
|
(host.authType === "key" && host.key)
|
||||||
|
) {
|
||||||
|
snapshots.push({
|
||||||
|
protocol: "ssh",
|
||||||
|
sourceType: "inline",
|
||||||
|
originalCredentialId: null,
|
||||||
|
data: {
|
||||||
|
username: host.username || undefined,
|
||||||
|
authType: host.authType,
|
||||||
|
password: host.password || undefined,
|
||||||
|
key: host.key || undefined,
|
||||||
|
keyPassword: host.keyPassword || undefined,
|
||||||
|
keyType: host.keyType || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled.rdp) {
|
||||||
|
const rdpAuthType =
|
||||||
|
host.rdpAuthType || (host.rdpCredentialId ? "credential" : "direct");
|
||||||
|
if (rdpAuthType === "credential" && host.rdpCredentialId) {
|
||||||
|
const credential = await repository.findCredentialByIdForUser(
|
||||||
|
host.rdpCredentialId,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
if (credential) {
|
||||||
|
snapshots.push({
|
||||||
|
protocol: "rdp",
|
||||||
|
sourceType: "credential",
|
||||||
|
originalCredentialId: host.rdpCredentialId,
|
||||||
|
data: {
|
||||||
|
username: credential.username || undefined,
|
||||||
|
authType: "credential",
|
||||||
|
password: credential.password || undefined,
|
||||||
|
domain: host.rdpDomain || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (host.rdpUser || host.rdpPassword) {
|
||||||
|
snapshots.push({
|
||||||
|
protocol: "rdp",
|
||||||
|
sourceType: "inline",
|
||||||
|
originalCredentialId: null,
|
||||||
|
data: {
|
||||||
|
username: host.rdpUser || undefined,
|
||||||
|
authType: "direct",
|
||||||
|
password: host.rdpPassword || undefined,
|
||||||
|
domain: host.rdpDomain || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled.vnc) {
|
||||||
|
const vncAuthType =
|
||||||
|
host.vncAuthType || (host.vncCredentialId ? "credential" : "direct");
|
||||||
|
if (vncAuthType === "credential" && host.vncCredentialId) {
|
||||||
|
const credential = await repository.findCredentialByIdForUser(
|
||||||
|
host.vncCredentialId,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
if (credential) {
|
||||||
|
snapshots.push({
|
||||||
|
protocol: "vnc",
|
||||||
|
sourceType: "credential",
|
||||||
|
originalCredentialId: host.vncCredentialId,
|
||||||
|
data: {
|
||||||
|
username: credential.username || undefined,
|
||||||
|
authType: "credential",
|
||||||
|
password: credential.password || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (host.vncUser || host.vncPassword) {
|
||||||
|
snapshots.push({
|
||||||
|
protocol: "vnc",
|
||||||
|
sourceType: "inline",
|
||||||
|
originalCredentialId: null,
|
||||||
|
data: {
|
||||||
|
username: host.vncUser || undefined,
|
||||||
|
authType: "direct",
|
||||||
|
password: host.vncPassword || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled.telnet) {
|
||||||
|
const telnetAuthType =
|
||||||
|
host.telnetAuthType ||
|
||||||
|
(host.telnetCredentialId ? "credential" : "direct");
|
||||||
|
if (telnetAuthType === "credential" && host.telnetCredentialId) {
|
||||||
|
const credential = await repository.findCredentialByIdForUser(
|
||||||
|
host.telnetCredentialId,
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
if (credential) {
|
||||||
|
snapshots.push({
|
||||||
|
protocol: "telnet",
|
||||||
|
sourceType: "credential",
|
||||||
|
originalCredentialId: host.telnetCredentialId,
|
||||||
|
data: {
|
||||||
|
username: credential.username || undefined,
|
||||||
|
authType: "credential",
|
||||||
|
password: credential.password || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (host.telnetUser || host.telnetPassword) {
|
||||||
|
snapshots.push({
|
||||||
|
protocol: "telnet",
|
||||||
|
sourceType: "inline",
|
||||||
|
originalCredentialId: null,
|
||||||
|
data: {
|
||||||
|
username: host.telnetUser || undefined,
|
||||||
|
authType: "direct",
|
||||||
|
password: host.telnetPassword || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptSnapshot(
|
||||||
|
secret: SharedHostSecretRecord,
|
||||||
|
userDEK: Buffer,
|
||||||
|
): SharedSecretData {
|
||||||
|
const recordId = snapshotRecordId(
|
||||||
|
secret.hostAccessId,
|
||||||
|
secret.targetUserId,
|
||||||
|
secret.protocol as ShareProtocol,
|
||||||
|
);
|
||||||
|
const decrypt = (value: string | null, fieldName: string) =>
|
||||||
|
value
|
||||||
|
? FieldCrypto.decryptField(value, userDEK, recordId, fieldName)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: decrypt(secret.encryptedUsername, "username"),
|
||||||
|
authType: secret.encryptedAuthType || "password",
|
||||||
|
password: decrypt(secret.encryptedPassword, "password"),
|
||||||
|
key: decrypt(secret.encryptedKey, "key"),
|
||||||
|
keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"),
|
||||||
|
keyType: secret.encryptedKeyType || undefined,
|
||||||
|
domain: decrypt(secret.encryptedDomain, "domain"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SharedHostSecretsManager };
|
||||||
+2
-1
@@ -217,8 +217,9 @@ export interface Host {
|
|||||||
hasKeyPassword?: boolean;
|
hasKeyPassword?: boolean;
|
||||||
|
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
permissionLevel?: "view";
|
permissionLevel?: "connect" | "view" | "edit" | "manage";
|
||||||
sharedExpiresAt?: string;
|
sharedExpiresAt?: string;
|
||||||
|
ownerUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHostData {
|
export interface JumpHostData {
|
||||||
|
|||||||
@@ -168,8 +168,15 @@ export type Host = {
|
|||||||
|
|
||||||
guacamoleConfig?: Record<string, unknown>;
|
guacamoleConfig?: Record<string, unknown>;
|
||||||
forceKeyboardInteractive?: boolean;
|
forceKeyboardInteractive?: boolean;
|
||||||
|
|
||||||
|
isShared?: boolean;
|
||||||
|
permissionLevel?: SharePermissionLevel;
|
||||||
|
sharedExpiresAt?: string;
|
||||||
|
ownerUsername?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
|
||||||
|
|
||||||
export type Credential = {
|
export type Credential = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
+79
-6
@@ -28,6 +28,7 @@ export async function updateRole(
|
|||||||
roleData: {
|
roleData: {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
permissions?: string[];
|
||||||
},
|
},
|
||||||
): Promise<{ role: Role }> {
|
): Promise<{ role: Role }> {
|
||||||
try {
|
try {
|
||||||
@@ -88,16 +89,30 @@ export async function removeRoleFromUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
|
||||||
|
|
||||||
|
export interface ShareTarget {
|
||||||
|
type: "user" | "role";
|
||||||
|
id: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function shareHost(
|
export async function shareHost(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
shareData: {
|
shareData: {
|
||||||
targetType: "user" | "role";
|
targets: ShareTarget[];
|
||||||
targetUserId?: string;
|
permissionLevel: SharePermissionLevel;
|
||||||
targetRoleId?: number;
|
|
||||||
permissionLevel: "view";
|
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
},
|
},
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
|
results: Array<{
|
||||||
|
type: "user" | "role";
|
||||||
|
id: string | number;
|
||||||
|
accessId: number;
|
||||||
|
created: boolean;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await rbacApi.post(
|
const response = await rbacApi.post(
|
||||||
`/rbac/host/${hostId}/share`,
|
`/rbac/host/${hostId}/share`,
|
||||||
@@ -109,9 +124,28 @@ export async function shareHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateHostAccess(
|
||||||
|
hostId: number,
|
||||||
|
accessId: number,
|
||||||
|
update: {
|
||||||
|
permissionLevel?: SharePermissionLevel;
|
||||||
|
durationHours?: number | null;
|
||||||
|
},
|
||||||
|
): Promise<{ success: boolean; expiresAt: string | null }> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.patch(
|
||||||
|
`/rbac/host/${hostId}/access/${accessId}`,
|
||||||
|
update,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "update host access");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getHostAccess(
|
export async function getHostAccess(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
): Promise<{ accessList: AccessRecord[] }> {
|
): Promise<{ accessList: AccessRecord[]; isOwner?: boolean }> {
|
||||||
try {
|
try {
|
||||||
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
|
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -120,6 +154,45 @@ export async function getHostAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PermissionCatalogEntry {
|
||||||
|
group: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPermissionsCatalog(): Promise<{
|
||||||
|
catalog: PermissionCatalogEntry[];
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.get("/rbac/permissions/catalog");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch permissions catalog");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSharedHosts(): Promise<{
|
||||||
|
sharedHosts: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string | null;
|
||||||
|
tags: string | null;
|
||||||
|
permissionLevel: SharePermissionLevel;
|
||||||
|
expiresAt: string | null;
|
||||||
|
grantedBy: string;
|
||||||
|
ownerUsername: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.get("/rbac/shared-hosts");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch shared hosts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function revokeHostAccess(
|
export async function revokeHostAccess(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
accessId: number,
|
accessId: number,
|
||||||
|
|||||||
+321
-286
@@ -106,8 +106,265 @@
|
|||||||
"filterTagsGroup": "Tags"
|
"filterTagsGroup": "Tags"
|
||||||
},
|
},
|
||||||
"homepage": {
|
"homepage": {
|
||||||
"failedToLoadAlerts": "Failed to load alerts",
|
"title": "Homepage",
|
||||||
"failedToDismissAlert": "Failed to dismiss alert"
|
"addWidget": "Add Widget",
|
||||||
|
"editWidget": "Edit Widget",
|
||||||
|
"deleteWidget": "Delete",
|
||||||
|
"widgetTypes": "Widget Types",
|
||||||
|
"serviceLink": "Service Link",
|
||||||
|
"folder": "Folder",
|
||||||
|
"clock": "Clock",
|
||||||
|
"notes": "Notes",
|
||||||
|
"hostStatus": "Host Status",
|
||||||
|
"bookmarkList": "Bookmarks",
|
||||||
|
"zoomIn": "Zoom In",
|
||||||
|
"zoomOut": "Zoom Out",
|
||||||
|
"resetView": "Reset View",
|
||||||
|
"lockLayout": "Lock Layout",
|
||||||
|
"unlockLayout": "Unlock Layout",
|
||||||
|
"noWidgets": "Right-click or click + to add your first widget",
|
||||||
|
"openFullView": "Open Full View",
|
||||||
|
"previewTitle": "Homepage Preview",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"title_label": "Title",
|
||||||
|
"widgetTitlePlaceholder": "Widget title (optional)",
|
||||||
|
"url": "URL",
|
||||||
|
"imageUrl": "Custom Image URL",
|
||||||
|
"imageUrlHint": "Leave blank to use the site favicon automatically",
|
||||||
|
"showImage": "Show Image",
|
||||||
|
"description": "Description",
|
||||||
|
"color": "Color",
|
||||||
|
"icon": "Icon",
|
||||||
|
"expanded": "Expanded by default",
|
||||||
|
"timezone": "Timezone",
|
||||||
|
"showSeconds": "Show seconds",
|
||||||
|
"format12h": "12-hour",
|
||||||
|
"format24h": "24-hour",
|
||||||
|
"content": "Content",
|
||||||
|
"backgroundColor": "Background Color",
|
||||||
|
"host": "Host",
|
||||||
|
"showMetrics": "Show Metrics",
|
||||||
|
"links": "Links",
|
||||||
|
"addLink": "Add Link",
|
||||||
|
"linkLabel": "Label",
|
||||||
|
"linkUrl": "URL",
|
||||||
|
"removeLink": "Remove",
|
||||||
|
"categoryLinks": "Links",
|
||||||
|
"categoryInfo": "Info",
|
||||||
|
"categorySystem": "System",
|
||||||
|
"widgetServiceLinkDesc": "A clickable tile linking to a service URL",
|
||||||
|
"widgetFolderDesc": "A container to group related widgets",
|
||||||
|
"widgetClockDesc": "A live clock with configurable timezone",
|
||||||
|
"widgetNotesDesc": "A markdown notes widget",
|
||||||
|
"widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host",
|
||||||
|
"widgetBookmarkListDesc": "A list of quick links",
|
||||||
|
"copyLink": "Copy Link",
|
||||||
|
"linkCopied": "Link copied!",
|
||||||
|
"location": "Location",
|
||||||
|
"temperatureUnit": "Temperature Unit",
|
||||||
|
"showForecast": "Show 3-day forecast",
|
||||||
|
"scrolling": "Allow Scrolling",
|
||||||
|
"feedUrl": "Feed URL",
|
||||||
|
"maxItems": "Max Items",
|
||||||
|
"showDescription": "Show Description",
|
||||||
|
"widgetWeatherName": "Weather",
|
||||||
|
"widgetWeatherDesc": "Live weather for any location",
|
||||||
|
"widgetIframeName": "iFrame Embed",
|
||||||
|
"widgetIframeDesc": "Embed any URL in an iframe",
|
||||||
|
"widgetRssName": "RSS Feed",
|
||||||
|
"widgetRssDesc": "Display items from an RSS or Atom feed",
|
||||||
|
"dragToFolder": "Drag widgets here or use + to add",
|
||||||
|
"showDisk": "Show Disk Usage",
|
||||||
|
"addToFolder": "Add widget to folder",
|
||||||
|
"noHostSelected": "No host selected",
|
||||||
|
"metricsNotAvailable": "Metrics not available",
|
||||||
|
"selectHost": "Select a host...",
|
||||||
|
"displayedMetrics": "Displayed Metrics",
|
||||||
|
"metricCpu": "CPU",
|
||||||
|
"metricMemory": "Memory",
|
||||||
|
"metricDisk": "Disk",
|
||||||
|
"metricUptime": "Uptime",
|
||||||
|
"metricSystem": "System",
|
||||||
|
"metricOs": "OS",
|
||||||
|
"metricKernel": "Kernel",
|
||||||
|
"metricHostname": "Hostname",
|
||||||
|
"metricNetwork": "Network",
|
||||||
|
"metricProcesses": "Processes",
|
||||||
|
"metricProcessesTotal": "total",
|
||||||
|
"metricProcessesRunning": "running",
|
||||||
|
"categoryMonitoring": "Monitoring",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noData": "No data available",
|
||||||
|
"allClear": "All clear",
|
||||||
|
"acknowledgeAlert": "Acknowledge",
|
||||||
|
"noPingUrls": "No URLs configured",
|
||||||
|
"pingLabel": "Label",
|
||||||
|
"addPingUrl": "Add URL",
|
||||||
|
"showLatency": "Show Latency",
|
||||||
|
"refreshInterval": "Refresh Interval",
|
||||||
|
"seconds": "seconds",
|
||||||
|
"filterActivityTypes": "Filter Types",
|
||||||
|
"filterTypesHint": "Leave empty to show all",
|
||||||
|
"showTimestamp": "Show Timestamp",
|
||||||
|
"uptimeUnavailable": "Uptime unavailable",
|
||||||
|
"uptimeLabel": "Uptime",
|
||||||
|
"overviewVersion": "Version",
|
||||||
|
"overviewUpdate": "Up to date",
|
||||||
|
"overviewUpdateAvailable": "Update available",
|
||||||
|
"overviewDatabase": "Database",
|
||||||
|
"overviewUptime": "Uptime",
|
||||||
|
"noHosts": "No hosts configured",
|
||||||
|
"hostGridHosts": "Hosts",
|
||||||
|
"hostGridAllHint": "Leave empty to show all hosts",
|
||||||
|
"columns": "Columns",
|
||||||
|
"showIp": "Show IP Address",
|
||||||
|
"connectionType": "Connection Type",
|
||||||
|
"layout": "Layout",
|
||||||
|
"showStatus": "Show Status",
|
||||||
|
"showHostName": "Show Host Name",
|
||||||
|
"noDockerActivity": "No Docker activity",
|
||||||
|
"noActivity": "No activity",
|
||||||
|
"showAcknowledged": "Show Acknowledged",
|
||||||
|
"showCurrentValue": "Show Current Value",
|
||||||
|
"chartMetric": "Metric",
|
||||||
|
"metricRange": "Range",
|
||||||
|
"widgetMetricsChartName": "Metrics Chart",
|
||||||
|
"widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host",
|
||||||
|
"widgetHostGridName": "Host Grid",
|
||||||
|
"widgetHostGridDesc": "Grid view of SSH host statuses",
|
||||||
|
"widgetAlertFeedName": "Alert Feed",
|
||||||
|
"widgetAlertFeedDesc": "Live alert firings with acknowledge support",
|
||||||
|
"widgetPingStatusName": "Ping Status",
|
||||||
|
"widgetPingStatusDesc": "HTTP ping status for one or more URLs",
|
||||||
|
"widgetRecentActivityName": "Recent Activity",
|
||||||
|
"widgetRecentActivityDesc": "Scrollable feed of recent Termix activity",
|
||||||
|
"widgetTermixUptimeName": "Termix Uptime",
|
||||||
|
"widgetTermixUptimeDesc": "Live uptime counter for the Termix server",
|
||||||
|
"widgetSystemOverviewName": "System Overview",
|
||||||
|
"widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance",
|
||||||
|
"widgetSshQuickConnectName": "SSH Quick Connect",
|
||||||
|
"widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions",
|
||||||
|
"widgetDockerActivityName": "Docker Activity",
|
||||||
|
"widgetDockerActivityDesc": "Recent Docker container events across all hosts",
|
||||||
|
"widgetCalendarName": "Calendar",
|
||||||
|
"widgetCalendarDesc": "A monthly calendar with today highlighted",
|
||||||
|
"widgetCountdownName": "Countdown",
|
||||||
|
"widgetCountdownDesc": "Countdown timer to a target date",
|
||||||
|
"widgetSearchBarName": "Search Bar",
|
||||||
|
"widgetSearchBarDesc": "Quick web search widget",
|
||||||
|
"widgetTextBannerName": "Text Banner",
|
||||||
|
"widgetTextBannerDesc": "A bold label or section header for the canvas",
|
||||||
|
"widgetImageWidgetName": "Image",
|
||||||
|
"widgetImageWidgetDesc": "Display an image from a URL",
|
||||||
|
"widgetMarkdownNotesName": "Markdown Notes",
|
||||||
|
"widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering",
|
||||||
|
"widgetCustomApiName": "Custom API",
|
||||||
|
"widgetCustomApiDesc": "Fetch and display data from any JSON API",
|
||||||
|
"widgetServiceGridName": "Service Grid",
|
||||||
|
"widgetServiceGridDesc": "A configurable grid of service tile links",
|
||||||
|
"widgetDashboardLinksName": "Dashboard Links",
|
||||||
|
"widgetDashboardLinksDesc": "Display your configured service links from the dashboard",
|
||||||
|
"widgetSearchLinksName": "Search Shortcuts",
|
||||||
|
"widgetSearchLinksDesc": "Quick search shortcut buttons with inline input",
|
||||||
|
"widgetLinkTreeName": "Link Tree",
|
||||||
|
"widgetLinkTreeDesc": "Grouped sections of links with headings",
|
||||||
|
"calMon": "Mo",
|
||||||
|
"calTue": "Tu",
|
||||||
|
"calWed": "We",
|
||||||
|
"calThu": "Th",
|
||||||
|
"calFri": "Fr",
|
||||||
|
"calSat": "Sa",
|
||||||
|
"calSun": "Su",
|
||||||
|
"startOnMonday": "Start week on Monday",
|
||||||
|
"countdownNoDate": "No target date set",
|
||||||
|
"countdownPast": "Event has passed",
|
||||||
|
"countdownDays": "days",
|
||||||
|
"countdownHours": "hrs",
|
||||||
|
"countdownMinutes": "min",
|
||||||
|
"countdownSeconds": "sec",
|
||||||
|
"countdownLabel": "Label",
|
||||||
|
"countdownLabelPlaceholder": "e.g. Launch Day",
|
||||||
|
"countdownShowDays": "Show Days",
|
||||||
|
"countdownShowHours": "Show Hours",
|
||||||
|
"targetDate": "Target Date",
|
||||||
|
"searchEngine": "Search Engine",
|
||||||
|
"customSearchUrl": "Custom Search URL",
|
||||||
|
"searchPlaceholder": "Search...",
|
||||||
|
"searchGo": "Go",
|
||||||
|
"searchPlaceholderLabel": "Placeholder Text",
|
||||||
|
"searchPlaceholderHint": "Text shown inside the search input",
|
||||||
|
"openInNewTab": "Open in New Tab",
|
||||||
|
"searchQueryPlaceholder": "Enter query...",
|
||||||
|
"noSearchShortcuts": "No shortcuts configured",
|
||||||
|
"addSearchShortcut": "Add Shortcut",
|
||||||
|
"fontSize": "Font Size",
|
||||||
|
"textAlign": "Text Align",
|
||||||
|
"fontWeight": "Font Weight",
|
||||||
|
"clearColor": "Clear Color",
|
||||||
|
"imageFit": "Image Fit",
|
||||||
|
"imageLinkUrl": "Link URL",
|
||||||
|
"noImage": "No image URL set",
|
||||||
|
"altText": "Alt Text",
|
||||||
|
"altTextPlaceholder": "Describe the image",
|
||||||
|
"renderMarkdown": "Render Markdown",
|
||||||
|
"displayMode": "Display Mode",
|
||||||
|
"displayField": "Display Field",
|
||||||
|
"jsonPath": "JSON Path",
|
||||||
|
"customApiLabel": "Label",
|
||||||
|
"customApiLabelPlaceholder": "e.g. Temperature",
|
||||||
|
"customApiUnit": "Unit",
|
||||||
|
"customApiNoUrl": "No API URL configured",
|
||||||
|
"customApiError": "Failed to fetch",
|
||||||
|
"customApiNotArray": "Response is not an array",
|
||||||
|
"addService": "Add Service",
|
||||||
|
"showLabels": "Show Labels",
|
||||||
|
"iconSize": "Icon Size",
|
||||||
|
"noDashboardLinks": "No dashboard links configured",
|
||||||
|
"noLimit": "No limit",
|
||||||
|
"sectionHeading": "Section Heading",
|
||||||
|
"addSection": "Add Section",
|
||||||
|
"compactMode": "Compact Mode",
|
||||||
|
"showDetailed": "Show Detailed",
|
||||||
|
"selectHosts": "Select Hosts",
|
||||||
|
"allHosts": "All hosts",
|
||||||
|
"listLayout": "List",
|
||||||
|
"gridLayout": "Grid",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"files": "Files",
|
||||||
|
"docker": "Docker",
|
||||||
|
"range15m": "15 minutes",
|
||||||
|
"range1h": "1 hour",
|
||||||
|
"range6h": "6 hours",
|
||||||
|
"range24h": "24 hours",
|
||||||
|
"metricNetRx": "Net Download",
|
||||||
|
"metricNetTx": "Net Upload",
|
||||||
|
"severityFilter": "Severity Filter",
|
||||||
|
"filterAll": "All",
|
||||||
|
"accentColor": "Accent Color",
|
||||||
|
"widgetSshTerminalName": "SSH Terminal",
|
||||||
|
"widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host",
|
||||||
|
"sshTerminalNoHost": "No host configured",
|
||||||
|
"sshTerminalConnect": "Connect",
|
||||||
|
"sshTerminalAutoConnect": "Auto-connect on load",
|
||||||
|
"widgetQuickConnectName": "Quick Connect",
|
||||||
|
"widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts",
|
||||||
|
"connectionTypes": "Connection Types",
|
||||||
|
"connType_terminal": "Terminal",
|
||||||
|
"connType_files": "File Manager",
|
||||||
|
"connType_docker": "Docker",
|
||||||
|
"connType_tunnel": "Tunnel",
|
||||||
|
"connType_host-metrics": "Host Metrics",
|
||||||
|
"connType_rdp": "RDP",
|
||||||
|
"connType_vnc": "VNC",
|
||||||
|
"connType_telnet": "Telnet",
|
||||||
|
"widgetFileManagerName": "File Manager",
|
||||||
|
"widgetFileManagerDesc": "Embedded SFTP file manager for a configured host",
|
||||||
|
"widgetDockerName": "Docker Manager",
|
||||||
|
"widgetDockerDesc": "Embedded Docker container manager for a configured host",
|
||||||
|
"widgetTunnelName": "Tunnel Manager",
|
||||||
|
"widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host",
|
||||||
|
"widgetNoHostSelected": "No host configured"
|
||||||
},
|
},
|
||||||
"serverConfig": {
|
"serverConfig": {
|
||||||
"title": "Server Configuration",
|
"title": "Server Configuration",
|
||||||
@@ -317,7 +574,7 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||||
"failedToRenameFolder": "Failed to rename folder",
|
"failedToRenameFolder": "Failed to rename folder",
|
||||||
"movedToFolder": "Moved to \"{{folder}}\"",
|
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
|
||||||
"editHostTooltip": "Edit host",
|
"editHostTooltip": "Edit host",
|
||||||
"statusChecks": "Status Checks",
|
"statusChecks": "Status Checks",
|
||||||
"metricsCollection": "Metrics Collection",
|
"metricsCollection": "Metrics Collection",
|
||||||
@@ -756,7 +1013,6 @@
|
|||||||
"failedToSaveFolder": "Failed to save folder",
|
"failedToSaveFolder": "Failed to save folder",
|
||||||
"folderDeleted": "Deleted folder \"{{name}}\"",
|
"folderDeleted": "Deleted folder \"{{name}}\"",
|
||||||
"deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.",
|
"deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.",
|
||||||
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
|
|
||||||
"failedToMoveHosts": "Failed to move hosts",
|
"failedToMoveHosts": "Failed to move hosts",
|
||||||
"expandAll": "Expand all folders",
|
"expandAll": "Expand all folders",
|
||||||
"collapseAll": "Collapse all folders",
|
"collapseAll": "Collapse all folders",
|
||||||
@@ -806,7 +1062,6 @@
|
|||||||
"failedToDeployKey": "Failed to deploy key",
|
"failedToDeployKey": "Failed to deploy key",
|
||||||
"deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.",
|
"deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.",
|
||||||
"movedToRoot": "Moved to root",
|
"movedToRoot": "Moved to root",
|
||||||
"failedToMoveHosts": "Failed to move hosts",
|
|
||||||
"enableTerminalFeature": "Enable Terminal",
|
"enableTerminalFeature": "Enable Terminal",
|
||||||
"disableTerminalFeature": "Disable Terminal",
|
"disableTerminalFeature": "Disable Terminal",
|
||||||
"enableFilesFeature": "Enable Files",
|
"enableFilesFeature": "Enable Files",
|
||||||
@@ -927,7 +1182,57 @@
|
|||||||
"shareHost": "Share Host",
|
"shareHost": "Share Host",
|
||||||
"shareHostTitle": "Share: {{name}}",
|
"shareHostTitle": "Share: {{name}}",
|
||||||
"sharing": {
|
"sharing": {
|
||||||
"requiresCredential": "This host must use a credential to enable sharing. Edit the host and assign a credential first."
|
"loadError": "Failed to load sharing data. Please try again.",
|
||||||
|
"shareWithSection": "Share with",
|
||||||
|
"usersTab": "Users",
|
||||||
|
"rolesTab": "Roles",
|
||||||
|
"searchPlaceholder": "Search users or roles...",
|
||||||
|
"noMatches": "No matches found",
|
||||||
|
"permissionLevelLabel": "Permission level",
|
||||||
|
"levels": {
|
||||||
|
"connect": {
|
||||||
|
"label": "Connect",
|
||||||
|
"description": "Open sessions only: terminal, remote desktop, file manager, tunnels and Docker. No access to the host configuration."
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"label": "View",
|
||||||
|
"description": "Connect, plus see the host configuration. Secrets are never shown."
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"label": "Edit",
|
||||||
|
"description": "View, plus modify the host. Secrets can be replaced but never read; credential assignments stay owner-only."
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"label": "Manage",
|
||||||
|
"description": "Edit, plus share the host with others, change permission levels and revoke access."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expiryLabel": "Access expiry",
|
||||||
|
"expiry": {
|
||||||
|
"never": "Never",
|
||||||
|
"oneHour": "1 hour",
|
||||||
|
"oneDay": "24 hours",
|
||||||
|
"sevenDays": "7 days",
|
||||||
|
"thirtyDays": "30 days",
|
||||||
|
"custom": "Custom"
|
||||||
|
},
|
||||||
|
"customHoursPlaceholder": "Hours until access expires",
|
||||||
|
"shareButton": "Share",
|
||||||
|
"shareWithCount": "Share ({{count}})",
|
||||||
|
"currentAccess": "Current access",
|
||||||
|
"noAccessEntries": "This host has not been shared yet",
|
||||||
|
"grantedBy": "Granted by",
|
||||||
|
"expires": "Expires",
|
||||||
|
"expired": "Expired",
|
||||||
|
"never": "Never",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"accessUpdated": "Access updated",
|
||||||
|
"accessUpdateFailed": "Failed to update access",
|
||||||
|
"sharedBadge": "Shared",
|
||||||
|
"sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)",
|
||||||
|
"viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.",
|
||||||
|
"sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.",
|
||||||
|
"ownerOnlyControl": "Only the host owner can change this"
|
||||||
},
|
},
|
||||||
"guac": {
|
"guac": {
|
||||||
"connection": "Connection",
|
"connection": "Connection",
|
||||||
@@ -1062,28 +1367,10 @@
|
|||||||
"backspaceKey": "Backspace Key",
|
"backspaceKey": "Backspace Key",
|
||||||
"saveHostFirst": "Save the host first.",
|
"saveHostFirst": "Save the host first.",
|
||||||
"sharingOptionsAfterSave": "Sharing options are available after the host has been saved.",
|
"sharingOptionsAfterSave": "Sharing options are available after the host has been saved.",
|
||||||
"sharingLoadError": "Failed to load sharing data. Check your connection and try again.",
|
|
||||||
"shareHostSection": "Share Host",
|
|
||||||
"shareWithUser": "Share with User",
|
|
||||||
"shareWithRole": "Share with Role",
|
|
||||||
"selectUser": "Select User",
|
|
||||||
"selectRole": "Select Role",
|
|
||||||
"selectUserOption": "Select a user...",
|
|
||||||
"selectRoleOption": "Select a role...",
|
|
||||||
"permissionLevel": "Permission Level",
|
"permissionLevel": "Permission Level",
|
||||||
"expiresInHours": "Expires in (hours)",
|
|
||||||
"noExpiryPlaceholder": "Leave empty for no expiry",
|
|
||||||
"shareBtn": "Share",
|
|
||||||
"currentAccess": "Current Access",
|
|
||||||
"typeHeader": "Type",
|
"typeHeader": "Type",
|
||||||
"targetHeader": "Target",
|
"targetHeader": "Target",
|
||||||
"permissionHeader": "Permission",
|
"permissionHeader": "Permission",
|
||||||
"grantedByHeader": "Granted By",
|
|
||||||
"expiresHeader": "Expires",
|
|
||||||
"noAccessEntries": "No access entries yet.",
|
|
||||||
"expiredLabel": "Expired",
|
|
||||||
"neverLabel": "Never",
|
|
||||||
"revokeBtn": "Revoke",
|
|
||||||
"cancelBtn": "Cancel",
|
"cancelBtn": "Cancel",
|
||||||
"savingBtn": "Saving...",
|
"savingBtn": "Saving...",
|
||||||
"updateHostBtn": "Update Host",
|
"updateHostBtn": "Update Host",
|
||||||
@@ -2583,7 +2870,16 @@
|
|||||||
"hostDefaultsCommandHistory": "Command History",
|
"hostDefaultsCommandHistory": "Command History",
|
||||||
"hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default",
|
"hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default",
|
||||||
"hostDefaultsSaved": "Host defaults saved",
|
"hostDefaultsSaved": "Host defaults saved",
|
||||||
"hostDefaultsSaveFailed": "Failed to save host defaults"
|
"hostDefaultsSaveFailed": "Failed to save host defaults",
|
||||||
|
"rolePermissions": {
|
||||||
|
"count": "{{count}} permissions",
|
||||||
|
"editAction": "Edit permissions",
|
||||||
|
"loadError": "Failed to load the permissions catalog",
|
||||||
|
"saved": "Role permissions saved",
|
||||||
|
"saveError": "Failed to save role permissions",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving..."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"newUi": {
|
"newUi": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -3092,266 +3388,5 @@
|
|||||||
"channels": "Notification Channels",
|
"channels": "Notification Channels",
|
||||||
"noChannelsHint": "Add channels in the Channels tab first",
|
"noChannelsHint": "Add channels in the Channels tab first",
|
||||||
"ruleSaveFailed": "Failed to save rule"
|
"ruleSaveFailed": "Failed to save rule"
|
||||||
},
|
|
||||||
"homepage": {
|
|
||||||
"title": "Homepage",
|
|
||||||
"addWidget": "Add Widget",
|
|
||||||
"editWidget": "Edit Widget",
|
|
||||||
"deleteWidget": "Delete",
|
|
||||||
"widgetTypes": "Widget Types",
|
|
||||||
"serviceLink": "Service Link",
|
|
||||||
"folder": "Folder",
|
|
||||||
"clock": "Clock",
|
|
||||||
"notes": "Notes",
|
|
||||||
"hostStatus": "Host Status",
|
|
||||||
"bookmarkList": "Bookmarks",
|
|
||||||
"zoomIn": "Zoom In",
|
|
||||||
"zoomOut": "Zoom Out",
|
|
||||||
"resetView": "Reset View",
|
|
||||||
"lockLayout": "Lock Layout",
|
|
||||||
"unlockLayout": "Unlock Layout",
|
|
||||||
"noWidgets": "Right-click or click + to add your first widget",
|
|
||||||
"openFullView": "Open Full View",
|
|
||||||
"previewTitle": "Homepage Preview",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"save": "Save",
|
|
||||||
"title_label": "Title",
|
|
||||||
"widgetTitlePlaceholder": "Widget title (optional)",
|
|
||||||
"url": "URL",
|
|
||||||
"imageUrl": "Custom Image URL",
|
|
||||||
"imageUrlHint": "Leave blank to use the site favicon automatically",
|
|
||||||
"showImage": "Show Image",
|
|
||||||
"description": "Description",
|
|
||||||
"color": "Color",
|
|
||||||
"icon": "Icon",
|
|
||||||
"expanded": "Expanded by default",
|
|
||||||
"timezone": "Timezone",
|
|
||||||
"showSeconds": "Show seconds",
|
|
||||||
"format12h": "12-hour",
|
|
||||||
"format24h": "24-hour",
|
|
||||||
"content": "Content",
|
|
||||||
"backgroundColor": "Background Color",
|
|
||||||
"host": "Host",
|
|
||||||
"showMetrics": "Show Metrics",
|
|
||||||
"links": "Links",
|
|
||||||
"addLink": "Add Link",
|
|
||||||
"linkLabel": "Label",
|
|
||||||
"linkUrl": "URL",
|
|
||||||
"removeLink": "Remove",
|
|
||||||
"categoryLinks": "Links",
|
|
||||||
"categoryInfo": "Info",
|
|
||||||
"categorySystem": "System",
|
|
||||||
"widgetServiceLinkDesc": "A clickable tile linking to a service URL",
|
|
||||||
"widgetFolderDesc": "A container to group related widgets",
|
|
||||||
"widgetClockDesc": "A live clock with configurable timezone",
|
|
||||||
"widgetNotesDesc": "A markdown notes widget",
|
|
||||||
"widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host",
|
|
||||||
"widgetBookmarkListDesc": "A list of quick links",
|
|
||||||
"copyLink": "Copy Link",
|
|
||||||
"linkCopied": "Link copied!",
|
|
||||||
"location": "Location",
|
|
||||||
"temperatureUnit": "Temperature Unit",
|
|
||||||
"showForecast": "Show 3-day forecast",
|
|
||||||
"scrolling": "Allow Scrolling",
|
|
||||||
"feedUrl": "Feed URL",
|
|
||||||
"maxItems": "Max Items",
|
|
||||||
"showDescription": "Show Description",
|
|
||||||
"widgetWeatherName": "Weather",
|
|
||||||
"widgetWeatherDesc": "Live weather for any location",
|
|
||||||
"widgetIframeName": "iFrame Embed",
|
|
||||||
"widgetIframeDesc": "Embed any URL in an iframe",
|
|
||||||
"widgetRssName": "RSS Feed",
|
|
||||||
"widgetRssDesc": "Display items from an RSS or Atom feed",
|
|
||||||
"dragToFolder": "Drag widgets here or use + to add",
|
|
||||||
"showDisk": "Show Disk Usage",
|
|
||||||
"addToFolder": "Add widget to folder",
|
|
||||||
"noHostSelected": "No host selected",
|
|
||||||
"metricsNotAvailable": "Metrics not available",
|
|
||||||
"selectHost": "Select a host...",
|
|
||||||
"displayedMetrics": "Displayed Metrics",
|
|
||||||
"metricCpu": "CPU",
|
|
||||||
"metricMemory": "Memory",
|
|
||||||
"metricDisk": "Disk",
|
|
||||||
"metricUptime": "Uptime",
|
|
||||||
"metricSystem": "System",
|
|
||||||
"metricOs": "OS",
|
|
||||||
"metricKernel": "Kernel",
|
|
||||||
"metricHostname": "Hostname",
|
|
||||||
"metricNetwork": "Network",
|
|
||||||
"metricProcesses": "Processes",
|
|
||||||
"metricProcessesTotal": "total",
|
|
||||||
"metricProcessesRunning": "running",
|
|
||||||
"categoryMonitoring": "Monitoring",
|
|
||||||
"loading": "Loading...",
|
|
||||||
"noData": "No data available",
|
|
||||||
"allClear": "All clear",
|
|
||||||
"acknowledgeAlert": "Acknowledge",
|
|
||||||
"noPingUrls": "No URLs configured",
|
|
||||||
"pingLabel": "Label",
|
|
||||||
"addPingUrl": "Add URL",
|
|
||||||
"showLatency": "Show Latency",
|
|
||||||
"refreshInterval": "Refresh Interval",
|
|
||||||
"seconds": "seconds",
|
|
||||||
"filterActivityTypes": "Filter Types",
|
|
||||||
"filterTypesHint": "Leave empty to show all",
|
|
||||||
"showTimestamp": "Show Timestamp",
|
|
||||||
"uptimeUnavailable": "Uptime unavailable",
|
|
||||||
"uptimeLabel": "Uptime",
|
|
||||||
"overviewVersion": "Version",
|
|
||||||
"overviewUpdate": "Up to date",
|
|
||||||
"overviewUpdateAvailable": "Update available",
|
|
||||||
"overviewDatabase": "Database",
|
|
||||||
"overviewUptime": "Uptime",
|
|
||||||
"noHosts": "No hosts configured",
|
|
||||||
"hostGridHosts": "Hosts",
|
|
||||||
"hostGridAllHint": "Leave empty to show all hosts",
|
|
||||||
"columns": "Columns",
|
|
||||||
"showIp": "Show IP Address",
|
|
||||||
"connectionType": "Connection Type",
|
|
||||||
"layout": "Layout",
|
|
||||||
"showStatus": "Show Status",
|
|
||||||
"showHostName": "Show Host Name",
|
|
||||||
"noDockerActivity": "No Docker activity",
|
|
||||||
"noActivity": "No activity",
|
|
||||||
"showAcknowledged": "Show Acknowledged",
|
|
||||||
"showCurrentValue": "Show Current Value",
|
|
||||||
"chartMetric": "Metric",
|
|
||||||
"metricRange": "Range",
|
|
||||||
"widgetMetricsChartName": "Metrics Chart",
|
|
||||||
"widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host",
|
|
||||||
"widgetHostGridName": "Host Grid",
|
|
||||||
"widgetHostGridDesc": "Grid view of SSH host statuses",
|
|
||||||
"widgetAlertFeedName": "Alert Feed",
|
|
||||||
"widgetAlertFeedDesc": "Live alert firings with acknowledge support",
|
|
||||||
"widgetPingStatusName": "Ping Status",
|
|
||||||
"widgetPingStatusDesc": "HTTP ping status for one or more URLs",
|
|
||||||
"widgetRecentActivityName": "Recent Activity",
|
|
||||||
"widgetRecentActivityDesc": "Scrollable feed of recent Termix activity",
|
|
||||||
"widgetTermixUptimeName": "Termix Uptime",
|
|
||||||
"widgetTermixUptimeDesc": "Live uptime counter for the Termix server",
|
|
||||||
"widgetSystemOverviewName": "System Overview",
|
|
||||||
"widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance",
|
|
||||||
"widgetSshQuickConnectName": "SSH Quick Connect",
|
|
||||||
"widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions",
|
|
||||||
"widgetDockerActivityName": "Docker Activity",
|
|
||||||
"widgetDockerActivityDesc": "Recent Docker container events across all hosts",
|
|
||||||
"widgetCalendarName": "Calendar",
|
|
||||||
"widgetCalendarDesc": "A monthly calendar with today highlighted",
|
|
||||||
"widgetCountdownName": "Countdown",
|
|
||||||
"widgetCountdownDesc": "Countdown timer to a target date",
|
|
||||||
"widgetSearchBarName": "Search Bar",
|
|
||||||
"widgetSearchBarDesc": "Quick web search widget",
|
|
||||||
"widgetTextBannerName": "Text Banner",
|
|
||||||
"widgetTextBannerDesc": "A bold label or section header for the canvas",
|
|
||||||
"widgetImageWidgetName": "Image",
|
|
||||||
"widgetImageWidgetDesc": "Display an image from a URL",
|
|
||||||
"widgetMarkdownNotesName": "Markdown Notes",
|
|
||||||
"widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering",
|
|
||||||
"widgetCustomApiName": "Custom API",
|
|
||||||
"widgetCustomApiDesc": "Fetch and display data from any JSON API",
|
|
||||||
"widgetServiceGridName": "Service Grid",
|
|
||||||
"widgetServiceGridDesc": "A configurable grid of service tile links",
|
|
||||||
"widgetDashboardLinksName": "Dashboard Links",
|
|
||||||
"widgetDashboardLinksDesc": "Display your configured service links from the dashboard",
|
|
||||||
"widgetSearchLinksName": "Search Shortcuts",
|
|
||||||
"widgetSearchLinksDesc": "Quick search shortcut buttons with inline input",
|
|
||||||
"widgetLinkTreeName": "Link Tree",
|
|
||||||
"widgetLinkTreeDesc": "Grouped sections of links with headings",
|
|
||||||
"calMon": "Mo",
|
|
||||||
"calTue": "Tu",
|
|
||||||
"calWed": "We",
|
|
||||||
"calThu": "Th",
|
|
||||||
"calFri": "Fr",
|
|
||||||
"calSat": "Sa",
|
|
||||||
"calSun": "Su",
|
|
||||||
"startOnMonday": "Start week on Monday",
|
|
||||||
"countdownNoDate": "No target date set",
|
|
||||||
"countdownPast": "Event has passed",
|
|
||||||
"countdownDays": "days",
|
|
||||||
"countdownHours": "hrs",
|
|
||||||
"countdownMinutes": "min",
|
|
||||||
"countdownSeconds": "sec",
|
|
||||||
"countdownLabel": "Label",
|
|
||||||
"countdownLabelPlaceholder": "e.g. Launch Day",
|
|
||||||
"countdownShowDays": "Show Days",
|
|
||||||
"countdownShowHours": "Show Hours",
|
|
||||||
"targetDate": "Target Date",
|
|
||||||
"searchEngine": "Search Engine",
|
|
||||||
"customSearchUrl": "Custom Search URL",
|
|
||||||
"searchPlaceholder": "Search...",
|
|
||||||
"searchGo": "Go",
|
|
||||||
"searchPlaceholderLabel": "Placeholder Text",
|
|
||||||
"searchPlaceholderHint": "Text shown inside the search input",
|
|
||||||
"openInNewTab": "Open in New Tab",
|
|
||||||
"searchQueryPlaceholder": "Enter query...",
|
|
||||||
"noSearchShortcuts": "No shortcuts configured",
|
|
||||||
"addSearchShortcut": "Add Shortcut",
|
|
||||||
"fontSize": "Font Size",
|
|
||||||
"textAlign": "Text Align",
|
|
||||||
"fontWeight": "Font Weight",
|
|
||||||
"clearColor": "Clear Color",
|
|
||||||
"imageFit": "Image Fit",
|
|
||||||
"imageLinkUrl": "Link URL",
|
|
||||||
"noImage": "No image URL set",
|
|
||||||
"altText": "Alt Text",
|
|
||||||
"altTextPlaceholder": "Describe the image",
|
|
||||||
"renderMarkdown": "Render Markdown",
|
|
||||||
"displayMode": "Display Mode",
|
|
||||||
"displayField": "Display Field",
|
|
||||||
"jsonPath": "JSON Path",
|
|
||||||
"customApiLabel": "Label",
|
|
||||||
"customApiLabelPlaceholder": "e.g. Temperature",
|
|
||||||
"customApiUnit": "Unit",
|
|
||||||
"customApiNoUrl": "No API URL configured",
|
|
||||||
"customApiError": "Failed to fetch",
|
|
||||||
"customApiNotArray": "Response is not an array",
|
|
||||||
"addService": "Add Service",
|
|
||||||
"showLabels": "Show Labels",
|
|
||||||
"iconSize": "Icon Size",
|
|
||||||
"noDashboardLinks": "No dashboard links configured",
|
|
||||||
"noLimit": "No limit",
|
|
||||||
"sectionHeading": "Section Heading",
|
|
||||||
"addSection": "Add Section",
|
|
||||||
"compactMode": "Compact Mode",
|
|
||||||
"showDetailed": "Show Detailed",
|
|
||||||
"selectHosts": "Select Hosts",
|
|
||||||
"allHosts": "All hosts",
|
|
||||||
"listLayout": "List",
|
|
||||||
"gridLayout": "Grid",
|
|
||||||
"terminal": "Terminal",
|
|
||||||
"files": "Files",
|
|
||||||
"docker": "Docker",
|
|
||||||
"range15m": "15 minutes",
|
|
||||||
"range1h": "1 hour",
|
|
||||||
"range6h": "6 hours",
|
|
||||||
"range24h": "24 hours",
|
|
||||||
"metricNetRx": "Net Download",
|
|
||||||
"metricNetTx": "Net Upload",
|
|
||||||
"severityFilter": "Severity Filter",
|
|
||||||
"filterAll": "All",
|
|
||||||
"accentColor": "Accent Color",
|
|
||||||
"widgetSshTerminalName": "SSH Terminal",
|
|
||||||
"widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host",
|
|
||||||
"sshTerminalNoHost": "No host configured",
|
|
||||||
"sshTerminalConnect": "Connect",
|
|
||||||
"sshTerminalAutoConnect": "Auto-connect on load",
|
|
||||||
"widgetQuickConnectName": "Quick Connect",
|
|
||||||
"widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts",
|
|
||||||
"connectionTypes": "Connection Types",
|
|
||||||
"connType_terminal": "Terminal",
|
|
||||||
"connType_files": "File Manager",
|
|
||||||
"connType_docker": "Docker",
|
|
||||||
"connType_tunnel": "Tunnel",
|
|
||||||
"connType_host-metrics": "Host Metrics",
|
|
||||||
"connType_rdp": "RDP",
|
|
||||||
"connType_vnc": "VNC",
|
|
||||||
"connType_telnet": "Telnet",
|
|
||||||
"widgetFileManagerName": "File Manager",
|
|
||||||
"widgetFileManagerDesc": "Embedded SFTP file manager for a configured host",
|
|
||||||
"widgetDockerName": "Docker Manager",
|
|
||||||
"widgetDockerDesc": "Embedded Docker container manager for a configured host",
|
|
||||||
"widgetTunnelName": "Tunnel Manager",
|
|
||||||
"widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host",
|
|
||||||
"widgetNoHostSelected": "No host configured"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -15,7 +15,7 @@ export interface Role {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
permissions: string | null;
|
permissions: string[] | string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ export interface AccessRecord {
|
|||||||
roleDisplayName: string | null;
|
roleDisplayName: string | null;
|
||||||
grantedBy: string;
|
grantedBy: string;
|
||||||
grantedByUsername: string;
|
grantedByUsername: string;
|
||||||
permissionLevel: "view";
|
permissionLevel: "connect" | "view" | "edit" | "manage";
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -2087,13 +2087,21 @@ export {
|
|||||||
assignRoleToUser,
|
assignRoleToUser,
|
||||||
removeRoleFromUser,
|
removeRoleFromUser,
|
||||||
shareHost,
|
shareHost,
|
||||||
|
updateHostAccess,
|
||||||
getHostAccess,
|
getHostAccess,
|
||||||
revokeHostAccess,
|
revokeHostAccess,
|
||||||
|
getPermissionsCatalog,
|
||||||
|
getSharedHosts,
|
||||||
shareSnippet,
|
shareSnippet,
|
||||||
getSnippetAccess,
|
getSnippetAccess,
|
||||||
revokeSnippetAccess,
|
revokeSnippetAccess,
|
||||||
getSharedSnippets,
|
getSharedSnippets,
|
||||||
} from "@/api/rbac-api";
|
} from "@/api/rbac-api";
|
||||||
|
export type {
|
||||||
|
SharePermissionLevel,
|
||||||
|
ShareTarget,
|
||||||
|
PermissionCatalogEntry,
|
||||||
|
} from "@/api/rbac-api";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DOCKER MANAGEMENT API
|
// DOCKER MANAGEMENT API
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
|
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
|
||||||
import type { Host, TabType } from "@/types/ui-types";
|
import type { Host, TabType } from "@/types/ui-types";
|
||||||
|
import { canEditHost } from "@/sidebar/host-permissions";
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -403,6 +404,11 @@ export function CommandPalette({
|
|||||||
<span className="text-sm font-semibold truncate">
|
<span className="text-sm font-semibold truncate">
|
||||||
{host.name}
|
{host.name}
|
||||||
</span>
|
</span>
|
||||||
|
{host.isShared && (
|
||||||
|
<span className="text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
|
||||||
|
{t("hosts.sharing.sharedBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{host.online && (
|
{host.online && (
|
||||||
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
||||||
)}
|
)}
|
||||||
@@ -481,25 +487,32 @@ export function CommandPalette({
|
|||||||
Telnet
|
Telnet
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
{canEditHost(host) && (
|
||||||
<button
|
<>
|
||||||
title="Edit Host"
|
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
title="Edit Host"
|
||||||
setIsOpen(false);
|
onClick={(e) => {
|
||||||
onOpenTab("host-manager");
|
e.stopPropagation();
|
||||||
setTimeout(() => {
|
setIsOpen(false);
|
||||||
window.dispatchEvent(
|
onOpenTab("host-manager");
|
||||||
new CustomEvent("host-manager:edit-host", {
|
setTimeout(() => {
|
||||||
detail: host.id,
|
window.dispatchEvent(
|
||||||
}),
|
new CustomEvent(
|
||||||
);
|
"host-manager:edit-host",
|
||||||
}, 100);
|
{
|
||||||
}}
|
detail: host.id,
|
||||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
},
|
||||||
>
|
),
|
||||||
<Pencil className="size-3.5" />
|
);
|
||||||
</button>
|
}, 100);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import type { Dispatch, SetStateAction } from "react";
|
import { useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
deleteRole,
|
deleteRole,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
getPermissionsCatalog,
|
||||||
revokeAllUserSessions,
|
revokeAllUserSessions,
|
||||||
revokeSession,
|
revokeSession,
|
||||||
|
updateRole,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import type { Role } from "@/main-axios";
|
import type { PermissionCatalogEntry, Role } from "@/main-axios";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
|
Check,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -375,6 +378,69 @@ export function AdminRolesSection({
|
|||||||
createRoleLoading,
|
createRoleLoading,
|
||||||
}: RolesSectionProps) {
|
}: RolesSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
|
||||||
|
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
||||||
|
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [savingPermissions, setSavingPermissions] = useState(false);
|
||||||
|
|
||||||
|
function rolePermissions(role: Role): string[] {
|
||||||
|
if (Array.isArray(role.permissions)) return role.permissions;
|
||||||
|
if (typeof role.permissions === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(role.permissions) as string[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPermissionsEditor(role: Role) {
|
||||||
|
if (editingRoleId === role.id) {
|
||||||
|
setEditingRoleId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (catalog.length === 0) {
|
||||||
|
const res = await getPermissionsCatalog();
|
||||||
|
setCatalog(res.catalog ?? []);
|
||||||
|
}
|
||||||
|
setEditingPermissions(new Set(rolePermissions(role)));
|
||||||
|
setEditingRoleId(role.id);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.rolePermissions.loadError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePermission(permission: string) {
|
||||||
|
setEditingPermissions((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(permission)) next.delete(permission);
|
||||||
|
else next.add(permission);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePermissions(role: Role) {
|
||||||
|
setSavingPermissions(true);
|
||||||
|
try {
|
||||||
|
const permissions = [...editingPermissions];
|
||||||
|
await updateRole(role.id, { permissions });
|
||||||
|
setRoles((prev) =>
|
||||||
|
prev.map((entry) =>
|
||||||
|
entry.id === role.id ? { ...entry, permissions } : entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEditingRoleId(null);
|
||||||
|
toast.success(t("admin.rolePermissions.saved"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.rolePermissions.saveError"));
|
||||||
|
} finally {
|
||||||
|
setSavingPermissions(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
@@ -467,52 +533,154 @@ export function AdminRolesSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{roles.map((role) => (
|
{roles.map((role) => {
|
||||||
<div
|
const permissionCount = rolePermissions(role).length;
|
||||||
key={role.id}
|
return (
|
||||||
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
|
<div
|
||||||
>
|
key={role.id}
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
className="flex flex-col py-2.5 border-b border-border last:border-0"
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
>
|
||||||
<span className="text-xs font-semibold truncate">
|
<div className="flex items-center justify-between">
|
||||||
{role.displayName}
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
</span>
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{role.isSystem ? (
|
<span className="text-xs font-semibold truncate">
|
||||||
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
|
{role.displayName}
|
||||||
{t("admin.systemBadge")}
|
</span>
|
||||||
</span>
|
{role.isSystem ? (
|
||||||
) : (
|
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
|
||||||
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
|
{t("admin.systemBadge")}
|
||||||
{t("admin.customBadge")}
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
|
||||||
|
{t("admin.customBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{permissionCount > 0 && (
|
||||||
|
<span className="text-[9px] px-1 py-px border border-border/60 text-muted-foreground">
|
||||||
|
{t("admin.rolePermissions.count", {
|
||||||
|
count: permissionCount,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
{role.name}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
{!role.isSystem && (
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`size-6 ${editingRoleId === role.id ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
|
title={t("admin.rolePermissions.editAction")}
|
||||||
|
onClick={() => openPermissionsEditor(role)}
|
||||||
|
>
|
||||||
|
<KeyRound className="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteRole(role.id);
|
||||||
|
setRoles((prev) =>
|
||||||
|
prev.filter((r) => r.id !== role.id),
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
t("admin.deleteRoleSuccess", {
|
||||||
|
name: role.displayName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground">
|
{editingRoleId === role.id && (
|
||||||
{role.name}
|
<div className="flex flex-col gap-2.5 mt-2.5 p-2.5 border border-border bg-muted/20">
|
||||||
</span>
|
{catalog.map((entry) => {
|
||||||
</div>
|
const wildcard = `${entry.group}.*`;
|
||||||
{!role.isSystem && (
|
const wildcardOn = editingPermissions.has(wildcard);
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
return (
|
||||||
<Button
|
<div key={entry.group} className="flex flex-col gap-1">
|
||||||
variant="ghost"
|
<button
|
||||||
size="icon"
|
onClick={() => togglePermission(wildcard)}
|
||||||
className="size-6 text-muted-foreground hover:text-destructive"
|
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
|
||||||
onClick={async () => {
|
>
|
||||||
await deleteRole(role.id);
|
<span
|
||||||
setRoles((prev) => prev.filter((r) => r.id !== role.id));
|
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${wildcardOn ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||||
toast.success(
|
>
|
||||||
t("admin.deleteRoleSuccess", {
|
{wildcardOn && (
|
||||||
name: role.displayName,
|
<Check className="size-2 text-background" />
|
||||||
}),
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
wildcardOn
|
||||||
|
? "text-accent-brand"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{entry.group}.*
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col gap-0.5 pl-4">
|
||||||
|
{entry.permissions.map((permission) => {
|
||||||
|
const checked =
|
||||||
|
wildcardOn || editingPermissions.has(permission);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={permission}
|
||||||
|
disabled={wildcardOn}
|
||||||
|
onClick={() => togglePermission(permission)}
|
||||||
|
className="flex items-center gap-1.5 text-[10px] text-left disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${checked ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||||
|
>
|
||||||
|
{checked && (
|
||||||
|
<Check className="size-2 text-background" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-muted-foreground">
|
||||||
|
{permission}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
})}
|
||||||
>
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
<Trash2 className="size-3" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</div>
|
size="sm"
|
||||||
)}
|
className="h-6 text-[10px]"
|
||||||
</div>
|
onClick={() => setEditingRoleId(null)}
|
||||||
))}
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||||
|
disabled={savingPermissions}
|
||||||
|
onClick={() => savePermissions(role)}
|
||||||
|
>
|
||||||
|
{savingPermissions
|
||||||
|
? t("admin.rolePermissions.saving")
|
||||||
|
: t("admin.rolePermissions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
);
|
);
|
||||||
|
|||||||
+1694
-1613
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,10 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
|||||||
socks5Password: h.socks5Password,
|
socks5Password: h.socks5Password,
|
||||||
socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [],
|
socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [],
|
||||||
overrideCredentialUsername: h.overrideCredentialUsername ?? false,
|
overrideCredentialUsername: h.overrideCredentialUsername ?? false,
|
||||||
|
isShared: h.isShared ?? false,
|
||||||
|
permissionLevel: h.permissionLevel,
|
||||||
|
sharedExpiresAt: h.sharedExpiresAt,
|
||||||
|
ownerUsername: h.ownerUsername,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+461
-228
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Plus,
|
Search,
|
||||||
|
Share2,
|
||||||
Shield,
|
Shield,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
@@ -12,17 +14,43 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import { SectionCard } from "@/components/section-card";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
getHostAccess,
|
getHostAccess,
|
||||||
shareHost,
|
shareHost,
|
||||||
|
updateHostAccess,
|
||||||
revokeHostAccess,
|
revokeHostAccess,
|
||||||
getUserList,
|
getUserList,
|
||||||
getRoles,
|
getRoles,
|
||||||
type AccessRecord,
|
type AccessRecord,
|
||||||
|
type SharePermissionLevel,
|
||||||
|
type ShareTarget,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
|
|
||||||
|
const PERMISSION_LEVELS: SharePermissionLevel[] = [
|
||||||
|
"connect",
|
||||||
|
"view",
|
||||||
|
"edit",
|
||||||
|
"manage",
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXPIRY_PRESETS = [
|
||||||
|
{ key: "never", hours: null },
|
||||||
|
{ key: "oneHour", hours: 1 },
|
||||||
|
{ key: "oneDay", hours: 24 },
|
||||||
|
{ key: "sevenDays", hours: 24 * 7 },
|
||||||
|
{ key: "thirtyDays", hours: 24 * 30 },
|
||||||
|
{ key: "custom", hours: undefined },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
|
||||||
|
|
||||||
export function HostShareModal({
|
export function HostShareModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -33,18 +61,28 @@ export function HostShareModal({
|
|||||||
host: Host | null;
|
host: Host | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [shareType, setShareType] = useState<"user" | "role">("user");
|
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||||
const [shareGranteeId, setShareGranteeId] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [shareExpiryHours, setShareExpiryHours] = useState("");
|
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<number>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [permissionLevel, setPermissionLevel] =
|
||||||
|
useState<SharePermissionLevel>("connect");
|
||||||
|
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("never");
|
||||||
|
const [customHours, setCustomHours] = useState("");
|
||||||
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
|
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
|
||||||
const [shareUsers, setShareUsers] = useState<
|
const [shareUsers, setShareUsers] = useState<
|
||||||
{ id: string; username: string }[]
|
{ id: string; username: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
const [shareRoles, setShareRoles] = useState<{ id: string; name: string }[]>(
|
const [shareRoles, setShareRoles] = useState<
|
||||||
[],
|
{ id: number; name: string; displayName?: string }[]
|
||||||
);
|
>([]);
|
||||||
const [sharingLoaded, setSharingLoaded] = useState(false);
|
const [sharingLoaded, setSharingLoaded] = useState(false);
|
||||||
const [sharingLoadError, setSharingLoadError] = useState(false);
|
const [sharingLoadError, setSharingLoadError] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !host) return;
|
if (!open || !host) return;
|
||||||
@@ -64,10 +102,13 @@ export function HostShareModal({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
setShareRoles(
|
setShareRoles(
|
||||||
(rolesRes.roles ?? []).map((r) => ({
|
(rolesRes.roles ?? [])
|
||||||
id: String(r.id),
|
.filter((r) => !r.isSystem)
|
||||||
name: r.name,
|
.map((r) => ({
|
||||||
})),
|
id: Number(r.id),
|
||||||
|
name: r.name,
|
||||||
|
displayName: r.displayName,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => setSharingLoadError(true));
|
.catch(() => setSharingLoadError(true));
|
||||||
@@ -77,21 +118,102 @@ export function HostShareModal({
|
|||||||
setSharingLoaded(false);
|
setSharingLoaded(false);
|
||||||
setSharingLoadError(false);
|
setSharingLoadError(false);
|
||||||
setAccessList([]);
|
setAccessList([]);
|
||||||
setShareGranteeId("");
|
setSearch("");
|
||||||
setShareExpiryHours("");
|
setSelectedUserIds(new Set());
|
||||||
setShareType("user");
|
setSelectedRoleIds(new Set());
|
||||||
|
setPermissionLevel("connect");
|
||||||
|
setExpiryPreset("never");
|
||||||
|
setCustomHours("");
|
||||||
|
setTargetTab("user");
|
||||||
}, [host?.id]);
|
}, [host?.id]);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return q
|
||||||
|
? shareUsers.filter((u) => u.username.toLowerCase().includes(q))
|
||||||
|
: shareUsers;
|
||||||
|
}, [shareUsers, search]);
|
||||||
|
|
||||||
|
const filteredRoles = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return q
|
||||||
|
? shareRoles.filter(
|
||||||
|
(r) =>
|
||||||
|
r.name.toLowerCase().includes(q) ||
|
||||||
|
(r.displayName ?? "").toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: shareRoles;
|
||||||
|
}, [shareRoles, search]);
|
||||||
|
|
||||||
|
const selectedCount = selectedUserIds.size + selectedRoleIds.size;
|
||||||
|
|
||||||
|
const durationHours = (() => {
|
||||||
|
if (expiryPreset === "never") return undefined;
|
||||||
|
if (expiryPreset === "custom") {
|
||||||
|
const hours = Number(customHours);
|
||||||
|
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours ?? undefined
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
async function refreshAccessList() {
|
async function refreshAccessList() {
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
const res = await getHostAccess(Number(host.id));
|
const res = await getHostAccess(Number(host.id));
|
||||||
setAccessList(res.accessList ?? []);
|
setAccessList(res.accessList ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open) return null;
|
async function handleShare() {
|
||||||
|
if (!host || selectedCount === 0) return;
|
||||||
|
const targets: ShareTarget[] = [
|
||||||
|
...[...selectedUserIds].map(
|
||||||
|
(id) => ({ type: "user", id }) as ShareTarget,
|
||||||
|
),
|
||||||
|
...[...selectedRoleIds].map(
|
||||||
|
(id) => ({ type: "role", id }) as ShareTarget,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
const hasCredential =
|
setSubmitting(true);
|
||||||
!!host?.credentialId || !!host?.rdpCredentialId || !!host?.vncCredentialId;
|
try {
|
||||||
|
await shareHost(Number(host.id), {
|
||||||
|
targets,
|
||||||
|
permissionLevel,
|
||||||
|
...(durationHours ? { durationHours } : {}),
|
||||||
|
});
|
||||||
|
await refreshAccessList();
|
||||||
|
setSelectedUserIds(new Set());
|
||||||
|
setSelectedRoleIds(new Set());
|
||||||
|
toast.success(t("hosts.hostSharedSuccessfully"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("hosts.failedToShareHost"));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLevelChange(
|
||||||
|
record: AccessRecord,
|
||||||
|
level: SharePermissionLevel,
|
||||||
|
) {
|
||||||
|
if (!host || record.permissionLevel === level) return;
|
||||||
|
try {
|
||||||
|
await updateHostAccess(Number(host.id), record.id, {
|
||||||
|
permissionLevel: level,
|
||||||
|
});
|
||||||
|
setAccessList((prev) =>
|
||||||
|
prev.map((entry) =>
|
||||||
|
entry.id === record.id ? { ...entry, permissionLevel: level } : entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
toast.success(t("hosts.sharing.accessUpdated"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("hosts.sharing.accessUpdateFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
||||||
@@ -101,228 +223,339 @@ export function HostShareModal({
|
|||||||
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-3.5 shrink-0" />
|
<ArrowLeft className="size-3.5 shrink-0" />
|
||||||
<span>{t("hosts.shareHostTitle", { name: host?.name ?? "" })}</span>
|
<span className="truncate">
|
||||||
|
{t("hosts.shareHostTitle", { name: host?.name ?? "" })}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{sharingLoadError && (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-3 gap-3">
|
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
|
||||||
{!hasCredential && host !== null && (
|
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||||
<div className="flex items-start gap-3 p-3 border border-yellow-500/30 bg-yellow-500/5 text-xs text-yellow-500">
|
<div>{t("hosts.sharing.loadError")}</div>
|
||||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
</div>
|
||||||
<div>{t("hosts.sharing.requiresCredential")}</div>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sharingLoadError && hasCredential && (
|
{/* Share form: fixed, non-scrolling */}
|
||||||
<div className="flex items-start gap-3 p-3 border border-destructive/30 bg-destructive/5 text-xs text-destructive">
|
<div className="flex flex-col gap-2 p-3 shrink-0 border-b border-border">
|
||||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>{t("hosts.guac.sharingLoadError")}</div>
|
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
<Users className="size-3.5" />
|
||||||
|
{t("hosts.sharing.shareWithSection")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<a
|
||||||
|
href="https://docs.termix.site/features/authentication/rbac"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[10px] text-accent-brand hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
{t("hosts.docsLink")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasCredential && (
|
<div className="flex gap-1.5">
|
||||||
<>
|
{(["user", "role"] as const).map((tab) => (
|
||||||
<SectionCard
|
<button
|
||||||
title={t("hosts.guac.shareHostSection")}
|
key={tab}
|
||||||
icon={<Users className="size-3.5" />}
|
onClick={() => setTargetTab(tab)}
|
||||||
action={
|
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${targetTab === tab ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||||
<a
|
|
||||||
href="https://docs.termix.site/features/authentication/rbac"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-[10px] text-accent-brand hover:underline normal-case tracking-normal font-normal"
|
|
||||||
>
|
|
||||||
{t("hosts.docsLink")}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 py-3">
|
{tab === "user" ? (
|
||||||
<div className="flex gap-2">
|
<User className="size-3 shrink-0" />
|
||||||
{(["user", "role"] as const).map((shareTypeOpt) => (
|
) : (
|
||||||
<button
|
<Shield className="size-3 shrink-0" />
|
||||||
key={shareTypeOpt}
|
)}
|
||||||
onClick={() => {
|
{tab === "user"
|
||||||
setShareType(shareTypeOpt);
|
? t("hosts.sharing.usersTab")
|
||||||
setShareGranteeId("");
|
: t("hosts.sharing.rolesTab")}
|
||||||
}}
|
{tab === "user" && selectedUserIds.size > 0 && (
|
||||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${shareType === shareTypeOpt ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
<span>({selectedUserIds.size})</span>
|
||||||
>
|
)}
|
||||||
{shareTypeOpt === "user" ? (
|
{tab === "role" && selectedRoleIds.size > 0 && (
|
||||||
<>
|
<span>({selectedRoleIds.size})</span>
|
||||||
<User className="size-3 inline mr-1" />
|
)}
|
||||||
{t("hosts.guac.shareWithUser")}
|
</button>
|
||||||
</>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
<Shield className="size-3 inline mr-1" />
|
<div className="relative">
|
||||||
{t("hosts.guac.shareWithRole")}
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
|
||||||
</>
|
<Input
|
||||||
)}
|
placeholder={t("hosts.sharing.searchPlaceholder")}
|
||||||
</button>
|
value={search}
|
||||||
))}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
</div>
|
className="pl-8"
|
||||||
<div className="flex flex-col gap-1.5">
|
/>
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
</div>
|
||||||
{shareType === "user"
|
|
||||||
? t("hosts.guac.selectUser")
|
<div className="flex flex-col border border-border h-28 overflow-y-auto">
|
||||||
: t("hosts.guac.selectRole")}
|
{targetTab === "user" &&
|
||||||
</label>
|
(filteredUsers.length === 0 ? (
|
||||||
<select
|
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
|
||||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
{t("hosts.sharing.noMatches")}
|
||||||
value={shareGranteeId}
|
|
||||||
onChange={(e) => setShareGranteeId(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{shareType === "user"
|
|
||||||
? t("hosts.guac.selectUserOption")
|
|
||||||
: t("hosts.guac.selectRoleOption")}
|
|
||||||
</option>
|
|
||||||
{shareType === "user"
|
|
||||||
? shareUsers.map((u) => (
|
|
||||||
<option key={u.id} value={u.id}>
|
|
||||||
{u.username}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
: shareRoles.map((r) => (
|
|
||||||
<option key={r.id} value={r.id}>
|
|
||||||
{r.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{t("hosts.guac.expiresInHours")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={t("hosts.guac.noExpiryPlaceholder")}
|
|
||||||
value={shareExpiryHours}
|
|
||||||
onChange={(e) => setShareExpiryHours(e.target.value)}
|
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
||||||
disabled={!shareGranteeId}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await shareHost(Number(host!.id), {
|
|
||||||
targetType: shareType,
|
|
||||||
...(shareType === "user"
|
|
||||||
? { targetUserId: shareGranteeId }
|
|
||||||
: { targetRoleId: Number(shareGranteeId) }),
|
|
||||||
permissionLevel: "view",
|
|
||||||
...(shareExpiryHours
|
|
||||||
? { durationHours: Number(shareExpiryHours) }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
await refreshAccessList();
|
|
||||||
setShareGranteeId("");
|
|
||||||
setShareExpiryHours("");
|
|
||||||
toast.success(t("hosts.hostSharedSuccessfully"));
|
|
||||||
} catch {
|
|
||||||
toast.error(t("hosts.failedToShareHost"));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="size-3.5 mr-1.5" />
|
|
||||||
{t("hosts.guac.shareBtn")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
) : (
|
||||||
|
filteredUsers.map((user) => {
|
||||||
<SectionCard
|
const isSelected = selectedUserIds.has(user.id);
|
||||||
title={t("hosts.guac.currentAccess")}
|
return (
|
||||||
icon={<ListChecks className="size-3.5" />}
|
<button
|
||||||
>
|
key={user.id}
|
||||||
<div className="py-2">
|
onClick={() =>
|
||||||
{accessList.length === 0 && (
|
setSelectedUserIds((prev) => {
|
||||||
<div className="px-2 py-4 text-xs text-muted-foreground/50 text-center">
|
const next = new Set(prev);
|
||||||
{t("hosts.guac.noAccessEntries")}
|
if (next.has(user.id)) next.delete(user.id);
|
||||||
</div>
|
else next.add(user.id);
|
||||||
)}
|
return next;
|
||||||
{accessList.map((r, i) => {
|
})
|
||||||
const expired =
|
}
|
||||||
r.expiresAt && new Date(r.expiresAt) < new Date();
|
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
|
||||||
return (
|
>
|
||||||
<div
|
<div
|
||||||
key={i}
|
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||||
className="flex flex-col gap-1 px-2 py-2.5 border-b border-border last:border-0 text-xs"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
{isSelected && (
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<Check className="size-2.5 text-background" />
|
||||||
{r.targetType === "user" ? (
|
)}
|
||||||
<User className="size-3 text-muted-foreground shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="font-semibold truncate">
|
|
||||||
{r.username ??
|
|
||||||
r.roleName ??
|
|
||||||
r.roleDisplayName ??
|
|
||||||
r.userId ??
|
|
||||||
r.roleId}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground capitalize shrink-0">
|
|
||||||
({r.targetType})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await revokeHostAccess(Number(host!.id), r.id);
|
|
||||||
setAccessList((prev) =>
|
|
||||||
prev.filter((_, idx) => idx !== i),
|
|
||||||
);
|
|
||||||
toast.success(t("hosts.accessRevoked"));
|
|
||||||
} catch {
|
|
||||||
toast.error(t("hosts.failedToRevokeAccess"));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("hosts.guac.revokeBtn")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
|
|
||||||
<span>
|
|
||||||
{t("hosts.guac.grantedByHeader")}:{" "}
|
|
||||||
<span className="text-foreground/70">
|
|
||||||
{r.grantedByUsername ?? "—"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className={expired ? "text-destructive" : ""}>
|
|
||||||
{t("hosts.guac.expiresHeader")}:{" "}
|
|
||||||
{expired ? (
|
|
||||||
<span className="inline-flex items-center gap-0.5 text-destructive">
|
|
||||||
<X className="size-3" />
|
|
||||||
{t("hosts.guac.expiredLabel")}
|
|
||||||
</span>
|
|
||||||
) : r.expiresAt ? (
|
|
||||||
<span className="text-foreground/70">
|
|
||||||
{new Date(r.expiresAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-foreground/70">
|
|
||||||
{t("hosts.guac.neverLabel")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<User className="size-3 text-muted-foreground shrink-0" />
|
||||||
})}
|
<span className="truncate">{user.username}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
))}
|
||||||
|
{targetTab === "role" &&
|
||||||
|
(filteredRoles.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
|
||||||
|
{t("hosts.sharing.noMatches")}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
) : (
|
||||||
</>
|
filteredRoles.map((role) => {
|
||||||
|
const isSelected = selectedRoleIds.has(role.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={role.id}
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedRoleIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(role.id)) next.delete(role.id);
|
||||||
|
else next.add(role.id);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="size-2.5 text-background" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{role.displayName || role.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t("hosts.sharing.permissionLevelLabel")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={permissionLevel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPermissionLevel(e.target.value as SharePermissionLevel)
|
||||||
|
}
|
||||||
|
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
{PERMISSION_LEVELS.map((level) => (
|
||||||
|
<option key={level} value={level}>
|
||||||
|
{t(`hosts.sharing.levels.${level}.label`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex flex-col gap-1 shrink-0">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
|
||||||
|
{t("hosts.sharing.expiryLabel")}
|
||||||
|
</span>
|
||||||
|
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
|
||||||
|
{t(`hosts.sharing.expiry.${expiryPreset}`)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="text-xs">
|
||||||
|
{EXPIRY_PRESETS.map((preset) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={preset.key}
|
||||||
|
onClick={() => setExpiryPreset(preset.key)}
|
||||||
|
>
|
||||||
|
{expiryPreset === preset.key ? (
|
||||||
|
<Check className="size-3 mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<span className="size-3 mr-1.5" />
|
||||||
|
)}
|
||||||
|
{t(`hosts.sharing.expiry.${preset.key}`)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||||
|
disabled={
|
||||||
|
selectedCount === 0 ||
|
||||||
|
submitting ||
|
||||||
|
(expiryPreset === "custom" && !durationHours)
|
||||||
|
}
|
||||||
|
onClick={handleShare}
|
||||||
|
>
|
||||||
|
<Share2 className="size-3.5 mr-1.5" />
|
||||||
|
{selectedCount > 0
|
||||||
|
? t("hosts.sharing.shareWithCount", { count: selectedCount })
|
||||||
|
: t("hosts.sharing.shareButton")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{t(`hosts.sharing.levels.${permissionLevel}.description`)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{expiryPreset === "custom" && (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
autoFocus
|
||||||
|
placeholder={t("hosts.sharing.customHoursPlaceholder")}
|
||||||
|
value={customHours}
|
||||||
|
onChange={(e) => setCustomHours(e.target.value)}
|
||||||
|
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Current access: takes remaining space, scrolls independently */}
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
<ListChecks className="size-3.5" />
|
||||||
|
{t("hosts.sharing.currentAccess")}
|
||||||
|
{accessList.length > 0 && (
|
||||||
|
<span className="text-muted-foreground/40">
|
||||||
|
({accessList.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{accessList.length === 0 && (
|
||||||
|
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
|
||||||
|
{t("hosts.sharing.noAccessEntries")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{accessList.map((record) => {
|
||||||
|
const expired =
|
||||||
|
record.expiresAt && new Date(record.expiresAt) < new Date();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={record.id}
|
||||||
|
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{record.targetType === "user" ? (
|
||||||
|
<User className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold truncate">
|
||||||
|
{record.username ??
|
||||||
|
record.roleDisplayName ??
|
||||||
|
record.roleName ??
|
||||||
|
record.userId ??
|
||||||
|
record.roleId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
|
||||||
|
{t(
|
||||||
|
`hosts.sharing.levels.${record.permissionLevel ?? "connect"}.label`,
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="text-xs">
|
||||||
|
{PERMISSION_LEVELS.map((level) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={level}
|
||||||
|
onClick={() => handleLevelChange(record, level)}
|
||||||
|
>
|
||||||
|
{record.permissionLevel === level ? (
|
||||||
|
<Check className="size-3 mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<span className="size-3 mr-1.5" />
|
||||||
|
)}
|
||||||
|
{t(`hosts.sharing.levels.${level}.label`)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await revokeHostAccess(Number(host!.id), record.id);
|
||||||
|
setAccessList((prev) =>
|
||||||
|
prev.filter((entry) => entry.id !== record.id),
|
||||||
|
);
|
||||||
|
toast.success(t("hosts.accessRevoked"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("hosts.failedToRevokeAccess"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("hosts.sharing.revoke")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
|
||||||
|
<span>
|
||||||
|
{t("hosts.sharing.grantedBy")}:{" "}
|
||||||
|
<span className="text-foreground/70">
|
||||||
|
{record.grantedByUsername ?? "?"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={expired ? "text-destructive" : ""}>
|
||||||
|
{t("hosts.sharing.expires")}:{" "}
|
||||||
|
{expired ? (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||||
|
<X className="size-3" />
|
||||||
|
{t("hosts.sharing.expired")}
|
||||||
|
</span>
|
||||||
|
) : record.expiresAt ? (
|
||||||
|
<span className="text-foreground/70">
|
||||||
|
{new Date(record.expiresAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-foreground/70">
|
||||||
|
{t("hosts.sharing.never")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function LogRow({
|
|||||||
<span className="text-[10px] text-muted-foreground/60 truncate">
|
<span className="text-[10px] text-muted-foreground/60 truncate">
|
||||||
{formatDate(log.startedAt)}
|
{formatDate(log.startedAt)}
|
||||||
{" · "}
|
{" · "}
|
||||||
{log.protocol.toUpperCase()}
|
{(log.protocol ?? "ssh").toUpperCase()}
|
||||||
{log.username ? ` · ${log.username}` : ""}
|
{log.username ? ` · ${log.username}` : ""}
|
||||||
{" · "}
|
{" · "}
|
||||||
{formatDuration(log.duration)}
|
{formatDuration(log.duration)}
|
||||||
|
|||||||
+129
-67
@@ -36,6 +36,7 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Users,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +65,11 @@ import type { SSHHostData } from "@/types/index";
|
|||||||
import { FolderIconEl } from "@/components/folder-style";
|
import { FolderIconEl } from "@/components/folder-style";
|
||||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||||
import { copyToClipboard } from "@/lib/clipboard";
|
import { copyToClipboard } from "@/lib/clipboard";
|
||||||
|
import {
|
||||||
|
canDeleteHost,
|
||||||
|
canEditHost,
|
||||||
|
canShareHost,
|
||||||
|
} from "@/sidebar/host-permissions";
|
||||||
import { FolderMetadataDialog } from "./FolderMetadataDialog";
|
import { FolderMetadataDialog } from "./FolderMetadataDialog";
|
||||||
import {
|
import {
|
||||||
useStatusColorScheme,
|
useStatusColorScheme,
|
||||||
@@ -266,8 +272,8 @@ function folderHostCount(folder: HostFolder): {
|
|||||||
export function HostItem({
|
export function HostItem({
|
||||||
host,
|
host,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
onEditHost,
|
onEditHost: onEditHostProp,
|
||||||
onShareHost,
|
onShareHost: onShareHostProp,
|
||||||
onProxmoxDiscover,
|
onProxmoxDiscover,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -306,6 +312,10 @@ export function HostItem({
|
|||||||
depth?: number;
|
depth?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Shared hosts expose actions matching the recipient's permission level.
|
||||||
|
const onEditHost = canEditHost(host) ? onEditHostProp : undefined;
|
||||||
|
const onShareHost = canShareHost(host) ? onShareHostProp : undefined;
|
||||||
|
const allowDelete = canDeleteHost(host);
|
||||||
const metricsEnabled =
|
const metricsEnabled =
|
||||||
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
|
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
|
||||||
const [trayOnClick, setTrayOnClick] = useState(
|
const [trayOnClick, setTrayOnClick] = useState(
|
||||||
@@ -328,8 +338,8 @@ export function HostItem({
|
|||||||
const isTouchOnly =
|
const isTouchOnly =
|
||||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||||
const shouldUseClickTray = trayOnClick || isTouchOnly;
|
const shouldUseClickTray = trayOnClick || isTouchOnly;
|
||||||
const showPasswordCopy = canCopyHostPassword(host);
|
const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
|
||||||
const showSudoPasswordCopy = canCopyHostSudoPassword(host);
|
const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
|
||||||
|
|
||||||
async function handleCopyPassword(
|
async function handleCopyPassword(
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
@@ -465,6 +475,26 @@ export function HostItem({
|
|||||||
<span className="text-[13px] font-medium truncate text-foreground leading-none">
|
<span className="text-[13px] font-medium truncate text-foreground leading-none">
|
||||||
{host.name}
|
{host.name}
|
||||||
</span>
|
</span>
|
||||||
|
{host.isShared && (
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
|
||||||
|
<Users className="size-2.5" />
|
||||||
|
{t("hosts.sharing.sharedBadge")}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{t("hosts.sharing.sharedBadgeTooltip", {
|
||||||
|
owner: host.ownerUsername || "?",
|
||||||
|
level: t(
|
||||||
|
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{!selectionMode && shouldUseClickTray && (
|
{!selectionMode && shouldUseClickTray && (
|
||||||
<button
|
<button
|
||||||
title={
|
title={
|
||||||
@@ -666,27 +696,31 @@ export function HostItem({
|
|||||||
{t("nav.copySudoPassword")}
|
{t("nav.copySudoPassword")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
{allowDelete && (
|
||||||
<DropdownMenuItem
|
<>
|
||||||
onClick={(e) => {
|
<DropdownMenuSeparator />
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
onDuplicate();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
onDuplicate();
|
||||||
<CopyPlus className="size-3.5 mr-2" />
|
}}
|
||||||
{t("hosts.cloneHostAction")}
|
>
|
||||||
</DropdownMenuItem>
|
<CopyPlus className="size-3.5 mr-2" />
|
||||||
<DropdownMenuSeparator />
|
{t("hosts.cloneHostAction")}
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
className="text-destructive focus:text-destructive"
|
<DropdownMenuSeparator />
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
className="text-destructive focus:text-destructive"
|
||||||
onDelete();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
onDelete();
|
||||||
<Trash2 className="size-3.5 mr-2" />
|
}}
|
||||||
{t("common.delete")}
|
>
|
||||||
</DropdownMenuItem>
|
<Trash2 className="size-3.5 mr-2" />
|
||||||
|
{t("common.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -862,27 +896,31 @@ export function HostItem({
|
|||||||
{t("nav.copySudoPassword")}
|
{t("nav.copySudoPassword")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
{allowDelete && (
|
||||||
<DropdownMenuItem
|
<>
|
||||||
onClick={(e) => {
|
<DropdownMenuSeparator />
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
onDuplicate();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
onDuplicate();
|
||||||
<CopyPlus className="size-3.5 mr-2" />
|
}}
|
||||||
{t("hosts.cloneHostAction")}
|
>
|
||||||
</DropdownMenuItem>
|
<CopyPlus className="size-3.5 mr-2" />
|
||||||
<DropdownMenuSeparator />
|
{t("hosts.cloneHostAction")}
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
className="text-destructive focus:text-destructive"
|
<DropdownMenuSeparator />
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
className="text-destructive focus:text-destructive"
|
||||||
onDelete();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
onDelete();
|
||||||
<Trash2 className="size-3.5 mr-2" />
|
}}
|
||||||
{t("common.delete")}
|
>
|
||||||
</DropdownMenuItem>
|
<Trash2 className="size-3.5 mr-2" />
|
||||||
|
{t("common.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -974,6 +1012,26 @@ export function HostItem({
|
|||||||
{host.pin && (
|
{host.pin && (
|
||||||
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
{host.isShared && (
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
|
||||||
|
<Users className="size-2.5" />
|
||||||
|
{t("hosts.sharing.sharedBadge")}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{t("hosts.sharing.sharedBadgeTooltip", {
|
||||||
|
owner: host.ownerUsername || "?",
|
||||||
|
level: t(
|
||||||
|
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{!selectionMode && shouldUseClickTray && (
|
{!selectionMode && shouldUseClickTray && (
|
||||||
<button
|
<button
|
||||||
title={
|
title={
|
||||||
@@ -1445,27 +1503,31 @@ export function HostItem({
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
{allowDelete && (
|
||||||
<DropdownMenuItem
|
<>
|
||||||
onClick={(e) => {
|
<DropdownMenuSeparator />
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
onDuplicate();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
onDuplicate();
|
||||||
<CopyPlus className="size-3.5 mr-2" />
|
}}
|
||||||
{t("hosts.cloneHostAction")}
|
>
|
||||||
</DropdownMenuItem>
|
<CopyPlus className="size-3.5 mr-2" />
|
||||||
<DropdownMenuSeparator />
|
{t("hosts.cloneHostAction")}
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
className="text-destructive focus:text-destructive"
|
<DropdownMenuSeparator />
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
className="text-destructive focus:text-destructive"
|
||||||
onDelete();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
onDelete();
|
||||||
<Trash2 className="size-3.5 mr-2" />
|
}}
|
||||||
{t("common.delete")}
|
>
|
||||||
</DropdownMenuItem>
|
<Trash2 className="size-3.5 mr-2" />
|
||||||
|
{t("common.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user