Merge branch 'main' into PAM-10

This commit is contained in:
x032205
2025-11-18 16:48:11 -05:00
98 changed files with 2123 additions and 784 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
});
}
};

View File

@@ -6,6 +6,7 @@ import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/
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 { removeTrailingSlash } from "@app/lib/fn";
import { ms } from "@app/lib/ms";
@@ -15,6 +16,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
]);
@@ -132,7 +134,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()
})
}
},

View File

@@ -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
});
}
};

View File

@@ -11,15 +11,24 @@ import {
SanitizedPostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { OrderByDirection } from "@app/lib/types";
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) => {

View File

@@ -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({

View File

@@ -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";
@@ -252,17 +253,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({
@@ -574,11 +575,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,
@@ -594,6 +595,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;
}

View File

@@ -1,6 +1,7 @@
export enum PamResource {
Postgres = "postgres",
MySQL = "mysql"
MySQL = "mysql",
SSH = "ssh"
}
export enum PamResourceOrderBy {

View File

@@ -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
};

View File

@@ -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 {

View File

@@ -14,15 +14,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<
@@ -62,4 +71,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>;
};

View File

@@ -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
};
};

View File

@@ -0,0 +1,5 @@
export enum SSHAuthMethod {
Password = "password",
PublicKey = "public-key",
Certificate = "certificate"
}

View 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
};
};

View 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);

View File

@@ -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>;

View File

@@ -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,

View File

@@ -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]))
});

View File

@@ -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;

View File

@@ -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)[];
};

View File

@@ -354,16 +354,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
(tx || db.replicaNode())(TableName.SecretApprovalRequest)
.join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join(
TableName.SecretApprovalPolicyApprover,
`${TableName.SecretApprovalRequest}.policyId`,
`${TableName.SecretApprovalPolicyApprover}.policyId`
)
.join(
TableName.SecretApprovalPolicy,
`${TableName.SecretApprovalRequest}.policyId`,
`${TableName.SecretApprovalPolicy}.id`
)
.leftJoin(
TableName.SecretApprovalPolicyApprover,
`${TableName.SecretApprovalPolicy}.id`,
`${TableName.SecretApprovalPolicyApprover}.policyId`
)
.leftJoin(
TableName.UserGroupMembership,
`${TableName.SecretApprovalPolicyApprover}.approverGroupId`,
`${TableName.UserGroupMembership}.groupId`
)
.where({ projectId })
.where((qb) => {
if (policyId) void qb.where(`${TableName.SecretApprovalPolicy}.id`, policyId);
@@ -373,10 +378,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
void bd
.where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId)
.orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId)
.orWhere(`${TableName.UserGroupMembership}.userId`, userId)
)
.select("status", `${TableName.SecretApprovalRequest}.id`)
.groupBy(`${TableName.SecretApprovalRequest}.id`, "status")
.count("status")
)
.select("status")
.from("temp")
@@ -499,7 +504,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
const query = (tx || db.replicaNode())
.select("*")
.select(db.raw("count(*) OVER() as total_count"))
.from(innerQuery)
.orderBy("createdAt", "desc") as typeof innerQuery;
@@ -519,6 +523,14 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
});
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const countResult = await (tx || db.replicaNode())
.count({ count: "*" })
.from(query.clone().as("count_query"))
.first();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const totalCount = Number(countResult?.count || 0);
const docs = await (tx || db)
.with("w", query)
.select("*")
@@ -526,9 +538,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.where("w.rank", ">=", offset)
.andWhere("w.rank", "<", offset + limit);
// @ts-expect-error knex does not infer
const totalCount = Number(docs[0]?.total_count || 0);
const formattedDoc = sqlNestRelationships({
data: docs,
key: "id",

View File

@@ -742,6 +742,7 @@
"pages": [
"documentation/platform/pki/enrollment-methods/overview",
"documentation/platform/pki/enrollment-methods/api",
"documentation/platform/pki/enrollment-methods/acme",
"documentation/platform/pki/enrollment-methods/est"
]
},
@@ -772,6 +773,8 @@
"group": "External CA Integrations",
"pages": [
"documentation/platform/pki/ca/acme-ca",
"documentation/platform/pki/ca/lets-encrypt",
"documentation/platform/pki/ca/digicert",
"documentation/platform/pki/ca/azure-adcs"
]
}

View File

