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 {
|
||||
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
|
||||
sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS shared_credentials (
|
||||
CREATE TABLE IF NOT EXISTS shared_host_secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
original_credential_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
encrypted_username TEXT NOT NULL,
|
||||
encrypted_auth_type TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
source_type TEXT NOT NULL DEFAULT 'credential',
|
||||
original_credential_id INTEGER,
|
||||
encrypted_username TEXT,
|
||||
encrypted_auth_type TEXT,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
encrypted_domain TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(host_access_id, target_user_id, protocol),
|
||||
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create shared_credentials table", {
|
||||
databaseLogger.warn("Failed to create shared_host_secrets table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
|
||||
sqlite.exec(
|
||||
"UPDATE host_access SET permission_level = 'connect' WHERE permission_level = 'view'",
|
||||
);
|
||||
setRawSettingValue("rbac_permission_levels_v2", "done");
|
||||
}
|
||||
} catch (migrateError) {
|
||||
databaseLogger.warn("Failed to migrate legacy view permission level", {
|
||||
operation: "schema_migration",
|
||||
error: migrateError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
|
||||
} catch {
|
||||
|
||||
@@ -523,7 +523,7 @@ export const hostAccess = sqliteTable("host_access", {
|
||||
|
||||
permissionLevel: text("permission_level")
|
||||
.notNull()
|
||||
.default("view"),
|
||||
.default("connect"),
|
||||
|
||||
expiresAt: text("expires_at"),
|
||||
|
||||
@@ -538,27 +538,32 @@ export const hostAccess = sqliteTable("host_access", {
|
||||
),
|
||||
});
|
||||
|
||||
export const sharedCredentials = sqliteTable("shared_credentials", {
|
||||
export const sharedHostSecrets = sqliteTable("shared_host_secrets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
hostAccessId: integer("host_access_id")
|
||||
.notNull()
|
||||
.references(() => hostAccess.id, { onDelete: "cascade" }),
|
||||
|
||||
originalCredentialId: integer("original_credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
targetUserId: text("target_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
encryptedUsername: text("encrypted_username").notNull(),
|
||||
encryptedAuthType: text("encrypted_auth_type").notNull(),
|
||||
protocol: text("protocol").notNull().default("ssh"),
|
||||
sourceType: text("source_type").notNull().default("credential"),
|
||||
|
||||
originalCredentialId: integer("original_credential_id").references(
|
||||
() => sshCredentials.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
|
||||
encryptedUsername: text("encrypted_username"),
|
||||
encryptedAuthType: text("encrypted_auth_type"),
|
||||
encryptedPassword: text("encrypted_password"),
|
||||
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||
encryptedKeyPassword: text("encrypted_key_password"),
|
||||
encryptedKeyType: text("encrypted_key_type"),
|
||||
encryptedDomain: text("encrypted_domain"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
|
||||
@@ -28,7 +28,7 @@ import { RoleRepository } from "./role-repository.js";
|
||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
import { SessionRepository } from "./session-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { SharedCredentialRepository } from "./shared-credential-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SnippetRepository } from "./snippet-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
||||
@@ -260,10 +260,10 @@ export function createCurrentSettingsRepository(): SettingsRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSharedCredentialRepository(): SharedCredentialRepository {
|
||||
return new SharedCredentialRepository(
|
||||
export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRepository {
|
||||
return new SharedHostSecretsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("shared_credential_repository_write"),
|
||||
createCurrentRepositoryWriteHook("shared_host_secrets_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface HostUpdateStateRecord {
|
||||
rdpCredentialId: number | null;
|
||||
vncCredentialId: number | null;
|
||||
telnetCredentialId: number | null;
|
||||
vaultProfileId: number | null;
|
||||
authType: string;
|
||||
}
|
||||
export interface HostListAccessEntry {
|
||||
@@ -74,6 +75,7 @@ export class HostResolutionRepository {
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
vaultProfileId: hosts.vaultProfileId,
|
||||
authType: hosts.authType,
|
||||
})
|
||||
.from(hosts)
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
hostAccess,
|
||||
hosts,
|
||||
roles,
|
||||
sharedCredentials,
|
||||
sharedHostSecrets,
|
||||
snippetAccess,
|
||||
snippets,
|
||||
users,
|
||||
@@ -152,10 +152,6 @@ export class RbacAccessRepository {
|
||||
})
|
||||
.where(eq(hostAccess.id, existing.id));
|
||||
|
||||
await this.context.drizzle
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.hostAccessId, existing.id));
|
||||
|
||||
await this.afterWrite();
|
||||
return { id: existing.id, created: false };
|
||||
}
|
||||
@@ -583,25 +579,73 @@ export class RbacAccessRepository {
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
}
|
||||
|
||||
async findSharedCredentialForHostAndUser(
|
||||
async findSharedSecretForHostUserProtocol(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<typeof sharedCredentials.$inferSelect | null> {
|
||||
protocol: string,
|
||||
): Promise<typeof sharedHostSecrets.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
sharedCredential: sharedCredentials,
|
||||
secret: sharedHostSecrets,
|
||||
})
|
||||
.from(sharedCredentials)
|
||||
.innerJoin(hostAccess, eq(sharedCredentials.hostAccessId, hostAccess.id))
|
||||
.from(sharedHostSecrets)
|
||||
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
eq(sharedCredentials.targetUserId, userId),
|
||||
eq(sharedHostSecrets.targetUserId, userId),
|
||||
eq(sharedHostSecrets.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.sharedCredential ?? null;
|
||||
return rows[0]?.secret ?? null;
|
||||
}
|
||||
|
||||
async listActiveHostAccessGrants(
|
||||
hostId: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<(typeof hostAccess.$inferSelect)[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findHostAccessById(
|
||||
accessId: number,
|
||||
hostId: number,
|
||||
): Promise<typeof hostAccess.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateHostAccessGrant(
|
||||
accessId: number,
|
||||
hostId: number,
|
||||
update: { permissionLevel?: string; expiresAt?: string | null },
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(hostAccess)
|
||||
.set(update)
|
||||
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface SessionRecordingListRecord {
|
||||
endedAt: string | null;
|
||||
duration: number | null;
|
||||
recordingPath: string | null;
|
||||
protocol: string;
|
||||
format: string | null;
|
||||
hostName: string | null;
|
||||
hostIp: string | null;
|
||||
}
|
||||
@@ -106,6 +108,8 @@ export class SessionRecordingRepository {
|
||||
endedAt: sessionRecordings.endedAt,
|
||||
duration: sessionRecordings.duration,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
protocol: sessionRecordings.protocol,
|
||||
format: sessionRecordings.format,
|
||||
hostName: hosts.name,
|
||||
hostIp: hosts.ip,
|
||||
})
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { sharedCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SharedCredentialRecord = typeof sharedCredentials.$inferSelect;
|
||||
export type NewSharedCredentialRecord = typeof sharedCredentials.$inferInsert;
|
||||
export type SharedCredentialUpdate = Partial<
|
||||
Omit<NewSharedCredentialRecord, "id">
|
||||
>;
|
||||
|
||||
export class SharedCredentialRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async existsForHostAccessAndTargetUser(
|
||||
hostAccessId: number,
|
||||
targetUserId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sharedCredentials.id })
|
||||
.from(sharedCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedCredentials.hostAccessId, hostAccessId),
|
||||
eq(sharedCredentials.targetUserId, targetUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async create(
|
||||
sharedCredential: NewSharedCredentialRecord,
|
||||
): Promise<SharedCredentialRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sharedCredentials)
|
||||
.values(sharedCredential)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<SharedCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.where(eq(sharedCredentials.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByOriginalCredentialId(
|
||||
credentialId: number,
|
||||
): Promise<SharedCredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.where(eq(sharedCredentials.originalCredentialId, credentialId));
|
||||
}
|
||||
|
||||
async updateById(
|
||||
id: number,
|
||||
update: SharedCredentialUpdate,
|
||||
): Promise<SharedCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sharedCredentials)
|
||||
.set(update)
|
||||
.where(eq(sharedCredentials.id, id))
|
||||
.returning();
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.id, id))
|
||||
.returning({ id: sharedCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.originalCredentialId, credentialId))
|
||||
.returning({ id: sharedCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByTargetUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.targetUserId, userId))
|
||||
.returning({ id: sharedCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
|
||||
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
|
||||
|
||||
export type ShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
export class SharedHostSecretsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async upsert(record: NewSharedHostSecretRecord): Promise<void> {
|
||||
const existing = await this.context.drizzle
|
||||
.select({ id: sharedHostSecrets.id })
|
||||
.from(sharedHostSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedHostSecrets.hostAccessId, record.hostAccessId),
|
||||
eq(sharedHostSecrets.targetUserId, record.targetUserId),
|
||||
eq(sharedHostSecrets.protocol, record.protocol ?? "ssh"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await this.context.drizzle
|
||||
.update(sharedHostSecrets)
|
||||
.set({ ...record, updatedAt: new Date().toISOString() })
|
||||
.where(eq(sharedHostSecrets.id, existing[0].id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(sharedHostSecrets).values(record);
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findForHostUserProtocol(
|
||||
hostId: number,
|
||||
targetUserId: string,
|
||||
protocol: ShareProtocol,
|
||||
): Promise<SharedHostSecretRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ secret: sharedHostSecrets })
|
||||
.from(sharedHostSecrets)
|
||||
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||
eq(sharedHostSecrets.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.secret ?? null;
|
||||
}
|
||||
|
||||
async existsForHostAccessAndTargetUser(
|
||||
hostAccessId: number,
|
||||
targetUserId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sharedHostSecrets.id })
|
||||
.from(sharedHostSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedHostSecrets.hostAccessId, hostAccessId),
|
||||
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteForHostAccessAndTarget(
|
||||
hostAccessId: number,
|
||||
targetUserId: string,
|
||||
keepProtocols: ShareProtocol[] = [],
|
||||
): Promise<void> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: sharedHostSecrets.id,
|
||||
protocol: sharedHostSecrets.protocol,
|
||||
})
|
||||
.from(sharedHostSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedHostSecrets.hostAccessId, hostAccessId),
|
||||
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||
),
|
||||
);
|
||||
|
||||
const staleIds = rows
|
||||
.filter((row) => !keepProtocols.includes(row.protocol as ShareProtocol))
|
||||
.map((row) => row.id);
|
||||
|
||||
if (staleIds.length > 0) {
|
||||
await this.context.drizzle
|
||||
.delete(sharedHostSecrets)
|
||||
.where(inArray(sharedHostSecrets.id, staleIds));
|
||||
await this.afterWrite();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByHostAccessId(hostAccessId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedHostSecrets)
|
||||
.where(eq(sharedHostSecrets.hostAccessId, hostAccessId))
|
||||
.returning({ id: sharedHostSecrets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteForRoleMember(
|
||||
roleId: number,
|
||||
targetUserId: string,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sharedHostSecrets.id })
|
||||
.from(sharedHostSecrets)
|
||||
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.roleId, roleId),
|
||||
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
await this.context.drizzle.delete(sharedHostSecrets).where(
|
||||
inArray(
|
||||
sharedHostSecrets.id,
|
||||
rows.map((row) => row.id),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedHostSecrets)
|
||||
.where(eq(sharedHostSecrets.originalCredentialId, credentialId))
|
||||
.returning({ id: sharedHostSecrets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByTargetUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedHostSecrets)
|
||||
.where(eq(sharedHostSecrets.targetUserId, userId))
|
||||
.returning({ id: sharedHostSecrets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async findHostIdsReferencingCredential(
|
||||
ownerId: string,
|
||||
credentialId: number,
|
||||
): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.userId, ownerId),
|
||||
or(
|
||||
eq(hosts.credentialId, credentialId),
|
||||
eq(hosts.rdpCredentialId, credentialId),
|
||||
eq(hosts.vncCredentialId, credentialId),
|
||||
eq(hosts.telnetCredentialId, credentialId),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
||||
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostRepository,
|
||||
@@ -523,10 +522,9 @@ router.put(
|
||||
credentialId,
|
||||
));
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.updateSharedCredentialsForOriginal(
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
|
||||
credentialId,
|
||||
userId,
|
||||
);
|
||||
@@ -633,38 +631,24 @@ router.delete(
|
||||
authType: "password",
|
||||
},
|
||||
);
|
||||
|
||||
for (const host of hostsUsingCredential) {
|
||||
const revokedCount =
|
||||
await createCurrentRbacAccessRepository().deleteHostAccessForHost(
|
||||
host.id,
|
||||
);
|
||||
|
||||
if (revokedCount > 0) {
|
||||
authLogger.info(
|
||||
"Auto-revoked host shares due to credential deletion",
|
||||
{
|
||||
operation: "auto_revoke_shares",
|
||||
hostId: host.id,
|
||||
credentialId,
|
||||
revokedCount,
|
||||
reason: "credential_deleted",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.deleteSharedCredentialsForOriginal(credentialId);
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
|
||||
await sharedSecretsManager.deleteForCredential(credentialId);
|
||||
|
||||
await createCurrentCredentialRepository().deleteForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
// Shares stay in place; re-snapshot so recipients fall back to whatever
|
||||
// auth the host still has (or lose the stale credential copy).
|
||||
for (const host of hostsUsingCredential) {
|
||||
await sharedSecretsManager.resyncHost(host.id);
|
||||
}
|
||||
|
||||
authLogger.success("SSH credential deleted", {
|
||||
operation: "credential_delete_success",
|
||||
userId,
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSessionRecordingRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSharedCredentialRepository,
|
||||
createCurrentSharedHostSecretsRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentTermixIdentityCaRepository,
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
|
||||
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
||||
try {
|
||||
await createCurrentSharedCredentialRepository().deleteByTargetUserId(
|
||||
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
|
||||
userId,
|
||||
);
|
||||
|
||||
|
||||
@@ -231,6 +231,76 @@ export function stripSensitiveFields(
|
||||
return result;
|
||||
}
|
||||
|
||||
// Connection essentials a connect-level recipient is allowed to see.
|
||||
const CONNECT_LEVEL_FIELDS = new Set([
|
||||
"id",
|
||||
"userId",
|
||||
"ownerId",
|
||||
"ownerUsername",
|
||||
"isShared",
|
||||
"permissionLevel",
|
||||
"sharedExpiresAt",
|
||||
"name",
|
||||
"ip",
|
||||
"port",
|
||||
"username",
|
||||
"folder",
|
||||
"tags",
|
||||
"pin",
|
||||
"authType",
|
||||
"connectionType",
|
||||
"credentialId",
|
||||
"enableTerminal",
|
||||
"enableTunnel",
|
||||
"enableFileManager",
|
||||
"enableDocker",
|
||||
"enableProxmox",
|
||||
"enableTmuxMonitor",
|
||||
"showTerminalInSidebar",
|
||||
"showFileManagerInSidebar",
|
||||
"showTunnelInSidebar",
|
||||
"showDockerInSidebar",
|
||||
"showServerStatsInSidebar",
|
||||
"enableSsh",
|
||||
"enableRdp",
|
||||
"enableVnc",
|
||||
"enableTelnet",
|
||||
"sshPort",
|
||||
"rdpPort",
|
||||
"vncPort",
|
||||
"telnetPort",
|
||||
"defaultPath",
|
||||
"scpLegacy",
|
||||
"tunnelConnections",
|
||||
"jumpHosts",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Shapes a shared host row for its recipient. Secrets are always stripped
|
||||
* (all levels); connect-level recipients are additionally reduced to
|
||||
* connection essentials since they may not view the host's configuration.
|
||||
*/
|
||||
export function sanitizeHostForRecipient(
|
||||
host: Record<string, unknown>,
|
||||
permissionLevel: string | undefined,
|
||||
): Record<string, unknown> {
|
||||
const stripped = stripSensitiveFields(host);
|
||||
|
||||
if (permissionLevel !== "connect") {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
const reduced: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(stripped)) {
|
||||
if (CONNECT_LEVEL_FIELDS.has(key)) {
|
||||
reduced[key] = value;
|
||||
}
|
||||
}
|
||||
return reduced;
|
||||
}
|
||||
|
||||
export function transformHostResponse(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
sanitizeHostForRecipient,
|
||||
stripSensitiveFields,
|
||||
transformHostResponse,
|
||||
} from "./host-normalizers.js";
|
||||
@@ -1070,7 +1071,7 @@ router.put(
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
Number(hostId),
|
||||
"write",
|
||||
"edit",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
@@ -1082,17 +1083,6 @@ router.put(
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
if (!accessInfo.isOwner) {
|
||||
sshLogger.warn("Shared user attempted to update host (view-only)", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can modify host configuration",
|
||||
});
|
||||
}
|
||||
|
||||
const hostRecord =
|
||||
await createCurrentHostResolutionRepository().findHostUpdateState(
|
||||
Number(hostId),
|
||||
@@ -1109,18 +1099,43 @@ router.put(
|
||||
|
||||
const ownerId = hostRecord.userId;
|
||||
|
||||
if (
|
||||
!accessInfo.isOwner &&
|
||||
sshDataObj.credentialId !== undefined &&
|
||||
sshDataObj.credentialId !== hostRecord.credentialId
|
||||
) {
|
||||
if (!accessInfo.isOwner) {
|
||||
// Shared editors work on the owner's real record but may never
|
||||
// repoint it at credential/vault references (those live in the
|
||||
// owner's personal vault) or switch the authentication type.
|
||||
const referenceViolations: Array<[unknown, number | null, string]> = [
|
||||
[sshDataObj.credentialId, hostRecord.credentialId, "credential"],
|
||||
[
|
||||
sshDataObj.rdpCredentialId,
|
||||
hostRecord.rdpCredentialId,
|
||||
"RDP credential",
|
||||
],
|
||||
[
|
||||
sshDataObj.vncCredentialId,
|
||||
hostRecord.vncCredentialId,
|
||||
"VNC credential",
|
||||
],
|
||||
[
|
||||
sshDataObj.telnetCredentialId,
|
||||
hostRecord.telnetCredentialId,
|
||||
"Telnet credential",
|
||||
],
|
||||
[
|
||||
sshDataObj.vaultProfileId,
|
||||
hostRecord.vaultProfileId,
|
||||
"Vault profile",
|
||||
],
|
||||
];
|
||||
|
||||
for (const [incoming, current, label] of referenceViolations) {
|
||||
if (incoming !== undefined && (incoming ?? null) !== current) {
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can change the credential",
|
||||
error: `Only the host owner can change the ${label}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!accessInfo.isOwner &&
|
||||
sshDataObj.authType !== undefined &&
|
||||
sshDataObj.authType !== hostRecord.authType
|
||||
) {
|
||||
@@ -1128,39 +1143,6 @@ router.put(
|
||||
error: "Only the host owner can change the authentication type",
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const newCredId =
|
||||
sshDataObj.credentialId !== undefined
|
||||
? sshDataObj.credentialId
|
||||
: hostRecord.credentialId;
|
||||
const newRdpCredId =
|
||||
sshDataObj.rdpCredentialId !== undefined
|
||||
? sshDataObj.rdpCredentialId
|
||||
: hostRecord.rdpCredentialId;
|
||||
const newVncCredId =
|
||||
sshDataObj.vncCredentialId !== undefined
|
||||
? sshDataObj.vncCredentialId
|
||||
: hostRecord.vncCredentialId;
|
||||
const newTelnetCredId =
|
||||
sshDataObj.telnetCredentialId !== undefined
|
||||
? sshDataObj.telnetCredentialId
|
||||
: hostRecord.telnetCredentialId;
|
||||
const hadCredential =
|
||||
hostRecord.credentialId !== null ||
|
||||
hostRecord.rdpCredentialId !== null ||
|
||||
hostRecord.vncCredentialId !== null ||
|
||||
hostRecord.telnetCredentialId !== null;
|
||||
const willHaveCredential =
|
||||
newCredId !== null ||
|
||||
newRdpCredId !== null ||
|
||||
newVncCredId !== null ||
|
||||
newTelnetCredId !== null;
|
||||
if (hadCredential && !willHaveCredential) {
|
||||
await createCurrentRbacAccessRepository().deleteHostAccessForHost(
|
||||
Number(hostId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
@@ -1169,6 +1151,23 @@ router.put(
|
||||
sshDataObj,
|
||||
);
|
||||
|
||||
// Keep every recipient's re-encrypted secret snapshots in sync with
|
||||
// the updated host record.
|
||||
try {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().resyncHost(Number(hostId));
|
||||
} catch (resyncError) {
|
||||
sshLogger.warn("Failed to resync shared host secrets after update", {
|
||||
operation: "host_update_resync",
|
||||
hostId: parseInt(hostId),
|
||||
error:
|
||||
resyncError instanceof Error
|
||||
? resyncError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedHost =
|
||||
await createCurrentHostResolutionRepository().findHostById(
|
||||
Number(hostId),
|
||||
@@ -1296,9 +1295,21 @@ router.get(
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedSharedHosts = sharedHosts;
|
||||
const ownerUsernames = new Map<string, string>();
|
||||
const userRepository = createCurrentUserRepository();
|
||||
for (const sharedHost of sharedHosts) {
|
||||
const ownerId = sharedHost.userId as string;
|
||||
if (!ownerUsernames.has(ownerId)) {
|
||||
try {
|
||||
const owner = await userRepository.findById(ownerId);
|
||||
ownerUsernames.set(ownerId, owner?.username ?? "");
|
||||
} catch {
|
||||
ownerUsernames.set(ownerId, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = [...decryptedOwnHosts, ...sanitizedSharedHosts];
|
||||
const data = [...decryptedOwnHosts, ...sharedHosts];
|
||||
|
||||
const result = await Promise.all(
|
||||
data.map(async (row: Record<string, unknown>) => {
|
||||
@@ -1307,6 +1318,9 @@ router.get(
|
||||
isShared: !!row.isShared,
|
||||
permissionLevel: row.permissionLevel || undefined,
|
||||
sharedExpiresAt: row.expiresAt || undefined,
|
||||
ownerUsername: row.isShared
|
||||
? ownerUsernames.get(row.userId as string) || undefined
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolved =
|
||||
@@ -1315,7 +1329,14 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
const sanitized = result.map((host) => stripSensitiveFields(host));
|
||||
const sanitized = result.map((host) =>
|
||||
host.isShared
|
||||
? sanitizeHostForRecipient(
|
||||
host,
|
||||
host.permissionLevel as string | undefined,
|
||||
)
|
||||
: stripSensitiveFields(host),
|
||||
);
|
||||
res.json(sanitized);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
||||
@@ -1370,13 +1391,28 @@ router.get(
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
try {
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostByIdForUser(
|
||||
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||
const host = await hostResolutionRepository.findHostByIdForUser(
|
||||
Number(hostId),
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!host) {
|
||||
if (host) {
|
||||
const result = transformHostResponse(host);
|
||||
const resolved =
|
||||
(await resolveHostCredentials(result, userId)) || result;
|
||||
|
||||
return res.json(stripSensitiveFields(resolved));
|
||||
}
|
||||
|
||||
// Not the owner: shared recipients get a sanitized view of the host.
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
Number(hostId),
|
||||
"connect",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
sshLogger.warn("SSH host not found", {
|
||||
operation: "host_fetch_by_id",
|
||||
hostId: parseInt(hostId),
|
||||
@@ -1385,10 +1421,38 @@ router.get(
|
||||
return res.status(404).json({ error: "SSH host not found" });
|
||||
}
|
||||
|
||||
const result = transformHostResponse(host);
|
||||
const resolved = (await resolveHostCredentials(result, userId)) || result;
|
||||
const ownerId = await hostResolutionRepository.findHostOwnerId(
|
||||
Number(hostId),
|
||||
);
|
||||
const sharedHost = ownerId
|
||||
? await hostResolutionRepository.findHostById(Number(hostId), ownerId)
|
||||
: null;
|
||||
|
||||
res.json(stripSensitiveFields(resolved));
|
||||
if (!sharedHost) {
|
||||
return res.status(404).json({ error: "SSH host not found" });
|
||||
}
|
||||
|
||||
let ownerUsername: string | undefined;
|
||||
try {
|
||||
const owner = ownerId
|
||||
? await createCurrentUserRepository().findById(ownerId)
|
||||
: null;
|
||||
ownerUsername = owner?.username ?? undefined;
|
||||
} catch {
|
||||
ownerUsername = undefined;
|
||||
}
|
||||
|
||||
const sharedResult = {
|
||||
...transformHostResponse(sharedHost),
|
||||
isShared: true,
|
||||
permissionLevel: accessInfo.permissionLevel,
|
||||
sharedExpiresAt: accessInfo.expiresAt || undefined,
|
||||
ownerUsername,
|
||||
};
|
||||
|
||||
res.json(
|
||||
sanitizeHostForRecipient(sharedResult, accessInfo.permissionLevel),
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
|
||||
operation: "host_fetch_by_id",
|
||||
@@ -2033,12 +2097,13 @@ async function resolveHostCredentials(
|
||||
|
||||
if (requestingUserId && requestingUserId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const sharedCred =
|
||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||
host.id as number,
|
||||
requestingUserId,
|
||||
"ssh",
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
|
||||
@@ -312,7 +312,7 @@ async function discoverProxmoxGuestsForHost(
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const pm = PermissionManager.getInstance();
|
||||
const access = await pm.canAccessHost(userId, parsedHostId, "execute");
|
||||
const access = await pm.canAccessHost(userId, parsedHostId, "connect");
|
||||
if (!access.hasAccess) {
|
||||
const error = new Error("Access denied");
|
||||
(error as Error & { status?: number }).status = 403;
|
||||
@@ -337,12 +337,13 @@ async function discoverProxmoxGuestsForHost(
|
||||
if (host.credentialId) {
|
||||
if (userId !== host.userId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const sharedCred =
|
||||
await SharedCredentialManager.getInstance().getSharedCredentialForUser(
|
||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||
host.id,
|
||||
userId,
|
||||
"ssh",
|
||||
);
|
||||
if (sharedCred) {
|
||||
resolvedCredentials = {
|
||||
|
||||
+444
-185
@@ -3,7 +3,15 @@ import express from "express";
|
||||
import type { Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import {
|
||||
PermissionManager,
|
||||
SHARE_PERMISSION_LEVELS,
|
||||
type SharePermissionLevel,
|
||||
} from "../../utils/permission-manager.js";
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
isValidPermission,
|
||||
} from "../../utils/permission-catalog.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
@@ -24,12 +32,69 @@ function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isSharePermissionLevel(value: unknown): value is SharePermissionLevel {
|
||||
return SHARE_PERMISSION_LEVELS.includes(value as SharePermissionLevel);
|
||||
}
|
||||
|
||||
function expiryFromDuration(durationHours: unknown): string | null {
|
||||
if (durationHours && typeof durationHours === "number" && durationHours > 0) {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setTime(expiryDate.getTime() + durationHours * 60 * 60 * 1000);
|
||||
return expiryDate.toISOString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sharing is controlled by the owner or any recipient holding "manage".
|
||||
async function canManageHostSharing(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<{ allowed: boolean; isOwner: boolean }> {
|
||||
const access = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"manage",
|
||||
);
|
||||
return { allowed: access.hasAccess, isOwner: access.isOwner };
|
||||
}
|
||||
|
||||
interface ShareTarget {
|
||||
type: "user" | "role";
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
function parseShareTargets(
|
||||
body: Record<string, unknown>,
|
||||
): ShareTarget[] | null {
|
||||
const rawTargets = body.targets;
|
||||
if (!Array.isArray(rawTargets) || rawTargets.length === 0) return null;
|
||||
|
||||
const targets: ShareTarget[] = [];
|
||||
for (const raw of rawTargets) {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const { type, id } = raw as { type?: unknown; id?: unknown };
|
||||
if (type === "user" && isNonEmptyString(id)) {
|
||||
targets.push({ type: "user", id });
|
||||
} else if (
|
||||
type === "role" &&
|
||||
typeof id === "number" &&
|
||||
Number.isInteger(id)
|
||||
) {
|
||||
targets.push({ type: "role", id });
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/host/{id}/share:
|
||||
* post:
|
||||
* summary: Share a host
|
||||
* description: Shares a host with a user or a role.
|
||||
* description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). Allowed for the host owner or recipients holding the manage level. Every auth type is shareable; per-recipient secret snapshots are created automatically.
|
||||
* tags:
|
||||
* - RBAC
|
||||
* parameters:
|
||||
@@ -44,26 +109,32 @@ function isNonEmptyString(value: unknown): value is string {
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [targets]
|
||||
* properties:
|
||||
* targetType:
|
||||
* targets:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [user, role]
|
||||
* targetUserId:
|
||||
* type: string
|
||||
* targetRoleId:
|
||||
* type: integer
|
||||
* durationHours:
|
||||
* type: number
|
||||
* id:
|
||||
* oneOf:
|
||||
* - type: string
|
||||
* - type: integer
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* enum: [view]
|
||||
* enum: [connect, view, edit, manage]
|
||||
* durationHours:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Host shared successfully.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 403:
|
||||
* description: Not host owner.
|
||||
* description: Caller may not share this host.
|
||||
* 404:
|
||||
* description: Target user or role not found.
|
||||
* 500:
|
||||
@@ -82,37 +153,25 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
targetType = "user",
|
||||
targetUserId,
|
||||
targetRoleId,
|
||||
durationHours,
|
||||
permissionLevel = "view",
|
||||
} = req.body;
|
||||
|
||||
if (!["user", "role"].includes(targetType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
|
||||
const targets = parseShareTargets(req.body ?? {});
|
||||
if (!targets) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
|
||||
});
|
||||
}
|
||||
|
||||
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target user ID is required when sharing with user" });
|
||||
}
|
||||
if (targetType === "role" && !targetRoleId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target role ID is required when sharing with role" });
|
||||
const { durationHours, permissionLevel = "connect" } = req.body;
|
||||
|
||||
if (!isSharePermissionLevel(permissionLevel)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid permission level",
|
||||
validLevels: SHARE_PERMISSION_LEVELS,
|
||||
});
|
||||
}
|
||||
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostUpdateState(
|
||||
hostId,
|
||||
);
|
||||
|
||||
if (!host || host.userId !== userId) {
|
||||
const sharing = await canManageHostSharing(userId, hostId);
|
||||
if (!sharing.allowed) {
|
||||
databaseLogger.warn("Permission denied", {
|
||||
operation: "rbac_permission_denied",
|
||||
userId,
|
||||
@@ -120,141 +179,126 @@ router.post(
|
||||
resourceId: hostId,
|
||||
action: "share",
|
||||
});
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
return res.status(403).json({ error: "You may not share this host" });
|
||||
}
|
||||
|
||||
if (
|
||||
!host.credentialId &&
|
||||
!host.rdpCredentialId &&
|
||||
!host.vncCredentialId &&
|
||||
host.authType !== "opkssh"
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.",
|
||||
code: "CREDENTIAL_REQUIRED_FOR_SHARING",
|
||||
});
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostUpdateState(
|
||||
hostId,
|
||||
);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
const ownerId = host.userId;
|
||||
|
||||
if (targetType === "user") {
|
||||
const targetUser =
|
||||
await createCurrentUserRepository().findById(targetUserId);
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
|
||||
for (const target of targets) {
|
||||
if (target.type === "user") {
|
||||
if (target.id === ownerId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Cannot share a host with its owner" });
|
||||
}
|
||||
const targetUser = await userRepository.findById(target.id as string);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
return res.status(404).json({
|
||||
error: "Target user not found",
|
||||
targetId: target.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const targetRole =
|
||||
await createCurrentRoleRepository().findRoleById(targetRoleId);
|
||||
|
||||
const targetRole = await roleRepository.findRoleById(
|
||||
target.id as number,
|
||||
);
|
||||
if (!targetRole) {
|
||||
return res.status(404).json({ error: "Target role not found" });
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: string | null = null;
|
||||
if (
|
||||
durationHours &&
|
||||
typeof durationHours === "number" &&
|
||||
durationHours > 0
|
||||
) {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
||||
expiresAt = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
const validLevels = ["view"];
|
||||
if (!validLevels.includes(permissionLevel)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid permission level. Only 'view' is supported.",
|
||||
validLevels,
|
||||
return res.status(404).json({
|
||||
error: "Target role not found",
|
||||
targetId: target.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const accessGrant =
|
||||
await createCurrentRbacAccessRepository().upsertHostAccess({
|
||||
const expiresAt = expiryFromDuration(durationHours);
|
||||
|
||||
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||
|
||||
const results: Array<{
|
||||
type: "user" | "role";
|
||||
id: string | number;
|
||||
accessId: number;
|
||||
created: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const accessGrant = await rbacAccessRepository.upsertHostAccess({
|
||||
hostId,
|
||||
grantedBy: userId,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
...(targetType === "user"
|
||||
? { targetType: "user" as const, targetUserId: targetUserId! }
|
||||
: { targetType: "role" as const, targetRoleId: targetRoleId! }),
|
||||
...(target.type === "user"
|
||||
? { targetType: "user" as const, targetUserId: target.id as string }
|
||||
: {
|
||||
targetType: "role" as const,
|
||||
targetRoleId: target.id as number,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!accessGrant.created) {
|
||||
const activeCredentialId =
|
||||
host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId;
|
||||
if (activeCredentialId) {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
try {
|
||||
if (target.type === "user") {
|
||||
await secretsManager.snapshotForUser(
|
||||
accessGrant.id,
|
||||
activeCredentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
accessGrant.id,
|
||||
activeCredentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
databaseLogger.info("Permission granted", {
|
||||
operation: "rbac_permission_grant",
|
||||
adminId: userId,
|
||||
hostId,
|
||||
resource: "host",
|
||||
action: "view",
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "Host access updated",
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
const activeCredentialId =
|
||||
host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId;
|
||||
if (activeCredentialId) {
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
accessGrant.id,
|
||||
activeCredentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
target.id as string,
|
||||
ownerId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
await secretsManager.snapshotForRole(
|
||||
accessGrant.id,
|
||||
activeCredentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
hostId,
|
||||
target.id as number,
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
} catch (snapshotError) {
|
||||
databaseLogger.warn("Share created but secret snapshot failed", {
|
||||
operation: "rbac_host_share_snapshot_failed",
|
||||
hostId,
|
||||
accessId: accessGrant.id,
|
||||
error:
|
||||
snapshotError instanceof Error
|
||||
? snapshotError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: target.type,
|
||||
id: target.id,
|
||||
accessId: accessGrant.id,
|
||||
created: accessGrant.created,
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.success("Host shared successfully", {
|
||||
operation: "rbac_host_share_success",
|
||||
userId,
|
||||
hostId,
|
||||
targetUserId: targetType === "user" ? targetUserId : undefined,
|
||||
targets: results.length,
|
||||
permissionLevel,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Host shared successfully with ${targetType}`,
|
||||
message: "Host shared successfully",
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to share host", error, {
|
||||
@@ -267,6 +311,143 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/host/{id}/access/{accessId}:
|
||||
* patch:
|
||||
* summary: Update a host access grant
|
||||
* description: Changes the permission level and/or expiry of an existing host access grant. Allowed for the host owner or recipients holding the manage level.
|
||||
* tags:
|
||||
* - RBAC
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: path
|
||||
* name: accessId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* enum: [connect, view, edit, manage]
|
||||
* durationHours:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* description: Hours from now until the grant expires; null clears the expiry.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Grant updated successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 403:
|
||||
* description: Caller may not manage sharing on this host.
|
||||
* 404:
|
||||
* description: Grant not found.
|
||||
* 500:
|
||||
* description: Failed to update grant.
|
||||
*/
|
||||
router.patch(
|
||||
"/host/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const accessIdParam = Array.isArray(req.params.accessId)
|
||||
? req.params.accessId[0]
|
||||
: req.params.accessId;
|
||||
const hostId = parseInt(id, 10);
|
||||
const accessId = parseInt(accessIdParam, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(hostId) || isNaN(accessId)) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const sharing = await canManageHostSharing(userId, hostId);
|
||||
if (!sharing.allowed) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "You may not manage sharing on this host" });
|
||||
}
|
||||
|
||||
const { permissionLevel, durationHours } = req.body ?? {};
|
||||
|
||||
if (permissionLevel === undefined && durationHours === undefined) {
|
||||
return res.status(400).json({
|
||||
error: "At least one of permissionLevel or durationHours is required",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
permissionLevel !== undefined &&
|
||||
!isSharePermissionLevel(permissionLevel)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid permission level",
|
||||
validLevels: SHARE_PERMISSION_LEVELS,
|
||||
});
|
||||
}
|
||||
|
||||
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||
const grant = await rbacAccessRepository.findHostAccessById(
|
||||
accessId,
|
||||
hostId,
|
||||
);
|
||||
if (!grant) {
|
||||
return res.status(404).json({ error: "Access grant not found" });
|
||||
}
|
||||
|
||||
const update: { permissionLevel?: string; expiresAt?: string | null } =
|
||||
{};
|
||||
if (permissionLevel !== undefined) {
|
||||
update.permissionLevel = permissionLevel;
|
||||
}
|
||||
if (durationHours !== undefined) {
|
||||
update.expiresAt =
|
||||
durationHours === null ? null : expiryFromDuration(durationHours);
|
||||
}
|
||||
|
||||
await rbacAccessRepository.updateHostAccessGrant(
|
||||
accessId,
|
||||
hostId,
|
||||
update,
|
||||
);
|
||||
|
||||
databaseLogger.info("Host access grant updated", {
|
||||
operation: "rbac_host_access_update",
|
||||
userId,
|
||||
hostId,
|
||||
accessId,
|
||||
permissionLevel,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Access updated",
|
||||
expiresAt: update.expiresAt ?? grant.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update host access", error, {
|
||||
operation: "update_host_access",
|
||||
hostId,
|
||||
accessId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update access" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/host/{id}/access/{accessId}:
|
||||
@@ -292,7 +473,7 @@ router.post(
|
||||
* 400:
|
||||
* description: Invalid ID.
|
||||
* 403:
|
||||
* description: Not host owner.
|
||||
* description: Caller may not manage sharing on this host.
|
||||
* 500:
|
||||
* description: Failed to revoke access.
|
||||
*/
|
||||
@@ -313,14 +494,11 @@ router.delete(
|
||||
}
|
||||
|
||||
try {
|
||||
const isHostOwner =
|
||||
await createCurrentHostResolutionRepository().isHostOwnedByUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!isHostOwner) {
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
const sharing = await canManageHostSharing(userId, hostId);
|
||||
if (!sharing.allowed) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "You may not manage sharing on this host" });
|
||||
}
|
||||
|
||||
await createCurrentRbacAccessRepository().revokeHostAccess(
|
||||
@@ -363,11 +541,11 @@ router.delete(
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The access list for the host.
|
||||
* description: The access list for the host, including each grant's permission level.
|
||||
* 400:
|
||||
* description: Invalid host ID.
|
||||
* 403:
|
||||
* description: Not host owner.
|
||||
* description: Caller may not manage sharing on this host.
|
||||
* 500:
|
||||
* description: Failed to get access list.
|
||||
*/
|
||||
@@ -384,20 +562,17 @@ router.get(
|
||||
}
|
||||
|
||||
try {
|
||||
const isHostOwner =
|
||||
await createCurrentHostResolutionRepository().isHostOwnedByUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!isHostOwner) {
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
const sharing = await canManageHostSharing(userId, hostId);
|
||||
if (!sharing.allowed) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "You may not manage sharing on this host" });
|
||||
}
|
||||
|
||||
const accessList =
|
||||
await createCurrentRbacAccessRepository().listHostAccess(hostId);
|
||||
|
||||
res.json({ accessList });
|
||||
res.json({ accessList, isOwner: sharing.isOwner });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get host access list", error, {
|
||||
operation: "get_host_access_list",
|
||||
@@ -475,17 +650,30 @@ router.get(
|
||||
displayName,
|
||||
description,
|
||||
isSystem,
|
||||
permissions,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}) => ({
|
||||
}) => {
|
||||
let parsedPermissions: string[] = [];
|
||||
try {
|
||||
parsedPermissions = permissions
|
||||
? (JSON.parse(permissions) as string[])
|
||||
: [];
|
||||
} catch {
|
||||
parsedPermissions = [];
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
displayName,
|
||||
description,
|
||||
isSystem,
|
||||
permissions: parsedPermissions,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
res.json({ roles: rolesList });
|
||||
@@ -606,6 +794,11 @@ router.post(
|
||||
* type: string
|
||||
* description:
|
||||
* type: string
|
||||
* permissions:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Permission strings validated against the permissions catalog (wildcards like hosts.* and * allowed).
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Role updated successfully.
|
||||
@@ -623,21 +816,47 @@ router.put(
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const roleId = parseInt(id, 10);
|
||||
const { displayName, description } = req.body;
|
||||
const { displayName, description, permissions } = req.body;
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
return res.status(400).json({ error: "Invalid role ID" });
|
||||
}
|
||||
|
||||
if (!displayName && description === undefined) {
|
||||
if (
|
||||
!displayName &&
|
||||
description === undefined &&
|
||||
permissions === undefined
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "At least one field (displayName or description) is required",
|
||||
error:
|
||||
"At least one field (displayName, description or permissions) is required",
|
||||
});
|
||||
}
|
||||
|
||||
if (permissions !== undefined) {
|
||||
if (
|
||||
!Array.isArray(permissions) ||
|
||||
permissions.some((perm) => typeof perm !== "string")
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "permissions must be an array of strings" });
|
||||
}
|
||||
|
||||
const invalid = (permissions as string[]).filter(
|
||||
(perm) => !isValidPermission(perm),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: "Unknown permissions",
|
||||
invalid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const existingRole =
|
||||
await createCurrentRoleRepository().findRoleById(roleId);
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
const existingRole = await roleRepository.findRoleById(roleId);
|
||||
|
||||
if (!existingRole) {
|
||||
return res.status(404).json({ error: "Role not found" });
|
||||
@@ -646,6 +865,7 @@ router.put(
|
||||
const updates: {
|
||||
displayName?: string;
|
||||
description?: string | null;
|
||||
permissions?: string | null;
|
||||
updatedAt: string;
|
||||
} = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -659,7 +879,18 @@ router.put(
|
||||
updates.description = description || null;
|
||||
}
|
||||
|
||||
await createCurrentRoleRepository().updateRole(roleId, updates);
|
||||
if (permissions !== undefined) {
|
||||
updates.permissions = JSON.stringify(permissions);
|
||||
}
|
||||
|
||||
await roleRepository.updateRole(roleId, updates);
|
||||
|
||||
if (permissions !== undefined) {
|
||||
const memberIds = await roleRepository.listRoleUserIds(roleId);
|
||||
for (const memberId of memberIds) {
|
||||
permissionManager.invalidateUserPermissionCache(memberId);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -675,6 +906,26 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/permissions/catalog:
|
||||
* get:
|
||||
* summary: Get the role permissions catalog
|
||||
* description: Returns the grouped catalog of role permission strings used by the role permissions editor.
|
||||
* tags:
|
||||
* - RBAC
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The permissions catalog.
|
||||
*/
|
||||
router.get(
|
||||
"/permissions/catalog",
|
||||
authenticateJWT,
|
||||
async (_req: AuthenticatedRequest, res: Response) => {
|
||||
res.json({ catalog: PERMISSION_CATALOG });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/roles/{id}:
|
||||
@@ -836,38 +1087,24 @@ router.post(
|
||||
grantedBy: currentUserId,
|
||||
});
|
||||
|
||||
const hostsSharedWithRole =
|
||||
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources(
|
||||
roleId,
|
||||
);
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
for (const sharedHost of hostsSharedWithRole) {
|
||||
if (sharedHost.credentialId) {
|
||||
try {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
sharedHost.hostAccessId,
|
||||
sharedHost.credentialId,
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().snapshotForRoleMember(
|
||||
roleId,
|
||||
targetUserId,
|
||||
sharedHost.hostOwnerId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credential for new role member",
|
||||
"Failed to snapshot shared host secrets for new role member",
|
||||
error,
|
||||
{
|
||||
operation: "assign_role_create_credentials",
|
||||
operation: "assign_role_snapshot_secrets",
|
||||
targetUserId,
|
||||
roleId,
|
||||
hostId: sharedHost.hostId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissionManager.invalidateUserPermissionCache(targetUserId);
|
||||
databaseLogger.info("Role assigned to user", {
|
||||
@@ -959,6 +1196,28 @@ router.delete(
|
||||
roleId,
|
||||
);
|
||||
|
||||
try {
|
||||
const { createCurrentSharedHostSecretsRepository } =
|
||||
await import("../repositories/factory.js");
|
||||
await createCurrentSharedHostSecretsRepository().deleteForRoleMember(
|
||||
roleId,
|
||||
targetUserId,
|
||||
);
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn(
|
||||
"Failed to clean shared host secrets after role removal",
|
||||
{
|
||||
operation: "remove_role_secret_cleanup",
|
||||
targetUserId,
|
||||
roleId,
|
||||
error:
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "Unknown error",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
permissionManager.invalidateUserPermissionCache(targetUserId);
|
||||
databaseLogger.info("Role removed from user", {
|
||||
operation: "rbac_role_remove",
|
||||
|
||||
@@ -58,12 +58,11 @@ async function syncSharedCredentialsForUserRoles(
|
||||
operation: string,
|
||||
) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.createSharedCredentialsForUserRoles(userId);
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
|
||||
} catch (error) {
|
||||
authLogger.warn("Failed to sync role shared credentials", {
|
||||
authLogger.warn("Failed to sync role shared host secrets", {
|
||||
operation,
|
||||
userId,
|
||||
error,
|
||||
|
||||
@@ -182,7 +182,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"execute",
|
||||
"connect",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
@@ -294,13 +294,13 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
||||
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const sharedCred =
|
||||
await sharedCredManager.getSharedCredentialForUser(
|
||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||
host.id,
|
||||
userId,
|
||||
"ssh",
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
|
||||
@@ -196,10 +196,13 @@ router.post(
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||
// Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields.
|
||||
const hostOwnerId =
|
||||
await hostResolutionRepository.findHostOwnerId(hostId);
|
||||
const host = hostOwnerId
|
||||
? await hostResolutionRepository.findHostById(hostId, hostOwnerId)
|
||||
: null;
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
@@ -210,7 +213,7 @@ router.post(
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"read",
|
||||
"connect",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
@@ -294,8 +297,51 @@ router.post(
|
||||
}
|
||||
|
||||
const hostRecord = host as Record<string, unknown>;
|
||||
const hostRepository = createCurrentHostResolutionRepository();
|
||||
const hostRepository = hostResolutionRepository;
|
||||
const isSharedConnection = host.userId !== userId;
|
||||
|
||||
if (isSharedConnection) {
|
||||
// Recipients never read the owner's raw secrets; wipe them and use
|
||||
// the per-recipient snapshot for the requested protocol instead.
|
||||
host.password = null;
|
||||
host.rdpUser = null;
|
||||
host.rdpPassword = null;
|
||||
host.vncUser = null;
|
||||
host.vncPassword = null;
|
||||
host.telnetUser = null;
|
||||
host.telnetPassword = null;
|
||||
|
||||
try {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const secret =
|
||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||
hostId,
|
||||
userId,
|
||||
connectionType as "rdp" | "vnc" | "telnet",
|
||||
);
|
||||
if (secret) {
|
||||
if (connectionType === "rdp") {
|
||||
host.rdpUser = secret.username ?? null;
|
||||
host.rdpPassword = secret.password ?? null;
|
||||
if (secret.domain) host.rdpDomain = secret.domain;
|
||||
} else if (connectionType === "vnc") {
|
||||
host.vncUser = secret.username ?? null;
|
||||
host.vncPassword = secret.password ?? null;
|
||||
} else if (connectionType === "telnet") {
|
||||
host.telnetUser = secret.username ?? null;
|
||||
host.telnetPassword = secret.password ?? null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
guacLogger.warn("Failed to resolve shared host secret", {
|
||||
operation: "guac_shared_secret_resolve",
|
||||
hostId,
|
||||
protocol: connectionType,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
|
||||
const rdpEffectiveAuthType =
|
||||
(host.rdpAuthType as string) ||
|
||||
@@ -309,11 +355,9 @@ router.post(
|
||||
|
||||
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
|
||||
try {
|
||||
const cred =
|
||||
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
|
||||
const cred = await hostRepository.findCredentialByIdForUser(
|
||||
host.rdpCredentialId as number,
|
||||
host.userId as string,
|
||||
userId,
|
||||
);
|
||||
if (cred) {
|
||||
if (cred.username) host.rdpUser = cred.username;
|
||||
@@ -331,11 +375,9 @@ router.post(
|
||||
|
||||
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
|
||||
try {
|
||||
const cred =
|
||||
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
|
||||
const cred = await hostRepository.findCredentialByIdForUser(
|
||||
host.vncCredentialId as number,
|
||||
host.userId as string,
|
||||
userId,
|
||||
);
|
||||
if (cred) {
|
||||
if (cred.password) host.vncPassword = cred.password;
|
||||
@@ -355,11 +397,9 @@ router.post(
|
||||
hostRecord.telnetCredentialId
|
||||
) {
|
||||
try {
|
||||
const cred =
|
||||
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
|
||||
const cred = await hostRepository.findCredentialByIdForUser(
|
||||
hostRecord.telnetCredentialId as number,
|
||||
host.userId as string,
|
||||
userId,
|
||||
);
|
||||
if (cred) {
|
||||
if (cred.username) host.telnetUser = cred.username;
|
||||
@@ -373,6 +413,7 @@ router.post(
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let token: string;
|
||||
let hostname = host.ip as string;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
expandOidcUsername,
|
||||
} from "./credential-username.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
import type { HostAction } from "../utils/permission-manager.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
@@ -24,16 +25,28 @@ export async function resolveHostById(
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"read",
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) return null;
|
||||
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const resolvedHost = await repository.findHostById(hostId, userId);
|
||||
|
||||
// Decrypt under the owner's DEK: shared hosts carry owner-encrypted fields
|
||||
// (socks5Password, inline auth, ...) that the requester's key cannot open.
|
||||
const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId;
|
||||
const resolvedHost = await repository.findHostById(hostId, ownerId);
|
||||
if (!resolvedHost) return null;
|
||||
|
||||
const host = resolvedHost as Record<string, unknown>;
|
||||
|
||||
if (userId !== ownerId) {
|
||||
// Owner-only operational secrets are never shared.
|
||||
host.sudoPassword = null;
|
||||
host.autostartPassword = null;
|
||||
host.autostartKey = null;
|
||||
host.autostartKeyPassword = null;
|
||||
}
|
||||
|
||||
// Parse JSON fields
|
||||
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
|
||||
try {
|
||||
@@ -78,88 +91,16 @@ export async function resolveHostById(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve credential if using credential-based auth
|
||||
if (host.credentialId) {
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
try {
|
||||
// Try user's own override credential first
|
||||
if (userId !== ownerId) {
|
||||
const resolved = await resolveSharedSshSecrets(
|
||||
host,
|
||||
hostId,
|
||||
userId,
|
||||
repository,
|
||||
);
|
||||
if (!resolved) return null;
|
||||
} else if (host.credentialId) {
|
||||
try {
|
||||
const overrideCredId = await repository.findOverrideCredentialId(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (overrideCredId) {
|
||||
const cred = (await repository.findCredentialByIdForUser(
|
||||
overrideCredId,
|
||||
userId,
|
||||
)) as Record<string, unknown> | null;
|
||||
if (cred) {
|
||||
host.password = cred.password;
|
||||
host.key = cred.key;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
host.keyType = cred.keyType;
|
||||
host.username = pickResolvedUsername(
|
||||
host.username,
|
||||
cred.username,
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
host.authType = cred.key
|
||||
? "key"
|
||||
: cred.password
|
||||
? "password"
|
||||
: "none";
|
||||
host.username = await expandOidcUsername(
|
||||
host.username as string | undefined,
|
||||
userId,
|
||||
);
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to shared credential
|
||||
}
|
||||
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (sharedCred) {
|
||||
host.password = sharedCred.password;
|
||||
host.key = sharedCred.key;
|
||||
host.keyPassword = sharedCred.keyPassword;
|
||||
host.keyType = sharedCred.keyType;
|
||||
host.username = pickResolvedUsername(
|
||||
host.username,
|
||||
sharedCred.username,
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
host.authType = sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none";
|
||||
host.username = await expandOidcUsername(
|
||||
host.username as string | undefined,
|
||||
userId,
|
||||
);
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to get shared credential", {
|
||||
operation: "host_resolver_shared_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const cred = (await repository.findCredentialByIdForUser(
|
||||
host.credentialId as number,
|
||||
ownerId,
|
||||
@@ -218,6 +159,90 @@ export async function resolveHostById(
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in SSH auth secrets for a shared (non-owner) requester. Order:
|
||||
* the recipient's own override credential, then their re-encrypted share
|
||||
* snapshot. Secret-less auth types (opkssh, vault, agent, none) pass through
|
||||
* untouched. Returns false when a secret-bearing host has no usable source.
|
||||
*/
|
||||
async function resolveSharedSshSecrets(
|
||||
host: Record<string, unknown>,
|
||||
hostId: number,
|
||||
userId: string,
|
||||
repository: ReturnType<typeof createCurrentHostResolutionRepository>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const overrideCredId = await repository.findOverrideCredentialId(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (overrideCredId) {
|
||||
const cred = (await repository.findCredentialByIdForUser(
|
||||
overrideCredId,
|
||||
userId,
|
||||
)) as Record<string, unknown> | null;
|
||||
if (cred) {
|
||||
host.password = cred.password;
|
||||
host.key = (cred.privateKey || cred.key) as string | null;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
host.keyType = cred.keyType;
|
||||
host.username = pickResolvedUsername(
|
||||
host.username,
|
||||
cred.username,
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
host.authType = host.key ? "key" : host.password ? "password" : "none";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to the share snapshot
|
||||
}
|
||||
|
||||
try {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
const secret =
|
||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||
hostId,
|
||||
userId,
|
||||
"ssh",
|
||||
);
|
||||
if (secret) {
|
||||
host.password = secret.password;
|
||||
host.key = secret.key;
|
||||
host.keyPassword = secret.keyPassword;
|
||||
host.keyType = secret.keyType;
|
||||
host.username = pickResolvedUsername(
|
||||
host.username,
|
||||
secret.username,
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
host.authType = secret.key
|
||||
? "key"
|
||||
: secret.password
|
||||
? "password"
|
||||
: "none";
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to get shared host secret", {
|
||||
operation: "host_resolver_shared_secret",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
|
||||
const needsSecrets =
|
||||
!!host.credentialId ||
|
||||
host.authType === "password" ||
|
||||
host.authType === "key" ||
|
||||
host.authType === "credential";
|
||||
if (!needsSecrets) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has access to a host (owner or shared access).
|
||||
*/
|
||||
@@ -225,7 +250,7 @@ export async function checkHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
hostUserId: string,
|
||||
requiredPermission: "read" | "execute" = "execute",
|
||||
requiredPermission: HostAction = "connect",
|
||||
): Promise<boolean> {
|
||||
if (userId === hostUserId) return true;
|
||||
|
||||
|
||||
@@ -36,35 +36,36 @@ async function resolveJumpHost(
|
||||
): Promise<JumpHostConfig | null> {
|
||||
try {
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const resolvedHost = await repository.findHostById(hostId, userId);
|
||||
const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId;
|
||||
const resolvedHost = await repository.findHostById(hostId, ownerId);
|
||||
|
||||
if (!resolvedHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = resolvedHost as Record<string, unknown>;
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
const secret =
|
||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||
hostId,
|
||||
userId,
|
||||
"ssh",
|
||||
);
|
||||
if (sharedCred) {
|
||||
if (secret) {
|
||||
return {
|
||||
...host,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.key
|
||||
password: secret.password,
|
||||
key: secret.key,
|
||||
keyPassword: secret.keyPassword,
|
||||
keyType: secret.keyType,
|
||||
authType: secret.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
: secret.password
|
||||
? "password"
|
||||
: "none",
|
||||
} as JumpHostConfig;
|
||||
|
||||
@@ -2,13 +2,14 @@ import type { Express, RequestHandler } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js";
|
||||
import { statsLogger } from "../../utils/logger.js";
|
||||
import type { HostAction } from "../../utils/permission-manager.js";
|
||||
|
||||
type HistoryRoutesDeps = {
|
||||
validateHostId: RequestHandler;
|
||||
canAccessHost: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
level: "read" | "write" | "execute" | "delete" | "share",
|
||||
level: HostAction,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -65,7 +66,7 @@ export function registerHostMetricsHistoryRoutes(
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const hasAccess = await canAccessHost(userId, hostId, "read");
|
||||
const hasAccess = await canAccessHost(userId, hostId, "connect");
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
@@ -898,7 +898,7 @@ async function fetchHostById(
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
id,
|
||||
"read",
|
||||
"connect",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
@@ -991,12 +991,13 @@ async function resolveHostCredentials(
|
||||
const isSharedHost = userId !== ownerId;
|
||||
|
||||
if (isSharedHost) {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const sharedCred =
|
||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||
host.id as number,
|
||||
userId,
|
||||
"ssh",
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
@@ -1730,7 +1731,7 @@ app.get("/status", async (req, res) => {
|
||||
|
||||
const result: Record<number, StatusEntry> = {};
|
||||
for (const [id, entry] of pollingManager.getAllStatuses().entries()) {
|
||||
const access = await permissionManager.canAccessHost(userId, id, "read");
|
||||
const access = await permissionManager.canAccessHost(userId, id, "connect");
|
||||
if (access.hasAccess) {
|
||||
result[id] = entry;
|
||||
}
|
||||
@@ -1771,7 +1772,7 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const access = await permissionManager.canAccessHost(userId, id, "read");
|
||||
const access = await permissionManager.canAccessHost(userId, id, "connect");
|
||||
if (!access.hasAccess) {
|
||||
return res.status(404).json({ error: "Status not available" });
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function registerCronRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "cron_list", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
||||
return { entries: parseCrontab(stdout) };
|
||||
}),
|
||||
@@ -96,7 +96,7 @@ export function registerCronRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"cron_replace",
|
||||
async (client, _host, req) => {
|
||||
const { entries } = req.body as { entries?: CronEntry[] };
|
||||
|
||||
@@ -51,7 +51,7 @@ export function registerFirewallRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/firewall/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "firewall_read", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
|
||||
return await collectFirewallMetrics(client);
|
||||
}),
|
||||
);
|
||||
@@ -61,7 +61,7 @@ export function registerFirewallRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"firewall_rule",
|
||||
async (client, host, req) => {
|
||||
const { op, protocol, port, target } = req.body as {
|
||||
@@ -106,7 +106,7 @@ export function registerFirewallRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"firewall_persist",
|
||||
async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
|
||||
@@ -147,7 +147,7 @@ export function registerHealthRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"connect",
|
||||
"health_get",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -191,7 +191,7 @@ export function registerHealthRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"connect",
|
||||
"health_config",
|
||||
async (_client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -226,7 +226,7 @@ export function registerHealthRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"connect",
|
||||
"health_run",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
@@ -45,7 +45,7 @@ export function registerManagerRoutes(
|
||||
app.get(
|
||||
"/host-metrics/platform/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "platform_detect", (client) =>
|
||||
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
|
||||
detectPlatform(client),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ export function registerLogRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id/files",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "logs_list", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
|
||||
const found = stdout
|
||||
.split("\n")
|
||||
@@ -62,7 +62,7 @@ export function registerLogRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"connect",
|
||||
"logs_tail",
|
||||
async (client, host, req) => {
|
||||
const path = req.query.path as string | undefined;
|
||||
|
||||
@@ -96,7 +96,7 @@ export function registerPackageRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/packages/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "packages_list", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildListUpgradableCommand(platform.pkg);
|
||||
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
|
||||
@@ -113,7 +113,7 @@ export function registerPackageRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"packages_action",
|
||||
async (client, host, req) => {
|
||||
const { action, pkg: name } = req.body as {
|
||||
|
||||
@@ -70,7 +70,7 @@ export function registerProcessRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/processes/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "processes_list", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
|
||||
return { processes: parseProcessList(stdout) };
|
||||
}),
|
||||
@@ -106,7 +106,7 @@ export function registerProcessRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"processes_signal",
|
||||
async (client, host, req) => {
|
||||
const { pid, signal } = req.body as {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AuthenticatedRequest } from "../../../../types/index.js";
|
||||
import { statsLogger } from "../../../utils/logger.js";
|
||||
import { ElevationError } from "./exec-elevated.js";
|
||||
import type { ManagerHost, RunOnHost } from "./types.js";
|
||||
import type { HostAction } from "../../../utils/permission-manager.js";
|
||||
|
||||
export class AccessDeniedError extends Error {
|
||||
constructor(message = "No access to this host") {
|
||||
@@ -25,7 +26,7 @@ export class ManagerInputError extends Error {
|
||||
*/
|
||||
export function managerHandler(
|
||||
runOnHost: RunOnHost,
|
||||
level: "read" | "execute",
|
||||
level: HostAction,
|
||||
operation: string,
|
||||
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
|
||||
) {
|
||||
|
||||
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/services/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "services_list", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "services_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
|
||||
return { services: parseServiceList(stdout) };
|
||||
}),
|
||||
@@ -106,7 +106,7 @@ export function registerServiceRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"services_action",
|
||||
async (client, host, req) => {
|
||||
const { unit, action } = req.body as {
|
||||
|
||||
@@ -112,7 +112,7 @@ export function registerSimpleReadRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/top-memory/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "top_memory", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
|
||||
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
|
||||
return { processes: parseTopMemory(stdout) };
|
||||
}),
|
||||
@@ -121,7 +121,7 @@ export function registerSimpleReadRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/timers/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "systemd_timers", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
|
||||
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
|
||||
return { timers: parseTimers(stdout) };
|
||||
}),
|
||||
@@ -130,7 +130,7 @@ export function registerSimpleReadRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/disk-breakdown/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "disk_breakdown", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
|
||||
const { stdout } = await execCommand(client, DF_CMD, 15000);
|
||||
return { mounts: parseDfMounts(stdout) };
|
||||
}),
|
||||
|
||||
@@ -153,7 +153,7 @@ export function registerSslRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/ssl/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "ssl_list", async (client, host) => {
|
||||
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const certs: CertInfo[] = [];
|
||||
if (platform.hasCertbot) {
|
||||
@@ -187,7 +187,7 @@ export function registerSslRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"ssl_issue",
|
||||
async (client, host, req) => {
|
||||
const body = req.body as Partial<IssueRequest>;
|
||||
@@ -236,7 +236,7 @@ export function registerSslRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"ssl_renew",
|
||||
async (client, host, req) => {
|
||||
const { client: acmeClient, dryRun } = req.body as {
|
||||
@@ -289,7 +289,7 @@ export function registerSslRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"ssl_revoke",
|
||||
async (client, host, req) => {
|
||||
const { client: acmeClient, name } = req.body as {
|
||||
|
||||
@@ -116,7 +116,7 @@ export function registerTailscaleRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/tailscale/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "tailscale_read", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "tailscale_read", async (client) => {
|
||||
const { stdout } = await execCommand(client, PROBE_CMD, 15000);
|
||||
return parseTailscaleData(stdout);
|
||||
}),
|
||||
@@ -154,7 +154,7 @@ export function registerTailscaleRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"tailscale_action",
|
||||
async (client, host, req) => {
|
||||
const { action } = req.body as { action: unknown };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { RequestHandler } from "express";
|
||||
import type { HostAction } from "../../../utils/permission-manager.js";
|
||||
|
||||
/** Minimal host shape managers need (includes the decrypted sudo password). */
|
||||
export interface ManagerHost {
|
||||
@@ -17,7 +18,7 @@ export interface ManagerHost {
|
||||
export type RunOnHost = <T>(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
level: "read" | "execute",
|
||||
level: HostAction,
|
||||
fn: (client: Client, host: ManagerHost) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export function registerUserRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/users/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "users_list", async (client) => {
|
||||
managerHandler(runOnHost, "connect", "users_list", async (client) => {
|
||||
const [passwd, groups, sudoers] = await Promise.all([
|
||||
execCommand(client, READ_USERS_CMD, 15000),
|
||||
execCommand(client, READ_GROUPS_CMD, 15000),
|
||||
@@ -98,7 +98,7 @@ export function registerUserRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"users_action",
|
||||
async (client, host, req) => {
|
||||
const { action, username, group } = req.body as {
|
||||
|
||||
@@ -143,7 +143,7 @@ export function registerWireGuardRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"connect",
|
||||
"wireguard_read",
|
||||
async (client, host) => {
|
||||
const result = await execElevated(
|
||||
@@ -194,7 +194,7 @@ export function registerWireGuardRoutes(
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"connect",
|
||||
"wireguard_action",
|
||||
async (client, host, req) => {
|
||||
const { interface: iface, action } = req.body as {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
defaultLayoutFromWidgets,
|
||||
type HostMetricsLayout,
|
||||
} from "../../../types/host-metrics.js";
|
||||
import type { HostAction } from "../../utils/permission-manager.js";
|
||||
|
||||
interface PrefStatsConfig {
|
||||
enabledWidgets?: string[];
|
||||
@@ -25,7 +26,7 @@ type HostMetricsPreferencesRoutesDeps = {
|
||||
canAccessHost: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
level: "read" | "execute",
|
||||
level: HostAction,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -88,7 +89,7 @@ export function registerHostMetricsPreferencesRoutes(
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
if (!(await canAccessHost(userId, hostId, "read"))) {
|
||||
if (!(await canAccessHost(userId, hostId, "connect"))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
@@ -166,7 +167,7 @@ export function registerHostMetricsPreferencesRoutes(
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
if (!(await canAccessHost(userId, hostId, "read"))) {
|
||||
if (!(await canAccessHost(userId, hostId, "connect"))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||
import { resolveHostById, checkHostAccess } from "../host-resolver.js";
|
||||
import type { HostAction } from "../../utils/permission-manager.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
||||
import {
|
||||
@@ -343,7 +344,7 @@ app.use(authManager.createAuthMiddleware());
|
||||
async function requireHost(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
permission: "read" | "execute" = "read",
|
||||
permission: HostAction = "connect",
|
||||
): Promise<SSHHost | null> {
|
||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.hostId), 10);
|
||||
@@ -503,7 +504,7 @@ app.get("/tmux_monitor/:hostId/overview", async (req, res) => {
|
||||
// Focus a pane: select its window and pane on the server so every attached
|
||||
// client (including the monitor's embedded terminal) switches to it.
|
||||
app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
|
||||
const host = await requireHost(req, res, "execute");
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
||||
@@ -530,7 +531,7 @@ app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
|
||||
|
||||
// Create a detached session. Starts the tmux server if none is running.
|
||||
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
|
||||
const host = await requireHost(req, res, "execute");
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const name = String((req.body as { name?: string })?.name || "").trim();
|
||||
@@ -563,7 +564,7 @@ app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
|
||||
// target's meaning (":" and "." are window/pane separators in tmux targets);
|
||||
// "=" prefixes the target for an exact-name match.
|
||||
app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
|
||||
const host = await requireHost(req, res, "execute");
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const sessionName = String(
|
||||
@@ -597,7 +598,7 @@ app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
|
||||
// Rename a session. Saved tags follow the session to its new name (for every
|
||||
// user — the session itself is shared on the host).
|
||||
app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
|
||||
const host = await requireHost(req, res, "execute");
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const body = req.body as { sessionName?: string; newName?: string };
|
||||
@@ -649,7 +650,7 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
|
||||
|
||||
// Kill a session and drop its saved tags.
|
||||
app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
|
||||
const host = await requireHost(req, res, "execute");
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const sessionName = String(
|
||||
@@ -688,7 +689,7 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
|
||||
// Kill a window (and every pane in it). Killing the last window of a session
|
||||
// ends the session — tmux semantics.
|
||||
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
|
||||
const host = await requireHost(req, res, "execute");
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const body = req.body as { sessionName?: string; windowIndex?: number };
|
||||
@@ -735,7 +736,7 @@ app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
|
||||
// Kill a single pane. Killing the last pane of a window closes the window,
|
||||
// and the last window of a session ends the session — tmux semantics.
|
||||
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
|
||||
const host = await requireHost(req, res, "execute");
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
||||
@@ -766,7 +767,7 @@ app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
|
||||
// "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
|
||||
// source pane's working directory.
|
||||
app.post("/tmux_monitor/:hostId/split", async (req, res) => {
|
||||
const host = await requireHost(req, res, "execute");
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const body = req.body as { paneId?: string; direction?: string };
|
||||
|
||||
@@ -36,7 +36,7 @@ async function resolveC2STunnelSource(
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
tunnelConfig.sourceHostId,
|
||||
"read",
|
||||
"connect",
|
||||
);
|
||||
if (!accessInfo.hasAccess) {
|
||||
throw new Error("Access denied to this host");
|
||||
|
||||
@@ -199,7 +199,7 @@ export function registerTunnelRoutes(app: express.Express): void {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
tunnelConfig.sourceHostId,
|
||||
"read",
|
||||
"connect",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
@@ -285,7 +285,7 @@ export function registerTunnelRoutes(app: express.Express): void {
|
||||
const endpointAccess = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
endpointHost.id,
|
||||
"read",
|
||||
"connect",
|
||||
);
|
||||
if (!endpointAccess.hasAccess) {
|
||||
tunnelLogger.warn(
|
||||
@@ -442,7 +442,7 @@ export function registerTunnelRoutes(app: express.Express): void {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
config.sourceHostId,
|
||||
"read",
|
||||
"connect",
|
||||
);
|
||||
if (!accessInfo.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
@@ -547,7 +547,7 @@ export function registerTunnelRoutes(app: express.Express): void {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
config.sourceHostId,
|
||||
"read",
|
||||
"connect",
|
||||
);
|
||||
if (!accessInfo.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
|
||||
@@ -101,6 +101,10 @@ import {
|
||||
await authManager.initialize();
|
||||
DataCrypto.initialize();
|
||||
|
||||
const { runSharedHostSecretsMigration } =
|
||||
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
|
||||
await runSharedHostSecretsMigration();
|
||||
|
||||
import("./utils/opkssh-binary-manager.js").then(
|
||||
({ OPKSSHBinaryManager }) => {
|
||||
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
||||
|
||||
@@ -322,6 +322,7 @@ describe("HostResolutionRepository", () => {
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: null,
|
||||
authType: "password",
|
||||
});
|
||||
await expect(repository.findHostUpdateState(999)).resolves.toBeNull();
|
||||
|
||||
@@ -52,19 +52,23 @@ describe("RbacAccessRepository", () => {
|
||||
override_credential_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE shared_credentials (
|
||||
CREATE TABLE shared_host_secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
original_credential_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
encrypted_username TEXT NOT NULL,
|
||||
encrypted_auth_type TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
source_type TEXT NOT NULL DEFAULT 'credential',
|
||||
original_credential_id INTEGER,
|
||||
encrypted_username TEXT,
|
||||
encrypted_auth_type TEXT,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
encrypted_domain TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(host_access_id, target_user_id, protocol)
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
@@ -128,10 +132,12 @@ describe("RbacAccessRepository", () => {
|
||||
(2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'),
|
||||
(5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
|
||||
|
||||
INSERT INTO shared_credentials (
|
||||
id, host_access_id, original_credential_id, target_user_id, encrypted_username, encrypted_auth_type
|
||||
INSERT INTO shared_host_secrets (
|
||||
id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type
|
||||
)
|
||||
VALUES (8, 2, 123, 'user-1', 'enc-user', 'enc-auth');
|
||||
VALUES
|
||||
(8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'),
|
||||
(9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct');
|
||||
|
||||
INSERT INTO snippets (id, user_id, name, content)
|
||||
VALUES (99, 'owner-1', 'deploy', 'echo deploy');
|
||||
@@ -261,14 +267,16 @@ describe("RbacAccessRepository", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds shared credential and host access owner for shared credential reads", async () => {
|
||||
it("finds shared secrets per protocol and host access owner", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await expect(
|
||||
repo.findSharedCredentialForHostAndUser(42, "user-1"),
|
||||
repo.findSharedSecretForHostUserProtocol(42, "user-1", "ssh"),
|
||||
).resolves.toMatchObject({
|
||||
id: 8,
|
||||
hostAccessId: 2,
|
||||
protocol: "ssh",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: 123,
|
||||
targetUserId: "user-1",
|
||||
encryptedUsername: "enc-user",
|
||||
@@ -276,12 +284,62 @@ describe("RbacAccessRepository", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.findSharedCredentialForHostAndUser(99, "user-1"),
|
||||
repo.findSharedSecretForHostUserProtocol(42, "user-1", "rdp"),
|
||||
).resolves.toMatchObject({
|
||||
id: 9,
|
||||
protocol: "rdp",
|
||||
sourceType: "inline",
|
||||
originalCredentialId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.findSharedSecretForHostUserProtocol(42, "user-1", "vnc"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repo.findSharedSecretForHostUserProtocol(99, "user-1", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1");
|
||||
await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("lists active grants, finds grants by id and updates grant level/expiry", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const grants = await repo.listActiveHostAccessGrants(42, activeAccessTime);
|
||||
expect(grants.map((grant) => grant.id).sort()).toEqual([1, 2]);
|
||||
|
||||
// Host 44's only grant expired before activeAccessTime.
|
||||
await expect(
|
||||
repo.listActiveHostAccessGrants(44, activeAccessTime),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({
|
||||
id: 1,
|
||||
hostId: 42,
|
||||
permissionLevel: "view",
|
||||
});
|
||||
await expect(repo.findHostAccessById(1, 99)).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
repo.updateHostAccessGrant(1, 42, {
|
||||
permissionLevel: "manage",
|
||||
expiresAt: "2026-07-01T00:00:00.000Z",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({
|
||||
permissionLevel: "manage",
|
||||
expiresAt: "2026-07-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.updateHostAccessGrant(999, 42, { permissionLevel: "view" }),
|
||||
).resolves.toBe(false);
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("lists shared snippets and preserves route-level direct-over-role behavior", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@ describe("SessionRecordingRepository", () => {
|
||||
expect(rows[0]).toMatchObject({
|
||||
hostIp: "10.0.0.2",
|
||||
recordingPath: "/tmp/two.log",
|
||||
protocol: "ssh",
|
||||
format: "text",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SharedCredentialRepository } from "../../../database/repositories/shared-credential-repository.js";
|
||||
|
||||
describe("SharedCredentialRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<{
|
||||
repository: SharedCredentialRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE shared_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
original_credential_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
encrypted_username TEXT NOT NULL,
|
||||
encrypted_auth_type TEXT NOT NULL,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO shared_credentials (
|
||||
id,
|
||||
host_access_id,
|
||||
original_credential_id,
|
||||
target_user_id,
|
||||
encrypted_username,
|
||||
encrypted_auth_type,
|
||||
encrypted_password
|
||||
)
|
||||
VALUES
|
||||
(1, 10, 100, 'user-1', 'u1', 'password', 'p1'),
|
||||
(2, 10, 100, 'user-2', 'u2', 'password', 'p2'),
|
||||
(3, 11, 101, 'user-2', 'u3', 'key', NULL);
|
||||
`);
|
||||
|
||||
return {
|
||||
repository: new SharedCredentialRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
it("checks existence and creates shared credentials", async () => {
|
||||
let writes = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.existsForHostAccessAndTargetUser(10, "user-1"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repository.existsForHostAccessAndTargetUser(10, "missing"),
|
||||
).resolves.toBe(false);
|
||||
|
||||
const created = await repository.create({
|
||||
hostAccessId: 12,
|
||||
originalCredentialId: 102,
|
||||
targetUserId: "user-3",
|
||||
encryptedUsername: "u4",
|
||||
encryptedAuthType: "password",
|
||||
encryptedPassword: "p4",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
hostAccessId: 12,
|
||||
originalCredentialId: 102,
|
||||
targetUserId: "user-3",
|
||||
encryptedUsername: "u4",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("finds, lists, and updates shared credentials", async () => {
|
||||
let writes = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(repository.findById(1)).resolves.toMatchObject({
|
||||
targetUserId: "user-1",
|
||||
});
|
||||
await expect(repository.findById(999)).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.listByOriginalCredentialId(100),
|
||||
).resolves.toHaveLength(2);
|
||||
|
||||
await expect(
|
||||
repository.updateById(2, {
|
||||
encryptedPassword: "updated",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
encryptedPassword: "updated",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes shared credentials", async () => {
|
||||
let writes = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(repository.deleteById(1)).resolves.toBe(true);
|
||||
await expect(repository.deleteById(999)).resolves.toBe(false);
|
||||
await expect(repository.deleteByTargetUserId("user-2")).resolves.toBe(2);
|
||||
await expect(repository.deleteByOriginalCredentialId(100)).resolves.toBe(0);
|
||||
|
||||
expect(
|
||||
sqlite.prepare("SELECT id FROM shared_credentials ORDER BY id").all(),
|
||||
).toEqual([]);
|
||||
expect(writes).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SharedHostSecretsRepository } from "../../../database/repositories/shared-host-secrets-repository.js";
|
||||
|
||||
describe("SharedHostSecretsRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<{
|
||||
repository: SharedHostSecretsRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite!.exec(`
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'connect',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
override_credential_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
credential_id INTEGER,
|
||||
rdp_credential_id INTEGER,
|
||||
vnc_credential_id INTEGER,
|
||||
telnet_credential_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE shared_host_secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
source_type TEXT NOT NULL DEFAULT 'credential',
|
||||
original_credential_id INTEGER,
|
||||
encrypted_username TEXT,
|
||||
encrypted_auth_type TEXT,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
encrypted_domain TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(host_access_id, target_user_id, protocol)
|
||||
);
|
||||
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id)
|
||||
VALUES
|
||||
(42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124),
|
||||
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL),
|
||||
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL);
|
||||
|
||||
INSERT INTO host_access (id, host_id, user_id, role_id, granted_by)
|
||||
VALUES
|
||||
(1, 42, 'user-1', NULL, 'owner-1'),
|
||||
(2, 42, NULL, 7, 'owner-1'),
|
||||
(3, 43, 'user-2', NULL, 'owner-1');
|
||||
`);
|
||||
|
||||
return {
|
||||
repository: new SharedHostSecretsRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
it("upserts snapshots per protocol and finds them by host/user/protocol", async () => {
|
||||
let writeCount = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repository.upsert({
|
||||
hostAccessId: 1,
|
||||
targetUserId: "user-1",
|
||||
protocol: "ssh",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: 123,
|
||||
encryptedUsername: "enc-user",
|
||||
encryptedAuthType: "password",
|
||||
encryptedPassword: "enc-pass",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.findForHostUserProtocol(42, "user-1", "ssh"),
|
||||
).resolves.toMatchObject({
|
||||
hostAccessId: 1,
|
||||
protocol: "ssh",
|
||||
encryptedPassword: "enc-pass",
|
||||
});
|
||||
|
||||
// Upsert on the same (grant, user, protocol) updates in place.
|
||||
await repository.upsert({
|
||||
hostAccessId: 1,
|
||||
targetUserId: "user-1",
|
||||
protocol: "ssh",
|
||||
sourceType: "inline",
|
||||
originalCredentialId: null,
|
||||
encryptedUsername: "enc-user-2",
|
||||
encryptedAuthType: "key",
|
||||
encryptedKey: "enc-key",
|
||||
});
|
||||
|
||||
const updated = await repository.findForHostUserProtocol(
|
||||
42,
|
||||
"user-1",
|
||||
"ssh",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
sourceType: "inline",
|
||||
encryptedUsername: "enc-user-2",
|
||||
encryptedKey: "enc-key",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.findForHostUserProtocol(42, "user-1", "rdp"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findForHostUserProtocol(43, "user-1", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes stale protocols while keeping the listed ones", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
for (const protocol of ["ssh", "rdp", "vnc"] as const) {
|
||||
await repository.upsert({
|
||||
hostAccessId: 1,
|
||||
targetUserId: "user-1",
|
||||
protocol,
|
||||
sourceType: "inline",
|
||||
encryptedAuthType: "direct",
|
||||
});
|
||||
}
|
||||
|
||||
await repository.deleteForHostAccessAndTarget(1, "user-1", ["ssh"]);
|
||||
|
||||
await expect(
|
||||
repository.findForHostUserProtocol(42, "user-1", "ssh"),
|
||||
).resolves.not.toBeNull();
|
||||
await expect(
|
||||
repository.findForHostUserProtocol(42, "user-1", "rdp"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findForHostUserProtocol(42, "user-1", "vnc"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("deletes by host access, target user, credential and role membership", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
await repository.upsert({
|
||||
hostAccessId: 1,
|
||||
targetUserId: "user-1",
|
||||
protocol: "ssh",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: 123,
|
||||
encryptedAuthType: "password",
|
||||
});
|
||||
await repository.upsert({
|
||||
hostAccessId: 2,
|
||||
targetUserId: "user-1",
|
||||
protocol: "ssh",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: 123,
|
||||
encryptedAuthType: "password",
|
||||
});
|
||||
await repository.upsert({
|
||||
hostAccessId: 3,
|
||||
targetUserId: "user-2",
|
||||
protocol: "ssh",
|
||||
sourceType: "inline",
|
||||
encryptedAuthType: "password",
|
||||
});
|
||||
|
||||
// Role-membership cleanup: only grant 2 targets role 7.
|
||||
expect(await repository.deleteForRoleMember(7, "user-1")).toBe(1);
|
||||
await expect(
|
||||
repository.existsForHostAccessAndTargetUser(2, "user-1"),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
repository.existsForHostAccessAndTargetUser(1, "user-1"),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(await repository.deleteByHostAccessId(1)).toBe(1);
|
||||
expect(await repository.deleteByTargetUserId("user-2")).toBe(1);
|
||||
expect(await repository.deleteByOriginalCredentialId(123)).toBe(0);
|
||||
});
|
||||
|
||||
it("finds host ids referencing a credential for the owner", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
await expect(
|
||||
repository.findHostIdsReferencingCredential("owner-1", 123),
|
||||
).resolves.toEqual([42]);
|
||||
await expect(
|
||||
repository.findHostIdsReferencingCredential("owner-1", 124),
|
||||
).resolves.toEqual([42]);
|
||||
await expect(
|
||||
repository.findHostIdsReferencingCredential("owner-1", 999),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
repository.findHostIdsReferencingCredential("owner-2", 123),
|
||||
).resolves.toEqual([44]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isValidPort,
|
||||
normalizeImportedHost,
|
||||
renameFolderPath,
|
||||
sanitizeHostForRecipient,
|
||||
stripSensitiveFields,
|
||||
transformHostResponse,
|
||||
} from "../../../database/routes/host-normalizers.js";
|
||||
@@ -219,3 +220,58 @@ describe("transformHostResponse", () => {
|
||||
expect(result.proxmoxConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeHostForRecipient", () => {
|
||||
const sharedHost = {
|
||||
id: 42,
|
||||
userId: "owner",
|
||||
ownerUsername: "owner",
|
||||
isShared: true,
|
||||
permissionLevel: "view",
|
||||
name: "prod",
|
||||
ip: "10.0.0.42",
|
||||
port: 22,
|
||||
username: "root",
|
||||
folder: "servers",
|
||||
tags: ["linux"],
|
||||
notes: "secret runbook",
|
||||
quickActions: [{ name: "restart", snippetId: "1" }],
|
||||
password: "hunter2",
|
||||
key: "PRIVATE",
|
||||
sudoPassword: "sudo",
|
||||
rdpPassword: "rdp",
|
||||
socks5Password: "socks",
|
||||
enableSsh: true,
|
||||
enableRdp: true,
|
||||
sshPort: 22,
|
||||
rdpPort: 3389,
|
||||
defaultPath: "/srv",
|
||||
};
|
||||
|
||||
it("always strips secrets for recipients", () => {
|
||||
const result = sanitizeHostForRecipient({ ...sharedHost }, "view");
|
||||
expect(result.password).toBeUndefined();
|
||||
expect(result.key).toBeUndefined();
|
||||
expect(result.sudoPassword).toBeUndefined();
|
||||
expect(result.rdpPassword).toBeUndefined();
|
||||
expect(result.socks5Password).toBeUndefined();
|
||||
// view keeps configuration fields
|
||||
expect(result.notes).toBe("secret runbook");
|
||||
expect(result.quickActions).toEqual(sharedHost.quickActions);
|
||||
});
|
||||
|
||||
it("reduces connect-level hosts to connection essentials", () => {
|
||||
const result = sanitizeHostForRecipient(
|
||||
{ ...sharedHost, permissionLevel: "connect" },
|
||||
"connect",
|
||||
);
|
||||
expect(result.name).toBe("prod");
|
||||
expect(result.ip).toBe("10.0.0.42");
|
||||
expect(result.enableRdp).toBe(true);
|
||||
expect(result.rdpPort).toBe(3389);
|
||||
expect(result.permissionLevel).toBe("connect");
|
||||
expect(result.notes).toBeUndefined();
|
||||
expect(result.quickActions).toBeUndefined();
|
||||
expect(result.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
host: null as Record<string, unknown> | null,
|
||||
hasAccess: true,
|
||||
overrideCredentialId: null as number | null,
|
||||
credentials: new Map<string, Record<string, unknown>>(),
|
||||
sharedSecret: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async () => (state.host?.userId as string) ?? null,
|
||||
findHostById: async () => (state.host ? { ...state.host } : null),
|
||||
findOverrideCredentialId: async () => state.overrideCredentialId,
|
||||
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
||||
state.credentials.get(`${credentialId}:${userId}`) ?? null,
|
||||
}),
|
||||
createCurrentVaultProfileRepository: () => ({
|
||||
findById: async () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => ({ hasAccess: state.hasAccess }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
getSecretForUser: async () => state.sharedSecret,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
||||
|
||||
function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 42,
|
||||
userId: "owner",
|
||||
name: "prod",
|
||||
ip: "10.0.0.42",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "owner-secret",
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
credentialId: null,
|
||||
vaultProfileId: null,
|
||||
sudoPassword: "owner-sudo",
|
||||
autostartPassword: "auto-pass",
|
||||
autostartKey: null,
|
||||
autostartKeyPassword: null,
|
||||
jumpHosts: null,
|
||||
tunnelConnections: null,
|
||||
statsConfig: null,
|
||||
terminalConfig: null,
|
||||
socks5ProxyChain: null,
|
||||
quickActions: null,
|
||||
overrideCredentialUsername: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.host = baseHost();
|
||||
state.hasAccess = true;
|
||||
state.overrideCredentialId = null;
|
||||
state.credentials.clear();
|
||||
state.sharedSecret = null;
|
||||
});
|
||||
|
||||
describe("resolveHostById", () => {
|
||||
it("returns null when access is denied", async () => {
|
||||
state.hasAccess = false;
|
||||
expect(await resolveHostById(42, "stranger")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the owner's credential on the owner path", async () => {
|
||||
// Empty host username so the credential's username is used as fallback.
|
||||
state.host = baseHost({
|
||||
authType: "credential",
|
||||
credentialId: 9,
|
||||
username: "",
|
||||
});
|
||||
state.credentials.set("9:owner", {
|
||||
id: 9,
|
||||
username: "cred-user",
|
||||
authType: "key",
|
||||
password: null,
|
||||
privateKey: "PRIVATE-KEY",
|
||||
key: null,
|
||||
keyPassword: "kp",
|
||||
keyType: "ssh-ed25519",
|
||||
certPublicKey: null,
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.key).toBe("PRIVATE-KEY");
|
||||
expect(host.username).toBe("cred-user");
|
||||
expect(host.authType).toBe("key");
|
||||
expect(host.sudoPassword).toBe("owner-sudo");
|
||||
});
|
||||
|
||||
it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => {
|
||||
state.host = baseHost({ username: "" });
|
||||
state.sharedSecret = {
|
||||
username: "shared-user",
|
||||
authType: "password",
|
||||
password: "shared-pass",
|
||||
};
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.password).toBe("shared-pass");
|
||||
expect(host.username).toBe("shared-user");
|
||||
expect(host.sudoPassword).toBeNull();
|
||||
expect(host.autostartPassword).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the recipient's override credential over the snapshot", async () => {
|
||||
state.host = baseHost({ username: "" });
|
||||
state.overrideCredentialId = 5;
|
||||
state.credentials.set("5:recipient", {
|
||||
id: 5,
|
||||
username: "my-user",
|
||||
authType: "password",
|
||||
password: "my-pass",
|
||||
privateKey: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
});
|
||||
state.sharedSecret = {
|
||||
username: "shared-user",
|
||||
authType: "password",
|
||||
password: "shared-pass",
|
||||
};
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.password).toBe("my-pass");
|
||||
expect(host.username).toBe("my-user");
|
||||
});
|
||||
|
||||
it("denies a non-owner when a secret-bearing host has no snapshot", async () => {
|
||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||
});
|
||||
|
||||
it("lets a non-owner through on secret-less auth types without a snapshot", async () => {
|
||||
state.host = baseHost({ authType: "none", password: null });
|
||||
const host = await resolveHostById(42, "recipient");
|
||||
expect(host).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: new Map<string, string>(),
|
||||
grants: [] as Array<Record<string, unknown>>,
|
||||
roleMembers: new Map<number, string[]>(),
|
||||
executedSql: [] as string[],
|
||||
snapshots: [] as Array<[number, number, string, string]>,
|
||||
usersWithKeys: new Set<string>(),
|
||||
saves: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
getCurrentRepositorySqlite: () => ({
|
||||
prepare: (sql: string) => ({
|
||||
all: (..._params: unknown[]) => {
|
||||
if (sql.includes("FROM host_access")) return state.grants;
|
||||
if (sql.includes("FROM user_roles")) {
|
||||
const roleId = _params[0] as number;
|
||||
return (state.roleMembers.get(roleId) ?? []).map((id) => ({
|
||||
user_id: id,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
exec: (sql: string) => {
|
||||
state.executedSql.push(sql);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
snapshotForUser: async (
|
||||
hostAccessId: number,
|
||||
hostId: number,
|
||||
targetUserId: string,
|
||||
ownerId: string,
|
||||
) => {
|
||||
if (targetUserId === "broken") throw new Error("boom");
|
||||
state.snapshots.push([hostAccessId, hostId, targetUserId, ownerId]);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
canUserAccessData: (userId: string) => state.usersWithKeys.has(userId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
forceSave: async (reason: string) => {
|
||||
state.saves.push(reason);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { runSharedHostSecretsMigration } from "../../../utils/crypto-migration/shared-host-secrets-migration.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings.clear();
|
||||
state.grants = [];
|
||||
state.roleMembers.clear();
|
||||
state.executedSql = [];
|
||||
state.snapshots = [];
|
||||
state.usersWithKeys = new Set(["owner", "alice", "bob"]);
|
||||
state.saves = [];
|
||||
});
|
||||
|
||||
describe("runSharedHostSecretsMigration", () => {
|
||||
it("re-snapshots direct and role grants, drops the legacy table and sets the flag", async () => {
|
||||
state.grants = [
|
||||
{
|
||||
hostAccessId: 1,
|
||||
hostId: 42,
|
||||
userId: "alice",
|
||||
roleId: null,
|
||||
ownerId: "owner",
|
||||
},
|
||||
{
|
||||
hostAccessId: 2,
|
||||
hostId: 42,
|
||||
userId: null,
|
||||
roleId: 9,
|
||||
ownerId: "owner",
|
||||
},
|
||||
];
|
||||
state.roleMembers.set(9, ["bob", "owner"]);
|
||||
|
||||
const result = await runSharedHostSecretsMigration();
|
||||
|
||||
expect(result).toEqual({ snapshotted: 2, skipped: 0 });
|
||||
expect(state.snapshots).toEqual([
|
||||
[1, 42, "alice", "owner"],
|
||||
[2, 42, "bob", "owner"],
|
||||
]);
|
||||
expect(
|
||||
state.executedSql.some((sql) =>
|
||||
sql.includes("DROP TABLE IF EXISTS shared_credentials"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(state.settings.get("shared_host_secrets_migrated_v1")).toBe("done");
|
||||
expect(state.saves).toContain("shared_host_secrets_migration");
|
||||
});
|
||||
|
||||
it("skips grants with missing DEKs and failed snapshots without crashing", async () => {
|
||||
state.usersWithKeys = new Set(["owner", "alice", "broken"]);
|
||||
state.grants = [
|
||||
{
|
||||
hostAccessId: 1,
|
||||
hostId: 42,
|
||||
userId: "alice",
|
||||
roleId: null,
|
||||
ownerId: "owner",
|
||||
},
|
||||
{
|
||||
hostAccessId: 2,
|
||||
hostId: 42,
|
||||
userId: "no-key",
|
||||
roleId: null,
|
||||
ownerId: "owner",
|
||||
},
|
||||
{
|
||||
hostAccessId: 3,
|
||||
hostId: 42,
|
||||
userId: "broken",
|
||||
roleId: null,
|
||||
ownerId: "owner",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await runSharedHostSecretsMigration();
|
||||
|
||||
expect(result).toEqual({ snapshotted: 1, skipped: 2 });
|
||||
expect(state.settings.get("shared_host_secrets_migrated_v1")).toBe("done");
|
||||
});
|
||||
|
||||
it("does nothing when the migration flag is already set", async () => {
|
||||
state.settings.set("shared_host_secrets_migrated_v1", "done");
|
||||
state.grants = [
|
||||
{
|
||||
hostAccessId: 1,
|
||||
hostId: 42,
|
||||
userId: "alice",
|
||||
roleId: null,
|
||||
ownerId: "owner",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await runSharedHostSecretsMigration();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(state.snapshots).toEqual([]);
|
||||
expect(state.executedSql).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
isValidPermission,
|
||||
} from "../../utils/permission-catalog.js";
|
||||
|
||||
describe("permission catalog", () => {
|
||||
it("accepts every cataloged permission and group wildcard", () => {
|
||||
for (const entry of PERMISSION_CATALOG) {
|
||||
expect(isValidPermission(`${entry.group}.*`)).toBe(true);
|
||||
for (const permission of entry.permissions) {
|
||||
expect(isValidPermission(permission)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the global wildcard", () => {
|
||||
expect(isValidPermission("*")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown permissions and malformed wildcards", () => {
|
||||
expect(isValidPermission("hosts.hack")).toBe(false);
|
||||
expect(isValidPermission("unknown.*")).toBe(false);
|
||||
expect(isValidPermission("")).toBe(false);
|
||||
expect(isValidPermission("hosts")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,39 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const accessState = vi.hoisted(() => ({
|
||||
ownerId: "owner" as string,
|
||||
grant: null as {
|
||||
id: number;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
} | null,
|
||||
touched: [] as number[],
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
isHostOwnedByUser: async (_hostId: number, userId: string) =>
|
||||
userId === accessState.ownerId,
|
||||
findHostOwnerId: async () => accessState.ownerId,
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
findActiveHostAccess: async () => accessState.grant,
|
||||
touchHostAccess: async (id: number) => {
|
||||
accessState.touched.push(id);
|
||||
},
|
||||
deleteExpiredHostAccess: async () => 0,
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
listUserRoleIds: async () => [],
|
||||
listUserRolePermissions: async () => [],
|
||||
userHasAnyRoleName: async () => false,
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PermissionManager } = await import("../../utils/permission-manager.js");
|
||||
|
||||
type PermissionManagerInstance = ReturnType<
|
||||
@@ -71,3 +104,65 @@ describe("PermissionManager.hasPermission wildcard matching", () => {
|
||||
expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PermissionManager.canAccessHost level hierarchy", () => {
|
||||
const manager = PermissionManager.getInstance();
|
||||
const actions = ["connect", "view", "edit", "manage"] as const;
|
||||
const levels = ["connect", "view", "edit", "manage"] as const;
|
||||
const rank = { connect: 1, view: 2, edit: 3, manage: 4 } as const;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
accessState.ownerId = "owner";
|
||||
accessState.grant = null;
|
||||
accessState.touched = [];
|
||||
});
|
||||
|
||||
it("grants the owner every action including delete", async () => {
|
||||
for (const action of [...actions, "delete"] as const) {
|
||||
const info = await manager.canAccessHost("owner", 42, action);
|
||||
expect(info).toMatchObject({ hasAccess: true, isOwner: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("denies everything without a grant", async () => {
|
||||
const info = await manager.canAccessHost("stranger", 42, "connect");
|
||||
expect(info).toMatchObject({ hasAccess: false, isShared: false });
|
||||
});
|
||||
|
||||
it("enforces the connect < view < edit < manage hierarchy", async () => {
|
||||
for (const level of levels) {
|
||||
accessState.grant = { id: 5, permissionLevel: level, expiresAt: null };
|
||||
for (const action of actions) {
|
||||
const info = await manager.canAccessHost("recipient", 42, action);
|
||||
expect(info.hasAccess).toBe(rank[level] >= rank[action]);
|
||||
expect(info.permissionLevel).toBe(level);
|
||||
expect(info.isShared).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("never grants delete to a shared recipient", async () => {
|
||||
accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
|
||||
const info = await manager.canAccessHost("recipient", 42, "delete");
|
||||
expect(info.hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes the legacy 'view' string mapping and unknown levels to connect", async () => {
|
||||
accessState.grant = { id: 5, permissionLevel: "bogus", expiresAt: null };
|
||||
const connect = await manager.canAccessHost("recipient", 42, "connect");
|
||||
expect(connect.hasAccess).toBe(true);
|
||||
expect(connect.permissionLevel).toBe("connect");
|
||||
|
||||
const view = await manager.canAccessHost("recipient", 42, "view");
|
||||
expect(view.hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it("only touches the grant timestamp on connect", async () => {
|
||||
accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
|
||||
await manager.canAccessHost("recipient", 42, "manage");
|
||||
expect(accessState.touched).toEqual([]);
|
||||
await manager.canAccessHost("recipient", 42, "connect");
|
||||
expect(accessState.touched).toEqual([5]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ownerDEK = crypto.randomBytes(32);
|
||||
const targetDEK = crypto.randomBytes(32);
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
sharedRows: [] as Array<Record<string, unknown>>,
|
||||
ownerCredential: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: () => ({
|
||||
findByIdForUser: async (_userId: string, _id: number) =>
|
||||
state.ownerCredential,
|
||||
}),
|
||||
createCurrentSharedCredentialRepository: () => ({
|
||||
existsForHostAccessAndTargetUser: async () => state.sharedRows.length > 0,
|
||||
create: async (row: Record<string, unknown>) => {
|
||||
const created = { id: state.sharedRows.length + 1, ...row };
|
||||
state.sharedRows.push(created);
|
||||
return created;
|
||||
},
|
||||
listByOriginalCredentialId: async () => state.sharedRows,
|
||||
updateById: async (id: number, update: Record<string, unknown>) => {
|
||||
const row = state.sharedRows.find((r) => r.id === id);
|
||||
if (row) Object.assign(row, update);
|
||||
return row ?? null;
|
||||
},
|
||||
deleteByOriginalCredentialId: async () => 0,
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
findSharedCredentialForHostAndUser: async () => state.sharedRows[0] ?? null,
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
listRoleUserIds: async () => [],
|
||||
listUserRoleIds: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
validateUserAccess: (userId: string) => {
|
||||
if (userId === "owner") return ownerDEK;
|
||||
if (userId === "target") return targetDEK;
|
||||
throw new Error(`User ${userId} has no data encryption key`);
|
||||
},
|
||||
getUserDataKey: (userId: string) =>
|
||||
userId === "owner" ? ownerDEK : userId === "target" ? targetDEK : null,
|
||||
canUserAccessData: () => true,
|
||||
},
|
||||
}));
|
||||
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { SharedCredentialManager } from "../../utils/shared-credential-manager.js";
|
||||
|
||||
const manager = SharedCredentialManager.getInstance();
|
||||
|
||||
beforeEach(() => {
|
||||
state.sharedRows = [];
|
||||
state.ownerCredential = {
|
||||
id: 42,
|
||||
userId: "owner",
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: FieldCrypto.encryptField("hunter2", ownerDEK, "42", "password"),
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
};
|
||||
});
|
||||
|
||||
describe("SharedCredentialManager", () => {
|
||||
it("shares a credential and the target can decrypt it", async () => {
|
||||
await manager.createSharedCredentialForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.sharedRows).toHaveLength(1);
|
||||
const row = state.sharedRows[0];
|
||||
expect(row.encryptedPassword).not.toBe("hunter2");
|
||||
expect(row.targetUserId).toBe("target");
|
||||
|
||||
const resolved = await manager.getSharedCredentialForUser(1, "target");
|
||||
expect(resolved).toMatchObject({
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "hunter2",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails the share when a participant has no key instead of queueing", async () => {
|
||||
await expect(
|
||||
manager.createSharedCredentialForUser(7, 42, "locked-user", "owner"),
|
||||
).rejects.toThrow(/no data encryption key/);
|
||||
expect(state.sharedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("re-encrypts existing share copies when the original changes", async () => {
|
||||
await manager.createSharedCredentialForUser(7, 42, "target", "owner");
|
||||
state.ownerCredential!.password = FieldCrypto.encryptField(
|
||||
"new-password",
|
||||
ownerDEK,
|
||||
"42",
|
||||
"password",
|
||||
);
|
||||
|
||||
await manager.updateSharedCredentialsForOriginal(42, "owner");
|
||||
|
||||
const resolved = await manager.getSharedCredentialForUser(1, "target");
|
||||
expect(resolved?.password).toBe("new-password");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import crypto from "crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ownerDEK = crypto.randomBytes(32);
|
||||
const targetDEK = crypto.randomBytes(32);
|
||||
|
||||
type SecretRow = Record<string, unknown> & {
|
||||
id: number;
|
||||
hostAccessId: number;
|
||||
targetUserId: string;
|
||||
protocol: string;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
hosts: new Map<number, Record<string, unknown>>(),
|
||||
credentials: new Map<number, Record<string, unknown>>(),
|
||||
secretRows: [] as Array<Record<string, unknown>>,
|
||||
// hostAccessId -> hostId, used by findForHostUserProtocol
|
||||
accessToHost: new Map<number, number>(),
|
||||
grants: [] as Array<Record<string, unknown>>,
|
||||
roleMembers: new Map<number, string[]>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
(state.hosts.get(hostId)?.userId as string) ?? null,
|
||||
findCredentialByIdForUser: async (credentialId: number) =>
|
||||
state.credentials.get(credentialId) ?? null,
|
||||
}),
|
||||
createCurrentSharedHostSecretsRepository: () => ({
|
||||
upsert: async (row: Record<string, unknown>) => {
|
||||
const existing = state.secretRows.find(
|
||||
(r) =>
|
||||
r.hostAccessId === row.hostAccessId &&
|
||||
r.targetUserId === row.targetUserId &&
|
||||
r.protocol === row.protocol,
|
||||
);
|
||||
if (existing) Object.assign(existing, row);
|
||||
else state.secretRows.push({ id: state.secretRows.length + 1, ...row });
|
||||
},
|
||||
deleteForHostAccessAndTarget: async (
|
||||
hostAccessId: number,
|
||||
targetUserId: string,
|
||||
keepProtocols: string[],
|
||||
) => {
|
||||
state.secretRows = state.secretRows.filter(
|
||||
(r) =>
|
||||
!(
|
||||
r.hostAccessId === hostAccessId &&
|
||||
r.targetUserId === targetUserId &&
|
||||
!keepProtocols.includes(r.protocol as string)
|
||||
),
|
||||
);
|
||||
},
|
||||
findForHostUserProtocol: async (
|
||||
hostId: number,
|
||||
targetUserId: string,
|
||||
protocol: string,
|
||||
) =>
|
||||
state.secretRows.find(
|
||||
(r) =>
|
||||
state.accessToHost.get(r.hostAccessId as number) === hostId &&
|
||||
r.targetUserId === targetUserId &&
|
||||
r.protocol === protocol,
|
||||
) ?? null,
|
||||
deleteByHostAccessId: async () => 0,
|
||||
deleteByTargetUserId: async () => 0,
|
||||
deleteByOriginalCredentialId: async () => 0,
|
||||
findHostIdsReferencingCredential: async (
|
||||
_ownerId: string,
|
||||
credentialId: number,
|
||||
) =>
|
||||
[...state.hosts.values()]
|
||||
.filter((h) => h.credentialId === credentialId)
|
||||
.map((h) => h.id as number),
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
listActiveHostAccessGrants: async (hostId: number) =>
|
||||
state.grants.filter((g) => g.hostId === hostId),
|
||||
listRoleHostAccessCredentialSources: async (roleId: number) =>
|
||||
state.grants
|
||||
.filter((g) => g.roleId === roleId)
|
||||
.map((g) => ({
|
||||
hostAccessId: g.id,
|
||||
hostId: g.hostId,
|
||||
hostOwnerId: state.hosts.get(g.hostId as number)?.userId,
|
||||
})),
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
listRoleUserIds: async (roleId: number) =>
|
||||
state.roleMembers.get(roleId) ?? [],
|
||||
listUserRoleIds: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
validateUserAccess: (userId: string) => {
|
||||
if (userId === "owner") return ownerDEK;
|
||||
if (userId === "target" || userId === "member-1") return targetDEK;
|
||||
throw new Error(`User ${userId} has no data encryption key`);
|
||||
},
|
||||
getUserDataKey: (userId: string) =>
|
||||
userId === "owner"
|
||||
? ownerDEK
|
||||
: userId === "target" || userId === "member-1"
|
||||
? targetDEK
|
||||
: null,
|
||||
canUserAccessData: () => true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js";
|
||||
|
||||
const manager = SharedHostSecretsManager.getInstance();
|
||||
|
||||
function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
// Records come back from the resolution repository already decrypted.
|
||||
return {
|
||||
id: 42,
|
||||
userId: "owner",
|
||||
connectionType: "ssh",
|
||||
name: "prod",
|
||||
ip: "10.0.0.42",
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "hunter2",
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
credentialId: null,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
rdpAuthType: null,
|
||||
vncAuthType: null,
|
||||
telnetAuthType: null,
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
rdpUser: null,
|
||||
rdpPassword: null,
|
||||
rdpDomain: null,
|
||||
vncUser: null,
|
||||
vncPassword: null,
|
||||
telnetUser: null,
|
||||
telnetPassword: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.hosts.clear();
|
||||
state.credentials.clear();
|
||||
state.secretRows = [];
|
||||
state.accessToHost = new Map([[7, 42]]);
|
||||
state.grants = [];
|
||||
state.roleMembers.clear();
|
||||
});
|
||||
|
||||
describe("SharedHostSecretsManager", () => {
|
||||
it("snapshots an inline-password SSH host and the target can decrypt it", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.secretRows).toHaveLength(1);
|
||||
const row = state.secretRows[0];
|
||||
expect(row.protocol).toBe("ssh");
|
||||
expect(row.sourceType).toBe("inline");
|
||||
expect(row.encryptedPassword).not.toBe("hunter2");
|
||||
|
||||
const secret = await manager.getSecretForUser(42, "target", "ssh");
|
||||
expect(secret).toMatchObject({
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "hunter2",
|
||||
});
|
||||
});
|
||||
|
||||
it("snapshots every enabled protocol from credential and inline sources", async () => {
|
||||
state.credentials.set(123, {
|
||||
id: 123,
|
||||
userId: "owner",
|
||||
username: "cred-user",
|
||||
authType: "key",
|
||||
password: null,
|
||||
privateKey: "PRIVATE-KEY",
|
||||
key: null,
|
||||
keyPassword: "kp",
|
||||
keyType: "ssh-ed25519",
|
||||
});
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
authType: "credential",
|
||||
credentialId: 123,
|
||||
password: null,
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
rdpDomain: "CORP",
|
||||
enableTelnet: true,
|
||||
telnetUser: "tel-user",
|
||||
telnetPassword: "tel-pass",
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.secretRows.map((row) => row.protocol).sort()).toEqual([
|
||||
"rdp",
|
||||
"ssh",
|
||||
"telnet",
|
||||
]);
|
||||
|
||||
const ssh = await manager.getSecretForUser(42, "target", "ssh");
|
||||
expect(ssh).toMatchObject({
|
||||
username: "cred-user",
|
||||
authType: "key",
|
||||
key: "PRIVATE-KEY",
|
||||
keyPassword: "kp",
|
||||
keyType: "ssh-ed25519",
|
||||
});
|
||||
|
||||
const rdp = await manager.getSecretForUser(42, "target", "rdp");
|
||||
expect(rdp).toMatchObject({
|
||||
username: "rdp-admin",
|
||||
password: "rdp-pass",
|
||||
domain: "CORP",
|
||||
authType: "direct",
|
||||
});
|
||||
|
||||
const telnet = await manager.getSecretForUser(42, "target", "telnet");
|
||||
expect(telnet).toMatchObject({
|
||||
username: "tel-user",
|
||||
password: "tel-pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("produces no snapshot rows for secret-less auth types", async () => {
|
||||
state.hosts.set(42, baseHost({ authType: "opkssh", password: null }));
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
expect(state.secretRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes stale protocol rows on re-snapshot", async () => {
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
}),
|
||||
);
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
expect(state.secretRows).toHaveLength(2);
|
||||
|
||||
// Owner turns RDP off; the RDP snapshot must disappear.
|
||||
state.hosts.set(42, baseHost());
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
|
||||
});
|
||||
|
||||
it("fails fast when a participant has no DEK", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
await expect(
|
||||
manager.snapshotForUser(7, 42, "locked-user", "owner"),
|
||||
).rejects.toThrow(/no data encryption key/);
|
||||
expect(state.secretRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("cannot be decrypted with the wrong DEK", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
const row = state.secretRows[0];
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(
|
||||
row.encryptedPassword as string,
|
||||
ownerDEK,
|
||||
"shared-7-target-ssh",
|
||||
"password",
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("resyncHost re-snapshots direct grants and role members", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
state.accessToHost = new Map([
|
||||
[1, 42],
|
||||
[2, 42],
|
||||
]);
|
||||
state.grants = [
|
||||
{ id: 1, hostId: 42, userId: "target", roleId: null },
|
||||
{ id: 2, hostId: 42, userId: null, roleId: 9 },
|
||||
];
|
||||
state.roleMembers.set(9, ["member-1", "owner"]);
|
||||
|
||||
await manager.resyncHost(42);
|
||||
|
||||
// target via grant 1, member-1 via grant 2; owner skipped.
|
||||
expect(
|
||||
state.secretRows.map((row) => [row.hostAccessId, row.targetUserId]),
|
||||
).toEqual([
|
||||
[1, "target"],
|
||||
[2, "member-1"],
|
||||
]);
|
||||
|
||||
// Owner rotates the inline password; resync updates the copies.
|
||||
state.hosts.set(42, baseHost({ password: "rotated" }));
|
||||
await manager.resyncHost(42);
|
||||
|
||||
const secret = await manager.getSecretForUser(42, "target", "ssh");
|
||||
expect(secret?.password).toBe("rotated");
|
||||
});
|
||||
|
||||
it("snapshotForRoleMember fans out from role grants", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
state.accessToHost = new Map([[2, 42]]);
|
||||
state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }];
|
||||
|
||||
await manager.snapshotForRoleMember(9, "member-1");
|
||||
|
||||
expect(state.secretRows).toHaveLength(1);
|
||||
expect(state.secretRows[0]).toMatchObject({
|
||||
hostAccessId: 2,
|
||||
targetUserId: "member-1",
|
||||
protocol: "ssh",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { DataCrypto } from "../data-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "../database-save-trigger.js";
|
||||
import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js";
|
||||
import { SharedCredentialManager } from "../shared-credential-manager.js";
|
||||
|
||||
interface SqliteLike {
|
||||
prepare(sql: string): {
|
||||
@@ -34,83 +32,17 @@ function dropColumnIfExists(
|
||||
return true;
|
||||
}
|
||||
|
||||
interface PendingShareRow {
|
||||
id: number;
|
||||
host_access_id: number;
|
||||
original_credential_id: number;
|
||||
target_user_id: string;
|
||||
}
|
||||
|
||||
// One-time cleanup of the pre-2.5 sharing machinery: pending share copies
|
||||
// (rows that were waiting for an offline user's DEK) are re-created now that
|
||||
// every migrated user's DEK is server-side, then the queue flag and the
|
||||
// CREDENTIAL_SHARING_KEY shadow columns are dropped.
|
||||
// One-time cleanup of the pre-2.5 sharing machinery: the
|
||||
// CREDENTIAL_SHARING_KEY shadow columns are dropped. Pending share copies are
|
||||
// no longer re-created here; the shared_host_secrets migration rebuilds every
|
||||
// active share from the live host_access grants instead.
|
||||
export async function runLegacySharedCredentialCleanup(): Promise<{
|
||||
resolved: number;
|
||||
dropped: number;
|
||||
columnsDropped: number;
|
||||
}> {
|
||||
const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike;
|
||||
const result = { resolved: 0, dropped: 0, columnsDropped: 0 };
|
||||
|
||||
if (tableHasColumn(sqlite, "shared_credentials", "needs_re_encryption")) {
|
||||
const pendingRows = sqlite
|
||||
.prepare(
|
||||
`SELECT id, host_access_id, original_credential_id, target_user_id
|
||||
FROM shared_credentials
|
||||
WHERE needs_re_encryption = 1 OR encrypted_username = ''`,
|
||||
)
|
||||
.all() as PendingShareRow[];
|
||||
|
||||
for (const row of pendingRows) {
|
||||
const owner = sqlite
|
||||
.prepare(
|
||||
`SELECT h.user_id AS ownerId
|
||||
FROM host_access ha JOIN ssh_data h ON h.id = ha.host_id
|
||||
WHERE ha.id = ?`,
|
||||
)
|
||||
.get(row.host_access_id) as { ownerId?: string } | undefined;
|
||||
|
||||
sqlite.prepare(`DELETE FROM shared_credentials WHERE id = ?`).run(row.id);
|
||||
|
||||
const ownerId = owner?.ownerId;
|
||||
if (
|
||||
ownerId &&
|
||||
DataCrypto.canUserAccessData(ownerId) &&
|
||||
DataCrypto.canUserAccessData(row.target_user_id)
|
||||
) {
|
||||
try {
|
||||
await SharedCredentialManager.getInstance().createSharedCredentialForUser(
|
||||
row.host_access_id,
|
||||
row.original_credential_id,
|
||||
row.target_user_id,
|
||||
ownerId,
|
||||
);
|
||||
result.resolved++;
|
||||
continue;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not re-create pending shared credential", {
|
||||
operation: "legacy_share_cleanup_recreate_failed",
|
||||
sharedCredentialId: row.id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.dropped++;
|
||||
databaseLogger.warn(
|
||||
"Dropped unresolvable pending shared credential; the owner can re-share the host",
|
||||
{
|
||||
operation: "legacy_share_cleanup_dropped",
|
||||
hostAccessId: row.host_access_id,
|
||||
targetUserId: row.target_user_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = { columnsDropped: 0 };
|
||||
|
||||
for (const [table, column] of [
|
||||
["shared_credentials", "needs_re_encryption"],
|
||||
["ssh_credentials", "system_password"],
|
||||
["ssh_credentials", "system_key"],
|
||||
["ssh_credentials", "system_key_password"],
|
||||
@@ -120,7 +52,7 @@ export async function runLegacySharedCredentialCleanup(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
if (result.resolved || result.dropped || result.columnsDropped) {
|
||||
if (result.columnsDropped) {
|
||||
await DatabaseSaveTrigger.forceSave("legacy_share_cleanup");
|
||||
databaseLogger.info("Legacy shared-credential cleanup finished", {
|
||||
operation: "legacy_share_cleanup",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { DataCrypto } from "../data-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "../database-save-trigger.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
getCurrentRepositorySqlite,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { SharedHostSecretsManager } from "../shared-host-secrets-manager.js";
|
||||
|
||||
const MIGRATION_FLAG = "shared_host_secrets_migrated_v1";
|
||||
|
||||
interface GrantRow {
|
||||
hostAccessId: number;
|
||||
hostId: number;
|
||||
userId: string | null;
|
||||
roleId: number | null;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
// One-time 2.5.x migration: rebuild every active share as per-protocol
|
||||
// secret snapshots in shared_host_secrets, then drop the SSH-only
|
||||
// shared_credentials table it replaces. Grants whose owner or recipient DEK
|
||||
// is unavailable are skipped (logged), never fatal to boot.
|
||||
export async function runSharedHostSecretsMigration(): Promise<{
|
||||
snapshotted: number;
|
||||
skipped: number;
|
||||
} | null> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
|
||||
try {
|
||||
if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sqlite = getCurrentRepositorySqlite();
|
||||
const result = { snapshotted: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
const grants = sqlite
|
||||
.prepare(
|
||||
`SELECT ha.id AS hostAccessId, ha.host_id AS hostId,
|
||||
ha.user_id AS userId, ha.role_id AS roleId,
|
||||
h.user_id AS ownerId
|
||||
FROM host_access ha
|
||||
JOIN ssh_data h ON h.id = ha.host_id
|
||||
WHERE ha.expires_at IS NULL OR ha.expires_at >= ?`,
|
||||
)
|
||||
.all(new Date().toISOString()) as GrantRow[];
|
||||
|
||||
const manager = SharedHostSecretsManager.getInstance();
|
||||
|
||||
for (const grant of grants) {
|
||||
const targetUserIds = grant.userId
|
||||
? [grant.userId]
|
||||
: grant.roleId
|
||||
? (
|
||||
sqlite
|
||||
.prepare(`SELECT user_id FROM user_roles WHERE role_id = ?`)
|
||||
.all(grant.roleId) as Array<{ user_id: string }>
|
||||
).map((row) => row.user_id)
|
||||
: [];
|
||||
|
||||
for (const targetUserId of targetUserIds) {
|
||||
if (targetUserId === grant.ownerId) continue;
|
||||
|
||||
if (
|
||||
!DataCrypto.canUserAccessData(grant.ownerId) ||
|
||||
!DataCrypto.canUserAccessData(targetUserId)
|
||||
) {
|
||||
result.skipped++;
|
||||
databaseLogger.warn(
|
||||
"Skipping share snapshot migration: missing DEK",
|
||||
{
|
||||
operation: "shared_host_secrets_migration_skip",
|
||||
hostAccessId: grant.hostAccessId,
|
||||
targetUserId,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await manager.snapshotForUser(
|
||||
grant.hostAccessId,
|
||||
grant.hostId,
|
||||
targetUserId,
|
||||
grant.ownerId,
|
||||
);
|
||||
result.snapshotted++;
|
||||
} catch (error) {
|
||||
result.skipped++;
|
||||
databaseLogger.warn("Failed to migrate share snapshot", {
|
||||
operation: "shared_host_secrets_migration_failed",
|
||||
hostAccessId: grant.hostAccessId,
|
||||
targetUserId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqlite.exec("DROP TABLE IF EXISTS shared_credentials");
|
||||
await settingsRepository.set(MIGRATION_FLAG, "done");
|
||||
await DatabaseSaveTrigger.forceSave("shared_host_secrets_migration");
|
||||
|
||||
databaseLogger.info("Shared host secrets migration finished", {
|
||||
operation: "shared_host_secrets_migration",
|
||||
...result,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Shared host secrets migration failed", error, {
|
||||
operation: "shared_host_secrets_migration",
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
const SHARE_PERMISSION_LEVELS = ["connect", "view", "edit", "manage"] as const;
|
||||
|
||||
type SharePermissionLevel = (typeof SHARE_PERMISSION_LEVELS)[number];
|
||||
|
||||
type HostAction = SharePermissionLevel | "delete";
|
||||
|
||||
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
|
||||
connect: 1,
|
||||
view: 2,
|
||||
edit: 3,
|
||||
manage: 4,
|
||||
};
|
||||
|
||||
function normalizeSharePermissionLevel(
|
||||
level: string | null | undefined,
|
||||
): SharePermissionLevel {
|
||||
return SHARE_PERMISSION_LEVELS.includes(level as SharePermissionLevel)
|
||||
? (level as SharePermissionLevel)
|
||||
: "connect";
|
||||
}
|
||||
|
||||
interface HostAccessInfo {
|
||||
hasAccess: boolean;
|
||||
isOwner: boolean;
|
||||
isShared: boolean;
|
||||
permissionLevel?: "view";
|
||||
permissionLevel?: SharePermissionLevel;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
@@ -146,7 +167,7 @@ class PermissionManager {
|
||||
async canAccessHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
action: "read" | "write" | "execute" | "delete" | "share" = "read",
|
||||
action: HostAction = "connect",
|
||||
): Promise<HostAccessInfo> {
|
||||
try {
|
||||
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||
@@ -180,30 +201,41 @@ class PermissionManager {
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "write" || action === "delete") {
|
||||
const grantedLevel = normalizeSharePermissionLevel(
|
||||
access.permissionLevel,
|
||||
);
|
||||
|
||||
if (
|
||||
action === "delete" ||
|
||||
LEVEL_RANK[grantedLevel] < LEVEL_RANK[action]
|
||||
) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: access.permissionLevel as "view",
|
||||
permissionLevel: grantedLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "connect") {
|
||||
try {
|
||||
await createCurrentRbacAccessRepository().touchHostAccess(access.id);
|
||||
await createCurrentRbacAccessRepository().touchHostAccess(
|
||||
access.id,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to update host access timestamp", {
|
||||
operation: "update_host_access_timestamp",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: access.permissionLevel as "view",
|
||||
permissionLevel: grantedLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
}
|
||||
@@ -283,7 +315,7 @@ class PermissionManager {
|
||||
|
||||
requireHostAccess(
|
||||
hostIdParam: string = "id",
|
||||
action: "read" | "write" | "execute" | "delete" | "share" = "read",
|
||||
action: HostAction = "connect",
|
||||
) {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
@@ -358,5 +390,11 @@ class PermissionManager {
|
||||
}
|
||||
}
|
||||
|
||||
export { PermissionManager };
|
||||
export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult };
|
||||
export { PermissionManager, SHARE_PERMISSION_LEVELS, LEVEL_RANK };
|
||||
export type {
|
||||
AuthenticatedRequest,
|
||||
HostAccessInfo,
|
||||
PermissionCheckResult,
|
||||
SharePermissionLevel,
|
||||
HostAction,
|
||||
};
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSharedCredentialRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { SharedCredentialRecord } from "../database/repositories/shared-credential-repository.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface CredentialData {
|
||||
username: string;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
}
|
||||
|
||||
// Shared credentials are per-target copies of the owner's credential,
|
||||
// re-encrypted under the target user's DEK at share time.
|
||||
class SharedCredentialManager {
|
||||
private static instance: SharedCredentialManager;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SharedCredentialManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new SharedCredentialManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
async createSharedCredentialForUser(
|
||||
hostAccessId: number,
|
||||
originalCredentialId: number,
|
||||
targetUserId: string,
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sharedCredentialRepository =
|
||||
createCurrentSharedCredentialRepository();
|
||||
const existing =
|
||||
await sharedCredentialRepository.existsForHostAccessAndTargetUser(
|
||||
hostAccessId,
|
||||
targetUserId,
|
||||
);
|
||||
|
||||
if (existing) return;
|
||||
|
||||
const ownerDEK = DataCrypto.validateUserAccess(ownerId);
|
||||
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
|
||||
|
||||
const credentialData = await this.getDecryptedCredential(
|
||||
originalCredentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
targetUserId,
|
||||
targetDEK,
|
||||
hostAccessId,
|
||||
);
|
||||
|
||||
await sharedCredentialRepository.create({
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
...encryptedForTarget,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create shared credential", error, {
|
||||
operation: "create_shared_credential",
|
||||
hostAccessId,
|
||||
targetUserId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createSharedCredentialsForRole(
|
||||
hostAccessId: number,
|
||||
originalCredentialId: number,
|
||||
roleId: number,
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const roleUserIds =
|
||||
await createCurrentRoleRepository().listRoleUserIds(roleId);
|
||||
|
||||
for (const userId of roleUserIds) {
|
||||
try {
|
||||
await this.createSharedCredentialForUser(
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
userId,
|
||||
ownerId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credential for role member",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_role",
|
||||
hostAccessId,
|
||||
roleId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credentials for role",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_role",
|
||||
hostAccessId,
|
||||
roleId,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createSharedCredentialsForRoleMember(
|
||||
roleId: number,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const hostsSharedWithRole =
|
||||
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources(
|
||||
roleId,
|
||||
);
|
||||
|
||||
for (const sharedHost of hostsSharedWithRole) {
|
||||
const activeCredentialId =
|
||||
sharedHost.credentialId ??
|
||||
sharedHost.rdpCredentialId ??
|
||||
sharedHost.vncCredentialId ??
|
||||
sharedHost.telnetCredentialId;
|
||||
|
||||
if (!activeCredentialId) continue;
|
||||
|
||||
try {
|
||||
await this.createSharedCredentialForUser(
|
||||
sharedHost.hostAccessId,
|
||||
activeCredentialId,
|
||||
targetUserId,
|
||||
sharedHost.hostOwnerId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credential for role member",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_role_member",
|
||||
roleId,
|
||||
targetUserId,
|
||||
hostId: sharedHost.hostId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credentials for role member",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_role_member",
|
||||
roleId,
|
||||
targetUserId,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createSharedCredentialsForUserRoles(userId: string): Promise<void> {
|
||||
try {
|
||||
const roleIds =
|
||||
await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||
|
||||
for (const roleId of roleIds) {
|
||||
await this.createSharedCredentialsForRoleMember(roleId, userId);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credentials for user roles",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_user_roles",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSharedCredentialForUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<CredentialData | null> {
|
||||
try {
|
||||
const userDEK = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
const cred =
|
||||
await createCurrentRbacAccessRepository().findSharedCredentialForHostAndUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!cred) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.decryptSharedCredential(cred, userDEK);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get shared credential", error, {
|
||||
operation: "get_shared_credential",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateSharedCredentialsForOriginal(
|
||||
credentialId: number,
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sharedCredentialRepository =
|
||||
createCurrentSharedCredentialRepository();
|
||||
const sharedCreds =
|
||||
await sharedCredentialRepository.listByOriginalCredentialId(
|
||||
credentialId,
|
||||
);
|
||||
|
||||
if (sharedCreds.length === 0) return;
|
||||
|
||||
const ownerDEK = DataCrypto.validateUserAccess(ownerId);
|
||||
const credentialData = await this.getDecryptedCredential(
|
||||
credentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
|
||||
for (const sharedCred of sharedCreds) {
|
||||
const targetDEK = DataCrypto.validateUserAccess(
|
||||
sharedCred.targetUserId,
|
||||
);
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
sharedCred.targetUserId,
|
||||
targetDEK,
|
||||
sharedCred.hostAccessId,
|
||||
);
|
||||
|
||||
await sharedCredentialRepository.updateById(sharedCred.id, {
|
||||
...encryptedForTarget,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update shared credentials", error, {
|
||||
operation: "update_shared_credentials",
|
||||
credentialId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSharedCredentialsForOriginal(
|
||||
credentialId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await createCurrentSharedCredentialRepository().deleteByOriginalCredentialId(
|
||||
credentialId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete shared credentials", error, {
|
||||
operation: "delete_shared_credentials",
|
||||
credentialId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getDecryptedCredential(
|
||||
credentialId: number,
|
||||
ownerId: string,
|
||||
ownerDEK: Buffer,
|
||||
): Promise<CredentialData> {
|
||||
const cred = await createCurrentCredentialRepository().findByIdForUser(
|
||||
ownerId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
if (!cred) {
|
||||
throw new Error(`Credential ${credentialId} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
username: cred.username,
|
||||
authType: cred.authType,
|
||||
password: cred.password
|
||||
? this.decryptField(cred.password, ownerDEK, credentialId, "password")
|
||||
: undefined,
|
||||
key: cred.key
|
||||
? this.decryptField(cred.key, ownerDEK, credentialId, "key")
|
||||
: undefined,
|
||||
keyPassword: cred.keyPassword
|
||||
? this.decryptField(
|
||||
cred.keyPassword,
|
||||
ownerDEK,
|
||||
credentialId,
|
||||
"keyPassword",
|
||||
)
|
||||
: undefined,
|
||||
keyType: cred.keyType,
|
||||
};
|
||||
}
|
||||
|
||||
private encryptCredentialForUser(
|
||||
credentialData: CredentialData,
|
||||
targetUserId: string,
|
||||
targetDEK: Buffer,
|
||||
hostAccessId: number,
|
||||
): {
|
||||
encryptedUsername: string;
|
||||
encryptedAuthType: string;
|
||||
encryptedPassword: string | null;
|
||||
encryptedKey: string | null;
|
||||
encryptedKeyPassword: string | null;
|
||||
encryptedKeyType: string | null;
|
||||
} {
|
||||
const recordId = `shared-${hostAccessId}-${targetUserId}`;
|
||||
|
||||
return {
|
||||
encryptedUsername: FieldCrypto.encryptField(
|
||||
credentialData.username,
|
||||
targetDEK,
|
||||
recordId,
|
||||
"username",
|
||||
),
|
||||
encryptedAuthType: credentialData.authType,
|
||||
encryptedPassword: credentialData.password
|
||||
? FieldCrypto.encryptField(
|
||||
credentialData.password,
|
||||
targetDEK,
|
||||
recordId,
|
||||
"password",
|
||||
)
|
||||
: null,
|
||||
encryptedKey: credentialData.key
|
||||
? FieldCrypto.encryptField(
|
||||
credentialData.key,
|
||||
targetDEK,
|
||||
recordId,
|
||||
"key",
|
||||
)
|
||||
: null,
|
||||
encryptedKeyPassword: credentialData.keyPassword
|
||||
? FieldCrypto.encryptField(
|
||||
credentialData.keyPassword,
|
||||
targetDEK,
|
||||
recordId,
|
||||
"key_password",
|
||||
)
|
||||
: null,
|
||||
encryptedKeyType: credentialData.keyType || null,
|
||||
};
|
||||
}
|
||||
|
||||
private decryptSharedCredential(
|
||||
sharedCred: SharedCredentialRecord,
|
||||
userDEK: Buffer,
|
||||
): CredentialData {
|
||||
const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`;
|
||||
|
||||
return {
|
||||
username: FieldCrypto.decryptField(
|
||||
sharedCred.encryptedUsername,
|
||||
userDEK,
|
||||
recordId,
|
||||
"username",
|
||||
),
|
||||
authType: sharedCred.encryptedAuthType,
|
||||
password: sharedCred.encryptedPassword
|
||||
? FieldCrypto.decryptField(
|
||||
sharedCred.encryptedPassword,
|
||||
userDEK,
|
||||
recordId,
|
||||
"password",
|
||||
)
|
||||
: undefined,
|
||||
key: sharedCred.encryptedKey
|
||||
? FieldCrypto.decryptField(
|
||||
sharedCred.encryptedKey,
|
||||
userDEK,
|
||||
recordId,
|
||||
"key",
|
||||
)
|
||||
: undefined,
|
||||
keyPassword: sharedCred.encryptedKeyPassword
|
||||
? FieldCrypto.decryptField(
|
||||
sharedCred.encryptedKeyPassword,
|
||||
userDEK,
|
||||
recordId,
|
||||
"key_password",
|
||||
)
|
||||
: undefined,
|
||||
keyType: sharedCred.encryptedKeyType || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private decryptField(
|
||||
encryptedValue: string,
|
||||
dek: Buffer,
|
||||
recordId: number | string,
|
||||
fieldName: string,
|
||||
): string {
|
||||
try {
|
||||
return FieldCrypto.decryptField(
|
||||
encryptedValue,
|
||||
dek,
|
||||
recordId.toString(),
|
||||
fieldName,
|
||||
);
|
||||
} catch {
|
||||
databaseLogger.warn("Field decryption failed, returning as-is", {
|
||||
operation: "decrypt_field",
|
||||
fieldName,
|
||||
recordId,
|
||||
});
|
||||
return encryptedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SharedCredentialManager };
|
||||
@@ -0,0 +1,544 @@
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSharedHostSecretsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type {
|
||||
SharedHostSecretRecord,
|
||||
ShareProtocol,
|
||||
} from "../database/repositories/shared-host-secrets-repository.js";
|
||||
import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
export interface SharedSecretData {
|
||||
username?: string;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
interface ProtocolSnapshot {
|
||||
protocol: ShareProtocol;
|
||||
sourceType: "credential" | "inline";
|
||||
originalCredentialId: number | null;
|
||||
data: SharedSecretData;
|
||||
}
|
||||
|
||||
function snapshotRecordId(
|
||||
hostAccessId: number,
|
||||
targetUserId: string,
|
||||
protocol: ShareProtocol,
|
||||
): string {
|
||||
return `shared-${hostAccessId}-${targetUserId}-${protocol}`;
|
||||
}
|
||||
|
||||
// Mirrors the connection-type migration fallback in transformHostResponse():
|
||||
// old hosts only set connectionType, the per-protocol enable flags came later.
|
||||
function enabledProtocols(
|
||||
host: HostResolutionHostRecord,
|
||||
): Record<ShareProtocol, boolean> {
|
||||
const ct = host.connectionType;
|
||||
const rdp = !!host.enableRdp;
|
||||
const vnc = !!host.enableVnc;
|
||||
const telnet = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdp && !vnc && !telnet && !!ct && ct !== "ssh";
|
||||
|
||||
return {
|
||||
ssh: isMigratedNonSsh ? false : host.enableSsh !== false,
|
||||
rdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||
vnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||
telnet: isMigratedNonSsh ? ct === "telnet" : telnet,
|
||||
};
|
||||
}
|
||||
|
||||
// Per-recipient copies of a shared host's connection secrets, re-encrypted
|
||||
// under the recipient's DEK. Every enabled protocol gets its own snapshot;
|
||||
// secret-less auth types (opkssh, vault, agent, none, ...) produce none.
|
||||
class SharedHostSecretsManager {
|
||||
private static instance: SharedHostSecretsManager;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SharedHostSecretsManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new SharedHostSecretsManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
async snapshotForUser(
|
||||
hostAccessId: number,
|
||||
hostId: number,
|
||||
targetUserId: string,
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
if (targetUserId === ownerId) return;
|
||||
|
||||
try {
|
||||
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
|
||||
DataCrypto.validateUserAccess(ownerId);
|
||||
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
ownerId,
|
||||
);
|
||||
if (!host) {
|
||||
throw new Error(`Host ${hostId} not found`);
|
||||
}
|
||||
|
||||
const snapshots = await this.collectProtocolSnapshots(host, ownerId);
|
||||
const repository = createCurrentSharedHostSecretsRepository();
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
const recordId = snapshotRecordId(
|
||||
hostAccessId,
|
||||
targetUserId,
|
||||
snapshot.protocol,
|
||||
);
|
||||
const encrypt = (value: string | undefined, fieldName: string) =>
|
||||
value
|
||||
? FieldCrypto.encryptField(value, targetDEK, recordId, fieldName)
|
||||
: null;
|
||||
|
||||
await repository.upsert({
|
||||
hostAccessId,
|
||||
targetUserId,
|
||||
protocol: snapshot.protocol,
|
||||
sourceType: snapshot.sourceType,
|
||||
originalCredentialId: snapshot.originalCredentialId,
|
||||
encryptedUsername: encrypt(snapshot.data.username, "username"),
|
||||
encryptedAuthType: snapshot.data.authType,
|
||||
encryptedPassword: encrypt(snapshot.data.password, "password"),
|
||||
encryptedKey: encrypt(snapshot.data.key, "key"),
|
||||
encryptedKeyPassword: encrypt(
|
||||
snapshot.data.keyPassword,
|
||||
"key_password",
|
||||
),
|
||||
encryptedKeyType: snapshot.data.keyType || null,
|
||||
encryptedDomain: encrypt(snapshot.data.domain, "domain"),
|
||||
});
|
||||
}
|
||||
|
||||
await repository.deleteForHostAccessAndTarget(
|
||||
hostAccessId,
|
||||
targetUserId,
|
||||
snapshots.map((snapshot) => snapshot.protocol),
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to snapshot shared host secrets", error, {
|
||||
operation: "shared_host_secrets_snapshot",
|
||||
hostAccessId,
|
||||
hostId,
|
||||
targetUserId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async snapshotForRole(
|
||||
hostAccessId: number,
|
||||
hostId: number,
|
||||
roleId: number,
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
const roleUserIds =
|
||||
await createCurrentRoleRepository().listRoleUserIds(roleId);
|
||||
|
||||
for (const userId of roleUserIds) {
|
||||
try {
|
||||
await this.snapshotForUser(hostAccessId, hostId, userId, ownerId);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to snapshot shared host secrets for role member",
|
||||
error,
|
||||
{
|
||||
operation: "shared_host_secrets_snapshot_role",
|
||||
hostAccessId,
|
||||
roleId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async snapshotForRoleMember(
|
||||
roleId: number,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
const hostsSharedWithRole =
|
||||
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources(
|
||||
roleId,
|
||||
);
|
||||
|
||||
for (const sharedHost of hostsSharedWithRole) {
|
||||
try {
|
||||
await this.snapshotForUser(
|
||||
sharedHost.hostAccessId,
|
||||
sharedHost.hostId,
|
||||
targetUserId,
|
||||
sharedHost.hostOwnerId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to snapshot shared host secrets for role member",
|
||||
error,
|
||||
{
|
||||
operation: "shared_host_secrets_snapshot_role_member",
|
||||
roleId,
|
||||
targetUserId,
|
||||
hostId: sharedHost.hostId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async snapshotForUserRoles(userId: string): Promise<void> {
|
||||
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||
|
||||
for (const roleId of roleIds) {
|
||||
await this.snapshotForRoleMember(roleId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-snapshot every active grant on a host. Called after the owner (or an
|
||||
// editor) changes the host so recipients keep working secrets.
|
||||
async resyncHost(hostId: number): Promise<void> {
|
||||
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||
const ownerId = await hostResolutionRepository.findHostOwnerId(hostId);
|
||||
if (!ownerId) return;
|
||||
|
||||
const grants =
|
||||
await createCurrentRbacAccessRepository().listActiveHostAccessGrants(
|
||||
hostId,
|
||||
);
|
||||
if (grants.length === 0) return;
|
||||
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
|
||||
for (const grant of grants) {
|
||||
const targetUserIds = grant.userId
|
||||
? [grant.userId]
|
||||
: grant.roleId
|
||||
? await roleRepository.listRoleUserIds(grant.roleId)
|
||||
: [];
|
||||
|
||||
for (const targetUserId of targetUserIds) {
|
||||
if (targetUserId === ownerId) continue;
|
||||
try {
|
||||
await this.snapshotForUser(grant.id, hostId, targetUserId, ownerId);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Skipping shared host secret resync for recipient",
|
||||
{
|
||||
operation: "shared_host_secrets_resync_skip",
|
||||
hostId,
|
||||
hostAccessId: grant.id,
|
||||
targetUserId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-snapshot every shared host that references a credential after the
|
||||
// owner edits that credential.
|
||||
async resyncHostsForCredential(
|
||||
credentialId: number,
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const hostIds =
|
||||
await createCurrentSharedHostSecretsRepository().findHostIdsReferencingCredential(
|
||||
ownerId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
for (const hostId of hostIds) {
|
||||
await this.resyncHost(hostId);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to resync shared host secrets for credential",
|
||||
error,
|
||||
{
|
||||
operation: "shared_host_secrets_resync_credential",
|
||||
credentialId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getSecretForUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
protocol: ShareProtocol,
|
||||
): Promise<SharedSecretData | null> {
|
||||
try {
|
||||
const userDEK = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
const secret =
|
||||
await createCurrentSharedHostSecretsRepository().findForHostUserProtocol(
|
||||
hostId,
|
||||
userId,
|
||||
protocol,
|
||||
);
|
||||
|
||||
if (!secret) return null;
|
||||
|
||||
return this.decryptSnapshot(secret, userDEK);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get shared host secret", error, {
|
||||
operation: "shared_host_secrets_get",
|
||||
hostId,
|
||||
userId,
|
||||
protocol,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteForHostAccess(hostAccessId: number): Promise<void> {
|
||||
try {
|
||||
await createCurrentSharedHostSecretsRepository().deleteByHostAccessId(
|
||||
hostAccessId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to delete shared host secrets for access grant",
|
||||
error,
|
||||
{
|
||||
operation: "shared_host_secrets_delete_access",
|
||||
hostAccessId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string): Promise<void> {
|
||||
try {
|
||||
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
|
||||
userId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to delete shared host secrets for user",
|
||||
error,
|
||||
{
|
||||
operation: "shared_host_secrets_delete_user",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteForCredential(credentialId: number): Promise<void> {
|
||||
try {
|
||||
await createCurrentSharedHostSecretsRepository().deleteByOriginalCredentialId(
|
||||
credentialId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to delete shared host secrets for credential",
|
||||
error,
|
||||
{
|
||||
operation: "shared_host_secrets_delete_credential",
|
||||
credentialId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async collectProtocolSnapshots(
|
||||
host: HostResolutionHostRecord,
|
||||
ownerId: string,
|
||||
): Promise<ProtocolSnapshot[]> {
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const enabled = enabledProtocols(host);
|
||||
const snapshots: ProtocolSnapshot[] = [];
|
||||
|
||||
if (enabled.ssh) {
|
||||
if (host.credentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
host.credentialId,
|
||||
ownerId,
|
||||
);
|
||||
if (credential) {
|
||||
snapshots.push({
|
||||
protocol: "ssh",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: host.credentialId,
|
||||
data: {
|
||||
username: credential.username || undefined,
|
||||
authType: credential.authType,
|
||||
password: credential.password || undefined,
|
||||
key: credential.privateKey || credential.key || undefined,
|
||||
keyPassword: credential.keyPassword || undefined,
|
||||
keyType: credential.keyType || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
(host.authType === "password" && host.password) ||
|
||||
(host.authType === "key" && host.key)
|
||||
) {
|
||||
snapshots.push({
|
||||
protocol: "ssh",
|
||||
sourceType: "inline",
|
||||
originalCredentialId: null,
|
||||
data: {
|
||||
username: host.username || undefined,
|
||||
authType: host.authType,
|
||||
password: host.password || undefined,
|
||||
key: host.key || undefined,
|
||||
keyPassword: host.keyPassword || undefined,
|
||||
keyType: host.keyType || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled.rdp) {
|
||||
const rdpAuthType =
|
||||
host.rdpAuthType || (host.rdpCredentialId ? "credential" : "direct");
|
||||
if (rdpAuthType === "credential" && host.rdpCredentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
host.rdpCredentialId,
|
||||
ownerId,
|
||||
);
|
||||
if (credential) {
|
||||
snapshots.push({
|
||||
protocol: "rdp",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: host.rdpCredentialId,
|
||||
data: {
|
||||
username: credential.username || undefined,
|
||||
authType: "credential",
|
||||
password: credential.password || undefined,
|
||||
domain: host.rdpDomain || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (host.rdpUser || host.rdpPassword) {
|
||||
snapshots.push({
|
||||
protocol: "rdp",
|
||||
sourceType: "inline",
|
||||
originalCredentialId: null,
|
||||
data: {
|
||||
username: host.rdpUser || undefined,
|
||||
authType: "direct",
|
||||
password: host.rdpPassword || undefined,
|
||||
domain: host.rdpDomain || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled.vnc) {
|
||||
const vncAuthType =
|
||||
host.vncAuthType || (host.vncCredentialId ? "credential" : "direct");
|
||||
if (vncAuthType === "credential" && host.vncCredentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
host.vncCredentialId,
|
||||
ownerId,
|
||||
);
|
||||
if (credential) {
|
||||
snapshots.push({
|
||||
protocol: "vnc",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: host.vncCredentialId,
|
||||
data: {
|
||||
username: credential.username || undefined,
|
||||
authType: "credential",
|
||||
password: credential.password || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (host.vncUser || host.vncPassword) {
|
||||
snapshots.push({
|
||||
protocol: "vnc",
|
||||
sourceType: "inline",
|
||||
originalCredentialId: null,
|
||||
data: {
|
||||
username: host.vncUser || undefined,
|
||||
authType: "direct",
|
||||
password: host.vncPassword || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled.telnet) {
|
||||
const telnetAuthType =
|
||||
host.telnetAuthType ||
|
||||
(host.telnetCredentialId ? "credential" : "direct");
|
||||
if (telnetAuthType === "credential" && host.telnetCredentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
host.telnetCredentialId,
|
||||
ownerId,
|
||||
);
|
||||
if (credential) {
|
||||
snapshots.push({
|
||||
protocol: "telnet",
|
||||
sourceType: "credential",
|
||||
originalCredentialId: host.telnetCredentialId,
|
||||
data: {
|
||||
username: credential.username || undefined,
|
||||
authType: "credential",
|
||||
password: credential.password || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (host.telnetUser || host.telnetPassword) {
|
||||
snapshots.push({
|
||||
protocol: "telnet",
|
||||
sourceType: "inline",
|
||||
originalCredentialId: null,
|
||||
data: {
|
||||
username: host.telnetUser || undefined,
|
||||
authType: "direct",
|
||||
password: host.telnetPassword || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
private decryptSnapshot(
|
||||
secret: SharedHostSecretRecord,
|
||||
userDEK: Buffer,
|
||||
): SharedSecretData {
|
||||
const recordId = snapshotRecordId(
|
||||
secret.hostAccessId,
|
||||
secret.targetUserId,
|
||||
secret.protocol as ShareProtocol,
|
||||
);
|
||||
const decrypt = (value: string | null, fieldName: string) =>
|
||||
value
|
||||
? FieldCrypto.decryptField(value, userDEK, recordId, fieldName)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
username: decrypt(secret.encryptedUsername, "username"),
|
||||
authType: secret.encryptedAuthType || "password",
|
||||
password: decrypt(secret.encryptedPassword, "password"),
|
||||
key: decrypt(secret.encryptedKey, "key"),
|
||||
keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"),
|
||||
keyType: secret.encryptedKeyType || undefined,
|
||||
domain: decrypt(secret.encryptedDomain, "domain"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { SharedHostSecretsManager };
|
||||
+2
-1
@@ -217,8 +217,9 @@ export interface Host {
|
||||
hasKeyPassword?: boolean;
|
||||
|
||||
isShared?: boolean;
|
||||
permissionLevel?: "view";
|
||||
permissionLevel?: "connect" | "view" | "edit" | "manage";
|
||||
sharedExpiresAt?: string;
|
||||
ownerUsername?: string;
|
||||
}
|
||||
|
||||
export interface JumpHostData {
|
||||
|
||||
@@ -168,8 +168,15 @@ export type Host = {
|
||||
|
||||
guacamoleConfig?: Record<string, unknown>;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
|
||||
isShared?: boolean;
|
||||
permissionLevel?: SharePermissionLevel;
|
||||
sharedExpiresAt?: string;
|
||||
ownerUsername?: string;
|
||||
};
|
||||
|
||||
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
|
||||
|
||||
export type Credential = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
+79
-6
@@ -28,6 +28,7 @@ export async function updateRole(
|
||||
roleData: {
|
||||
displayName?: string;
|
||||
description?: string | null;
|
||||
permissions?: string[];
|
||||
},
|
||||
): Promise<{ role: Role }> {
|
||||
try {
|
||||
@@ -88,16 +89,30 @@ export async function removeRoleFromUser(
|
||||
}
|
||||
}
|
||||
|
||||
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
|
||||
|
||||
export interface ShareTarget {
|
||||
type: "user" | "role";
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export async function shareHost(
|
||||
hostId: number,
|
||||
shareData: {
|
||||
targetType: "user" | "role";
|
||||
targetUserId?: string;
|
||||
targetRoleId?: number;
|
||||
permissionLevel: "view";
|
||||
targets: ShareTarget[];
|
||||
permissionLevel: SharePermissionLevel;
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
expiresAt: string | null;
|
||||
results: Array<{
|
||||
type: "user" | "role";
|
||||
id: string | number;
|
||||
accessId: number;
|
||||
created: boolean;
|
||||
}>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
`/rbac/host/${hostId}/share`,
|
||||
@@ -109,9 +124,28 @@ export async function shareHost(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHostAccess(
|
||||
hostId: number,
|
||||
accessId: number,
|
||||
update: {
|
||||
permissionLevel?: SharePermissionLevel;
|
||||
durationHours?: number | null;
|
||||
},
|
||||
): Promise<{ success: boolean; expiresAt: string | null }> {
|
||||
try {
|
||||
const response = await rbacApi.patch(
|
||||
`/rbac/host/${hostId}/access/${accessId}`,
|
||||
update,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update host access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostAccess(
|
||||
hostId: number,
|
||||
): Promise<{ accessList: AccessRecord[] }> {
|
||||
): Promise<{ accessList: AccessRecord[]; isOwner?: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
|
||||
return response.data;
|
||||
@@ -120,6 +154,45 @@ export async function getHostAccess(
|
||||
}
|
||||
}
|
||||
|
||||
export interface PermissionCatalogEntry {
|
||||
group: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export async function getPermissionsCatalog(): Promise<{
|
||||
catalog: PermissionCatalogEntry[];
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/permissions/catalog");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch permissions catalog");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSharedHosts(): Promise<{
|
||||
sharedHosts: Array<{
|
||||
id: number;
|
||||
name: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string | null;
|
||||
tags: string | null;
|
||||
permissionLevel: SharePermissionLevel;
|
||||
expiresAt: string | null;
|
||||
grantedBy: string;
|
||||
ownerUsername: string;
|
||||
}>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/shared-hosts");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch shared hosts");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeHostAccess(
|
||||
hostId: number,
|
||||
accessId: number,
|
||||
|
||||
+321
-286
@@ -106,8 +106,265 @@
|
||||
"filterTagsGroup": "Tags"
|
||||
},
|
||||
"homepage": {
|
||||
"failedToLoadAlerts": "Failed to load alerts",
|
||||
"failedToDismissAlert": "Failed to dismiss alert"
|
||||
"title": "Homepage",
|
||||
"addWidget": "Add Widget",
|
||||
"editWidget": "Edit Widget",
|
||||
"deleteWidget": "Delete",
|
||||
"widgetTypes": "Widget Types",
|
||||
"serviceLink": "Service Link",
|
||||
"folder": "Folder",
|
||||
"clock": "Clock",
|
||||
"notes": "Notes",
|
||||
"hostStatus": "Host Status",
|
||||
"bookmarkList": "Bookmarks",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"resetView": "Reset View",
|
||||
"lockLayout": "Lock Layout",
|
||||
"unlockLayout": "Unlock Layout",
|
||||
"noWidgets": "Right-click or click + to add your first widget",
|
||||
"openFullView": "Open Full View",
|
||||
"previewTitle": "Homepage Preview",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"title_label": "Title",
|
||||
"widgetTitlePlaceholder": "Widget title (optional)",
|
||||
"url": "URL",
|
||||
"imageUrl": "Custom Image URL",
|
||||
"imageUrlHint": "Leave blank to use the site favicon automatically",
|
||||
"showImage": "Show Image",
|
||||
"description": "Description",
|
||||
"color": "Color",
|
||||
"icon": "Icon",
|
||||
"expanded": "Expanded by default",
|
||||
"timezone": "Timezone",
|
||||
"showSeconds": "Show seconds",
|
||||
"format12h": "12-hour",
|
||||
"format24h": "24-hour",
|
||||
"content": "Content",
|
||||
"backgroundColor": "Background Color",
|
||||
"host": "Host",
|
||||
"showMetrics": "Show Metrics",
|
||||
"links": "Links",
|
||||
"addLink": "Add Link",
|
||||
"linkLabel": "Label",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Remove",
|
||||
"categoryLinks": "Links",
|
||||
"categoryInfo": "Info",
|
||||
"categorySystem": "System",
|
||||
"widgetServiceLinkDesc": "A clickable tile linking to a service URL",
|
||||
"widgetFolderDesc": "A container to group related widgets",
|
||||
"widgetClockDesc": "A live clock with configurable timezone",
|
||||
"widgetNotesDesc": "A markdown notes widget",
|
||||
"widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host",
|
||||
"widgetBookmarkListDesc": "A list of quick links",
|
||||
"copyLink": "Copy Link",
|
||||
"linkCopied": "Link copied!",
|
||||
"location": "Location",
|
||||
"temperatureUnit": "Temperature Unit",
|
||||
"showForecast": "Show 3-day forecast",
|
||||
"scrolling": "Allow Scrolling",
|
||||
"feedUrl": "Feed URL",
|
||||
"maxItems": "Max Items",
|
||||
"showDescription": "Show Description",
|
||||
"widgetWeatherName": "Weather",
|
||||
"widgetWeatherDesc": "Live weather for any location",
|
||||
"widgetIframeName": "iFrame Embed",
|
||||
"widgetIframeDesc": "Embed any URL in an iframe",
|
||||
"widgetRssName": "RSS Feed",
|
||||
"widgetRssDesc": "Display items from an RSS or Atom feed",
|
||||
"dragToFolder": "Drag widgets here or use + to add",
|
||||
"showDisk": "Show Disk Usage",
|
||||
"addToFolder": "Add widget to folder",
|
||||
"noHostSelected": "No host selected",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"selectHost": "Select a host...",
|
||||
"displayedMetrics": "Displayed Metrics",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Memory",
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Uptime",
|
||||
"metricSystem": "System",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Hostname",
|
||||
"metricNetwork": "Network",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "running",
|
||||
"categoryMonitoring": "Monitoring",
|
||||
"loading": "Loading...",
|
||||
"noData": "No data available",
|
||||
"allClear": "All clear",
|
||||
"acknowledgeAlert": "Acknowledge",
|
||||
"noPingUrls": "No URLs configured",
|
||||
"pingLabel": "Label",
|
||||
"addPingUrl": "Add URL",
|
||||
"showLatency": "Show Latency",
|
||||
"refreshInterval": "Refresh Interval",
|
||||
"seconds": "seconds",
|
||||
"filterActivityTypes": "Filter Types",
|
||||
"filterTypesHint": "Leave empty to show all",
|
||||
"showTimestamp": "Show Timestamp",
|
||||
"uptimeUnavailable": "Uptime unavailable",
|
||||
"uptimeLabel": "Uptime",
|
||||
"overviewVersion": "Version",
|
||||
"overviewUpdate": "Up to date",
|
||||
"overviewUpdateAvailable": "Update available",
|
||||
"overviewDatabase": "Database",
|
||||
"overviewUptime": "Uptime",
|
||||
"noHosts": "No hosts configured",
|
||||
"hostGridHosts": "Hosts",
|
||||
"hostGridAllHint": "Leave empty to show all hosts",
|
||||
"columns": "Columns",
|
||||
"showIp": "Show IP Address",
|
||||
"connectionType": "Connection Type",
|
||||
"layout": "Layout",
|
||||
"showStatus": "Show Status",
|
||||
"showHostName": "Show Host Name",
|
||||
"noDockerActivity": "No Docker activity",
|
||||
"noActivity": "No activity",
|
||||
"showAcknowledged": "Show Acknowledged",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"widgetMetricsChartName": "Metrics Chart",
|
||||
"widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host",
|
||||
"widgetHostGridName": "Host Grid",
|
||||
"widgetHostGridDesc": "Grid view of SSH host statuses",
|
||||
"widgetAlertFeedName": "Alert Feed",
|
||||
"widgetAlertFeedDesc": "Live alert firings with acknowledge support",
|
||||
"widgetPingStatusName": "Ping Status",
|
||||
"widgetPingStatusDesc": "HTTP ping status for one or more URLs",
|
||||
"widgetRecentActivityName": "Recent Activity",
|
||||
"widgetRecentActivityDesc": "Scrollable feed of recent Termix activity",
|
||||
"widgetTermixUptimeName": "Termix Uptime",
|
||||
"widgetTermixUptimeDesc": "Live uptime counter for the Termix server",
|
||||
"widgetSystemOverviewName": "System Overview",
|
||||
"widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance",
|
||||
"widgetSshQuickConnectName": "SSH Quick Connect",
|
||||
"widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions",
|
||||
"widgetDockerActivityName": "Docker Activity",
|
||||
"widgetDockerActivityDesc": "Recent Docker container events across all hosts",
|
||||
"widgetCalendarName": "Calendar",
|
||||
"widgetCalendarDesc": "A monthly calendar with today highlighted",
|
||||
"widgetCountdownName": "Countdown",
|
||||
"widgetCountdownDesc": "Countdown timer to a target date",
|
||||
"widgetSearchBarName": "Search Bar",
|
||||
"widgetSearchBarDesc": "Quick web search widget",
|
||||
"widgetTextBannerName": "Text Banner",
|
||||
"widgetTextBannerDesc": "A bold label or section header for the canvas",
|
||||
"widgetImageWidgetName": "Image",
|
||||
"widgetImageWidgetDesc": "Display an image from a URL",
|
||||
"widgetMarkdownNotesName": "Markdown Notes",
|
||||
"widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering",
|
||||
"widgetCustomApiName": "Custom API",
|
||||
"widgetCustomApiDesc": "Fetch and display data from any JSON API",
|
||||
"widgetServiceGridName": "Service Grid",
|
||||
"widgetServiceGridDesc": "A configurable grid of service tile links",
|
||||
"widgetDashboardLinksName": "Dashboard Links",
|
||||
"widgetDashboardLinksDesc": "Display your configured service links from the dashboard",
|
||||
"widgetSearchLinksName": "Search Shortcuts",
|
||||
"widgetSearchLinksDesc": "Quick search shortcut buttons with inline input",
|
||||
"widgetLinkTreeName": "Link Tree",
|
||||
"widgetLinkTreeDesc": "Grouped sections of links with headings",
|
||||
"calMon": "Mo",
|
||||
"calTue": "Tu",
|
||||
"calWed": "We",
|
||||
"calThu": "Th",
|
||||
"calFri": "Fr",
|
||||
"calSat": "Sa",
|
||||
"calSun": "Su",
|
||||
"startOnMonday": "Start week on Monday",
|
||||
"countdownNoDate": "No target date set",
|
||||
"countdownPast": "Event has passed",
|
||||
"countdownDays": "days",
|
||||
"countdownHours": "hrs",
|
||||
"countdownMinutes": "min",
|
||||
"countdownSeconds": "sec",
|
||||
"countdownLabel": "Label",
|
||||
"countdownLabelPlaceholder": "e.g. Launch Day",
|
||||
"countdownShowDays": "Show Days",
|
||||
"countdownShowHours": "Show Hours",
|
||||
"targetDate": "Target Date",
|
||||
"searchEngine": "Search Engine",
|
||||
"customSearchUrl": "Custom Search URL",
|
||||
"searchPlaceholder": "Search...",
|
||||
"searchGo": "Go",
|
||||
"searchPlaceholderLabel": "Placeholder Text",
|
||||
"searchPlaceholderHint": "Text shown inside the search input",
|
||||
"openInNewTab": "Open in New Tab",
|
||||
"searchQueryPlaceholder": "Enter query...",
|
||||
"noSearchShortcuts": "No shortcuts configured",
|
||||
"addSearchShortcut": "Add Shortcut",
|
||||
"fontSize": "Font Size",
|
||||
"textAlign": "Text Align",
|
||||
"fontWeight": "Font Weight",
|
||||
"clearColor": "Clear Color",
|
||||
"imageFit": "Image Fit",
|
||||
"imageLinkUrl": "Link URL",
|
||||
"noImage": "No image URL set",
|
||||
"altText": "Alt Text",
|
||||
"altTextPlaceholder": "Describe the image",
|
||||
"renderMarkdown": "Render Markdown",
|
||||
"displayMode": "Display Mode",
|
||||
"displayField": "Display Field",
|
||||
"jsonPath": "JSON Path",
|
||||
"customApiLabel": "Label",
|
||||
"customApiLabelPlaceholder": "e.g. Temperature",
|
||||
"customApiUnit": "Unit",
|
||||
"customApiNoUrl": "No API URL configured",
|
||||
"customApiError": "Failed to fetch",
|
||||
"customApiNotArray": "Response is not an array",
|
||||
"addService": "Add Service",
|
||||
"showLabels": "Show Labels",
|
||||
"iconSize": "Icon Size",
|
||||
"noDashboardLinks": "No dashboard links configured",
|
||||
"noLimit": "No limit",
|
||||
"sectionHeading": "Section Heading",
|
||||
"addSection": "Add Section",
|
||||
"compactMode": "Compact Mode",
|
||||
"showDetailed": "Show Detailed",
|
||||
"selectHosts": "Select Hosts",
|
||||
"allHosts": "All hosts",
|
||||
"listLayout": "List",
|
||||
"gridLayout": "Grid",
|
||||
"terminal": "Terminal",
|
||||
"files": "Files",
|
||||
"docker": "Docker",
|
||||
"range15m": "15 minutes",
|
||||
"range1h": "1 hour",
|
||||
"range6h": "6 hours",
|
||||
"range24h": "24 hours",
|
||||
"metricNetRx": "Net Download",
|
||||
"metricNetTx": "Net Upload",
|
||||
"severityFilter": "Severity Filter",
|
||||
"filterAll": "All",
|
||||
"accentColor": "Accent Color",
|
||||
"widgetSshTerminalName": "SSH Terminal",
|
||||
"widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host",
|
||||
"sshTerminalNoHost": "No host configured",
|
||||
"sshTerminalConnect": "Connect",
|
||||
"sshTerminalAutoConnect": "Auto-connect on load",
|
||||
"widgetQuickConnectName": "Quick Connect",
|
||||
"widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts",
|
||||
"connectionTypes": "Connection Types",
|
||||
"connType_terminal": "Terminal",
|
||||
"connType_files": "File Manager",
|
||||
"connType_docker": "Docker",
|
||||
"connType_tunnel": "Tunnel",
|
||||
"connType_host-metrics": "Host Metrics",
|
||||
"connType_rdp": "RDP",
|
||||
"connType_vnc": "VNC",
|
||||
"connType_telnet": "Telnet",
|
||||
"widgetFileManagerName": "File Manager",
|
||||
"widgetFileManagerDesc": "Embedded SFTP file manager for a configured host",
|
||||
"widgetDockerName": "Docker Manager",
|
||||
"widgetDockerDesc": "Embedded Docker container manager for a configured host",
|
||||
"widgetTunnelName": "Tunnel Manager",
|
||||
"widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host",
|
||||
"widgetNoHostSelected": "No host configured"
|
||||
},
|
||||
"serverConfig": {
|
||||
"title": "Server Configuration",
|
||||
@@ -317,7 +574,7 @@
|
||||
"status": "Status",
|
||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||
"failedToRenameFolder": "Failed to rename folder",
|
||||
"movedToFolder": "Moved to \"{{folder}}\"",
|
||||
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
|
||||
"editHostTooltip": "Edit host",
|
||||
"statusChecks": "Status Checks",
|
||||
"metricsCollection": "Metrics Collection",
|
||||
@@ -756,7 +1013,6 @@
|
||||
"failedToSaveFolder": "Failed to save folder",
|
||||
"folderDeleted": "Deleted folder \"{{name}}\"",
|
||||
"deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.",
|
||||
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
|
||||
"failedToMoveHosts": "Failed to move hosts",
|
||||
"expandAll": "Expand all folders",
|
||||
"collapseAll": "Collapse all folders",
|
||||
@@ -806,7 +1062,6 @@
|
||||
"failedToDeployKey": "Failed to deploy key",
|
||||
"deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.",
|
||||
"movedToRoot": "Moved to root",
|
||||
"failedToMoveHosts": "Failed to move hosts",
|
||||
"enableTerminalFeature": "Enable Terminal",
|
||||
"disableTerminalFeature": "Disable Terminal",
|
||||
"enableFilesFeature": "Enable Files",
|
||||
@@ -927,7 +1182,57 @@
|
||||
"shareHost": "Share Host",
|
||||
"shareHostTitle": "Share: {{name}}",
|
||||
"sharing": {
|
||||
"requiresCredential": "This host must use a credential to enable sharing. Edit the host and assign a credential first."
|
||||
"loadError": "Failed to load sharing data. Please try again.",
|
||||
"shareWithSection": "Share with",
|
||||
"usersTab": "Users",
|
||||
"rolesTab": "Roles",
|
||||
"searchPlaceholder": "Search users or roles...",
|
||||
"noMatches": "No matches found",
|
||||
"permissionLevelLabel": "Permission level",
|
||||
"levels": {
|
||||
"connect": {
|
||||
"label": "Connect",
|
||||
"description": "Open sessions only: terminal, remote desktop, file manager, tunnels and Docker. No access to the host configuration."
|
||||
},
|
||||
"view": {
|
||||
"label": "View",
|
||||
"description": "Connect, plus see the host configuration. Secrets are never shown."
|
||||
},
|
||||
"edit": {
|
||||
"label": "Edit",
|
||||
"description": "View, plus modify the host. Secrets can be replaced but never read; credential assignments stay owner-only."
|
||||
},
|
||||
"manage": {
|
||||
"label": "Manage",
|
||||
"description": "Edit, plus share the host with others, change permission levels and revoke access."
|
||||
}
|
||||
},
|
||||
"expiryLabel": "Access expiry",
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"customHoursPlaceholder": "Hours until access expires",
|
||||
"shareButton": "Share",
|
||||
"shareWithCount": "Share ({{count}})",
|
||||
"currentAccess": "Current access",
|
||||
"noAccessEntries": "This host has not been shared yet",
|
||||
"grantedBy": "Granted by",
|
||||
"expires": "Expires",
|
||||
"expired": "Expired",
|
||||
"never": "Never",
|
||||
"revoke": "Revoke",
|
||||
"accessUpdated": "Access updated",
|
||||
"accessUpdateFailed": "Failed to update access",
|
||||
"sharedBadge": "Shared",
|
||||
"sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)",
|
||||
"viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.",
|
||||
"sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.",
|
||||
"ownerOnlyControl": "Only the host owner can change this"
|
||||
},
|
||||
"guac": {
|
||||
"connection": "Connection",
|
||||
@@ -1062,28 +1367,10 @@
|
||||
"backspaceKey": "Backspace Key",
|
||||
"saveHostFirst": "Save the host first.",
|
||||
"sharingOptionsAfterSave": "Sharing options are available after the host has been saved.",
|
||||
"sharingLoadError": "Failed to load sharing data. Check your connection and try again.",
|
||||
"shareHostSection": "Share Host",
|
||||
"shareWithUser": "Share with User",
|
||||
"shareWithRole": "Share with Role",
|
||||
"selectUser": "Select User",
|
||||
"selectRole": "Select Role",
|
||||
"selectUserOption": "Select a user...",
|
||||
"selectRoleOption": "Select a role...",
|
||||
"permissionLevel": "Permission Level",
|
||||
"expiresInHours": "Expires in (hours)",
|
||||
"noExpiryPlaceholder": "Leave empty for no expiry",
|
||||
"shareBtn": "Share",
|
||||
"currentAccess": "Current Access",
|
||||
"typeHeader": "Type",
|
||||
"targetHeader": "Target",
|
||||
"permissionHeader": "Permission",
|
||||
"grantedByHeader": "Granted By",
|
||||
"expiresHeader": "Expires",
|
||||
"noAccessEntries": "No access entries yet.",
|
||||
"expiredLabel": "Expired",
|
||||
"neverLabel": "Never",
|
||||
"revokeBtn": "Revoke",
|
||||
"cancelBtn": "Cancel",
|
||||
"savingBtn": "Saving...",
|
||||
"updateHostBtn": "Update Host",
|
||||
@@ -2583,7 +2870,16 @@
|
||||
"hostDefaultsCommandHistory": "Command History",
|
||||
"hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default",
|
||||
"hostDefaultsSaved": "Host defaults saved",
|
||||
"hostDefaultsSaveFailed": "Failed to save host defaults"
|
||||
"hostDefaultsSaveFailed": "Failed to save host defaults",
|
||||
"rolePermissions": {
|
||||
"count": "{{count}} permissions",
|
||||
"editAction": "Edit permissions",
|
||||
"loadError": "Failed to load the permissions catalog",
|
||||
"saved": "Role permissions saved",
|
||||
"saveError": "Failed to save role permissions",
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
}
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
@@ -3092,266 +3388,5 @@
|
||||
"channels": "Notification Channels",
|
||||
"noChannelsHint": "Add channels in the Channels tab first",
|
||||
"ruleSaveFailed": "Failed to save rule"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Homepage",
|
||||
"addWidget": "Add Widget",
|
||||
"editWidget": "Edit Widget",
|
||||
"deleteWidget": "Delete",
|
||||
"widgetTypes": "Widget Types",
|
||||
"serviceLink": "Service Link",
|
||||
"folder": "Folder",
|
||||
"clock": "Clock",
|
||||
"notes": "Notes",
|
||||
"hostStatus": "Host Status",
|
||||
"bookmarkList": "Bookmarks",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"resetView": "Reset View",
|
||||
"lockLayout": "Lock Layout",
|
||||
"unlockLayout": "Unlock Layout",
|
||||
"noWidgets": "Right-click or click + to add your first widget",
|
||||
"openFullView": "Open Full View",
|
||||
"previewTitle": "Homepage Preview",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"title_label": "Title",
|
||||
"widgetTitlePlaceholder": "Widget title (optional)",
|
||||
"url": "URL",
|
||||
"imageUrl": "Custom Image URL",
|
||||
"imageUrlHint": "Leave blank to use the site favicon automatically",
|
||||
"showImage": "Show Image",
|
||||
"description": "Description",
|
||||
"color": "Color",
|
||||
"icon": "Icon",
|
||||
"expanded": "Expanded by default",
|
||||
"timezone": "Timezone",
|
||||
"showSeconds": "Show seconds",
|
||||
"format12h": "12-hour",
|
||||
"format24h": "24-hour",
|
||||
"content": "Content",
|
||||
"backgroundColor": "Background Color",
|
||||
"host": "Host",
|
||||
"showMetrics": "Show Metrics",
|
||||
"links": "Links",
|
||||
"addLink": "Add Link",
|
||||
"linkLabel": "Label",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Remove",
|
||||
"categoryLinks": "Links",
|
||||
"categoryInfo": "Info",
|
||||
"categorySystem": "System",
|
||||
"widgetServiceLinkDesc": "A clickable tile linking to a service URL",
|
||||
"widgetFolderDesc": "A container to group related widgets",
|
||||
"widgetClockDesc": "A live clock with configurable timezone",
|
||||
"widgetNotesDesc": "A markdown notes widget",
|
||||
"widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host",
|
||||
"widgetBookmarkListDesc": "A list of quick links",
|
||||
"copyLink": "Copy Link",
|
||||
"linkCopied": "Link copied!",
|
||||
"location": "Location",
|
||||
"temperatureUnit": "Temperature Unit",
|
||||
"showForecast": "Show 3-day forecast",
|
||||
"scrolling": "Allow Scrolling",
|
||||
"feedUrl": "Feed URL",
|
||||
"maxItems": "Max Items",
|
||||
"showDescription": "Show Description",
|
||||
"widgetWeatherName": "Weather",
|
||||
"widgetWeatherDesc": "Live weather for any location",
|
||||
"widgetIframeName": "iFrame Embed",
|
||||
"widgetIframeDesc": "Embed any URL in an iframe",
|
||||
"widgetRssName": "RSS Feed",
|
||||
"widgetRssDesc": "Display items from an RSS or Atom feed",
|
||||
"dragToFolder": "Drag widgets here or use + to add",
|
||||
"showDisk": "Show Disk Usage",
|
||||
"addToFolder": "Add widget to folder",
|
||||
"noHostSelected": "No host selected",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"selectHost": "Select a host...",
|
||||
"displayedMetrics": "Displayed Metrics",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Memory",
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Uptime",
|
||||
"metricSystem": "System",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Hostname",
|
||||
"metricNetwork": "Network",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "running",
|
||||
"categoryMonitoring": "Monitoring",
|
||||
"loading": "Loading...",
|
||||
"noData": "No data available",
|
||||
"allClear": "All clear",
|
||||
"acknowledgeAlert": "Acknowledge",
|
||||
"noPingUrls": "No URLs configured",
|
||||
"pingLabel": "Label",
|
||||
"addPingUrl": "Add URL",
|
||||
"showLatency": "Show Latency",
|
||||
"refreshInterval": "Refresh Interval",
|
||||
"seconds": "seconds",
|
||||
"filterActivityTypes": "Filter Types",
|
||||
"filterTypesHint": "Leave empty to show all",
|
||||
"showTimestamp": "Show Timestamp",
|
||||
"uptimeUnavailable": "Uptime unavailable",
|
||||
"uptimeLabel": "Uptime",
|
||||
"overviewVersion": "Version",
|
||||
"overviewUpdate": "Up to date",
|
||||
"overviewUpdateAvailable": "Update available",
|
||||
"overviewDatabase": "Database",
|
||||
"overviewUptime": "Uptime",
|
||||
"noHosts": "No hosts configured",
|
||||
"hostGridHosts": "Hosts",
|
||||
"hostGridAllHint": "Leave empty to show all hosts",
|
||||
"columns": "Columns",
|
||||
"showIp": "Show IP Address",
|
||||
"connectionType": "Connection Type",
|
||||
"layout": "Layout",
|
||||
"showStatus": "Show Status",
|
||||
"showHostName": "Show Host Name",
|
||||
"noDockerActivity": "No Docker activity",
|
||||
"noActivity": "No activity",
|
||||
"showAcknowledged": "Show Acknowledged",
|
||||
"showCurrentValue": "Show Current Value",
|
||||
"chartMetric": "Metric",
|
||||
"metricRange": "Range",
|
||||
"widgetMetricsChartName": "Metrics Chart",
|
||||
"widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host",
|
||||
"widgetHostGridName": "Host Grid",
|
||||
"widgetHostGridDesc": "Grid view of SSH host statuses",
|
||||
"widgetAlertFeedName": "Alert Feed",
|
||||
"widgetAlertFeedDesc": "Live alert firings with acknowledge support",
|
||||
"widgetPingStatusName": "Ping Status",
|
||||
"widgetPingStatusDesc": "HTTP ping status for one or more URLs",
|
||||
"widgetRecentActivityName": "Recent Activity",
|
||||
"widgetRecentActivityDesc": "Scrollable feed of recent Termix activity",
|
||||
"widgetTermixUptimeName": "Termix Uptime",
|
||||
"widgetTermixUptimeDesc": "Live uptime counter for the Termix server",
|
||||
"widgetSystemOverviewName": "System Overview",
|
||||
"widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance",
|
||||
"widgetSshQuickConnectName": "SSH Quick Connect",
|
||||
"widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions",
|
||||
"widgetDockerActivityName": "Docker Activity",
|
||||
"widgetDockerActivityDesc": "Recent Docker container events across all hosts",
|
||||
"widgetCalendarName": "Calendar",
|
||||
"widgetCalendarDesc": "A monthly calendar with today highlighted",
|
||||
"widgetCountdownName": "Countdown",
|
||||
"widgetCountdownDesc": "Countdown timer to a target date",
|
||||
"widgetSearchBarName": "Search Bar",
|
||||
"widgetSearchBarDesc": "Quick web search widget",
|
||||
"widgetTextBannerName": "Text Banner",
|
||||
"widgetTextBannerDesc": "A bold label or section header for the canvas",
|
||||
"widgetImageWidgetName": "Image",
|
||||
"widgetImageWidgetDesc": "Display an image from a URL",
|
||||
"widgetMarkdownNotesName": "Markdown Notes",
|
||||
"widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering",
|
||||
"widgetCustomApiName": "Custom API",
|
||||
"widgetCustomApiDesc": "Fetch and display data from any JSON API",
|
||||
"widgetServiceGridName": "Service Grid",
|
||||
"widgetServiceGridDesc": "A configurable grid of service tile links",
|
||||
"widgetDashboardLinksName": "Dashboard Links",
|
||||
"widgetDashboardLinksDesc": "Display your configured service links from the dashboard",
|
||||
"widgetSearchLinksName": "Search Shortcuts",
|
||||
"widgetSearchLinksDesc": "Quick search shortcut buttons with inline input",
|
||||
"widgetLinkTreeName": "Link Tree",
|
||||
"widgetLinkTreeDesc": "Grouped sections of links with headings",
|
||||
"calMon": "Mo",
|
||||
"calTue": "Tu",
|
||||
"calWed": "We",
|
||||
"calThu": "Th",
|
||||
"calFri": "Fr",
|
||||
"calSat": "Sa",
|
||||
"calSun": "Su",
|
||||
"startOnMonday": "Start week on Monday",
|
||||
"countdownNoDate": "No target date set",
|
||||
"countdownPast": "Event has passed",
|
||||
"countdownDays": "days",
|
||||
"countdownHours": "hrs",
|
||||
"countdownMinutes": "min",
|
||||
"countdownSeconds": "sec",
|
||||
"countdownLabel": "Label",
|
||||
"countdownLabelPlaceholder": "e.g. Launch Day",
|
||||
"countdownShowDays": "Show Days",
|
||||
"countdownShowHours": "Show Hours",
|
||||
"targetDate": "Target Date",
|
||||
"searchEngine": "Search Engine",
|
||||
"customSearchUrl": "Custom Search URL",
|
||||
"searchPlaceholder": "Search...",
|
||||
"searchGo": "Go",
|
||||
"searchPlaceholderLabel": "Placeholder Text",
|
||||
"searchPlaceholderHint": "Text shown inside the search input",
|
||||
"openInNewTab": "Open in New Tab",
|
||||
"searchQueryPlaceholder": "Enter query...",
|
||||
"noSearchShortcuts": "No shortcuts configured",
|
||||
"addSearchShortcut": "Add Shortcut",
|
||||
"fontSize": "Font Size",
|
||||
"textAlign": "Text Align",
|
||||
"fontWeight": "Font Weight",
|
||||
"clearColor": "Clear Color",
|
||||
"imageFit": "Image Fit",
|
||||
"imageLinkUrl": "Link URL",
|
||||
"noImage": "No image URL set",
|
||||
"altText": "Alt Text",
|
||||
"altTextPlaceholder": "Describe the image",
|
||||
"renderMarkdown": "Render Markdown",
|
||||
"displayMode": "Display Mode",
|
||||
"displayField": "Display Field",
|
||||
"jsonPath": "JSON Path",
|
||||
"customApiLabel": "Label",
|
||||
"customApiLabelPlaceholder": "e.g. Temperature",
|
||||
"customApiUnit": "Unit",
|
||||
"customApiNoUrl": "No API URL configured",
|
||||
"customApiError": "Failed to fetch",
|
||||
"customApiNotArray": "Response is not an array",
|
||||
"addService": "Add Service",
|
||||
"showLabels": "Show Labels",
|
||||
"iconSize": "Icon Size",
|
||||
"noDashboardLinks": "No dashboard links configured",
|
||||
"noLimit": "No limit",
|
||||
"sectionHeading": "Section Heading",
|
||||
"addSection": "Add Section",
|
||||
"compactMode": "Compact Mode",
|
||||
"showDetailed": "Show Detailed",
|
||||
"selectHosts": "Select Hosts",
|
||||
"allHosts": "All hosts",
|
||||
"listLayout": "List",
|
||||
"gridLayout": "Grid",
|
||||
"terminal": "Terminal",
|
||||
"files": "Files",
|
||||
"docker": "Docker",
|
||||
"range15m": "15 minutes",
|
||||
"range1h": "1 hour",
|
||||
"range6h": "6 hours",
|
||||
"range24h": "24 hours",
|
||||
"metricNetRx": "Net Download",
|
||||
"metricNetTx": "Net Upload",
|
||||
"severityFilter": "Severity Filter",
|
||||
"filterAll": "All",
|
||||
"accentColor": "Accent Color",
|
||||
"widgetSshTerminalName": "SSH Terminal",
|
||||
"widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host",
|
||||
"sshTerminalNoHost": "No host configured",
|
||||
"sshTerminalConnect": "Connect",
|
||||
"sshTerminalAutoConnect": "Auto-connect on load",
|
||||
"widgetQuickConnectName": "Quick Connect",
|
||||
"widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts",
|
||||
"connectionTypes": "Connection Types",
|
||||
"connType_terminal": "Terminal",
|
||||
"connType_files": "File Manager",
|
||||
"connType_docker": "Docker",
|
||||
"connType_tunnel": "Tunnel",
|
||||
"connType_host-metrics": "Host Metrics",
|
||||
"connType_rdp": "RDP",
|
||||
"connType_vnc": "VNC",
|
||||
"connType_telnet": "Telnet",
|
||||
"widgetFileManagerName": "File Manager",
|
||||
"widgetFileManagerDesc": "Embedded SFTP file manager for a configured host",
|
||||
"widgetDockerName": "Docker Manager",
|
||||
"widgetDockerDesc": "Embedded Docker container manager for a configured host",
|
||||
"widgetTunnelName": "Tunnel Manager",
|
||||
"widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host",
|
||||
"widgetNoHostSelected": "No host configured"
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -15,7 +15,7 @@ export interface Role {
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
permissions: string | null;
|
||||
permissions: string[] | string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export interface AccessRecord {
|
||||
roleDisplayName: string | null;
|
||||
grantedBy: string;
|
||||
grantedByUsername: string;
|
||||
permissionLevel: "view";
|
||||
permissionLevel: "connect" | "view" | "edit" | "manage";
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -2087,13 +2087,21 @@ export {
|
||||
assignRoleToUser,
|
||||
removeRoleFromUser,
|
||||
shareHost,
|
||||
updateHostAccess,
|
||||
getHostAccess,
|
||||
revokeHostAccess,
|
||||
getPermissionsCatalog,
|
||||
getSharedHosts,
|
||||
shareSnippet,
|
||||
getSnippetAccess,
|
||||
revokeSnippetAccess,
|
||||
getSharedSnippets,
|
||||
} from "@/api/rbac-api";
|
||||
export type {
|
||||
SharePermissionLevel,
|
||||
ShareTarget,
|
||||
PermissionCatalogEntry,
|
||||
} from "@/api/rbac-api";
|
||||
|
||||
// ============================================================================
|
||||
// DOCKER MANAGEMENT API
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
|
||||
import type { Host, TabType } from "@/types/ui-types";
|
||||
import { canEditHost } from "@/sidebar/host-permissions";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
@@ -403,6 +404,11 @@ export function CommandPalette({
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{host.name}
|
||||
</span>
|
||||
{host.isShared && (
|
||||
<span className="text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
|
||||
{t("hosts.sharing.sharedBadge")}
|
||||
</span>
|
||||
)}
|
||||
{host.online && (
|
||||
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
||||
)}
|
||||
@@ -481,6 +487,8 @@ export function CommandPalette({
|
||||
Telnet
|
||||
</button>
|
||||
)}
|
||||
{canEditHost(host) && (
|
||||
<>
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
<button
|
||||
title="Edit Host"
|
||||
@@ -490,9 +498,12 @@ export function CommandPalette({
|
||||
onOpenTab("host-manager");
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:edit-host", {
|
||||
new CustomEvent(
|
||||
"host-manager:edit-host",
|
||||
{
|
||||
detail: host.id,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
}, 100);
|
||||
}}
|
||||
@@ -500,6 +511,8 @@ export function CommandPalette({
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
deleteRole,
|
||||
deleteUser,
|
||||
getPermissionsCatalog,
|
||||
revokeAllUserSessions,
|
||||
revokeSession,
|
||||
updateRole,
|
||||
} from "@/main-axios";
|
||||
import type { Role } from "@/main-axios";
|
||||
import type { PermissionCatalogEntry, Role } from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -375,6 +378,69 @@ export function AdminRolesSection({
|
||||
createRoleLoading,
|
||||
}: RolesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
|
||||
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
||||
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [savingPermissions, setSavingPermissions] = useState(false);
|
||||
|
||||
function rolePermissions(role: Role): string[] {
|
||||
if (Array.isArray(role.permissions)) return role.permissions;
|
||||
if (typeof role.permissions === "string") {
|
||||
try {
|
||||
return JSON.parse(role.permissions) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function openPermissionsEditor(role: Role) {
|
||||
if (editingRoleId === role.id) {
|
||||
setEditingRoleId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (catalog.length === 0) {
|
||||
const res = await getPermissionsCatalog();
|
||||
setCatalog(res.catalog ?? []);
|
||||
}
|
||||
setEditingPermissions(new Set(rolePermissions(role)));
|
||||
setEditingRoleId(role.id);
|
||||
} catch {
|
||||
toast.error(t("admin.rolePermissions.loadError"));
|
||||
}
|
||||
}
|
||||
|
||||
function togglePermission(permission: string) {
|
||||
setEditingPermissions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(permission)) next.delete(permission);
|
||||
else next.add(permission);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function savePermissions(role: Role) {
|
||||
setSavingPermissions(true);
|
||||
try {
|
||||
const permissions = [...editingPermissions];
|
||||
await updateRole(role.id, { permissions });
|
||||
setRoles((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.id === role.id ? { ...entry, permissions } : entry,
|
||||
),
|
||||
);
|
||||
setEditingRoleId(null);
|
||||
toast.success(t("admin.rolePermissions.saved"));
|
||||
} catch {
|
||||
toast.error(t("admin.rolePermissions.saveError"));
|
||||
} finally {
|
||||
setSavingPermissions(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
@@ -467,11 +533,14 @@ export function AdminRolesSection({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{roles.map((role) => (
|
||||
{roles.map((role) => {
|
||||
const permissionCount = rolePermissions(role).length;
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
|
||||
className="flex flex-col py-2.5 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
@@ -486,6 +555,13 @@ export function AdminRolesSection({
|
||||
{t("admin.customBadge")}
|
||||
</span>
|
||||
)}
|
||||
{permissionCount > 0 && (
|
||||
<span className="text-[9px] px-1 py-px border border-border/60 text-muted-foreground">
|
||||
{t("admin.rolePermissions.count", {
|
||||
count: permissionCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
{role.name}
|
||||
@@ -493,13 +569,24 @@ export function AdminRolesSection({
|
||||
</div>
|
||||
{!role.isSystem && (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-6 ${editingRoleId === role.id ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("admin.rolePermissions.editAction")}
|
||||
onClick={() => openPermissionsEditor(role)}
|
||||
>
|
||||
<KeyRound className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
await deleteRole(role.id);
|
||||
setRoles((prev) => prev.filter((r) => r.id !== role.id));
|
||||
setRoles((prev) =>
|
||||
prev.filter((r) => r.id !== role.id),
|
||||
);
|
||||
toast.success(
|
||||
t("admin.deleteRoleSuccess", {
|
||||
name: role.displayName,
|
||||
@@ -512,7 +599,88 @@ export function AdminRolesSection({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{editingRoleId === role.id && (
|
||||
<div className="flex flex-col gap-2.5 mt-2.5 p-2.5 border border-border bg-muted/20">
|
||||
{catalog.map((entry) => {
|
||||
const wildcard = `${entry.group}.*`;
|
||||
const wildcardOn = editingPermissions.has(wildcard);
|
||||
return (
|
||||
<div key={entry.group} className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => togglePermission(wildcard)}
|
||||
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
|
||||
>
|
||||
<span
|
||||
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${wildcardOn ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{wildcardOn && (
|
||||
<Check className="size-2 text-background" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
wildcardOn
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{entry.group}.*
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex flex-col gap-0.5 pl-4">
|
||||
{entry.permissions.map((permission) => {
|
||||
const checked =
|
||||
wildcardOn || editingPermissions.has(permission);
|
||||
return (
|
||||
<button
|
||||
key={permission}
|
||||
disabled={wildcardOn}
|
||||
onClick={() => togglePermission(permission)}
|
||||
className="flex items-center gap-1.5 text-[10px] text-left disabled:opacity-60"
|
||||
>
|
||||
<span
|
||||
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${checked ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{checked && (
|
||||
<Check className="size-2 text-background" />
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{permission}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setEditingRoleId(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={savingPermissions}
|
||||
onClick={() => savePermissions(role)}
|
||||
>
|
||||
{savingPermissions
|
||||
? t("admin.rolePermissions.saving")
|
||||
: t("admin.rolePermissions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
|
||||
+101
-20
@@ -57,6 +57,7 @@ import {
|
||||
HostFilesTab,
|
||||
} from "./HostEditorFeatureTabs";
|
||||
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
|
||||
import { canEditHost } from "./host-permissions";
|
||||
import {
|
||||
HostEditorRdpTab,
|
||||
HostEditorTelnetTab,
|
||||
@@ -201,6 +202,13 @@ export function HostEditor({
|
||||
(c) => c.id === form.credentialId,
|
||||
);
|
||||
|
||||
// Shared hosts: view-level recipients see a read-only editor; edit-level
|
||||
// recipients may change the host but never its credential/vault references
|
||||
// or auth type (owner-only, enforced server-side too).
|
||||
const isSharedHost = !!host?.isShared;
|
||||
const readOnly = isSharedHost && host !== null && !canEditHost(host);
|
||||
const lockAuthReferences = isSharedHost && !readOnly;
|
||||
|
||||
const handleProtocolToggle = (
|
||||
proto: keyof typeof protocols,
|
||||
value: boolean,
|
||||
@@ -232,6 +240,24 @@ export function HostEditor({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{isSharedHost && (
|
||||
<div className="flex items-start gap-2.5 p-3 border border-accent-brand/30 bg-accent-brand/5 text-xs text-muted-foreground">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5 text-accent-brand" />
|
||||
<div>
|
||||
{readOnly
|
||||
? t("hosts.sharing.viewOnlyBanner", {
|
||||
owner: host?.ownerUsername || "?",
|
||||
})
|
||||
: t("hosts.sharing.sharedEditBanner", {
|
||||
owner: host?.ownerUsername || "?",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<fieldset
|
||||
disabled={readOnly}
|
||||
className={`flex flex-col gap-3 min-w-0 ${readOnly ? "opacity-80" : ""}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "general" && (
|
||||
<HostEditorGeneralTab
|
||||
@@ -289,15 +315,26 @@ export function HostEditor({
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
disabled={lockAuthReferences}
|
||||
title={
|
||||
lockAuthReferences
|
||||
? t("hosts.sharing.ownerOnlyControl")
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setField("authType", m as HostAuthType);
|
||||
}}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${authMethod === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${authMethod === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{lockAuthReferences && (
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("hosts.sharing.ownerOnlyControl")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -313,10 +350,12 @@ export function HostEditor({
|
||||
!form.overrideCredentialUsername
|
||||
}
|
||||
onFocus={() => {
|
||||
if (form.username === "root") setField("username", "");
|
||||
if (form.username === "root")
|
||||
setField("username", "");
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (form.username === "") setField("username", "root");
|
||||
if (form.username === "")
|
||||
setField("username", "root");
|
||||
}}
|
||||
onChange={(e) => setField("username", e.target.value)}
|
||||
/>
|
||||
@@ -375,7 +414,9 @@ export function HostEditor({
|
||||
value={
|
||||
form.key === "existing_key" ? "" : form.key
|
||||
}
|
||||
onChange={(e) => setField("key", e.target.value)}
|
||||
onChange={(e) =>
|
||||
setField("key", e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -452,7 +493,9 @@ export function HostEditor({
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={(e) => setField("password", e.target.value)}
|
||||
onChange={(e) =>
|
||||
setField("password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -461,10 +504,14 @@ export function HostEditor({
|
||||
</label>
|
||||
<select
|
||||
value={form.keyType}
|
||||
onChange={(e) => setField("keyType", e.target.value)}
|
||||
onChange={(e) =>
|
||||
setField("keyType", e.target.value)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="auto">{t("hosts.keyTypeAuto")}</option>
|
||||
<option value="auto">
|
||||
{t("hosts.keyTypeAuto")}
|
||||
</option>
|
||||
<option value="ssh-rsa">RSA</option>
|
||||
<option value="ssh-ed25519">Ed25519</option>
|
||||
<option value="ecdsa-sha2-nistp256">
|
||||
@@ -477,8 +524,12 @@ export function HostEditor({
|
||||
ECDSA P-521
|
||||
</option>
|
||||
<option value="ssh-dss">DSA</option>
|
||||
<option value="ssh-rsa-sha2-256">RSA SHA2-256</option>
|
||||
<option value="ssh-rsa-sha2-512">RSA SHA2-512</option>
|
||||
<option value="ssh-rsa-sha2-256">
|
||||
RSA SHA2-256
|
||||
</option>
|
||||
<option value="ssh-rsa-sha2-512">
|
||||
RSA SHA2-512
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
@@ -543,6 +594,12 @@ export function HostEditor({
|
||||
</label>
|
||||
<select
|
||||
value={form.credentialId}
|
||||
disabled={lockAuthReferences}
|
||||
title={
|
||||
lockAuthReferences
|
||||
? t("hosts.sharing.ownerOnlyControl")
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newId = e.target.value;
|
||||
setField("credentialId", newId);
|
||||
@@ -554,7 +611,7 @@ export function HostEditor({
|
||||
setField("username", cred.username);
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">
|
||||
{t("hosts.selectACredential")}
|
||||
@@ -600,7 +657,9 @@ export function HostEditor({
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={(e) => setField("password", e.target.value)}
|
||||
onChange={(e) =>
|
||||
setField("password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -683,7 +742,9 @@ export function HostEditor({
|
||||
{tailscaleDevices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.hostname} (
|
||||
{d.addresses.find((a) => a.startsWith("100.")) ??
|
||||
{d.addresses.find((a) =>
|
||||
a.startsWith("100."),
|
||||
) ??
|
||||
d.addresses[0] ??
|
||||
""}
|
||||
)
|
||||
@@ -746,7 +807,9 @@ export function HostEditor({
|
||||
<SettingRow
|
||||
label={t("hosts.allowLegacyAlgorithmsLabel")}
|
||||
badge={
|
||||
form.allowLegacyAlgorithms ? t("hosts.insecure") : undefined
|
||||
form.allowLegacyAlgorithms
|
||||
? t("hosts.insecure")
|
||||
: undefined
|
||||
}
|
||||
description={t("hosts.allowLegacyAlgorithmsDesc")}
|
||||
>
|
||||
@@ -773,7 +836,9 @@ export function HostEditor({
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.sudoPassword}
|
||||
onChange={(e) => setField("sudoPassword", e.target.value)}
|
||||
onChange={(e) =>
|
||||
setField("sudoPassword", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -815,7 +880,10 @@ export function HostEditor({
|
||||
const newTheme = e.target.value;
|
||||
setField("theme", newTheme);
|
||||
setPreviewTerminalTheme(newTheme);
|
||||
if (newTheme === "custom" && !form.customThemeColors) {
|
||||
if (
|
||||
newTheme === "custom" &&
|
||||
!form.customThemeColors
|
||||
) {
|
||||
setField("customThemeColors", {
|
||||
...TERMINAL_THEMES.termixDark.colors,
|
||||
});
|
||||
@@ -1374,7 +1442,10 @@ export function HostEditor({
|
||||
value={ev.key}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.environmentVariables];
|
||||
updated[i] = { ...updated[i], key: e.target.value };
|
||||
updated[i] = {
|
||||
...updated[i],
|
||||
key: e.target.value,
|
||||
};
|
||||
setField("environmentVariables", updated);
|
||||
}}
|
||||
/>
|
||||
@@ -1458,7 +1529,9 @@ export function HostEditor({
|
||||
<Input
|
||||
placeholder="mosh"
|
||||
value={form.moshCommand}
|
||||
onChange={(e) => setField("moshCommand", e.target.value)}
|
||||
onChange={(e) =>
|
||||
setField("moshCommand", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1676,7 +1749,8 @@ export function HostEditor({
|
||||
tunnelIndex: i,
|
||||
hostName: host.name,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.sshPort ?? host.port,
|
||||
sourceSSHPort:
|
||||
host.sshPort ?? host.port,
|
||||
sourceUsername: form.username,
|
||||
sourcePassword:
|
||||
form.password || undefined,
|
||||
@@ -1721,7 +1795,9 @@ export function HostEditor({
|
||||
autoStart: tun.autoStart ?? false,
|
||||
isPinned: false,
|
||||
});
|
||||
toast.success(t("hosts.tunnelConnecting"));
|
||||
toast.success(
|
||||
t("hosts.tunnelConnecting"),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
@@ -1749,7 +1825,9 @@ export function HostEditor({
|
||||
onClick={() =>
|
||||
setField(
|
||||
"serverTunnels",
|
||||
form.serverTunnels.filter((_, idx) => idx !== i),
|
||||
form.serverTunnels.filter(
|
||||
(_, idx) => idx !== i,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -1976,6 +2054,7 @@ export function HostEditor({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-3 mb-6">
|
||||
<Button
|
||||
@@ -1988,6 +2067,7 @@ export function HostEditor({
|
||||
>
|
||||
{t("hosts.guac.cancelBtn")}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
|
||||
@@ -2000,6 +2080,7 @@ export function HostEditor({
|
||||
? t("hosts.guac.updateHostBtn")
|
||||
: t("hosts.guac.addHostBtn")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -113,6 +113,10 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
socks5Password: h.socks5Password,
|
||||
socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [],
|
||||
overrideCredentialUsername: h.overrideCredentialUsername ?? false,
|
||||
isShared: h.isShared ?? false,
|
||||
permissionLevel: h.permissionLevel,
|
||||
sharedExpiresAt: h.sharedExpiresAt,
|
||||
ownerUsername: h.ownerUsername,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+385
-152
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ListChecks,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
@@ -12,17 +14,43 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import {
|
||||
getHostAccess,
|
||||
shareHost,
|
||||
updateHostAccess,
|
||||
revokeHostAccess,
|
||||
getUserList,
|
||||
getRoles,
|
||||
type AccessRecord,
|
||||
type SharePermissionLevel,
|
||||
type ShareTarget,
|
||||
} from "@/main-axios";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const PERMISSION_LEVELS: SharePermissionLevel[] = [
|
||||
"connect",
|
||||
"view",
|
||||
"edit",
|
||||
"manage",
|
||||
];
|
||||
|
||||
const EXPIRY_PRESETS = [
|
||||
{ key: "never", hours: null },
|
||||
{ key: "oneHour", hours: 1 },
|
||||
{ key: "oneDay", hours: 24 },
|
||||
{ key: "sevenDays", hours: 24 * 7 },
|
||||
{ key: "thirtyDays", hours: 24 * 30 },
|
||||
{ key: "custom", hours: undefined },
|
||||
] as const;
|
||||
|
||||
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
|
||||
|
||||
export function HostShareModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -33,18 +61,28 @@ export function HostShareModal({
|
||||
host: Host | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [shareType, setShareType] = useState<"user" | "role">("user");
|
||||
const [shareGranteeId, setShareGranteeId] = useState("");
|
||||
const [shareExpiryHours, setShareExpiryHours] = useState("");
|
||||
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [permissionLevel, setPermissionLevel] =
|
||||
useState<SharePermissionLevel>("connect");
|
||||
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("never");
|
||||
const [customHours, setCustomHours] = useState("");
|
||||
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
|
||||
const [shareUsers, setShareUsers] = useState<
|
||||
{ id: string; username: string }[]
|
||||
>([]);
|
||||
const [shareRoles, setShareRoles] = useState<{ id: string; name: string }[]>(
|
||||
[],
|
||||
);
|
||||
const [shareRoles, setShareRoles] = useState<
|
||||
{ id: number; name: string; displayName?: string }[]
|
||||
>([]);
|
||||
const [sharingLoaded, setSharingLoaded] = useState(false);
|
||||
const [sharingLoadError, setSharingLoadError] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !host) return;
|
||||
@@ -64,9 +102,12 @@ export function HostShareModal({
|
||||
})),
|
||||
);
|
||||
setShareRoles(
|
||||
(rolesRes.roles ?? []).map((r) => ({
|
||||
id: String(r.id),
|
||||
(rolesRes.roles ?? [])
|
||||
.filter((r) => !r.isSystem)
|
||||
.map((r) => ({
|
||||
id: Number(r.id),
|
||||
name: r.name,
|
||||
displayName: r.displayName,
|
||||
})),
|
||||
);
|
||||
})
|
||||
@@ -77,21 +118,102 @@ export function HostShareModal({
|
||||
setSharingLoaded(false);
|
||||
setSharingLoadError(false);
|
||||
setAccessList([]);
|
||||
setShareGranteeId("");
|
||||
setShareExpiryHours("");
|
||||
setShareType("user");
|
||||
setSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
setSelectedRoleIds(new Set());
|
||||
setPermissionLevel("connect");
|
||||
setExpiryPreset("never");
|
||||
setCustomHours("");
|
||||
setTargetTab("user");
|
||||
}, [host?.id]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return q
|
||||
? shareUsers.filter((u) => u.username.toLowerCase().includes(q))
|
||||
: shareUsers;
|
||||
}, [shareUsers, search]);
|
||||
|
||||
const filteredRoles = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return q
|
||||
? shareRoles.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
(r.displayName ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
: shareRoles;
|
||||
}, [shareRoles, search]);
|
||||
|
||||
const selectedCount = selectedUserIds.size + selectedRoleIds.size;
|
||||
|
||||
const durationHours = (() => {
|
||||
if (expiryPreset === "never") return undefined;
|
||||
if (expiryPreset === "custom") {
|
||||
const hours = Number(customHours);
|
||||
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
|
||||
}
|
||||
return (
|
||||
EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours ?? undefined
|
||||
);
|
||||
})();
|
||||
|
||||
async function refreshAccessList() {
|
||||
if (!host) return;
|
||||
const res = await getHostAccess(Number(host.id));
|
||||
setAccessList(res.accessList ?? []);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
async function handleShare() {
|
||||
if (!host || selectedCount === 0) return;
|
||||
const targets: ShareTarget[] = [
|
||||
...[...selectedUserIds].map(
|
||||
(id) => ({ type: "user", id }) as ShareTarget,
|
||||
),
|
||||
...[...selectedRoleIds].map(
|
||||
(id) => ({ type: "role", id }) as ShareTarget,
|
||||
),
|
||||
];
|
||||
|
||||
const hasCredential =
|
||||
!!host?.credentialId || !!host?.rdpCredentialId || !!host?.vncCredentialId;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await shareHost(Number(host.id), {
|
||||
targets,
|
||||
permissionLevel,
|
||||
...(durationHours ? { durationHours } : {}),
|
||||
});
|
||||
await refreshAccessList();
|
||||
setSelectedUserIds(new Set());
|
||||
setSelectedRoleIds(new Set());
|
||||
toast.success(t("hosts.hostSharedSuccessfully"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToShareHost"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLevelChange(
|
||||
record: AccessRecord,
|
||||
level: SharePermissionLevel,
|
||||
) {
|
||||
if (!host || record.permissionLevel === level) return;
|
||||
try {
|
||||
await updateHostAccess(Number(host.id), record.id, {
|
||||
permissionLevel: level,
|
||||
});
|
||||
setAccessList((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.id === record.id ? { ...entry, permissionLevel: level } : entry,
|
||||
),
|
||||
);
|
||||
toast.success(t("hosts.sharing.accessUpdated"));
|
||||
} catch {
|
||||
toast.error(t("hosts.sharing.accessUpdateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
||||
@@ -101,185 +223,298 @@ export function HostShareModal({
|
||||
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span>{t("hosts.shareHostTitle", { name: host?.name ?? "" })}</span>
|
||||
<span className="truncate">
|
||||
{t("hosts.shareHostTitle", { name: host?.name ?? "" })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-3 gap-3">
|
||||
{!hasCredential && host !== null && (
|
||||
<div className="flex items-start gap-3 p-3 border border-yellow-500/30 bg-yellow-500/5 text-xs text-yellow-500">
|
||||
{sharingLoadError && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||
<div>{t("hosts.sharing.requiresCredential")}</div>
|
||||
<div>{t("hosts.sharing.loadError")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharingLoadError && hasCredential && (
|
||||
<div className="flex items-start gap-3 p-3 border border-destructive/30 bg-destructive/5 text-xs text-destructive">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||
<div>{t("hosts.guac.sharingLoadError")}</div>
|
||||
{/* Share form: fixed, non-scrolling */}
|
||||
<div className="flex flex-col gap-2 p-3 shrink-0 border-b border-border">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<Users className="size-3.5" />
|
||||
{t("hosts.sharing.shareWithSection")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCredential && (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.shareHostSection")}
|
||||
icon={<Users className="size-3.5" />}
|
||||
action={
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/rbac"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline normal-case tracking-normal font-normal"
|
||||
className="text-[10px] text-accent-brand hover:underline shrink-0"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
{(["user", "role"] as const).map((shareTypeOpt) => (
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
{(["user", "role"] as const).map((tab) => (
|
||||
<button
|
||||
key={shareTypeOpt}
|
||||
onClick={() => {
|
||||
setShareType(shareTypeOpt);
|
||||
setShareGranteeId("");
|
||||
}}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${shareType === shareTypeOpt ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
key={tab}
|
||||
onClick={() => setTargetTab(tab)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${targetTab === tab ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{shareTypeOpt === "user" ? (
|
||||
<>
|
||||
<User className="size-3 inline mr-1" />
|
||||
{t("hosts.guac.shareWithUser")}
|
||||
</>
|
||||
{tab === "user" ? (
|
||||
<User className="size-3 shrink-0" />
|
||||
) : (
|
||||
<>
|
||||
<Shield className="size-3 inline mr-1" />
|
||||
{t("hosts.guac.shareWithRole")}
|
||||
</>
|
||||
<Shield className="size-3 shrink-0" />
|
||||
)}
|
||||
{tab === "user"
|
||||
? t("hosts.sharing.usersTab")
|
||||
: t("hosts.sharing.rolesTab")}
|
||||
{tab === "user" && selectedUserIds.size > 0 && (
|
||||
<span>({selectedUserIds.size})</span>
|
||||
)}
|
||||
{tab === "role" && selectedRoleIds.size > 0 && (
|
||||
<span>({selectedRoleIds.size})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{shareType === "user"
|
||||
? t("hosts.guac.selectUser")
|
||||
: t("hosts.guac.selectRole")}
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={shareGranteeId}
|
||||
onChange={(e) => setShareGranteeId(e.target.value)}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder={t("hosts.sharing.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border border-border h-28 overflow-y-auto">
|
||||
{targetTab === "user" &&
|
||||
(filteredUsers.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.sharing.noMatches")}
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => {
|
||||
const isSelected = selectedUserIds.has(user.id);
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() =>
|
||||
setSelectedUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(user.id)) next.delete(user.id);
|
||||
else next.add(user.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
|
||||
>
|
||||
<option value="">
|
||||
{shareType === "user"
|
||||
? t("hosts.guac.selectUserOption")
|
||||
: t("hosts.guac.selectRoleOption")}
|
||||
</option>
|
||||
{shareType === "user"
|
||||
? shareUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))
|
||||
: shareRoles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
<div
|
||||
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check className="size-2.5 text-background" />
|
||||
)}
|
||||
</div>
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{user.username}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
))}
|
||||
{targetTab === "role" &&
|
||||
(filteredRoles.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.sharing.noMatches")}
|
||||
</div>
|
||||
) : (
|
||||
filteredRoles.map((role) => {
|
||||
const isSelected = selectedRoleIds.has(role.id);
|
||||
return (
|
||||
<button
|
||||
key={role.id}
|
||||
onClick={() =>
|
||||
setSelectedRoleIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(role.id)) next.delete(role.id);
|
||||
else next.add(role.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check className="size-2.5 text-background" />
|
||||
)}
|
||||
</div>
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">
|
||||
{role.displayName || role.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sharing.permissionLevelLabel")}
|
||||
</span>
|
||||
<select
|
||||
value={permissionLevel}
|
||||
onChange={(e) =>
|
||||
setPermissionLevel(e.target.value as SharePermissionLevel)
|
||||
}
|
||||
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{PERMISSION_LEVELS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{t(`hosts.sharing.levels.${level}.label`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.expiresInHours")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hosts.guac.noExpiryPlaceholder")}
|
||||
value={shareExpiryHours}
|
||||
onChange={(e) => setShareExpiryHours(e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex flex-col gap-1 shrink-0">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
|
||||
{t("hosts.sharing.expiryLabel")}
|
||||
</span>
|
||||
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
|
||||
{t(`hosts.sharing.expiry.${expiryPreset}`)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
{EXPIRY_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.key}
|
||||
onClick={() => setExpiryPreset(preset.key)}
|
||||
>
|
||||
{expiryPreset === preset.key ? (
|
||||
<Check className="size-3 mr-1.5" />
|
||||
) : (
|
||||
<span className="size-3 mr-1.5" />
|
||||
)}
|
||||
{t(`hosts.sharing.expiry.${preset.key}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={!shareGranteeId}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await shareHost(Number(host!.id), {
|
||||
targetType: shareType,
|
||||
...(shareType === "user"
|
||||
? { targetUserId: shareGranteeId }
|
||||
: { targetRoleId: Number(shareGranteeId) }),
|
||||
permissionLevel: "view",
|
||||
...(shareExpiryHours
|
||||
? { durationHours: Number(shareExpiryHours) }
|
||||
: {}),
|
||||
});
|
||||
await refreshAccessList();
|
||||
setShareGranteeId("");
|
||||
setShareExpiryHours("");
|
||||
toast.success(t("hosts.hostSharedSuccessfully"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToShareHost"));
|
||||
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={
|
||||
selectedCount === 0 ||
|
||||
submitting ||
|
||||
(expiryPreset === "custom" && !durationHours)
|
||||
}
|
||||
}}
|
||||
onClick={handleShare}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t("hosts.guac.shareBtn")}
|
||||
<Share2 className="size-3.5 mr-1.5" />
|
||||
{selectedCount > 0
|
||||
? t("hosts.sharing.shareWithCount", { count: selectedCount })
|
||||
: t("hosts.sharing.shareButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.guac.currentAccess")}
|
||||
icon={<ListChecks className="size-3.5" />}
|
||||
>
|
||||
<div className="py-2">
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t(`hosts.sharing.levels.${permissionLevel}.description`)}
|
||||
</p>
|
||||
|
||||
{expiryPreset === "custom" && (
|
||||
<Input
|
||||
type="number"
|
||||
autoFocus
|
||||
placeholder={t("hosts.sharing.customHoursPlaceholder")}
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current access: takes remaining space, scrolls independently */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<ListChecks className="size-3.5" />
|
||||
{t("hosts.sharing.currentAccess")}
|
||||
{accessList.length > 0 && (
|
||||
<span className="text-muted-foreground/40">
|
||||
({accessList.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{accessList.length === 0 && (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.guac.noAccessEntries")}
|
||||
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.sharing.noAccessEntries")}
|
||||
</div>
|
||||
)}
|
||||
{accessList.map((r, i) => {
|
||||
{accessList.map((record) => {
|
||||
const expired =
|
||||
r.expiresAt && new Date(r.expiresAt) < new Date();
|
||||
record.expiresAt && new Date(record.expiresAt) < new Date();
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 px-2 py-2.5 border-b border-border last:border-0 text-xs"
|
||||
key={record.id}
|
||||
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{r.targetType === "user" ? (
|
||||
{record.targetType === "user" ? (
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold truncate">
|
||||
{r.username ??
|
||||
r.roleName ??
|
||||
r.roleDisplayName ??
|
||||
r.userId ??
|
||||
r.roleId}
|
||||
</span>
|
||||
<span className="text-muted-foreground capitalize shrink-0">
|
||||
({r.targetType})
|
||||
{record.username ??
|
||||
record.roleDisplayName ??
|
||||
record.roleName ??
|
||||
record.userId ??
|
||||
record.roleId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
|
||||
{t(
|
||||
`hosts.sharing.levels.${record.permissionLevel ?? "connect"}.label`,
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
{PERMISSION_LEVELS.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onClick={() => handleLevelChange(record, level)}
|
||||
>
|
||||
{record.permissionLevel === level ? (
|
||||
<Check className="size-3 mr-1.5" />
|
||||
) : (
|
||||
<span className="size-3 mr-1.5" />
|
||||
)}
|
||||
{t(`hosts.sharing.levels.${level}.label`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await revokeHostAccess(Number(host!.id), r.id);
|
||||
await revokeHostAccess(Number(host!.id), record.id);
|
||||
setAccessList((prev) =>
|
||||
prev.filter((_, idx) => idx !== i),
|
||||
prev.filter((entry) => entry.id !== record.id),
|
||||
);
|
||||
toast.success(t("hosts.accessRevoked"));
|
||||
} catch {
|
||||
@@ -287,30 +522,31 @@ export function HostShareModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("hosts.guac.revokeBtn")}
|
||||
{t("hosts.sharing.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
|
||||
<span>
|
||||
{t("hosts.guac.grantedByHeader")}:{" "}
|
||||
{t("hosts.sharing.grantedBy")}:{" "}
|
||||
<span className="text-foreground/70">
|
||||
{r.grantedByUsername ?? "—"}
|
||||
{record.grantedByUsername ?? "?"}
|
||||
</span>
|
||||
</span>
|
||||
<span className={expired ? "text-destructive" : ""}>
|
||||
{t("hosts.guac.expiresHeader")}:{" "}
|
||||
{t("hosts.sharing.expires")}:{" "}
|
||||
{expired ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||
<X className="size-3" />
|
||||
{t("hosts.guac.expiredLabel")}
|
||||
{t("hosts.sharing.expired")}
|
||||
</span>
|
||||
) : r.expiresAt ? (
|
||||
) : record.expiresAt ? (
|
||||
<span className="text-foreground/70">
|
||||
{new Date(r.expiresAt).toLocaleDateString()}
|
||||
{new Date(record.expiresAt).toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground/70">
|
||||
{t("hosts.guac.neverLabel")}
|
||||
{t("hosts.sharing.never")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -319,9 +555,6 @@ export function HostShareModal({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -117,7 +117,7 @@ function LogRow({
|
||||
<span className="text-[10px] text-muted-foreground/60 truncate">
|
||||
{formatDate(log.startedAt)}
|
||||
{" · "}
|
||||
{log.protocol.toUpperCase()}
|
||||
{(log.protocol ?? "ssh").toUpperCase()}
|
||||
{log.username ? ` · ${log.username}` : ""}
|
||||
{" · "}
|
||||
{formatDuration(log.duration)}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Share2,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -64,6 +65,11 @@ import type { SSHHostData } from "@/types/index";
|
||||
import { FolderIconEl } from "@/components/folder-style";
|
||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import {
|
||||
canDeleteHost,
|
||||
canEditHost,
|
||||
canShareHost,
|
||||
} from "@/sidebar/host-permissions";
|
||||
import { FolderMetadataDialog } from "./FolderMetadataDialog";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
@@ -266,8 +272,8 @@ function folderHostCount(folder: HostFolder): {
|
||||
export function HostItem({
|
||||
host,
|
||||
onOpenTab,
|
||||
onEditHost,
|
||||
onShareHost,
|
||||
onEditHost: onEditHostProp,
|
||||
onShareHost: onShareHostProp,
|
||||
onProxmoxDiscover,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@@ -306,6 +312,10 @@ export function HostItem({
|
||||
depth?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Shared hosts expose actions matching the recipient's permission level.
|
||||
const onEditHost = canEditHost(host) ? onEditHostProp : undefined;
|
||||
const onShareHost = canShareHost(host) ? onShareHostProp : undefined;
|
||||
const allowDelete = canDeleteHost(host);
|
||||
const metricsEnabled =
|
||||
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
|
||||
const [trayOnClick, setTrayOnClick] = useState(
|
||||
@@ -328,8 +338,8 @@ export function HostItem({
|
||||
const isTouchOnly =
|
||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||
const shouldUseClickTray = trayOnClick || isTouchOnly;
|
||||
const showPasswordCopy = canCopyHostPassword(host);
|
||||
const showSudoPasswordCopy = canCopyHostSudoPassword(host);
|
||||
const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
|
||||
const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
|
||||
|
||||
async function handleCopyPassword(
|
||||
e: MouseEvent,
|
||||
@@ -465,6 +475,26 @@ export function HostItem({
|
||||
<span className="text-[13px] font-medium truncate text-foreground leading-none">
|
||||
{host.name}
|
||||
</span>
|
||||
{host.isShared && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
|
||||
<Users className="size-2.5" />
|
||||
{t("hosts.sharing.sharedBadge")}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("hosts.sharing.sharedBadgeTooltip", {
|
||||
owner: host.ownerUsername || "?",
|
||||
level: t(
|
||||
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
|
||||
),
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!selectionMode && shouldUseClickTray && (
|
||||
<button
|
||||
title={
|
||||
@@ -666,6 +696,8 @@ export function HostItem({
|
||||
{t("nav.copySudoPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{allowDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -687,6 +719,8 @@ export function HostItem({
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -862,6 +896,8 @@ export function HostItem({
|
||||
{t("nav.copySudoPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{allowDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -883,6 +919,8 @@ export function HostItem({
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -974,6 +1012,26 @@ export function HostItem({
|
||||
{host.pin && (
|
||||
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
||||
)}
|
||||
{host.isShared && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
|
||||
<Users className="size-2.5" />
|
||||
{t("hosts.sharing.sharedBadge")}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("hosts.sharing.sharedBadgeTooltip", {
|
||||
owner: host.ownerUsername || "?",
|
||||
level: t(
|
||||
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
|
||||
),
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!selectionMode && shouldUseClickTray && (
|
||||
<button
|
||||
title={
|
||||
@@ -1445,6 +1503,8 @@ export function HostItem({
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{allowDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -1466,6 +1526,8 @@ export function HostItem({
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -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