mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-08 15:13:55 -05:00
75
backend/package-lock.json
generated
75
backend/package-lock.json
generated
@@ -128,6 +128,7 @@
|
||||
"sjcl": "^1.0.8",
|
||||
"smee-client": "^2.0.0",
|
||||
"snowflake-sdk": "^1.14.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"tedious": "^18.2.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
@@ -164,6 +165,7 @@
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/sjcl": "^1.0.34",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
@@ -15634,6 +15636,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ssh2": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
|
||||
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/sshpk": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.10.3.tgz",
|
||||
@@ -18061,6 +18090,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
|
||||
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bullmq": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz",
|
||||
@@ -18901,6 +18939,20 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/create-hash": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
|
||||
@@ -24996,9 +25048,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.22.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz",
|
||||
"integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==",
|
||||
"version": "2.23.1",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz",
|
||||
"integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -31492,6 +31544,23 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/sjcl": "^1.0.34",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
@@ -257,6 +258,7 @@
|
||||
"sjcl": "^1.0.8",
|
||||
"smee-client": "^2.0.0",
|
||||
"snowflake-sdk": "^1.14.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"tedious": "^18.2.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
SanitizedPostgresAccountWithResourceSchema,
|
||||
UpdatePostgresAccountSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import {
|
||||
CreateSSHAccountSchema,
|
||||
SanitizedSSHAccountWithResourceSchema,
|
||||
UpdateSSHAccountSchema
|
||||
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
|
||||
import { registerPamResourceEndpoints } from "./pam-account-endpoints";
|
||||
|
||||
@@ -30,5 +35,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fasti
|
||||
createAccountSchema: CreateMySQLAccountSchema,
|
||||
updateAccountSchema: UpdateMySQLAccountSchema
|
||||
});
|
||||
},
|
||||
[PamResource.SSH]: async (server: FastifyZodProvider) => {
|
||||
registerPamResourceEndpoints({
|
||||
server,
|
||||
resourceType: PamResource.SSH,
|
||||
accountResponseSchema: SanitizedSSHAccountWithResourceSchema,
|
||||
createAccountSchema: CreateSSHAccountSchema,
|
||||
updateAccountSchema: UpdateSSHAccountSchema
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
@@ -12,6 +13,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
const SanitizedAccountSchema = z.union([
|
||||
SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS
|
||||
SanitizedPostgresAccountWithResourceSchema,
|
||||
SanitizedMySQLAccountWithResourceSchema
|
||||
]);
|
||||
@@ -93,7 +95,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
gatewayClientPrivateKey: z.string(),
|
||||
gatewayServerCertificateChain: z.string(),
|
||||
relayHost: z.string(),
|
||||
metadata: z.record(z.string(), z.string()).optional()
|
||||
metadata: z.record(z.string(), z.string().optional()).optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
SanitizedPostgresResourceSchema,
|
||||
UpdatePostgresResourceSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import {
|
||||
CreateSSHResourceSchema,
|
||||
SanitizedSSHResourceSchema,
|
||||
UpdateSSHResourceSchema
|
||||
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
|
||||
import { registerPamResourceEndpoints } from "./pam-resource-endpoints";
|
||||
|
||||
@@ -30,5 +35,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fast
|
||||
createResourceSchema: CreateMySQLResourceSchema,
|
||||
updateResourceSchema: UpdateMySQLResourceSchema
|
||||
});
|
||||
},
|
||||
[PamResource.SSH]: async (server: FastifyZodProvider) => {
|
||||
registerPamResourceEndpoints({
|
||||
server,
|
||||
resourceType: PamResource.SSH,
|
||||
resourceResponseSchema: SanitizedSSHResourceSchema,
|
||||
createResourceSchema: CreateSSHResourceSchema,
|
||||
updateResourceSchema: UpdateSSHResourceSchema
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,15 +9,24 @@ import {
|
||||
PostgresResourceListItemSchema,
|
||||
SanitizedPostgresResourceSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import {
|
||||
SanitizedSSHResourceSchema,
|
||||
SSHResourceListItemSchema
|
||||
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
const SanitizedResourceSchema = z.union([SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema]);
|
||||
const SanitizedResourceSchema = z.union([
|
||||
SanitizedPostgresResourceSchema,
|
||||
SanitizedMySQLResourceSchema,
|
||||
SanitizedSSHResourceSchema
|
||||
]);
|
||||
|
||||
const ResourceOptionsSchema = z.discriminatedUnion("resource", [
|
||||
PostgresResourceListItemSchema,
|
||||
MySQLResourceListItemSchema
|
||||
MySQLResourceListItemSchema,
|
||||
SSHResourceListItemSchema
|
||||
]);
|
||||
|
||||
export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
||||
|
||||
@@ -4,12 +4,21 @@ import { PamSessionsSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas";
|
||||
import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||
import {
|
||||
PamSessionCommandLogSchema,
|
||||
SanitizedSessionSchema,
|
||||
TerminalEventSchema
|
||||
} from "@app/ee/services/pam-session/pam-session-schemas";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
const SessionCredentialsSchema = z.union([PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema]);
|
||||
const SessionCredentialsSchema = z.union([
|
||||
SSHSessionCredentialsSchema,
|
||||
PostgresSessionCredentialsSchema,
|
||||
MySQLSessionCredentialsSchema
|
||||
]);
|
||||
|
||||
export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||
// Meant to be hit solely by gateway identities
|
||||
@@ -50,7 +59,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
return { credentials };
|
||||
return { credentials: credentials as z.infer<typeof SessionCredentialsSchema> };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,7 +76,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
body: z.object({
|
||||
logs: PamSessionCommandLogSchema.array()
|
||||
logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema]))
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -27,6 +27,7 @@ import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
|
||||
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
|
||||
import { PamResource } from "../pam-resource/pam-resource-enums";
|
||||
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
|
||||
import { TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types";
|
||||
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
|
||||
import { PamSessionStatus } from "../pam-session/pam-session-enums";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
@@ -251,17 +252,17 @@ export const pamAccountServiceFactory = ({
|
||||
gatewayV2Service
|
||||
);
|
||||
|
||||
// Logic to prevent overwriting unedited censored values
|
||||
const finalCredentials = { ...credentials };
|
||||
if (credentials.password === "__INFISICAL_UNCHANGED__") {
|
||||
const decryptedCredentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
projectId: account.projectId,
|
||||
kmsService
|
||||
});
|
||||
const decryptedCredentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
projectId: account.projectId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
finalCredentials.password = decryptedCredentials.password;
|
||||
}
|
||||
// Logic to prevent overwriting unedited censored values
|
||||
const finalCredentials = await factory.handleOverwritePreventionForCensoredValues(
|
||||
credentials,
|
||||
decryptedCredentials
|
||||
);
|
||||
|
||||
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
|
||||
const encryptedCredentials = await encryptAccountCredentials({
|
||||
@@ -486,11 +487,11 @@ export const pamAccountServiceFactory = ({
|
||||
case PamResource.Postgres:
|
||||
case PamResource.MySQL:
|
||||
{
|
||||
const connectionCredentials = await decryptResourceConnectionDetails({
|
||||
const connectionCredentials = (await decryptResourceConnectionDetails({
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
kmsService,
|
||||
projectId: account.projectId
|
||||
});
|
||||
})) as TSqlResourceConnectionDetails;
|
||||
|
||||
const credentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
@@ -506,6 +507,19 @@ export const pamAccountServiceFactory = ({
|
||||
};
|
||||
}
|
||||
break;
|
||||
case PamResource.SSH:
|
||||
{
|
||||
const credentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
metadata = {
|
||||
username: credentials.username
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum PamResource {
|
||||
Postgres = "postgres",
|
||||
MySQL = "mysql"
|
||||
MySQL = "mysql",
|
||||
SSH = "ssh"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
|
||||
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
|
||||
import { sshResourceFactory } from "./ssh/ssh-resource-factory";
|
||||
|
||||
type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnectionDetails, TPamAccountCredentials>;
|
||||
|
||||
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
|
||||
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation,
|
||||
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation
|
||||
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation,
|
||||
[PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation
|
||||
};
|
||||
|
||||
@@ -192,19 +192,18 @@ export const pamResourceServiceFactory = ({
|
||||
gatewayV2Service
|
||||
);
|
||||
|
||||
// Logic to prevent overwriting unedited censored values
|
||||
const finalCredentials = { ...rotationAccountCredentials };
|
||||
if (
|
||||
resource.encryptedRotationAccountCredentials &&
|
||||
rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__"
|
||||
) {
|
||||
let finalCredentials = { ...rotationAccountCredentials };
|
||||
if (resource.encryptedRotationAccountCredentials) {
|
||||
const decryptedCredentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: resource.encryptedRotationAccountCredentials,
|
||||
projectId: resource.projectId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
finalCredentials.password = decryptedCredentials.password;
|
||||
finalCredentials = await factory.handleOverwritePreventionForCensoredValues(
|
||||
rotationAccountCredentials,
|
||||
decryptedCredentials
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -12,15 +12,24 @@ import {
|
||||
TPostgresResource,
|
||||
TPostgresResourceConnectionDetails
|
||||
} from "./postgres/postgres-resource-types";
|
||||
import {
|
||||
TSSHAccount,
|
||||
TSSHAccountCredentials,
|
||||
TSSHResource,
|
||||
TSSHResourceConnectionDetails
|
||||
} from "./ssh/ssh-resource-types";
|
||||
|
||||
// Resource types
|
||||
export type TPamResource = TPostgresResource | TMySQLResource;
|
||||
export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails;
|
||||
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
|
||||
export type TPamResourceConnectionDetails =
|
||||
| TPostgresResourceConnectionDetails
|
||||
| TMySQLResourceConnectionDetails
|
||||
| TSSHResourceConnectionDetails;
|
||||
|
||||
// Account types
|
||||
export type TPamAccount = TPostgresAccount | TMySQLAccount;
|
||||
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
|
||||
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials;
|
||||
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials;
|
||||
|
||||
// Resource DTOs
|
||||
export type TCreateResourceDTO = Pick<
|
||||
@@ -51,4 +60,5 @@ export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C exten
|
||||
validateConnection: TPamResourceFactoryValidateConnection<T>;
|
||||
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
|
||||
rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<C>;
|
||||
handleOverwritePreventionForCensoredValues: (updatedAccountCredentials: C, currentCredentials: C) => Promise<C>;
|
||||
};
|
||||
|
||||
@@ -337,9 +337,24 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverwritePreventionForCensoredValues = async (
|
||||
updatedAccountCredentials: TSqlAccountCredentials,
|
||||
currentCredentials: TSqlAccountCredentials
|
||||
) => {
|
||||
if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") {
|
||||
return {
|
||||
...updatedAccountCredentials,
|
||||
password: currentCredentials.password
|
||||
};
|
||||
}
|
||||
|
||||
return updatedAccountCredentials;
|
||||
};
|
||||
|
||||
return {
|
||||
validateConnection,
|
||||
validateAccountCredentials,
|
||||
rotateAccountCredentials
|
||||
rotateAccountCredentials,
|
||||
handleOverwritePreventionForCensoredValues
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum SSHAuthMethod {
|
||||
Password = "password",
|
||||
PublicKey = "public-key",
|
||||
Certificate = "certificate"
|
||||
}
|
||||
265
backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts
Normal file
265
backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { Client } from "ssh2";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { GatewayProxyProtocol } from "@app/lib/gateway";
|
||||
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns";
|
||||
import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service";
|
||||
import { PamResource } from "../pam-resource-enums";
|
||||
import {
|
||||
TPamResourceFactory,
|
||||
TPamResourceFactoryRotateAccountCredentials,
|
||||
TPamResourceFactoryValidateAccountCredentials
|
||||
} from "../pam-resource-types";
|
||||
import { SSHAuthMethod } from "./ssh-resource-enums";
|
||||
import { TSSHAccountCredentials, TSSHResourceConnectionDetails } from "./ssh-resource-types";
|
||||
|
||||
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
|
||||
|
||||
export const executeWithGateway = async <T>(
|
||||
config: {
|
||||
connectionDetails: TSSHResourceConnectionDetails;
|
||||
resourceType: PamResource;
|
||||
gatewayId: string;
|
||||
},
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
|
||||
operation: (proxyPort: number) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const { connectionDetails, gatewayId } = config;
|
||||
const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true);
|
||||
const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
|
||||
gatewayId,
|
||||
targetHost,
|
||||
targetPort: connectionDetails.port
|
||||
});
|
||||
|
||||
if (!platformConnectionDetails) {
|
||||
throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" });
|
||||
}
|
||||
|
||||
return withGatewayV2Proxy(
|
||||
async (proxyPort) => {
|
||||
return operation(proxyPort);
|
||||
},
|
||||
{
|
||||
protocol: GatewayProxyProtocol.Tcp,
|
||||
relayHost: platformConnectionDetails.relayHost,
|
||||
gateway: platformConnectionDetails.gateway,
|
||||
relay: platformConnectionDetails.relay
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const sshResourceFactory: TPamResourceFactory<TSSHResourceConnectionDetails, TSSHAccountCredentials> = (
|
||||
resourceType,
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
gatewayV2Service
|
||||
) => {
|
||||
const validateConnection = async () => {
|
||||
try {
|
||||
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const client = new Client();
|
||||
let handshakeComplete = false;
|
||||
|
||||
client.on("error", (err) => {
|
||||
logger.info(
|
||||
{ error: err.message, handshakeComplete },
|
||||
"[SSH Resource Factory] SSH client error event received"
|
||||
);
|
||||
// If we got an authentication error, it means we successfully reached the SSH server
|
||||
// and completed the SSH handshake - that's good enough for connection validation
|
||||
if (handshakeComplete || err.message.includes("authentication") || err.message.includes("publickey")) {
|
||||
logger.info(
|
||||
{ handshakeComplete, errorMessage: err.message },
|
||||
"[SSH Resource Factory] SSH connection validation succeeded (auth error after handshake)"
|
||||
);
|
||||
client.end();
|
||||
resolve();
|
||||
} else {
|
||||
logger.error(
|
||||
{ error: err.message, handshakeComplete },
|
||||
"[SSH Resource Factory] SSH connection validation failed"
|
||||
);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
client.on("handshake", () => {
|
||||
// SSH handshake completed - the server is reachable and responding
|
||||
logger.info("[SSH Resource Factory] SSH handshake event received - setting handshakeComplete to true");
|
||||
handshakeComplete = true;
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("timeout", () => {
|
||||
logger.error("[SSH Resource Factory] SSH connection timeout");
|
||||
reject(new Error("Connection timeout"));
|
||||
});
|
||||
|
||||
// Attempt connection with a dummy username (we don't care about auth success)
|
||||
// The goal is just to verify SSH server is reachable and responding
|
||||
client.connect({
|
||||
host: "localhost",
|
||||
port: proxyPort,
|
||||
username: "infisical-connection-test",
|
||||
password: "infisical-connection-test-password",
|
||||
readyTimeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
tryKeyboard: false,
|
||||
// We want to fail fast on auth, we're just testing reachability
|
||||
authHandler: () => {
|
||||
// If authHandler is called, SSH handshake succeeded
|
||||
handshakeComplete = true;
|
||||
return false; // Don't continue with auth
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return connectionDetails;
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<TSSHAccountCredentials> = async (
|
||||
credentials
|
||||
) => {
|
||||
try {
|
||||
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const client = new Client();
|
||||
|
||||
client.on("ready", () => {
|
||||
logger.info(
|
||||
{ username: credentials.username, authMethod: credentials.authMethod },
|
||||
"[SSH Resource Factory] SSH authentication successful"
|
||||
);
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
logger.error(
|
||||
{ error: err.message, username: credentials.username, authMethod: credentials.authMethod },
|
||||
"[SSH Resource Factory] SSH authentication failed"
|
||||
);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
client.on("timeout", () => {
|
||||
logger.error(
|
||||
{ username: credentials.username, authMethod: credentials.authMethod },
|
||||
"[SSH Resource Factory] SSH authentication timeout"
|
||||
);
|
||||
reject(new Error("Connection timeout"));
|
||||
});
|
||||
|
||||
// Build connection config based on auth method
|
||||
const baseConfig = {
|
||||
host: "localhost",
|
||||
port: proxyPort,
|
||||
username: credentials.username,
|
||||
readyTimeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
};
|
||||
|
||||
switch (credentials.authMethod) {
|
||||
case SSHAuthMethod.Password:
|
||||
client.connect({
|
||||
...baseConfig,
|
||||
password: credentials.password,
|
||||
tryKeyboard: false
|
||||
});
|
||||
break;
|
||||
case SSHAuthMethod.PublicKey:
|
||||
client.connect({
|
||||
...baseConfig,
|
||||
privateKey: credentials.privateKey,
|
||||
tryKeyboard: false
|
||||
});
|
||||
break;
|
||||
default:
|
||||
reject(new Error(`Unsupported SSH auth method: ${(credentials as TSSHAccountCredentials).authMethod}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// Check for common authentication failure messages
|
||||
if (
|
||||
error.message.includes("authentication") ||
|
||||
error.message.includes("All configured authentication methods failed") ||
|
||||
error.message.includes("publickey")
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: "Account credentials invalid."
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === "Connection timeout") {
|
||||
throw new BadRequestError({
|
||||
message: "Connection timeout. Verify that the SSH server is reachable"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<TSSHAccountCredentials> = async (
|
||||
rotationAccountCredentials
|
||||
) => {
|
||||
return rotationAccountCredentials;
|
||||
};
|
||||
|
||||
const handleOverwritePreventionForCensoredValues = async (
|
||||
updatedAccountCredentials: TSSHAccountCredentials,
|
||||
currentCredentials: TSSHAccountCredentials
|
||||
) => {
|
||||
if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) {
|
||||
return updatedAccountCredentials;
|
||||
}
|
||||
|
||||
if (
|
||||
updatedAccountCredentials.authMethod === SSHAuthMethod.Password &&
|
||||
currentCredentials.authMethod === SSHAuthMethod.Password
|
||||
) {
|
||||
if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") {
|
||||
return {
|
||||
...updatedAccountCredentials,
|
||||
password: currentCredentials.password
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
updatedAccountCredentials.authMethod === SSHAuthMethod.PublicKey &&
|
||||
currentCredentials.authMethod === SSHAuthMethod.PublicKey
|
||||
) {
|
||||
if (updatedAccountCredentials.privateKey === "__INFISICAL_UNCHANGED__") {
|
||||
return {
|
||||
...updatedAccountCredentials,
|
||||
privateKey: currentCredentials.privateKey
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return updatedAccountCredentials;
|
||||
};
|
||||
|
||||
return {
|
||||
validateConnection,
|
||||
validateAccountCredentials,
|
||||
rotateAccountCredentials,
|
||||
handleOverwritePreventionForCensoredValues
|
||||
};
|
||||
};
|
||||
117
backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts
Normal file
117
backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamResource } from "../pam-resource-enums";
|
||||
import {
|
||||
BaseCreatePamAccountSchema,
|
||||
BaseCreatePamResourceSchema,
|
||||
BasePamAccountSchema,
|
||||
BasePamAccountSchemaWithResource,
|
||||
BasePamResourceSchema,
|
||||
BaseUpdatePamAccountSchema,
|
||||
BaseUpdatePamResourceSchema
|
||||
} from "../pam-resource-schemas";
|
||||
import { SSHAuthMethod } from "./ssh-resource-enums";
|
||||
|
||||
export const BaseSSHResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.SSH) });
|
||||
|
||||
export const SSHResourceListItemSchema = z.object({
|
||||
name: z.literal("SSH"),
|
||||
resource: z.literal(PamResource.SSH)
|
||||
});
|
||||
|
||||
export const SSHResourceConnectionDetailsSchema = z.object({
|
||||
host: z.string().trim().max(255),
|
||||
port: z.number()
|
||||
});
|
||||
|
||||
export const SSHPasswordCredentialsSchema = z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Password),
|
||||
username: z.string().trim().max(255),
|
||||
password: z.string().trim().max(255)
|
||||
});
|
||||
|
||||
export const SSHPublicKeyCredentialsSchema = z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||
username: z.string().trim().max(255),
|
||||
privateKey: z.string().trim().max(5000)
|
||||
});
|
||||
|
||||
export const SSHCertificateCredentialsSchema = z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||
username: z.string().trim().max(255)
|
||||
});
|
||||
|
||||
export const SSHAccountCredentialsSchema = z.discriminatedUnion("authMethod", [
|
||||
SSHPasswordCredentialsSchema,
|
||||
SSHPublicKeyCredentialsSchema,
|
||||
SSHCertificateCredentialsSchema
|
||||
]);
|
||||
|
||||
export const SSHResourceSchema = BaseSSHResourceSchema.extend({
|
||||
connectionDetails: SSHResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({
|
||||
connectionDetails: SSHResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: z
|
||||
.discriminatedUnion("authMethod", [
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Password),
|
||||
username: z.string()
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||
username: z.string()
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||
username: z.string()
|
||||
})
|
||||
])
|
||||
.nullable()
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({
|
||||
connectionDetails: SSHResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({
|
||||
connectionDetails: SSHResourceConnectionDetailsSchema.optional(),
|
||||
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
// Accounts
|
||||
export const SSHAccountSchema = BasePamAccountSchema.extend({
|
||||
credentials: SSHAccountCredentialsSchema
|
||||
});
|
||||
|
||||
export const CreateSSHAccountSchema = BaseCreatePamAccountSchema.extend({
|
||||
credentials: SSHAccountCredentialsSchema
|
||||
});
|
||||
|
||||
export const UpdateSSHAccountSchema = BaseUpdatePamAccountSchema.extend({
|
||||
credentials: SSHAccountCredentialsSchema.optional()
|
||||
});
|
||||
|
||||
export const SanitizedSSHAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
|
||||
credentials: z.discriminatedUnion("authMethod", [
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Password),
|
||||
username: z.string()
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||
username: z.string()
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||
username: z.string()
|
||||
})
|
||||
])
|
||||
});
|
||||
|
||||
// Sessions
|
||||
export const SSHSessionCredentialsSchema = SSHResourceConnectionDetailsSchema.and(SSHAccountCredentialsSchema);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
SSHAccountCredentialsSchema,
|
||||
SSHAccountSchema,
|
||||
SSHResourceConnectionDetailsSchema,
|
||||
SSHResourceSchema
|
||||
} from "./ssh-resource-schemas";
|
||||
|
||||
// Resources
|
||||
export type TSSHResource = z.infer<typeof SSHResourceSchema>;
|
||||
export type TSSHResourceConnectionDetails = z.infer<typeof SSHResourceConnectionDetailsSchema>;
|
||||
|
||||
// Accounts
|
||||
export type TSSHAccount = z.infer<typeof SSHAccountSchema>;
|
||||
export type TSSHAccountCredentials = z.infer<typeof SSHAccountCredentialsSchema>;
|
||||
@@ -2,7 +2,7 @@ import { TPamSessions } from "@app/db/schemas";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types";
|
||||
import { TPamSanitizedSession, TPamSessionCommandLog, TTerminalEvent } from "./pam-session-types";
|
||||
|
||||
export const decryptSessionCommandLogs = async ({
|
||||
projectId,
|
||||
@@ -22,7 +22,7 @@ export const decryptSessionCommandLogs = async ({
|
||||
cipherTextBlob: encryptedLogs
|
||||
});
|
||||
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog;
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as (TPamSessionCommandLog | TTerminalEvent)[];
|
||||
};
|
||||
|
||||
export const decryptSession = async (
|
||||
@@ -32,7 +32,7 @@ export const decryptSession = async (
|
||||
) => {
|
||||
return {
|
||||
...session,
|
||||
commandLogs: session.encryptedLogsBlob
|
||||
logs: session.encryptedLogsBlob
|
||||
? await decryptSessionCommandLogs({
|
||||
projectId,
|
||||
encryptedLogs: session.encryptedLogsBlob,
|
||||
|
||||
@@ -8,8 +8,18 @@ export const PamSessionCommandLogSchema = z.object({
|
||||
timestamp: z.coerce.date()
|
||||
});
|
||||
|
||||
// SSH Terminal Event schemas
|
||||
export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]);
|
||||
|
||||
export const TerminalEventSchema = z.object({
|
||||
timestamp: z.coerce.date(),
|
||||
eventType: TerminalEventTypeSchema,
|
||||
data: z.string(), // Base64 encoded binary data
|
||||
elapsedTime: z.number() // Seconds since session start (for replay)
|
||||
});
|
||||
|
||||
export const SanitizedSessionSchema = PamSessionsSchema.omit({
|
||||
encryptedLogsBlob: true
|
||||
}).extend({
|
||||
commandLogs: PamSessionCommandLogSchema.array()
|
||||
logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema]))
|
||||
});
|
||||
|
||||
@@ -12,10 +12,10 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
import { TUpdateSessionLogsDTO } from "./pam-session.types";
|
||||
import { TPamSessionDALFactory } from "./pam-session-dal";
|
||||
import { PamSessionStatus } from "./pam-session-enums";
|
||||
import { decryptSession } from "./pam-session-fns";
|
||||
import { TUpdateSessionLogsDTO } from "./pam-session-types";
|
||||
|
||||
type TPamSessionServiceFactoryDep = {
|
||||
pamSessionDAL: TPamSessionDALFactory;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas";
|
||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas";
|
||||
|
||||
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
|
||||
export type TTerminalEvent = z.infer<typeof TerminalEventSchema>;
|
||||
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
|
||||
|
||||
// DTOs
|
||||
export type TUpdateSessionLogsDTO = {
|
||||
sessionId: string;
|
||||
logs: TPamSessionCommandLog[];
|
||||
logs: (TPamSessionCommandLog | TTerminalEvent)[];
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import { PamResourceType, PamSessionStatus } from "../enums";
|
||||
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
|
||||
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
|
||||
import { TSSHAccount, TSSHResource } from "./ssh-resource";
|
||||
|
||||
export * from "./mysql-resource";
|
||||
export * from "./postgres-resource";
|
||||
export * from "./ssh-resource";
|
||||
|
||||
export type TPamResource = TPostgresResource | TMySQLResource;
|
||||
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
|
||||
|
||||
export type TPamAccount = TPostgresAccount | TMySQLAccount;
|
||||
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
|
||||
|
||||
export type TPamFolder = {
|
||||
id: string;
|
||||
@@ -19,6 +21,22 @@ export type TPamFolder = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
// Session log types
|
||||
export type TPamCommandLog = {
|
||||
input: string;
|
||||
output: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type TTerminalEvent = {
|
||||
timestamp: string;
|
||||
eventType: "input" | "output" | "resize" | "error";
|
||||
data: string; // Base64 encoded binary data
|
||||
elapsedTime: number; // Seconds since session start (for replay)
|
||||
};
|
||||
|
||||
export type TPamSessionLog = TPamCommandLog | TTerminalEvent;
|
||||
|
||||
export type TPamSession = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -37,11 +55,7 @@ export type TPamSession = {
|
||||
endedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
commandLogs: {
|
||||
input: string;
|
||||
output: string;
|
||||
timestamp: string;
|
||||
}[];
|
||||
logs: TPamSessionLog[];
|
||||
};
|
||||
|
||||
// Resource DTOs
|
||||
|
||||
47
frontend/src/hooks/api/pam/types/ssh-resource.ts
Normal file
47
frontend/src/hooks/api/pam/types/ssh-resource.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { PamResourceType } from "../enums";
|
||||
import { TBasePamAccount } from "./base-account";
|
||||
import { TBasePamResource } from "./base-resource";
|
||||
|
||||
export enum SSHAuthMethod {
|
||||
Password = "password",
|
||||
PublicKey = "public-key",
|
||||
Certificate = "certificate"
|
||||
}
|
||||
|
||||
export type TSSHConnectionDetails = {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export type TSSHPasswordCredentials = {
|
||||
authMethod: SSHAuthMethod.Password;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type TSSHPublicKeyCredentials = {
|
||||
authMethod: SSHAuthMethod.PublicKey;
|
||||
username: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
export type TSSHCertificateCredentials = {
|
||||
authMethod: SSHAuthMethod.Certificate;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type TSSHCredentials =
|
||||
| TSSHPasswordCredentials
|
||||
| TSSHPublicKeyCredentials
|
||||
| TSSHCertificateCredentials;
|
||||
|
||||
// Resources
|
||||
export type TSSHResource = TBasePamResource & { resourceType: PamResourceType.SSH } & {
|
||||
connectionDetails: TSSHConnectionDetails;
|
||||
rotationAccountCredentials?: TSSHCredentials | null;
|
||||
};
|
||||
|
||||
// Accounts
|
||||
export type TSSHAccount = TBasePamAccount & {
|
||||
credentials: TSSHCredentials;
|
||||
};
|
||||
@@ -58,15 +58,19 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
|
||||
return duration;
|
||||
}, [duration]);
|
||||
|
||||
const command = useMemo(
|
||||
() =>
|
||||
account &&
|
||||
(account.resource.resourceType === PamResourceType.Postgres ||
|
||||
account.resource.resourceType === PamResourceType.MySQL)
|
||||
? `infisical pam db access-account ${account.id} --duration ${cliDuration}`
|
||||
: "",
|
||||
[account, cliDuration]
|
||||
);
|
||||
const command = useMemo(() => {
|
||||
if (!account) return "";
|
||||
|
||||
switch (account.resource.resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
case PamResourceType.MySQL:
|
||||
return `infisical pam db access-account ${account.id} --duration ${cliDuration}`;
|
||||
case PamResourceType.SSH:
|
||||
return `infisical pam ssh access-account ${account.id} --duration ${cliDuration}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}, [account, cliDuration]);
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DiscriminativePick } from "@app/types";
|
||||
import { PamAccountHeader } from "../PamAccountHeader";
|
||||
import { MySQLAccountForm } from "./MySQLAccountForm";
|
||||
import { PostgresAccountForm } from "./PostgresAccountForm";
|
||||
import { SshAccountForm } from "./SshAccountForm";
|
||||
|
||||
type FormProps = {
|
||||
onComplete: (account: TPamAccount) => void;
|
||||
@@ -65,6 +66,10 @@ const CreateForm = ({
|
||||
return (
|
||||
<MySQLAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
||||
);
|
||||
case PamResourceType.SSH:
|
||||
return (
|
||||
<SshAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
@@ -90,9 +95,11 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
|
||||
|
||||
switch (account.resource.resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
|
||||
return <PostgresAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||
case PamResourceType.MySQL:
|
||||
return <MySQLAccountForm account={account} onSubmit={onSubmit} />;
|
||||
return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||
case PamResourceType.SSH:
|
||||
return <SshAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, FormProvider, useForm, useFormContext, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { PamResourceType, TSSHAccount } from "@app/hooks/api/pam";
|
||||
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
|
||||
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
|
||||
type Props = {
|
||||
account?: TSSHAccount;
|
||||
resourceId?: string;
|
||||
resourceType?: PamResourceType;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const SSHPasswordCredentialsSchema = z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Password),
|
||||
username: z.string().trim().min(1, "Username is required"),
|
||||
password: z.string().trim().min(1, "Password is required")
|
||||
});
|
||||
|
||||
const SSHPublicKeyCredentialsSchema = z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||
username: z.string().trim().min(1, "Username is required"),
|
||||
privateKey: z.string().trim().min(1, "Private key is required")
|
||||
});
|
||||
|
||||
const SSHCertificateCredentialsSchema = z.object({
|
||||
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||
username: z.string().trim().min(1, "Username is required")
|
||||
});
|
||||
|
||||
const BaseSshAccountSchema = z.discriminatedUnion("authMethod", [
|
||||
SSHPasswordCredentialsSchema,
|
||||
SSHPublicKeyCredentialsSchema,
|
||||
SSHCertificateCredentialsSchema
|
||||
]);
|
||||
|
||||
const formSchema = genericAccountFieldsSchema.extend({
|
||||
credentials: BaseSshAccountSchema,
|
||||
// We don't support rotation for now, just feed a false value to
|
||||
// make the schema happy
|
||||
rotationEnabled: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const SshAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
const { control, setValue } = useFormContext();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const authMethod =
|
||||
useWatch({ control, name: "credentials.authMethod" }) || SSHAuthMethod.Password;
|
||||
const password = useWatch({ control, name: "credentials.password" });
|
||||
|
||||
useEffect(() => {
|
||||
if (password === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [password]);
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
|
||||
<Controller
|
||||
name="credentials.authMethod"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-3"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Authentication Method"
|
||||
>
|
||||
<Select
|
||||
value={value || SSHAuthMethod.Password}
|
||||
onValueChange={(newAuthMethod) => {
|
||||
onChange(newAuthMethod);
|
||||
// Clear out credentials from other auth methods
|
||||
setValue("credentials.password", undefined, { shouldDirty: true });
|
||||
setValue("credentials.privateKey", undefined, { shouldDirty: true });
|
||||
}}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
<SelectItem value={SSHAuthMethod.Password}>Password</SelectItem>
|
||||
<SelectItem value={SSHAuthMethod.PublicKey}>SSH Key</SelectItem>
|
||||
<SelectItem value={SSHAuthMethod.Certificate}>Certificate</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="credentials.username"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-3"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Username"
|
||||
>
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{authMethod === SSHAuthMethod.Password && (
|
||||
<Controller
|
||||
name="credentials.password"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Password"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
onFocus={() => {
|
||||
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
field.onChange("");
|
||||
}
|
||||
setShowPassword(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isUpdate && field.value === "") {
|
||||
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
|
||||
}
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{authMethod === SSHAuthMethod.PublicKey && (
|
||||
<Controller
|
||||
name="credentials.privateKey"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Private Key"
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
value={field.value === UNCHANGED_PASSWORD_SENTINEL ? "" : field.value}
|
||||
className="min-h-32 resize-y font-mono text-xs"
|
||||
placeholder={
|
||||
isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL
|
||||
? "Private key unchanged - click to update"
|
||||
: "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----"
|
||||
}
|
||||
onFocus={() => {
|
||||
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
|
||||
field.onChange("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isUpdate && field.value === "") {
|
||||
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{authMethod === SSHAuthMethod.Certificate && (
|
||||
<p className="mb-0 text-xs text-mineshaft-400">
|
||||
Certificate-based authentication will use the certificate configured on the SSH resource.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SshAccountForm = ({ account, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(account);
|
||||
|
||||
const getDefaultCredentials = () => {
|
||||
if (!account) return undefined;
|
||||
|
||||
if (account.credentials.authMethod === SSHAuthMethod.Password) {
|
||||
return {
|
||||
...account.credentials,
|
||||
password: UNCHANGED_PASSWORD_SENTINEL
|
||||
};
|
||||
}
|
||||
|
||||
if (account.credentials.authMethod === SSHAuthMethod.PublicKey) {
|
||||
return {
|
||||
...account.credentials,
|
||||
privateKey: UNCHANGED_PASSWORD_SENTINEL
|
||||
};
|
||||
}
|
||||
|
||||
return account.credentials;
|
||||
};
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: account
|
||||
? {
|
||||
...account,
|
||||
credentials: getDefaultCredentials()
|
||||
}
|
||||
: {
|
||||
name: "",
|
||||
description: "",
|
||||
credentials: {
|
||||
authMethod: SSHAuthMethod.Password,
|
||||
username: "",
|
||||
password: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<GenericAccountFields />
|
||||
<SshAccountFields isUpdate={isUpdate} />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Account" : "Create Account"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { DiscriminativePick } from "@app/types";
|
||||
import { PamResourceHeader } from "../PamResourceHeader";
|
||||
import { MySQLResourceForm } from "./MySQLResourceForm";
|
||||
import { PostgresResourceForm } from "./PostgresResourceForm";
|
||||
import { SSHResourceForm } from "./SSHResourceForm";
|
||||
|
||||
type FormProps = {
|
||||
onComplete: (resource: TPamResource) => void;
|
||||
@@ -51,6 +52,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
|
||||
return <PostgresResourceForm onSubmit={onSubmit} />;
|
||||
case PamResourceType.MySQL:
|
||||
return <MySQLResourceForm onSubmit={onSubmit} />;
|
||||
case PamResourceType.SSH:
|
||||
return <SSHResourceForm onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
@@ -79,6 +82,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
|
||||
return <PostgresResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
case PamResourceType.MySQL:
|
||||
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
case PamResourceType.SSH:
|
||||
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, ModalClose } from "@app/components/v2";
|
||||
import { PamResourceType, TSSHResource } from "@app/hooks/api/pam";
|
||||
|
||||
import { SshResourceFields } from "./shared/SshResourceFields";
|
||||
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
|
||||
|
||||
type Props = {
|
||||
resource?: TSSHResource;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const BaseSshConnectionDetailsSchema = z.object({
|
||||
host: z.string().trim().min(1, "Host is required"),
|
||||
port: z.number().int().min(1).max(65535)
|
||||
});
|
||||
|
||||
const formSchema = genericResourceFieldsSchema.extend({
|
||||
resourceType: z.literal(PamResourceType.SSH),
|
||||
connectionDetails: BaseSshConnectionDetailsSchema
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const SSHResourceForm = ({ resource, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(resource);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: resource ?? {
|
||||
resourceType: PamResourceType.SSH,
|
||||
connectionDetails: {
|
||||
host: "",
|
||||
port: 22
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<GenericResourceFields />
|
||||
<SshResourceFields />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Details" : "Create Resource"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { FormControl, Input } from "@app/components/v2";
|
||||
|
||||
export const SshResourceFields = () => {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
|
||||
<div className="mt-[0.675rem] flex items-start gap-2">
|
||||
<Controller
|
||||
name="connectionDetails.host"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Host"
|
||||
>
|
||||
<Input placeholder="example.com or 192.168.1.1" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="connectionDetails.port"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-28"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Port"
|
||||
>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -78,7 +78,6 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => {
|
||||
// We temporarily show a special license modal for these because we will have to write some code to complete the integration
|
||||
if (
|
||||
resource === PamResourceType.RDP ||
|
||||
resource === PamResourceType.SSH ||
|
||||
resource === PamResourceType.Kubernetes ||
|
||||
resource === PamResourceType.MCP ||
|
||||
resource === PamResourceType.Redis ||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Input } from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { TPamCommandLog } from "@app/hooks/api/pam";
|
||||
|
||||
import { formatLogContent } from "./PamSessionLogsSection.utils";
|
||||
|
||||
type Props = {
|
||||
logs: TPamCommandLog[];
|
||||
};
|
||||
|
||||
export const CommandLogView = ({ logs }: Props) => {
|
||||
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const toggleExpand = (timestamp: string) => {
|
||||
setExpandedLogTimestamps((prev) => {
|
||||
if (prev.has(timestamp)) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set([timestamp]);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
logs.filter((log) => {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
return (
|
||||
log.input.toLowerCase().includes(searchValue) ||
|
||||
log.output.toLowerCase().includes(searchValue)
|
||||
);
|
||||
}),
|
||||
[logs, search]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search logs..."
|
||||
className="flex-1 bg-mineshaft-800"
|
||||
containerClassName="bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log, index) => {
|
||||
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
|
||||
const formattedInput = formatLogContent(log.input);
|
||||
const logKey = `${log.timestamp}-${index}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={logKey}
|
||||
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:inset-ring-2 focus:inset-ring-mineshaft-400 focus:outline-hidden ${
|
||||
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
|
||||
}`}
|
||||
onClick={() => toggleExpand(log.timestamp)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-bunker-400">
|
||||
<div className="flex items-center gap-2 select-none">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className={twMerge(
|
||||
"size-3 transition-transform duration-100 ease-in-out",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-2 font-mono ${
|
||||
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
|
||||
}`}
|
||||
>
|
||||
<HighlightText text={formattedInput} highlight={search} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
"grid transition-all duration-100 ease-in-out",
|
||||
isExpanded && log.output ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{log.output && (
|
||||
<div className="pt-2 text-bunker-300">
|
||||
<HighlightText text={log.output} highlight={search} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex grow items-center justify-center text-bunker-300">
|
||||
{search.length ? (
|
||||
<div className="text-center">
|
||||
<div className="mb-2">No logs match search criteria</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="mb-2">Session logs are not yet available</div>
|
||||
<div className="text-xs text-bunker-400">
|
||||
Logs will be uploaded after the session duration has elapsed.
|
||||
<br />
|
||||
If logs do not appear after some time, please contact your Gateway administrators.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +1,19 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
|
||||
|
||||
import { Input } from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { TPamSession } from "@app/hooks/api/pam";
|
||||
|
||||
import { formatLogContent } from "./PamSessionLogsSection.utils";
|
||||
import { CommandLogView } from "./CommandLogView";
|
||||
import { TerminalEventView } from "./TerminalEventView";
|
||||
|
||||
type Props = {
|
||||
session: TPamSession;
|
||||
};
|
||||
|
||||
export const PamSessionLogsSection = ({ session }: Props) => {
|
||||
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const toggleExpand = (timestamp: string) => {
|
||||
setExpandedLogTimestamps((prev) => {
|
||||
if (prev.has(timestamp)) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set([timestamp]);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
session.commandLogs.filter((log) => {
|
||||
const { input, output } = log;
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
input.toLowerCase().includes(searchValue) || output.toLowerCase().includes(searchValue)
|
||||
);
|
||||
}),
|
||||
[session.commandLogs, search]
|
||||
);
|
||||
// Determine log type based on resource type
|
||||
const isSSHSession = session.resourceType === PamResourceType.SSH;
|
||||
const isDatabaseSession =
|
||||
session.resourceType === PamResourceType.Postgres ||
|
||||
session.resourceType === PamResourceType.MySQL;
|
||||
const hasLogs = session.logs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
@@ -46,91 +21,20 @@ export const PamSessionLogsSection = ({ session }: Props) => {
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Session Logs</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
const newSearch = e.target.value;
|
||||
setSearch(newSearch);
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search logs..."
|
||||
className="flex-1 bg-mineshaft-800"
|
||||
containerClassName="bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log) => {
|
||||
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
|
||||
const formattedInput = formatLogContent(log.input);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={log.timestamp}
|
||||
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:inset-ring-2 focus:inset-ring-mineshaft-400 focus:outline-hidden ${
|
||||
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
|
||||
}`}
|
||||
onClick={() => toggleExpand(log.timestamp)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-bunker-400">
|
||||
<div className="flex items-center gap-2 select-none">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className={twMerge(
|
||||
"size-3 transition-transform duration-100 ease-in-out",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-2 font-mono ${
|
||||
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
|
||||
}`}
|
||||
>
|
||||
<HighlightText text={formattedInput} highlight={search} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
"grid transition-all duration-100 ease-in-out",
|
||||
isExpanded && log.output ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{log.output && (
|
||||
<div className="pt-2 text-bunker-300">
|
||||
<HighlightText text={log.output} highlight={search} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex grow items-center justify-center text-bunker-300">
|
||||
{search.length ? (
|
||||
<div className="text-center">
|
||||
<div className="mb-2">No logs match search criteria</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="mb-2">Session logs are not yet available</div>
|
||||
<div className="text-xs text-bunker-400">
|
||||
Logs will be uploaded after the session duration has elapsed.
|
||||
<br />
|
||||
If logs do not appear after some time, please contact your Gateway administrators.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isDatabaseSession && hasLogs && <CommandLogView logs={session.logs as TPamCommandLog[]} />}
|
||||
{isSSHSession && hasLogs && <TerminalEventView events={session.logs as TTerminalEvent[]} />}
|
||||
{!hasLogs && (
|
||||
<div className="flex grow items-center justify-center text-bunker-300">
|
||||
<div className="text-center">
|
||||
<div className="mb-2">Session logs are not yet available</div>
|
||||
<div className="text-xs text-bunker-400">
|
||||
Logs will be uploaded after the session duration has elapsed.
|
||||
<br />
|
||||
If logs do not appear after some time, please contact your Gateway administrators.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Input } from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { TTerminalEvent } from "@app/hooks/api/pam";
|
||||
|
||||
import { aggregateTerminalEvents } from "./terminal-utils";
|
||||
|
||||
type Props = {
|
||||
events: TTerminalEvent[];
|
||||
};
|
||||
|
||||
export const TerminalEventView = ({ events }: Props) => {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const aggregatedEvents = useMemo(() => aggregateTerminalEvents(events), [events]);
|
||||
|
||||
const filteredEvents = useMemo(
|
||||
() =>
|
||||
aggregatedEvents.filter((event) => {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
if (!searchValue) return true;
|
||||
return event.data.toLowerCase().includes(searchValue);
|
||||
}),
|
||||
[aggregatedEvents, search]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search terminal output..."
|
||||
className="flex-1 bg-mineshaft-800"
|
||||
containerClassName="bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
|
||||
{filteredEvents.length > 0 ? (
|
||||
filteredEvents.map((event, index) => {
|
||||
const eventKey = `${event.timestamp}-${index}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={eventKey}
|
||||
className="flex w-full flex-col rounded-md border border-mineshaft-700 bg-mineshaft-800 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between text-bunker-400">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>{new Date(event.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 font-mono whitespace-pre-wrap text-bunker-100">
|
||||
<HighlightText text={event.data} highlight={search} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex grow items-center justify-center text-bunker-300">
|
||||
{search.length ? (
|
||||
<div className="text-center">
|
||||
<div className="mb-2">No terminal output matches search criteria</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="mb-2">Terminal session logs are not yet available</div>
|
||||
<div className="text-xs text-bunker-400">
|
||||
Logs will be uploaded after the session duration has elapsed.
|
||||
<br />
|
||||
If logs do not appear after some time, please contact your Gateway administrators.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { TTerminalEvent } from "@app/hooks/api/pam";
|
||||
|
||||
// Strip ANSI escape codes from terminal output
|
||||
export const stripAnsiCodes = (text: string): string => {
|
||||
// Remove ANSI escape sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1b\][0-9];[^\x07]*\x07/g, "");
|
||||
};
|
||||
|
||||
export type AggregatedTerminalEvent = {
|
||||
timestamp: string;
|
||||
eventType: string;
|
||||
data: string;
|
||||
elapsedTime: number;
|
||||
eventCount: number;
|
||||
};
|
||||
|
||||
// Aggregate consecutive output events to avoid character-by-character display
|
||||
export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => {
|
||||
// Filter to only show output events (input is echoed, so redundant)
|
||||
const outputEvents = events.filter((e) => e.eventType === "output");
|
||||
|
||||
if (outputEvents.length === 0) return [];
|
||||
|
||||
// First, combine all events into one string to process
|
||||
const allText = outputEvents
|
||||
.map((e) => {
|
||||
try {
|
||||
return stripAnsiCodes(atob(e.data));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Split on lines that contain shell prompts
|
||||
// Pattern matches: user@hostname:path# or user@hostname:path$
|
||||
const promptPattern = /^[\w-]+@[\w-]+[^\s]*[:#$]\s+/;
|
||||
|
||||
const lines = allText.split("\n");
|
||||
const segments: string[] = [];
|
||||
let currentSegment: string[] = [];
|
||||
|
||||
lines.forEach((line) => {
|
||||
const hasPrompt = promptPattern.test(line);
|
||||
|
||||
if (hasPrompt && currentSegment.length > 0) {
|
||||
// Found a new prompt, save current segment and start new one
|
||||
segments.push(currentSegment.join("\n"));
|
||||
currentSegment = [line];
|
||||
} else {
|
||||
// Add line to current segment
|
||||
currentSegment.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the last segment
|
||||
if (currentSegment.length > 0) {
|
||||
segments.push(currentSegment.join("\n"));
|
||||
}
|
||||
|
||||
// Filter out empty segments and convert to aggregated events
|
||||
const validSegments = segments.filter((seg) => seg.trim().length > 0);
|
||||
|
||||
return validSegments.map((segment) => ({
|
||||
timestamp: outputEvents[0].timestamp,
|
||||
eventType: "output",
|
||||
data: segment,
|
||||
elapsedTime: outputEvents[0].elapsedTime,
|
||||
eventCount: Math.ceil(outputEvents.length / validSegments.length)
|
||||
}));
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faBoxOpen,
|
||||
faChevronDown,
|
||||
@@ -25,18 +25,19 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
|
||||
import { PAM_RESOURCE_TYPE_MAP, TTerminalEvent, TPamSession } from "@app/hooks/api/pam";
|
||||
|
||||
import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils";
|
||||
import { aggregateTerminalEvents } from "../../PamSessionsByIDPage/components/terminal-utils";
|
||||
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
|
||||
|
||||
type Props = {
|
||||
session: TPamSession;
|
||||
search: string;
|
||||
filteredCommandLogs: TPamSession["commandLogs"];
|
||||
filteredLogs: TPamSession["logs"];
|
||||
};
|
||||
|
||||
export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) => {
|
||||
export const PamSessionRow = ({ session, search, filteredLogs }: Props) => {
|
||||
const router = useRouter();
|
||||
const [showAllLogs, setShowAllLogs] = useState(false);
|
||||
|
||||
@@ -55,8 +56,24 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
||||
|
||||
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
||||
|
||||
// Check if logs are terminal events and aggregate them
|
||||
const processedLogs = useMemo(() => {
|
||||
if (filteredLogs.length === 0) return [];
|
||||
|
||||
// Check if first log is a terminal event
|
||||
const isTerminalEvents = "data" in filteredLogs[0];
|
||||
|
||||
if (isTerminalEvents) {
|
||||
// Aggregate terminal events for better display
|
||||
return aggregateTerminalEvents(filteredLogs as TTerminalEvent[]);
|
||||
}
|
||||
|
||||
// Return command logs as-is
|
||||
return filteredLogs;
|
||||
}, [filteredLogs]);
|
||||
|
||||
const LOGS_TO_SHOW = 5;
|
||||
const logsToShow = showAllLogs ? filteredCommandLogs : filteredCommandLogs.slice(0, LOGS_TO_SHOW);
|
||||
const logsToShow = showAllLogs ? processedLogs : processedLogs.slice(0, LOGS_TO_SHOW);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -135,8 +152,8 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.PamResources}
|
||||
I={ProjectPermissionActions.Read}
|
||||
a={ProjectPermissionSub.PamSessions}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
@@ -157,32 +174,56 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
{filteredCommandLogs.length > 0 && (
|
||||
{filteredLogs.length > 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={5} className="py-3 text-xs">
|
||||
{logsToShow.map((log) => {
|
||||
const formattedInput = formatLogContent(log.input);
|
||||
// Handle command logs (database sessions)
|
||||
if ("input" in log && "output" in log) {
|
||||
const formattedInput = formatLogContent(log.input);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${id}-log-${log.timestamp}`}
|
||||
className="mb-4 flex flex-col gap-1 last:mb-0"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-bunker-400">
|
||||
<FontAwesomeIcon icon={faTerminal} className="size-3" />
|
||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={`${id}-log-${log.timestamp}`}
|
||||
className="mb-4 flex flex-col gap-1 last:mb-0"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-bunker-400">
|
||||
<FontAwesomeIcon icon={faTerminal} className="size-3" />
|
||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="font-mono break-all whitespace-pre-wrap">
|
||||
<HighlightText text={formattedInput} highlight={search} />
|
||||
<div className="font-mono break-all whitespace-pre-wrap">
|
||||
<HighlightText text={formattedInput} highlight={search} />
|
||||
</div>
|
||||
<div className="font-mono text-bunker-300">
|
||||
<HighlightText text={log.output.trim()} highlight={search} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-bunker-300">
|
||||
<HighlightText text={log.output.trim()} highlight={search} />
|
||||
);
|
||||
}
|
||||
|
||||
// Handle aggregated terminal events (SSH sessions)
|
||||
if ("data" in log && typeof log.data === "string") {
|
||||
return (
|
||||
<div
|
||||
key={`${id}-log-${log.timestamp}`}
|
||||
className="mb-4 flex flex-col gap-1 last:mb-0"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-bunker-400">
|
||||
<FontAwesomeIcon icon={faTerminal} className="size-3" />
|
||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="font-mono break-all whitespace-pre-wrap text-bunker-300">
|
||||
<HighlightText text={log.data.trim()} highlight={search} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{filteredCommandLogs.length > LOGS_TO_SHOW && (
|
||||
{filteredLogs.length > LOGS_TO_SHOW && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="link"
|
||||
@@ -193,7 +234,7 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
||||
>
|
||||
{showAllLogs
|
||||
? "Show less"
|
||||
: `Show ${filteredCommandLogs.length - LOGS_TO_SHOW} more log${filteredCommandLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
|
||||
: `Show ${filteredLogs.length - LOGS_TO_SHOW} more log${filteredLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -113,7 +113,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
|
||||
id,
|
||||
resourceName,
|
||||
userId,
|
||||
commandLogs
|
||||
logs
|
||||
} = session;
|
||||
|
||||
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
||||
@@ -131,11 +131,25 @@ export const PamSessionsTable = ({ sessions }: Props) => {
|
||||
|
||||
const filteredLogs =
|
||||
searchValue.length >= 2
|
||||
? commandLogs.filter(
|
||||
(log) =>
|
||||
log.input.toLowerCase().includes(searchValue) ||
|
||||
log.output.toLowerCase().includes(searchValue)
|
||||
)
|
||||
? logs.filter((log) => {
|
||||
// Handle command logs (database sessions)
|
||||
if ("input" in log && "output" in log) {
|
||||
return (
|
||||
log.input.toLowerCase().includes(searchValue) ||
|
||||
log.output.toLowerCase().includes(searchValue)
|
||||
);
|
||||
}
|
||||
// Handle terminal events (SSH sessions)
|
||||
if ("data" in log) {
|
||||
try {
|
||||
const decodedData = atob(log.data);
|
||||
return decodedData.toLowerCase().includes(searchValue);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
@@ -385,7 +399,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
|
||||
key={session.id}
|
||||
session={session}
|
||||
search={search.trim().toLowerCase()}
|
||||
filteredCommandLogs={filteredLogs}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
|
||||
Reference in New Issue
Block a user