@@ -35,7 +35,7 @@ Infisical consists of several tightly integrated products, each designed to solv
- [Secrets Management](/documentation/platform/secrets-mgmt/overview): Securely store, access, and distribute secrets across environments with fine-grained controls, automatic rotation, and audit logging.
- [Secrets Scanning](/documentation/platform/secret-scanning/overview): Detect hardcoded secrets in code, CI pipelines, and infrastructure—integrated with GitHub, GitLab, Bitbucket, and more.
- [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs.
- [Certificate Management](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs.
- [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control.
- [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility.
- [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals.

View File

@@ -16,15 +16,37 @@ Key Features:
- Role Assignment: Identities must be assigned [roles](/documentation/platform/access-controls/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level.
- Auth/Token Configuration: Identities must be configured with corresponding authentication methods and access token properties to securely interact with the Infisical API.
## Scopes
Identities can be created either at the organization-level or the project-level. Outside of identity management and scope of operation, organization and project identities are functionally identical.
- Project identities are managed at the project-level and can only operate within their respective project.
Project-level identities are useful for organizations that delegate responsibility to autonomous teams via projects.
- Organization identities are managed at the organization-level and can be assigned to one or more projects, as well as
perform organization-level operations. Organization-level identities are useful for organizations that have cross-project operations.
## Workflow
A typical workflow for using identities consists of four steps:
<Tabs>
<Tab title="Project Identities">
A typical workflow for using project identities consists of three steps:
1. Creating the identity with a name and [role](/documentation/platform/access-controls/role-based-access-controls) in Organization Access Control > Machine Identities.
This step also involves configuring an authentication method for it.
2. Adding the identity to the project(s) you want it to have access to.
3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back.
4. Authenticating subsequent requests with the Infisical API using the short-lived access token.
1. Creating the identity with a name and [role](/documentation/platform/access-controls/role-based-access-controls) in Project > Access Control > Machine Identities.
This step also involves configuring an authentication method for it.
2. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back.
3. Authenticating subsequent requests with the Infisical API using the short-lived access token.
</Tab>
<Tab title="Organization Identities">
A typical workflow for using organization identities consists of four steps:
1. Creating the identity with a name and [role](/documentation/platform/access-controls/role-based-access-controls) in Organization > Access Control > Machine Identities.
This step also involves configuring an authentication method for it.
2. Adding the identity to the project(s) you want it to have access to.
3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back.
4. Authenticating subsequent requests with the Infisical API using the short-lived access token.
</Tab>
</Tabs>
## Authentication Methods

View File

@@ -1,66 +1,63 @@
---
title: "ACME-compatible CA"
description: "Learn how to automatically provision and manage TLS certificates using ACME Certificate Authorities like Let's Encrypt with Infisical PKI"
description: "Learn how to connect Infisical to an ACME-compatible CA to issue certificates."
---
## Concept
The Infisical ACME integration allows you to connect with ACME (Automatic Certificate Management Environment) Certificate Authorities to automatically issue and manage publicly trusted TLS certificates for your [subscribers](/documentation/platform/pki/subscribers). This integration enables you to leverage established public CA infrastructure like Let's Encrypt while centralizing your certificate management within Infisical.
Infisical can connect to any upstream ACME-compatible CA (e.g. Lets's Encrypt, DigiCert, etc.) supporting the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) to issue certificates back to your end-entities. This integration uses the [DNS-01 challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) method as part of the ACME domain validation challenge workflow for a requested certificate.
ACME is a protocol that automates the process of certificate issuance and renewal through domain validation challenges. The integration is perfect for obtaining trusted X.509 certificates for public-facing services and is capable of automatically renewing certificates as needed.
The upstream ACME-compatible CA integration lets you connect Infisical to providers by specifying
their **ACME Directory URL** such as:
- [Let's Encrypt](/documentation/platform/pki/ca/lets-encrypt): `https://acme-v02.api.letsencrypt.org/directory`.
- [DigiCert](/documentation/platform/pki/ca/digicert): `https://acme.digicert.com/v2/acme/directory`.
- Google GTS: `https://dv.acme-v02.api.pki.goog/directory`.
- Buypass: `https://api.buypass.com/acme/directory`.
- ZeroSSL: `https://acme.zerossl.com/v2/DV90`.
- SSL.com: `https://acme.ssl.com/sslcom-dv-rsa`.
When Infisical requests a certificate from an ACME-compatible CA, it creates a TXT record at `_acme-challenge.{your-domain}` in your configured DNS provider (e.g. Route53, Cloudflare, etc.); this TXT record contains the challenge token issued by the ACME-compatible CA to validate domain control for the requested certificate.
The ACME provider checks for the existence of this TXT record to verify domain control before issuing the certificate back to Infisical.
After validation completes successfully, Infisical automatically removes the TXT record from your DNS provider.
<div align="center">
```mermaid
graph TD
A[ACME CA Provider<br>e.g., Let's Encrypt] <-->|ACME v2 Protocol| B[Infisical]
B -->|Creates TXT Records<br>via Route53/Cloudflare| C[DNS Validation]
B -->|Manages Certificates| D[Subscribers]
A[ACME-compatible CA] <-->|ACME v2 Protocol| B[Infisical]
B -->|Creates TXT Records<br>via DNS Provider| C[DNS Validation]
B -->|Manages Certificates| D[End-Entities]
```
</div>
As part of the workflow, you configure DNS provider credentials, register an ACME CA provider with Infisical, and create subscribers to represent the certificates you wish to issue. Each issued certificate is automatically managed through its lifecycle, including renewal before expiration.
We recommend reading about [ACME protocol](https://tools.ietf.org/html/rfc8555) and [DNS-01 challenges](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) for a fuller understanding of the underlying technology.
We recommend reading about [ACME protocol](https://tools.ietf.org/html/rfc8555) and [DNS-01 challenges](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) for a fuller understanding of the underlying workflow.
## Workflow
A typical workflow for using Infisical with ACME Certificate Authorities consists of the following steps:
A typical workflow for using Infisical with an external ACME-compatible CA consists of the following steps:
1. Setting up AWS Route53 or Cloudflare credentials with appropriate DNS permissions.
2. Creating an AWS/Cloudflare connection in Infisical to store the credentials.
3. Registering an ACME Certificate Authority (like Let's Encrypt) with Infisical.
4. Creating subscribers that use the ACME CA as their issuing authority.
5. Managing certificate lifecycle events such as issuance, renewal, and revocation through Infisical.
1. Setting up your DNS provider (e.g. Route53, Cloudflare, etc.) with appropriate DNS permissions.
2. Creating an [App Connection](/integrations/app-connections/overview) in Infisical to store credentials for Infisical to connect to your DNS provider and create/remove DNS records as part of the DNS-01 challenge.
3. Registering an [External CA](/documentation/platform/pki/ca/external-ca) in Infisical with the ACME type and inputting required configuration including the **ACME Directory URL** of the upstream ACME-compatible CA and the **App Connection** for your DNS provider.
## Understanding ACME DNS-01 Challenge
Once this is complete, you can create a [certificate profile](/documentation/platform/pki/certificates/profiles) linked to the External CA proceed to request a certificate against it.
The DNS-01 challenge is the method used by ACME CA providers to verify that you control a domain before issuing a certificate. Here's how Infisical handles this process:
## Guide to Connecting Infisical to an ACME-compatible CA
1. **Challenge Request**: When you request a certificate, the ACME provider (like Let's Encrypt) issues a challenge token.
2. **DNS Record Creation**: Infisical creates a TXT record at `_acme-challenge.<YOUR_DOMAIN>` with a value derived from the challenge token.
3. **DNS Propagation**: The TXT record must propagate through the DNS system (usually takes a few minutes, depending on TTL settings).
4. **Validation**: The ACME provider checks for the existence of this TXT record to verify domain control.
5. **Cleanup**: After validation completes successfully, Infisical automatically removes the TXT record from your DNS.
This automated process eliminates the need for manual intervention in domain validation, streamlining certificate issuance.
## Guide
In the following steps, we explore how to set up ACME Certificate Authority integration with Infisical using Let's Encrypt as an example.
In the following steps, we explore how to connect Infisical to an ACME-compatible CA.
<Steps>
<Step title="Create App Connection with Required Permissions">
Before proceeding with the ACME CA registration, you need to set up an App Connection with the appropriate permissions for DNS validation:
<Step title="Create an App Connection to your DNS provider">
Before registering an ACME-compatible CA with Infisical, you need to set up an [App Connection](/integrations/app-connections/overview) with the appropriate permissions for Infisical to perform the DNS-01 challenge with your DNS provider.
If you dont see a specific DNS provider listed below or need a dedicated one, please reach out to sales@infisical.com and well help get that enabled for you.
<Tabs>
<Tab title="Route53">
1. Navigate to your Organization Settings > App Connections and create a new AWS connection.
1. Navigate to your Certificate Management Project > App Connections and create a new AWS connection.
2. Ensure your AWS connection has the following minimum permissions for Route53 DNS validation:
@@ -112,7 +109,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte
For detailed instructions on setting up an AWS connection, see the [AWS Connection](/integrations/app-connections/aws) documentation.
</Tab>
<Tab title="Cloudflare">
1. Navigate to your Organization Settings > App Connections and create a new Cloudflare connection.
1. Navigate to your Certificate Management Project > App Connections and create a new Cloudflare connection.
2. Ensure your Cloudflare token has the following minimum permissions for DNS validation:
@@ -125,51 +122,33 @@ In the following steps, we explore how to set up ACME Certificate Authority inte
</Tab>
</Tabs>
</Step>
<Step title="Register ACME Certificate Authority">
<Step title="Register an ACME-compatible CA">
<Tabs>
<Tab title="Infisical UI">
<Steps>
<Step title="Create ACME CA">
To register an ACME CA, head to your Project > Internal PKI > Certificate Authorities and press the **+** button in the External Certificate Authorities section.
To register an ACME-compatible CA, head to your Certificate Management Project > Certificate Authorities > External Certificate Authorities and press **Create CA**.
![pki register external ca](/images/platform/pki/ca/external-ca/create-external-ca-button.png)
![pki register external ca](/images/platform/pki/ca/external-ca/create-external-ca-button.png)
Fill out the details for the ACME CA registration:
Here, set the **CA Type** to **ACME** and fill out details for it.
![pki register external ca details](/images/platform/pki/ca/external-ca/create-external-ca-form.png)
![pki register external ca details](/images/platform/pki/ca/external-ca/create-external-ca-form.png)
Here's guidance on each field:
Here's some guidance for each field:
- **Type**: Select "ACME" as the External CA type.
- **Name**: Enter a name for the ACME CA (e.g., "lets-encrypt-production").
- **DNS App Connection**: Select from available DNS app connections or configure a new one. This connection provides Infisical with the credentials needed to create and remove DNS records for ACME validation.
- **Zone ID**: Enter the Zone ID for the domain(s) you'll be requesting certificates for.
- **Directory URL**: Enter the ACME v2 directory URL for your chosen CA provider (e.g., `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt).
- **Account Email**: Email address to associate with your ACME account. This email will receive important notifications about your certificates.
- **Enable Direct Issuance**: Toggle on to allow direct certificate issuance without requiring subscribers.
- **EAB Key Identifier (KID)**: (Optional) The Key Identifier (KID) provided by your ACME CA for External Account Binding (EAB). This is required by some ACME providers (e.g., ZeroSSL, DigiCert) to link your ACME account to an external account you've pre-registered with them.
- **EAB HMAC Key**: (Optional) The HMAC Key provided by your ACME CA for External Account Binding (EAB). This key is used in conjunction with the KID to prove ownership of the external account during ACME account registration.
- Name: A slug-friendly name for the ACME-compatible CA such as `lets-encrypt-production`.
- DNS App Connection: The App Connection from Step 1 used for Infisical to connect to your DNS provider and create/remove DNS records as part of the DNS-01 challenge in ACME.
- Zone / Zone ID: Enter the Zone / Zone ID for the domain(s) you'll be requesting certificates for.
- Directory URL: Enter the **ACME Directory URL** for your desired upstream ACME-compatible CA such as `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt.
- Account Email: The email address to associate with your ACME account. This email will receive important notifications about your certificates.
- EAB Key Identifier (KID): (Optional) The Key Identifier (KID) provided by your ACME CA for External Account Binding (EAB). This is required by some ACME providers (e.g., ZeroSSL, DigiCert) to link your ACME account to an external account you've pre-registered with them.
- EAB HMAC Key: (Optional) The HMAC Key provided by your ACME CA for External Account Binding (EAB). This key is used in conjunction with the KID to prove ownership of the external account during ACME account registration.
Finally, press **Create** to register the ACME CA with Infisical.
</Step>
<Step title="Verify ACME CA Registration">
Once registered, your ACME CA will appear in the External Certificate Authorities section.
Finally, press **Create** to register the ACME-compatible CA with Infisical.
![pki external ca list](/images/platform/pki/ca/external-ca/external-ca-list.png)
From here, you can:
- View the status of the ACME CA registration
- Edit the configuration settings
- Disable or re-enable the ACME CA
- Delete the ACME CA registration if no longer needed
You can now use this ACME CA to issue certificates for your subscribers.
</Step>
</Steps>
Great! Youve successfully registered an external ACME-compatible CA with Infisical. Now check out the [Certificates](/documentation/platform/pki/certificates/overview) section to learn more about how to issue X.509 certificates using the ACME-compatible CA.
</Tab>
<Tab title="API">
To register an ACME CA with Infisical using the API, make a request to the Create External CA endpoint:
To register an ACME CA with Infisical using the API, make a request to the [Create External CA](https://infisical.com/docs/api-reference/endpoints/certificate-authorities/acme/create) endpoint:
### Sample request
@@ -227,78 +206,9 @@ In the following steps, we explore how to set up ACME Certificate Authority inte
</Tab>
</Tabs>
</Step>
<Step title="Create Subscriber for ACME CA">
Next, create a subscriber that uses your ACME CA for certificate issuance. Navigate to your Project > Subscribers and create a new subscriber.
Configure the subscriber with:
- **Issuing CA**: Select your registered ACME CA
- **Common Name**: The domain for which you want to issue certificates (e.g., `example.com`)
- **Alternative Names**: Additional domains to include in the certificate
Check out the [Subscribers](/documentation/platform/pki/subscribers) page for detailed instructions on creating and managing subscribers.
</Step>
<Step title="Issue Certificate">
Once your subscriber is configured, you can issue certificates either through the Infisical UI or programmatically via the API.
When you request a certificate:
1. Infisical generates a key pair for the certificate
2. Sends a Certificate Signing Request (CSR) to the ACME CA
3. Receives a DNS-01 challenge from the ACME provider
4. Creates a TXT record in Route53/Cloudflare to satisfy the challenge
5. Notifies the ACME provider that the challenge is ready for validation
6. Once validated, the ACME provider issues the certificate
7. Infisical stores and manages the certificate for your subscriber
The certificate will be automatically renewed before expiration according to your subscriber configuration.
</Step>
<Step title="Use Certificate in Your Applications">
The issued certificate and private key are now available through Infisical and can be:
- Downloaded directly from the Infisical UI
- Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/certificate-profiles/get-latest-active-bundle)
</Step>
</Steps>
## Example: Let's Encrypt Integration
Let's Encrypt is a free, automated, and open Certificate Authority that provides domain-validated SSL/TLS certificates. Here's how the integration works with Infisical:
### Production Environment
- **Directory URL**: `https://acme-v02.api.letsencrypt.org/directory`
- **Rate Limits**: 50 certificates per registered domain per week
- **Certificate Validity**: 90 days with automatic renewal
- **Trusted By**: All major browsers and operating systems
### Staging Environment (for testing)
- **Directory URL**: `https://acme-staging-v02.api.letsencrypt.org/directory`
- **Rate Limits**: Much higher limits for testing
- **Certificate Validity**: 90 days (not trusted by browsers)
- **Use Case**: Testing your ACME integration without hitting production rate limits
<Note>
Always test your ACME integration using Let's Encrypt's staging environment
first. This allows you to verify your DNS configuration and certificate
issuance process without consuming your production rate limits.
</Note>
## Example: DigiCert Integration
DigiCert is a leading commercial Certificate Authority providing a wide range of trusted SSL/TLS certificates. Infisical can integrate with [DigiCert's ACME](https://docs.digicert.com/en/certcentral/certificate-tools/certificate-lifecycle-automation-guides/third-party-acme-integration/request-and-manage-certificates-with-acme.html) service to automate the provisioning and management of these certificates.
- **Directory URL**: `https://acme.digicert.com/v2/acme/directory`
- **External Account Binding (EAB)**: Required. You will need a Key Identifier (KID) and HMAC Key from your DigiCert account to register the ACME CA in Infisical.
- **Certificate Validity**: Typically 90 days, with automatic renewal through Infisical.
- **Trusted By**: All major browsers and operating systems.
<Note>
When integrating with DigiCert ACME, ensure you have obtained the necessary
External Account Binding (EAB) Key Identifier (KID) and HMAC Key from your
DigiCert account.
</Note>
## FAQ
<AccordionGroup>
@@ -325,17 +235,8 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of
- Reduce the impact of compromised certificates
- Ensure systems stay up-to-date with certificate management practices
When configured, Infisical automatically handles certificate renewal for subscribers.
</Accordion>
<Accordion title="Can I use multiple ACME providers?">
Yes! You can register multiple ACME CAs in the same project:
- Different providers for different domains or use cases
- Staging and production environments for the same provider
- Backup providers for redundancy
Each subscriber can be configured to use a specific ACME CA based on your requirements.
Yes. You can register multiple ACME CAs in the same project.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,16 @@
---
title: "DigiCert"
description: "Learn how to connect Infisical to DigiCert to issue certificates."
---
## Concept
Infisical can connect to [DigiCert](https://www.digicert.com/) using the [ACME-compatible CA integration](/documentation/platform/pki/ca/acme-ca) to issue certificates back to your end-entities.
## Guide to Connecting Infisical to DigiCert CA
To connect Infisical to DigiCert, follow the steps in the [ACME-compatible CA integration](/documentation/platform/pki/ca/acme-ca) guide but use the DigiCert **ACME Directory URL**: `https://acme.digicert.com/v2/acme/directory`.
DigiCert requires **External Account Binding (EAB)** for all ACME registrations. You will need to obtain both a Key Identifier (KID) and an HMAC Key from your DigiCert account before registering the ACME CA in Infisical.
DigiCert typically issues certificates with a 90-day validity period.

View File

@@ -6,7 +6,7 @@ description: "Learn how to connect External Certificate Authorities with Infisic
## Concept
Infisical lets you integrate with External Certificate Authorities (CAs), allowing you to use existing PKI infrastructure or connect to public CAs to issue digital certificates for your end-entities.
Infisical lets you integrate with External Certificate Authorities (CAs), allowing you to use existing PKI infrastructure or connect to public CAs to issue certificates for your end-entities.
<div align="center">
@@ -23,7 +23,7 @@ As shown above, these CAs commonly fall under two categories:
- External Private CAs: CAs like AWS Private CA, HashiCorp Vault PKI, Azure ADCS, etc. that are privately owned and are used to issue certificates for internal services; these are often either cloud-hosted private CAs or on-prem / enterprise CAs.
- External Public CAs: CAs like Let's Encrypt, DigiCert, GlobalSign, etc. that are publicly trusted and are used to issue certificates for public-facing services.
Note that Infisical can also act as an _ACME client_, allowing you to integrate upstream with any ACME-compatible CA to automate certificate issuance and renewal.
Note that Infisical can act as an _ACME client_, allowing you to integrate upstream with any [ACME-compatible CA](/documentation/platform/pki/ca/acme-ca) to automate certificate issuance and renewal.
## Workflow

View File

@@ -0,0 +1,16 @@
---
title: "Let's Encrypt"
description: "Learn how to connect Infisical to Let's Encrypt to issue certificates."
---
## Concept
Infisical can connect to [Let's Encrypt](https://letsencrypt.org/) using the [ACME-compatible CA integration](/documentation/platform/pki/ca/acme-ca) to issue certificates back to your end-entities.
## Guide to Connecting Infisical to Let's Encrypt CA
To connect Infisical to Let's Encrypt, follow the steps in the [ACME-compatible CA integration](/documentation/platform/pki/ca/acme-ca) guide but use the Let's Encrypt **ACME Directory URL**: `https://acme-v02.api.letsencrypt.org/directory`.
Note that Lets Encrypt issues 90-day certificates and enforces a limit of 50 certificates per registered domain per week.
We strongly recommend testing your setup against the Let's Encrypt staging environment first at the **ACME Directory URL** `https://acme-staging-v02.api.letsencrypt.org/directory` prior to switching to the production environment. This allows you to verify your DNS configuration and certificate issuance process without consuming production rate limits.

View File

@@ -22,10 +22,7 @@ where you can manage various aspects of its lifecycle including deployment to cl
To issue a certificate, you must first create a [certificate profile](/documentation/platform/pki/certificates/profiles) and a [certificate template](/documentation/platform/pki/certificates/templates) to go along with it.
The [enrollment method](/documentation/platform/pki/enrollment-methods/overview) configured on the certificate profile determines how a certificate is issued for it.
Refer to the documentation for each enrollment method below to learn more about how to issue certificates using it.
- [API](/documentation/platform/pki/enrollment-methods/api): Issue a certificate over UI or by making an API request to Infisical.
- [EST](/documentation/platform/pki/enrollment-methods/est): Issue a certificate over the EST protocol.
Refer to the documentation for each enrollment method to learn more about how to issue certificates using it.
## Guide to Renewing Certificates
@@ -49,24 +46,33 @@ Note that server-driven certificate renewal is only available for certificates i
A certificate can be considered for auto-renewal at time of issuance if the **Enable Auto-Renewal By Default** option is selected on its [certificate profile](/documentation/platform/pki/certificates/profiles) or after issuance by toggling this option manually.
<Info>
For server-driven certificate renewal workflows, you can programmatically fetch the latest active certificate bundle for a certificate profile using the [Get Latest Active Certificate Bundle](/api-reference/endpoints/certificate-profiles/get-latest-active-bundle) API endpoint.
This ensures you always retrieve the most current valid certificate, including any that have been automatically renewed, making it particularly useful for deployment pipelines and automation workflows where you don't want to track individual serial numbers.
For server-driven certificate renewal workflows, you can programmatically
fetch the latest active certificate bundle for a certificate profile using the
[Get Latest Active Certificate
Bundle](/api-reference/endpoints/certificate-profiles/get-latest-active-bundle)
API endpoint. This ensures you always retrieve the most current valid
certificate, including any that have been automatically renewed, making it
particularly useful for deployment pipelines and automation workflows where
you don't want to track individual serial numbers.
</Info>
The following examples demonstrate different approaches to certificate renewal:
- Using the ACME enrollment method, you may connect an ACME client like [certbot](https://certbot.eff.org/) to fetch back and renew certificates for Apache, Nginx, or other server. The ACME client will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to the server's configuration.
- Using the ACME enrollment method, you may use [cert-manager](https://cert-manager.io/) with Infisical to issue and renew certificates for Kubernetes workloads; cert-manager will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to Kubernetes secrets.
- Using the API enrollment method, you may push and auto-renew certificates to AWS and Azure using [certificate syncs](/documentation/platform/pki/certificate-syncs/overview). Certificates issued over the API enrollment method, where key pairs are generated server-side, are also eligible for server-side auto-renewal; once renewed, certificates are automatically pushed back to their sync destination.
- Using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme), you may connect an ACME client like [certbot](https://certbot.eff.org/) to fetch back and renew certificates for [Apache](/documentation/platform/pki/integration-guides/apache-certbot), [Nginx](/documentation/platform/pki/integration-guides/nginx-certbot), or other server. The ACME client will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to the server's configuration.
- Using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme), you may use [cert-manager](https://cert-manager.io/) with Infisical to issue and renew certificates for Kubernetes workloads; cert-manager will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to Kubernetes secrets.
- Using the [API enrollment method](/documentation/platform/pki/enrollment-methods/api), you may push and auto-renew certificates to AWS and Azure using [certificate syncs](/documentation/platform/pki/certificate-syncs/overview). Certificates issued over the API enrollment method, where key pairs are generated server-side, are also eligible for server-side auto-renewal; once renewed, certificates are automatically pushed back to their sync destination.
## Guide to Exporting Certificates
## Guide to Downloading Certificates
In the following steps, we explore how to export certificates from Infisical in different formats for use in your applications and infrastructure.
In the following steps, we explore different options for exporting already-issued certificates from Infisical in different formats for use in your applications and infrastructure.
### Accessing the Export Certificate Modal
### Download Latest Profile Certificate
To export any certificate, first navigate to your project's certificate inventory and locate the certificate you want to export. Click on the **Export Certificate** option from the certificate's action menu.
You can download the latest certificate issued against a [certificate profile](/documentation/platform/pki/certificates/profiles) using the [latest certificate bundle](/api-reference/endpoints/certificate-profiles/get-latest-active-bundle) endpoint.
### Download Specific Certificate
To export a specific certificate, first navigate to your project's certificate inventory and locate the certificate you want to export. Click on the **Export Certificate** option from the certificate's action menu.
![pki export certificate option](/images/platform/pki/certificate/cert-export-option.png)
@@ -108,6 +114,7 @@ To export any certificate, first navigate to your project's certificate inventor
```
</Step>
</Steps>
</Tab>
<Tab title="PKCS12 Format">
<Steps>
@@ -158,6 +165,7 @@ To export any certificate, first navigate to your project's certificate inventor
</Info>
</Step>
</Steps>
</Tab>
</Tabs>

View File

@@ -7,7 +7,7 @@ sidebarTitle: "Templates"
A certificate template is a policy structure specifying permitted attributes for requested certificates. This includes constraints around subject naming conventions, SAN fields, key usages, and extended key usages.
Each certificate requested against a profile is validated against the template bound to that profile. If the request fails any criteria included in the template, the certificate is not issued. This helps administrators enforce uniformity and security standards across all issued certificates.
Each certificate requested against a [certificate profile](/documentation/platform/pki/certificates/profiles) is validated against the template bound to that profile. If the request fails any criteria included in the template, the certificate is not issued. This helps administrators enforce uniformity and security standards across all issued certificates.
## Guide to Creating a Certificate Template

View File

@@ -3,6 +3,62 @@ title: "Certificate Enrollment via ACME"
sidebarTitle: "ACME"
---
<Info>
ACME-based certificate enrollment is currently under development and will be included in a future release.
</Info>
## Concept
The ACME enrollment method allows you to issue and manage certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) using the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment).
This method is suitable for web servers, load balancers, and other general-purpose servers that can run an [ACME client](https://letsencrypt.org/docs/client-options/) for automated certificate management.
Infisical's ACME enrollment method is based on [RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555/).
## Prerequisites
Install an [ACME client](https://letsencrypt.org/docs/client-options/) onto your server. This client will handle [ACME challenges](https://letsencrypt.org/docs/challenge-types/) and request/renew certificates from Infisical.
## Guide to Certificate Enrollment via ACME
In the following steps, we explore how to issue a X.509 certificate using the ACME enrollment method.
<Steps>
<Step title="Create a certificate profile in Infisical">
Create a [certificate
profile](/documentation/platform/pki/certificates/profiles) with **ACME**
selected as the enrollment method.
![pki acme config](/images/platform/pki/enrollment-methods/acme/acme-config.png)
</Step>
<Step title="Obtain the ACME configuration">
Once you've created the certificate profile, you can obtain its ACME configuration details by clicking the **Reveal ACME EAB** option on the profile.
![pki acme eab config](/images/platform/pki/enrollment-methods/acme/acme-eab.png)
From the ACME configuration, gather the following values:
- ACME Directory URL: The URL that the ACME client will use to communicate with Infisical's ACME server.
- EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request.
- EAB Secret: A secret key that authenticates your ACME client with Infisical.
</Step>
<Step title="Configure your ACME client">
Provide the **ACME Directory URL**, **EAB KID**, and **EAB Secret** from Step 2 to your ACME client to authenticate with Infisical and request a certificate.
For example, if using [Certbot](https://certbot.eff.org/) as an ACME client, you can configure and start requesting certificates with the following command:
```bash
sudo certbot certonly \
--standalone \
--server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \
--eab-kid "your-eab-kid" \
--eab-hmac-key "your-eab-secret" \
-d example.infisical.com \
--email admin@example.com \
--agree-tos \
--non-interactive
```
Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`.
For client-specific setup and usage instructions, refer to the documentation for your ACME client.
</Step>
</Steps>

View File

@@ -5,7 +5,7 @@ sidebarTitle: "API"
## Concept
The API enrollment method allows you to issue certificates against a specific certificate profile over Web UI or by making an API request to Infisical.
The API enrollment method allows you to issue certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) over Web UI or by making an API request to Infisical.
## Guide to Certificate Enrollment via API
@@ -15,7 +15,7 @@ In the following steps, we explore how to issue a X.509 certificate using the AP
<Tab title="Infisical UI">
<Steps>
<Step title="Create a certificate profile">
<Step title="Create a certificate profile in Infisical">
Create a [certificate
profile](/documentation/platform/pki/certificates/profiles) with **API**
selected as the enrollment method.
@@ -54,7 +54,7 @@ Here, select the certificate profile from step 1 that will be used to issue the
<Tab title="API">
<Steps>
<Step title="Create a certificate profile">
<Step title="Create a certificate profile in Infisical">
To create a certificate [profile](/documentation/platform/pki/certificates/profiles), make an API request to the [Create Certificate Profile](/api-reference/endpoints/certificate-profiles/create) API endpoint.

View File

@@ -5,7 +5,7 @@ sidebarTitle: "EST"
## Concept
The API enrollment method allows you to issue and manage certificates against a specific certificate profile using the [EST protocol](https://en.wikipedia.org/wiki/Enrollment_over_Secure_Transport).
The EST enrollment method allows you to issue and manage certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) using the [EST protocol](https://en.wikipedia.org/wiki/Enrollment_over_Secure_Transport).
This method is suitable for environments requiring strong authentication and encrypted communication, such as in IoT, enterprise networks, and secure web services.
Infisical's EST service is based on [RFC 7030](https://datatracker.ietf.org/doc/html/rfc7030) and implements the following endpoints:
@@ -32,7 +32,7 @@ and structured under `https://app.infisical.com:8443/.well-known/est/{profile_id
In the following steps, we explore how to issue a X.509 certificate using the EST enrollment method.
<Steps>
<Step title="Set up up a certificate profile">
<Step title="Create a certificate profile in Infisical">
Create a [certificate
profile](/documentation/platform/pki/certificates/profiles) with **EST**
selected as the enrollment method and fill in EST-specific configuration.

View File

@@ -5,7 +5,10 @@ sidebarTitle: "Overview"
Enrollment methods determine how certificates are issued and managed for a [certificate profile](/documentation/platform/pki/certificates/profiles).
Refer to the documentation for each enrollment method to learn more about how to enroll certificates using it.
Refer to the documentation for each enrollment method below to learn more about how to enroll certificates using it.
- [API](/documentation/platform/pki/enrollment-methods/api): Enroll certificates via API.
- [EST](/documentation/platform/pki/enrollment-methods/est): Enroll certificates via EST protocol.
- [ACME](/documentation/platform/pki/enrollment-methods/acme): Enroll certificates using the ACME protocol.
- [EST](/documentation/platform/pki/enrollment-methods/est): Enroll certificates using the EST protocol.
Note that beyond using an enrollment method, you can also deliver a certificate to a target destination using supported [certificate syncs](https://infisical.com/docs/documentation/platform/pki/certificate-syncs/overview).

View File

@@ -12,7 +12,7 @@ Core capabilities include:
- [Private CA](/documentation/platform/pki/ca/private-ca): Create and manage your own private CA hierarchy including root and intermediate CAs.
- [External CA integration](/documentation/platform/pki/ca/external-ca): Integrate with external public and private CAs including [Azure ADCS](/documentation/platform/pki/ca/azure-adcs) and [ACME-compatible CAs](/documentation/platform/pki/ca/acme-ca) like Let's Encrypt and DigiCert.
- [Certificate Enrollment](/documentation/platform/pki/enrollment-methods/overview): Support enrollment methods including [API](/documentation/platform/pki/enrollment-methods/api), ACME, [EST](/documentation/platform/pki/enrollment-methods/est), and more to automate certificate issuance for services, devices, and workloads.
- [Certificate Enrollment](/documentation/platform/pki/enrollment-methods/overview): Support enrollment methods including [API](/documentation/platform/pki/enrollment-methods/api), [ACME](/documentation/platform/pki/enrollment-methods/acme), [EST](/documentation/platform/pki/enrollment-methods/est), and more to automate certificate issuance for services, devices, and workloads.
- Certificate Inventory: Track and monitor issued X.509 certificates, maintaining a comprehensive inventory of all active and expired certificates.
- Certificate Lifecycle Automation: Automate issuance, [renewal](/documentation/platform/pki/certificates/certificates#guide-to-renewing-certificates), and [revocation](/documentation/platform/pki/certificates/certificates#guide-to-revoking-certificates) with policy-based workflows, ensuring certificates remain valid, compliant, and up to date across your infrastructure.
- [Certificate Syncs](/documentation/platform/pki/certificate-syncs/overview): Push certificates to cloud certificate managers like [AWS Certificate Manager](/documentation/platform/pki/certificate-syncs/aws-certificate-manager) and [Azure Key Vault](/documentation/platform/pki/certificate-syncs/azure-key-vault).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 991 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 KiB

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 989 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -694,4 +694,20 @@ For enterprise deployments requiring compliance certifications:
### Standards Compliance
**FIPS 140-3 Compliance**. Infisical is actively working on FIPS 140-3 compliance to meet U.S. and Canadian government cryptographic standards. This will provide validated cryptographic modules for organizations requiring certified encryption implementations.
#### FIPS 140-3 Compliance
Infisical is compliant with FIPS 140-3, meeting U.S. and Canadian government cryptographic standards through validated cryptographic modules.
This certification is designed for organizations that require government-approved encryption implementations.
To deploy a FIPS-compliant instance, use the [infisical/infisical-fips](https://hub.docker.com/r/infisical/infisical-fips) Docker image, available to Enterprise customers.
Our FIPS 140-3 attestation letter is available in the [Infisical Trust Center](https://trust.infisical.com/).
#### SOC 2 Compliance
Infisical is SOC 2 compliant, demonstrating adherence to rigorous security, availability, and confidentiality standards established by the American Institute of CPAs (AICPA).
This certification validates our security controls and operational practices for organizations requiring third-party audited security assurance. Our SOC 2 report is available in the [Infisical Trust Center](https://trust.infisical.com/).
#### HIPAA Compliance
Infisical is HIPAA compliant, meeting the security and privacy requirements of the Health Insurance Portability and Accountability Act.
This compliance framework ensures appropriate safeguards for protected health information (PHI) for healthcare organizations and their business associates.
Our HIPAA certification is available in the [Infisical Trust Center](https://trust.infisical.com/).

View File

@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "A project with this name already exists.",
"no-mobile": " To use Infisical, please log in through a device with larger dimensions. ",
"email": "Email",
"password": "Password",
"first-name": "First Name",

View File

@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "Ya existe un proyecto con este nombre.",
"no-mobile": "Para usar Infisical, inicia sesión con un dispositivo de mayores dimesiones.",
"email": "Correo electrónico",
"password": "Contraseña",
"first-name": "Nombre",

View File

@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "Un projet avec ce nom existe déjà.",
"no-mobile": " Pour utiliser Infisical, veuillez vous connecter avec un appareil avec des dimensions plus grandes. ",
"email": "Email",
"password": "Mot de passe",
"first-name": "Prénom",

View File

@@ -30,7 +30,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "동일한 이름을 가진 프로젝트가 이미 존재해요.",
"no-mobile": " Infisical을 사용하려면, 큰 화면을 가진 디바이스로 로그인하여 주세요.",
"email": "메일",
"password": "비밀번호",
"first-name": "이름",

View File

@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "Já exite um projeto com este nome.",
"no-mobile": "Para usar o Infisical, faça o login através de um dispositivo com dimensões maiores.",
"email": "Email",
"password": "Senha",
"first-name": "Primeiro Nome",

View File

@@ -41,7 +41,6 @@
"common": {
"head-title": "{{title}} | Infisical",
"error_project-already-exists": "Bu isimle bir proje zaten mevcut.",
"no-mobile": " Infisical'ı kullanmak için, lütfen daha büyük boyutlara sahip bir cihaz üzerinden giriş yapın. ",
"email": "Email",
"password": "Şifre",
"first-name": "Adınız",

View File

@@ -76,8 +76,8 @@ export const ProjectOverviewChangeSection = ({ showSlugField = false }: Props) =
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-medium text-mineshaft-100">Project Overview</h2>
<div className="justify-betweens mb-8 flex flex-wrap gap-2">
<h2 className="flex-1 text-xl font-medium text-mineshaft-100">Project Overview</h2>
<div className="space-x-2">
<Button
variant="outline_bg"

View File

@@ -8,13 +8,15 @@ import {
} 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;
@@ -26,6 +28,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;
@@ -44,11 +62,7 @@ export type TPamSession = {
endedAt?: string | null;
createdAt: string;
updatedAt: string;
commandLogs: {
input: string;
output: string;
timestamp: string;
}[];
logs: TPamSessionLog[];
};
// Resource DTOs

View 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;
};

View File

@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Outlet } from "@tanstack/react-router";
import { Banner } from "@app/components/page-frames/Banner";
@@ -15,7 +12,6 @@ import { InsecureConnectionBanner } from "../OrganizationLayout/components/Insec
import { AdminNavBar } from "./AdminNavBar";
export const AdminLayout = () => {
const { t } = useTranslation();
const { config } = useServerConfig();
const { data: serverDetails, isLoading } = useFetchServerStatus();
const { subscription } = useSubscription();
@@ -26,7 +22,7 @@ export const AdminLayout = () => {
<>
<Banner />
<div
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
className={`dark ${containerHeight} flex w-full flex-col overflow-x-hidden bg-bunker-800 transition-all`}
>
<Navbar />
{!isLoading && !serverDetails?.redisConfigured && <RedisBanner />}
@@ -40,12 +36,6 @@ export const AdminLayout = () => {
</div>
</div>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
</div>
<Banner />
</>
);

View File

@@ -13,7 +13,7 @@ export const KmsLayout = () => {
const location = useLocation();
return (
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"

View File

@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Outlet, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
@@ -27,8 +24,6 @@ export const OrganizationLayout = () => {
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
const { t } = useTranslation();
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
const { data: serverDetails, isLoading } = useFetchServerStatus();
@@ -38,7 +33,7 @@ export const OrganizationLayout = () => {
<>
<Banner />
<div
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
className={`dark ${containerHeight} flex w-full flex-col overflow-x-hidden bg-bunker-800 transition-all`}
>
<Navbar />
<div className="flex grow flex-col overflow-y-hidden">
@@ -61,12 +56,6 @@ export const OrganizationLayout = () => {
isOpen={popUp?.createOrg?.isOpen}
onClose={() => handlePopUpToggle("createOrg", false)}
/>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
</div>
<Banner />
</>
);

View File

@@ -16,15 +16,19 @@ import {
faSignOut,
faToolbox,
faUser,
faUserCog,
faUserPlus,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
import { UserPlusIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import SecurityClient from "@app/components/utilities/SecurityClient";
import {
BreadcrumbContainer,
@@ -44,7 +48,13 @@ import {
} from "@app/components/v2";
import { Badge, InstanceIcon, OrgIcon, SubOrgIcon } from "@app/components/v3";
import { envConfig } from "@app/config/env";
import { useOrganization, useSubscription, useUser } from "@app/context";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription,
useUser
} from "@app/context";
import { isInfisicalCloud } from "@app/helpers/platform";
import { useToggle } from "@app/hooks";
import {
@@ -294,7 +304,7 @@ export const Navbar = () => {
</>
) : (
<>
<div className="flex items-center overflow-hidden">
<div className="flex min-w-12 items-center overflow-hidden">
<DropdownMenu modal={false} open={isOrgSelectOpen} onOpenChange={setIsOrgSelectOpen}>
<div className="group flex cursor-pointer items-center gap-2 overflow-hidden text-sm text-white transition-all duration-100 hover:text-primary">
<Badge
@@ -324,7 +334,7 @@ export const Navbar = () => {
<span>{currentOrg?.name}</span>
</button>
</Badge>
<div className="mr-1 rounded-sm border border-mineshaft-500 px-1 text-xs text-bunker-300 no-underline!">
<div className="mr-1 hidden rounded-sm border border-mineshaft-500 px-1 text-xs text-bunker-300 no-underline! md:inline-block">
{getPlan(subscription)}
</div>
{subscription.cardDeclined && (
@@ -468,7 +478,7 @@ export const Navbar = () => {
className={twMerge(
"gap-x-1.5 text-sm",
!isOrgScope &&
"bg-transparent text-mineshaft-200 hover:!bg-transparent hover:underline [&>svg]:!text-sub-org"
"min-w-6 bg-transparent text-mineshaft-200 hover:!bg-transparent hover:underline [&>svg]:!text-sub-org"
)}
>
<Link to="/organization/projects">
@@ -567,15 +577,36 @@ export const Navbar = () => {
</Button>
</Tooltip>
)}
{user.superAdmin && !location.pathname.startsWith("/admin") && (
<Link
className="mr-2 flex items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
to="/admin"
>
<InstanceIcon className="mr-2 inline-block size-3.5" />
Server Console
</Link>
)}
{/* eslint-disable-next-line no-nested-ternary */}
{!location.pathname.startsWith("/admin") ? (
user.superAdmin ? (
<Link
className="mr-2 flex h-[34px] items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
to="/admin"
>
<InstanceIcon className="inline-block size-3.5" />
<span className="ml-2 hidden md:inline-block">Server Console</span>
</Link>
) : (
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) =>
isAllowed ? (
<Link
className="mr-2 flex h-[34px] items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
to="/organization/access-management"
search={{
selectedTab: "members",
action: "invite-members"
}}
>
<UserPlusIcon className="inline-block size-3.5" />
<span className="ml-2 hidden md:inline-block">Invite Members</span>
</Link>
) : null
}
</OrgPermissionCan>
)
) : null}
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<div className="rounded-l-md border border-r-0 border-mineshaft-500 px-2.5 py-1 hover:bg-mineshaft-600">
@@ -657,8 +688,27 @@ export const Navbar = () => {
</div>
</div>
<Link to="/personal-settings">
<DropdownMenuItem>Personal Settings</DropdownMenuItem>
<DropdownMenuItem icon={<FontAwesomeIcon icon={faUserCog} />}>
Personal Settings
</DropdownMenuItem>
</Link>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) =>
isAllowed ? (
<Link
to="/organization/access-management"
search={{
selectedTab: "members",
action: "invite-members"
}}
>
<DropdownMenuItem icon={<FontAwesomeIcon icon={faUserPlus} />}>
Invite Members
</DropdownMenuItem>
</Link>
) : null
}
</OrgPermissionCan>
<a
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"

View File

@@ -21,7 +21,7 @@ export const OrgNavBar = ({ isHidden }: Props) => {
return (
<>
{!isHidden && (
<div className="dark hidden w-full flex-col overflow-x-hidden border-b border-mineshaft-600 bg-mineshaft-900 px-4 md:flex">
<div className="dark flex w-full flex-col overflow-x-hidden border-b border-mineshaft-600 bg-mineshaft-900 px-4">
<motion.div
key="menu-org-items"
initial={{ x: -150 }}

View File

@@ -28,7 +28,7 @@ export const PamLayout = () => {
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"

View File

@@ -1,29 +1,16 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Outlet } from "@tanstack/react-router";
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
export const PersonalSettingsLayout = () => {
const { t } = useTranslation();
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden bg-bunker-800 md:flex">
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4 dark:scheme-dark">
<Outlet />
</main>
</div>
<div className="dark flex h-screen w-full flex-col overflow-x-hidden bg-bunker-800">
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex grow flex-col overflow-y-hidden md:flex-row">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4 dark:scheme-dark">
<Outlet />
</main>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
</div>
</>
</div>
);
};

View File

@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
@@ -17,7 +14,6 @@ export const PkiManagerLayout = () => {
const { currentProject } = useProject();
const { assumedPrivilegeDetails } = useProjectPermission();
const { subscription } = useSubscription();
const { t } = useTranslation();
const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || "");
const { data: templatesData } = useListWorkspaceCertificateTemplates({
@@ -32,147 +28,137 @@ export const PkiManagerLayout = () => {
const location = useLocation();
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/cert-management/$projectId/policies"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Certificates</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/certificate-authorities"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive || location.pathname.match(/\/ca\//) ? "selected" : ""}>
Certificate Authorities
</Tab>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/alerting"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Alerting</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Integrations</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>App Connections</Tab>
)}
</Link>
{showLegacySection && (
<>
{(subscription.pkiLegacyTemplates || hasExistingSubscribers) && (
<Link
to="/projects/cert-management/$projectId/subscribers"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>Subscribers (Legacy)</Tab>
)}
</Link>
)}
{(subscription.pkiLegacyTemplates || hasExistingTemplates) && (
<Link
to="/projects/cert-management/$projectId/certificate-templates"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Certificate Templates (Legacy)
</Tab>
)}
</Link>
)}
</>
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/cert-management/$projectId/policies"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Certificates</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/certificate-authorities"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive || location.pathname.match(/\/ca\//) ? "selected" : ""}>
Certificate Authorities
</Tab>
)}
<Link
to="/projects/cert-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive ||
location.pathname.match(/\/groups\/|\/identities\/|\/members\/|\/roles\//)
? "selected"
: ""
}
</Link>
<Link
to="/projects/cert-management/$projectId/alerting"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Alerting</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Integrations</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>App Connections</Tab>}
</Link>
{showLegacySection && (
<>
{(subscription.pkiLegacyTemplates || hasExistingSubscribers) && (
<Link
to="/projects/cert-management/$projectId/subscribers"
params={{
projectId: currentProject.id
}}
>
Access Control
</Tab>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>Subscribers (Legacy)</Tab>
)}
</Link>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4">
<Outlet />
</div>
{(subscription.pkiLegacyTemplates || hasExistingTemplates) && (
<Link
to="/projects/cert-management/$projectId/certificate-templates"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Certificate Templates (Legacy)
</Tab>
)}
</Link>
)}
</>
)}
<Link
to="/projects/cert-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive ||
location.pathname.match(/\/groups\/|\/identities\/|\/members\/|\/roles\//)
? "selected"
: ""
}
>
Access Control
</Tab>
)}
</Link>
<Link
to="/projects/cert-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4">
<Outlet />
</div>
</>
</div>
);
};

View File

@@ -106,7 +106,9 @@ export const ProjectSelect = () => {
</p>
<Badge variant="project">
<ProjectIcon />
{currentWorkspace.type ? PROJECT_TYPE_NAME[currentWorkspace.type] : "Project"}
<span className="hidden sm:inline-block">
{currentWorkspace.type ? PROJECT_TYPE_NAME[currentWorkspace.type] : "Project"}
</span>
</Badge>
</Link>
<DropdownMenuTrigger asChild>

View File

@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
@@ -20,7 +17,6 @@ export const SecretManagerLayout = () => {
const { assumedPrivilegeDetails } = useProjectPermission();
const location = useLocation();
const { t } = useTranslation();
const projectSlug = currentProject?.slug || "";
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({
@@ -42,141 +38,131 @@ export const SecretManagerLayout = () => {
(secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/secret-management/$projectId/overview"
params={{
projectId: currentProject.id,
...(currentProject.environments.length
? { envSlug: currentProject.environments[0]?.slug }
: {})
}}
>
{({ isActive }) => (
<Tab
value={
isActive || location.pathname.match(/\/secrets\/|\/commits\//)
? "selected"
: ""
}
>
Overview
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/approval"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Approvals
{Boolean(
secretApprovalReqCount?.open || accessApprovalRequestCount?.pendingCount
) && (
<Badge variant="warning" isSquare className="ml-1.5">
{pendingRequestsCount}
</Badge>
)}
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Integrations</Tab>}
</Link>
{Boolean(secretRotations?.length) && (
<Link
to="/projects/secret-management/$projectId/secret-rotation"
params={{
projectId: currentProject.id
}}
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/projects/secret-management/$projectId/overview"
params={{
projectId: currentProject.id,
...(currentProject.environments.length
? { envSlug: currentProject.environments[0]?.slug }
: {})
}}
>
{({ isActive }) => (
<Tab
value={
isActive || location.pathname.match(/\/secrets\/|\/commits\//)
? "selected"
: ""
}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>Secret Rotations</Tab>
)}
</Link>
Overview
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/approval"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>
Approvals
{Boolean(
secretApprovalReqCount?.open || accessApprovalRequestCount?.pendingCount
) && (
<Badge variant="warning" isSquare className="ml-1.5">
{pendingRequestsCount}
</Badge>
)}
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/integrations"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Integrations</Tab>}
</Link>
{Boolean(secretRotations?.length) && (
<Link
to="/projects/secret-management/$projectId/app-connections"
to="/projects/secret-management/$projectId/secret-rotation"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""}>App Connections</Tab>
<Tab value={isActive ? "selected" : ""}>Secret Rotations</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive ||
location.pathname.match(/\/groups\/|\/identities\/|\/members\/|\/roles\//)
? "selected"
: ""
}
>
Access Control
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/secret-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4">
<Outlet />
</div>
)}
<Link
to="/projects/secret-management/$projectId/app-connections"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>App Connections</Tab>}
</Link>
<Link
to="/projects/secret-management/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive ||
location.pathname.match(/\/groups\/|\/identities\/|\/members\/|\/roles\//)
? "selected"
: ""
}
>
Access Control
</Tab>
)}
</Link>
<Link
to="/projects/secret-management/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/projects/secret-management/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
<div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4">
<Outlet />
</div>
</>
</div>
);
};

View File

@@ -36,7 +36,7 @@ export const SecretScanningLayout = () => {
);
return (
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"

View File

@@ -18,7 +18,7 @@ export const SshLayout = () => {
const location = useLocation();
return (
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="dark flex h-full w-full flex-col overflow-x-hidden">
<div className="border-b border-mineshaft-600 bg-mineshaft-900">
<motion.div
key="menu-project-items"

View File

@@ -368,7 +368,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
name="type"
defaultValue={CaType.ACME}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Type" errorText={error?.message} isError={Boolean(error)}>
<FormControl label="CA Type" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}

View File

@@ -49,7 +49,7 @@ export const OrgGroupsSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Groups</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/groups" />

View File

@@ -97,7 +97,7 @@ export const IdentitySection = withPermission(
return (
<div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex w-full items-center gap-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-1 items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/machine-identities" />
@@ -139,7 +139,7 @@ export const IdentitySection = withPermission(
</div>
{/* Identity Auth Templates Section */}
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Identity Auth Templates</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/auth-templates" />

View File

@@ -1,7 +1,8 @@
import { useState } from "react";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { useEffect, useState } from "react";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { BanIcon } from "lucide-react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { BanIcon, UserPlusIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -16,6 +17,7 @@ import {
Tooltip
} from "@app/components/v2";
import { Badge, DocumentationLinkBadge } from "@app/components/v3";
import { ROUTE_PATHS } from "@app/const/routes";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -35,6 +37,7 @@ import { OrgMembersTable } from "./OrgMembersTable";
export const OrgMembersSection = () => {
const { subscription } = useSubscription();
const { currentOrg, isSubOrganization } = useOrganization();
const navigate = useNavigate();
const orgId = currentOrg?.id ?? "";
const { user } = useUser();
const userId = user?.id || "";
@@ -55,6 +58,22 @@ export const OrgMembersSection = () => {
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
const urlAction = useSearch({
from: ROUTE_PATHS.Organization.AccessControlPage.id,
select: (el) => el.action,
structuralSharing: true
});
useEffect(() => {
if (urlAction === "invite-members") {
handlePopUpOpen("addMember");
navigate({
to: ".",
search: ({ action, ...search }) => search
});
}
}, [urlAction]);
const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership();
const { mutateAsync: deleteBatchMutateAsync } = useDeleteOrgMembershipBatch();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
@@ -184,7 +203,7 @@ export const OrgMembersSection = () => {
</div>
</div>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Users</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/user-identities" />
@@ -194,13 +213,13 @@ export const OrgMembersSection = () => {
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
leftIcon={<UserPlusIcon size={16} />}
onClick={() =>
isSubOrganization ? handlePopUpOpen("addMemberToSubOrg") : handleAddMemberModal()
}
isDisabled={!isAllowed}
>
Add Member
{isSubOrganization ? "Add Members" : "Invite Members"}
</Button>
)}
</OrgPermissionCan>

View File

@@ -392,7 +392,7 @@ export const OrgMembersTable = ({
}}
/>
</Th>
<Th className="w-1/3">
<Th className="min-w-40 md:w-1/3 md:min-w-0">
<div className="flex items-center">
Name
<IconButton
@@ -505,7 +505,7 @@ export const OrgMembersTable = ({
<Td
className={twMerge("group max-w-0", isActive ? "" : "text-mineshaft-400")}
>
<div className="flex items-center gap-x-2">
<div className="flex w-full items-center gap-x-2">
<p className="truncate">
{name ?? <span className="text-mineshaft-400">Not Set</span>}
</p>

View File

@@ -189,7 +189,7 @@ export const OrgRoleTable = () => {
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">
{isSubOrganization ? "Sub-" : ""}Organization Roles

View File

@@ -209,7 +209,7 @@ export const AppConnectionsTable = ({ projectId, projectType }: Props) => {
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">App Connections</p>

View File

@@ -150,8 +150,8 @@ const Page = () => {
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<div className="flex flex-col gap-4 md:flex-row">
<div className="w-full md:w-96">
<GroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
</div>
<GroupMembersSection groupId={groupId} groupSlug={data.group.slug} />

View File

@@ -116,8 +116,8 @@ const Page = () => {
)}
</div>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<div className="flex flex-col gap-4 md:flex-row">
<div className="w-full md:w-96">
<IdentityDetailsSection
isOrgIdentity={data.identity.orgId === currentOrg.id}
identityId={identityId}

View File

@@ -125,7 +125,7 @@ export const AllProjectView = ({
return (
<div>
<div className="flex w-full flex-row">
<div className="flex w-full flex-row flex-wrap gap-2 md:flex-nowrap md:gap-0">
<ProjectListToggle value={projectListView} onChange={onProjectListViewChange} />
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"

View File

@@ -360,7 +360,7 @@ export const MyProjectView = ({
return (
<div>
<div className="flex w-full flex-row">
<div className="flex w-full flex-row flex-wrap gap-2 md:flex-nowrap md:gap-0">
<ProjectListToggle value={projectListView} onChange={onProjectListViewChange} />
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50/60 duration-200 focus:bg-mineshaft-700/80"

View File

@@ -37,7 +37,7 @@ export const ExternalMigrationsTab = () => {
</p>
</div>
<div className="flex items-start justify-between">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<p className="text-base font-medium text-mineshaft-100">

View File

@@ -71,7 +71,7 @@ export const VaultConnectionSection = () => {
return (
<div>
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<img
src="/images/integrations/Vault.png"

View File

@@ -34,9 +34,11 @@ export const ProjectTemplatesSection = () => {
project setup
</p>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Project Templates</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/project-templates" />
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Project Templates</p>
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/project-templates" />
</div>
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.ProjectTemplates}
@@ -57,7 +59,7 @@ export const ProjectTemplatesSection = () => {
handlePopUpOpen("addTemplate");
}}
isDisabled={!isAllowed}
className="ml-auto"
// className="ml-auto"
>
Add Template
</Button>

View File

@@ -242,8 +242,8 @@ const Page = withPermission(
)}
</div>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<div className="flex flex-col gap-4 md:flex-row">
<div className="w-full md:w-96">
<UserDetailsSection membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
</div>
<div className="w-full space-y-2">

View File

@@ -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;

View File

@@ -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}`);
}

View File

@@ -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>
);
};

View File

@@ -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}`);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 ||

View File

@@ -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>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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)
}));
};

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -90,7 +90,7 @@ export const SecretSyncsTab = () => {
return (
<>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="flex items-center gap-1.5">
<p className="text-lg font-medium text-mineshaft-100">Secret Syncs</p>

View File

@@ -959,9 +959,9 @@ export const OverviewPage = () => {
}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
<div className="flex flex-row items-center justify-center space-x-2">
<div className="flex flex-row flex-wrap items-center gap-2">
{isTableFiltered && (
<Button
variant="plain"

View File

@@ -294,7 +294,7 @@ export const AccessApprovalRequest = ({
return (
<>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Access Requests</p>

View File

@@ -240,7 +240,7 @@ export const ApprovalPolicyList = ({ projectId }: IProps) => {
return (
<>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="flex items-center gap-x-2">
<p className="text-xl font-medium text-mineshaft-100">Policies</p>

View File

@@ -690,7 +690,7 @@ export const ActionBar = ({
return (
<>
<div className="mt-4 flex items-center space-x-2">
<div className="mt-4 flex flex-wrap items-center gap-2">
<SecretSearchInput
isSingleEnv
className="w-2/5"

View File

@@ -40,12 +40,13 @@ export const SecretSharingSection = () => {
<p className="mb-3 text-xl font-medium">Allow Secret Sharing</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<div className="w-max">
<div>
<Checkbox
id="secretSharing"
isDisabled={!isAllowed || isLoading}
isChecked={currentProject?.secretSharing ?? true}
onCheckedChange={(state) => handleToggle(state as boolean)}
allowMultilineLabel
>
This feature enables your project members to securely share secrets.
</Checkbox>

View File

@@ -40,12 +40,13 @@ export const SecretSnapshotsLegacySection = () => {
<p className="mb-3 text-xl font-medium">Show Secret Snapshots ( legacy )</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<div className="w-max">
<div>
<Checkbox
id="showSnapshotsLegacy"
isDisabled={!isAllowed || isLoading}
isChecked={currentProject?.showSnapshotsLegacy ?? false}
onCheckedChange={(state) => handleToggle(state as boolean)}
allowMultilineLabel
>
This feature enables your project members to view secret snapshots in the legacy
format.