diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts index 6b6a4cbed..9c7cf161d 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -3,6 +3,11 @@ import { SanitizedAwsIamAccountWithResourceSchema, UpdateAwsIamAccountSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { + CreateKubernetesAccountSchema, + SanitizedKubernetesAccountWithResourceSchema, + UpdateKubernetesAccountSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { CreateMySQLAccountSchema, SanitizedMySQLAccountWithResourceSchema, @@ -50,6 +55,15 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Kubernetes, + accountResponseSchema: SanitizedKubernetesAccountWithResourceSchema, + createAccountSchema: CreateKubernetesAccountSchema, + updateAccountSchema: UpdateKubernetesAccountSchema + }); + }, [PamResource.AwsIam]: async (server: FastifyZodProvider) => { registerPamResourceEndpoints({ server, diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 0c78b6449..802d43093 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -4,6 +4,7 @@ import { PamFoldersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums"; import { SanitizedAwsIamAccountWithResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { SanitizedKubernetesAccountWithResourceSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { GatewayAccessResponseSchema } from "@app/ee/services/pam-resource/pam-resource-schemas"; @@ -21,10 +22,17 @@ const SanitizedAccountSchema = z.union([ SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS SanitizedPostgresAccountWithResourceSchema, SanitizedMySQLAccountWithResourceSchema, + SanitizedKubernetesAccountWithResourceSchema, SanitizedAwsIamAccountWithResourceSchema ]); -type TSanitizedAccount = z.infer; +const ListPamAccountsResponseSchema = z.object({ + accounts: SanitizedAccountSchema.array(), + folders: PamFoldersSchema.array(), + totalCount: z.number().default(0), + folderId: z.string().optional(), + folderPaths: z.record(z.string(), z.string()) +}); export const registerPamAccountRouter = async (server: FastifyZodProvider) => { server.route({ @@ -55,13 +63,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { .optional() }), response: { - 200: z.object({ - accounts: SanitizedAccountSchema.array(), - folders: PamFoldersSchema.array(), - totalCount: z.number().default(0), - folderId: z.string().optional(), - folderPaths: z.record(z.string(), z.string()) - }) + 200: ListPamAccountsResponseSchema } }, onRequest: verifyAuth([AuthMode.JWT]), @@ -98,7 +100,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { } }); - return { accounts: accounts as TSanitizedAccount[], folders, totalCount, folderId, folderPaths }; + return { accounts, folders, totalCount, folderId, folderPaths } as z.infer; } }); @@ -135,6 +137,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Postgres) }), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.MySQL) }), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.SSH) }), + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Kubernetes) }), // AWS IAM (no gateway, returns console URL) z.object({ sessionId: z.string(), diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index fcd9840b4..e3c9cf60c 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -3,6 +3,11 @@ import { SanitizedAwsIamResourceSchema, UpdateAwsIamResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { + CreateKubernetesResourceSchema, + SanitizedKubernetesResourceSchema, + UpdateKubernetesResourceSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { CreateMySQLResourceSchema, MySQLResourceSchema, @@ -50,6 +55,15 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Kubernetes, + resourceResponseSchema: SanitizedKubernetesResourceSchema, + createResourceSchema: CreateKubernetesResourceSchema, + updateResourceSchema: UpdateKubernetesResourceSchema + }); + }, [PamResource.AwsIam]: async (server: FastifyZodProvider) => { registerPamResourceEndpoints({ server, diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts index b6a7532ed..8e4326f3f 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -5,6 +5,10 @@ import { AwsIamResourceListItemSchema, SanitizedAwsIamResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { + KubernetesResourceListItemSchema, + SanitizedKubernetesResourceSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { MySQLResourceListItemSchema, SanitizedMySQLResourceSchema @@ -27,6 +31,7 @@ const SanitizedResourceSchema = z.union([ SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema, SanitizedSSHResourceSchema, + SanitizedKubernetesResourceSchema, SanitizedAwsIamResourceSchema ]); @@ -34,6 +39,7 @@ const ResourceOptionsSchema = z.discriminatedUnion("resource", [ PostgresResourceListItemSchema, MySQLResourceListItemSchema, SSHResourceListItemSchema, + KubernetesResourceListItemSchema, AwsIamResourceListItemSchema ]); diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index 3c39a9516..574b8b7c3 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { PamSessionsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { KubernetesSessionCredentialsSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; 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 { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { + HttpEventSchema, PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema @@ -17,7 +19,8 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SessionCredentialsSchema = z.union([ SSHSessionCredentialsSchema, PostgresSessionCredentialsSchema, - MySQLSessionCredentialsSchema + MySQLSessionCredentialsSchema, + KubernetesSessionCredentialsSchema ]); export const registerPamSessionRouter = async (server: FastifyZodProvider) => { @@ -89,7 +92,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { sessionId: z.string().uuid() }), body: z.object({ - logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) + logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema, HttpEventSchema])) }), response: { 200: z.object({ diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index c3baeff0c..ec8b37c36 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -689,13 +689,30 @@ export const pamAccountServiceFactory = ({ throw new BadRequestError({ message: "Gateway ID is required for this resource type" }); } + const { host, port } = + resourceType !== PamResource.Kubernetes + ? connectionDetails + : (() => { + const url = new URL(connectionDetails.url); + let portNumber: number | undefined; + if (url.port) { + portNumber = Number(url.port); + } else { + portNumber = url.protocol === "https:" ? 443 : 80; + } + return { + host: url.hostname, + port: portNumber + }; + })(); + const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ gatewayId, duration, sessionId: session.id, resourceType: resource.resourceType as PamResource, - host: (connectionDetails as TSqlResourceConnectionDetails).host, - port: (connectionDetails as TSqlResourceConnectionDetails).port, + host, + port, actorMetadata: { id: actor.id, type: actor.type, @@ -746,6 +763,13 @@ export const pamAccountServiceFactory = ({ }; } break; + case PamResource.Kubernetes: + metadata = { + resourceName: resource.name, + accountName: account.name, + accountPath + }; + break; default: break; } diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts new file mode 100644 index 000000000..21d7da806 --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts @@ -0,0 +1,3 @@ +export enum KubernetesAuthMethod { + ServiceAccountToken = "service-account-token" +} diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts new file mode 100644 index 000000000..dddeb37ba --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts @@ -0,0 +1,225 @@ +import axios, { AxiosError } from "axios"; +import https from "https"; + +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway/types"; +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 { KubernetesAuthMethod } from "./kubernetes-resource-enums"; +import { TKubernetesAccountCredentials, TKubernetesResourceConnectionDetails } from "./kubernetes-resource-types"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +export const executeWithGateway = async ( + config: { + connectionDetails: TKubernetesResourceConnectionDetails; + resourceType: PamResource; + gatewayId: string; + }, + gatewayV2Service: Pick, + operation: (baseUrl: string, httpsAgent: https.Agent) => Promise +): Promise => { + const { connectionDetails, gatewayId } = config; + const url = new URL(connectionDetails.url); + const [targetHost] = await verifyHostInputValidity(url.hostname, true); + + let targetPort: number; + if (url.port) { + targetPort = Number(url.port); + } else if (url.protocol === "https:") { + targetPort = 443; + } else { + targetPort = 80; + } + + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort + }); + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); + } + const httpsAgent = new https.Agent({ + ca: connectionDetails.sslCertificate, + rejectUnauthorized: connectionDetails.sslRejectUnauthorized, + servername: targetHost + }); + return withGatewayV2Proxy( + async (proxyPort) => { + const protocol = url.protocol === "https:" ? "https" : "http"; + const baseUrl = `${protocol}://localhost:${proxyPort}`; + return operation(baseUrl, httpsAgent); + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay, + httpsAgent + } + ); +}; + +export const kubernetesResourceFactory: TPamResourceFactory< + TKubernetesResourceConnectionDetails, + TKubernetesAccountCredentials +> = (resourceType, connectionDetails, gatewayId, gatewayV2Service) => { + const validateConnection = async () => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { + await executeWithGateway( + { connectionDetails, gatewayId, resourceType }, + gatewayV2Service, + async (baseUrl, httpsAgent) => { + // Validate connection by checking API server version + try { + await axios.get(`${baseUrl}/version`, { + ...(httpsAgent ? { httpsAgent } : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + }); + } catch (error) { + if (error instanceof AxiosError) { + // If we get a 401/403, it means we reached the API server but need auth - that's fine for connection validation + if (error.response?.status === 401 || error.response?.status === 403) { + logger.info( + { status: error.response.status }, + "[Kubernetes Resource Factory] Kubernetes connection validation succeeded (auth required)" + ); + return connectionDetails; + } + throw new BadRequestError({ + message: `Unable to connect to Kubernetes API server: ${error.response?.statusText || error.message}` + }); + } + throw error; + } + + logger.info("[Kubernetes Resource Factory] Kubernetes connection validation succeeded"); + return connectionDetails; + } + ); + return connectionDetails; + } catch (error) { + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials< + TKubernetesAccountCredentials + > = async (credentials) => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { + await executeWithGateway( + { connectionDetails, gatewayId, resourceType }, + gatewayV2Service, + async (baseUrl, httpsAgent) => { + const { authMethod } = credentials; + if (authMethod === KubernetesAuthMethod.ServiceAccountToken) { + // Validate service account token using SelfSubjectReview API (whoami) + // This endpoint doesn't require any special permissions from the service account + try { + await axios.post( + `${baseUrl}/apis/authentication.k8s.io/v1/selfsubjectreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "SelfSubjectReview" + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.serviceAccountToken}` + }, + ...(httpsAgent ? { httpsAgent } : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + logger.info("[Kubernetes Resource Factory] Kubernetes service account token authentication successful"); + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 401 || error.response?.status === 403) { + throw new BadRequestError({ + message: + "Account credentials invalid. Service account token is not valid or does not have required permissions." + }); + } + throw new BadRequestError({ + message: `Unable to validate account credentials: ${error.response?.statusText || error.message}` + }); + } + throw error; + } + } else { + throw new BadRequestError({ + message: `Unsupported Kubernetes auth method: ${authMethod as string}` + }); + } + } + ); + return credentials; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials< + TKubernetesAccountCredentials + > = async () => { + throw new BadRequestError({ + message: `Unable to rotate account credentials for ${resourceType}: not implemented` + }); + }; + + const handleOverwritePreventionForCensoredValues = async ( + updatedAccountCredentials: TKubernetesAccountCredentials, + currentCredentials: TKubernetesAccountCredentials + ) => { + if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) { + return updatedAccountCredentials; + } + + if ( + updatedAccountCredentials.authMethod === KubernetesAuthMethod.ServiceAccountToken && + currentCredentials.authMethod === KubernetesAuthMethod.ServiceAccountToken + ) { + if (updatedAccountCredentials.serviceAccountToken === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + serviceAccountToken: currentCredentials.serviceAccountToken + }; + } + } + + return updatedAccountCredentials; + }; + + return { + validateConnection, + validateAccountCredentials, + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues + }; +}; diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts new file mode 100644 index 000000000..b7d3546c5 --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts @@ -0,0 +1,8 @@ +import { KubernetesResourceListItemSchema } from "./kubernetes-resource-schemas"; + +export const getKubernetesResourceListItem = () => { + return { + name: KubernetesResourceListItemSchema.shape.name.value, + resource: KubernetesResourceListItemSchema.shape.resource.value + }; +}; diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts new file mode 100644 index 000000000..7d83096eb --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; +import { KubernetesAuthMethod } from "./kubernetes-resource-enums"; + +export const BaseKubernetesResourceSchema = BasePamResourceSchema.extend({ + resourceType: z.literal(PamResource.Kubernetes) +}); + +export const KubernetesResourceListItemSchema = z.object({ + name: z.literal("Kubernetes"), + resource: z.literal(PamResource.Kubernetes) +}); + +export const KubernetesResourceConnectionDetailsSchema = z.object({ + url: z.string().url().trim().max(500), + sslRejectUnauthorized: z.boolean(), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() +}); + +export const KubernetesServiceAccountTokenCredentialsSchema = z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken), + serviceAccountToken: z.string().trim().max(10000) +}); + +export const KubernetesAccountCredentialsSchema = z.discriminatedUnion("authMethod", [ + KubernetesServiceAccountTokenCredentialsSchema +]); + +export const KubernetesResourceSchema = BaseKubernetesResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedKubernetesResourceSchema = BaseKubernetesResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: z + .discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken) + }) + ]) + .nullable() + .optional() +}); + +export const CreateKubernetesResourceSchema = BaseCreatePamResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateKubernetesResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const KubernetesAccountSchema = BasePamAccountSchema.extend({ + credentials: KubernetesAccountCredentialsSchema +}); + +export const CreateKubernetesAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: KubernetesAccountCredentialsSchema +}); + +export const UpdateKubernetesAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: KubernetesAccountCredentialsSchema.optional() +}); + +export const SanitizedKubernetesAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: z.discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken) + }) + ]) +}); + +// Sessions +export const KubernetesSessionCredentialsSchema = KubernetesResourceConnectionDetailsSchema.and( + KubernetesAccountCredentialsSchema +); diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts new file mode 100644 index 000000000..d23163d26 --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + KubernetesAccountCredentialsSchema, + KubernetesAccountSchema, + KubernetesResourceConnectionDetailsSchema, + KubernetesResourceSchema +} from "./kubernetes-resource-schemas"; + +// Resources +export type TKubernetesResource = z.infer; +export type TKubernetesResourceConnectionDetails = z.infer; + +// Accounts +export type TKubernetesAccount = z.infer; +export type TKubernetesAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts index bea1667fb..c8c57b03b 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -2,6 +2,7 @@ export enum PamResource { Postgres = "postgres", MySQL = "mysql", SSH = "ssh", + Kubernetes = "kubernetes", AwsIam = "aws-iam" } diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts index 1d1a84f33..bf8d13d66 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -1,4 +1,5 @@ import { awsIamResourceFactory } from "./aws-iam/aws-iam-resource-factory"; +import { kubernetesResourceFactory } from "./kubernetes/kubernetes-resource-factory"; import { PamResource } from "./pam-resource-enums"; import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; @@ -10,5 +11,6 @@ export const PAM_RESOURCE_FACTORY_MAP: Record { - return [getPostgresResourceListItem(), getMySQLResourceListItem(), getAwsIamResourceListItem()].sort((a, b) => - a.name.localeCompare(b.name) - ); + return [ + getPostgresResourceListItem(), + getMySQLResourceListItem(), + getAwsIamResourceListItem(), + getKubernetesResourceListItem() + ].sort((a, b) => a.name.localeCompare(b.name)); }; // Resource diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index 2a27fb76e..5291e044a 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -7,6 +7,12 @@ import { TAwsIamResource, TAwsIamResourceConnectionDetails } from "./aws-iam/aws-iam-resource-types"; +import { + TKubernetesAccount, + TKubernetesAccountCredentials, + TKubernetesResource, + TKubernetesResourceConnectionDetails +} from "./kubernetes/kubernetes-resource-types"; import { TMySQLAccount, TMySQLAccountCredentials, @@ -28,21 +34,23 @@ import { } from "./ssh/ssh-resource-types"; // Resource types -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource; +export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource | TKubernetesResource; export type TPamResourceConnectionDetails = | TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails | TSSHResourceConnectionDetails + | TKubernetesResourceConnectionDetails | TAwsIamResourceConnectionDetails; // Account types -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount; +export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount | TKubernetesAccount; export type TPamAccountCredentials = | TPostgresAccountCredentials // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents | TMySQLAccountCredentials | TSSHAccountCredentials + | TKubernetesAccountCredentials | TAwsIamAccountCredentials; // Resource DTOs diff --git a/backend/src/ee/services/pam-session/pam-session-schemas.ts b/backend/src/ee/services/pam-session/pam-session-schemas.ts index db2493196..b336d15c8 100644 --- a/backend/src/ee/services/pam-session/pam-session-schemas.ts +++ b/backend/src/ee/services/pam-session/pam-session-schemas.ts @@ -11,6 +11,8 @@ export const PamSessionCommandLogSchema = z.object({ // SSH Terminal Event schemas export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]); +export const HttpEventTypeSchema = z.enum(["request", "response"]); + export const TerminalEventSchema = z.object({ timestamp: z.coerce.date(), eventType: TerminalEventTypeSchema, @@ -18,8 +20,29 @@ export const TerminalEventSchema = z.object({ elapsedTime: z.number() // Seconds since session start (for replay) }); +export const HttpBaseEventSchema = z.object({ + timestamp: z.coerce.date(), + requestId: z.string(), + eventType: TerminalEventTypeSchema, + headers: z.record(z.string(), z.array(z.string())), + body: z.string().optional() +}); + +export const HttpRequestEventSchema = HttpBaseEventSchema.extend({ + eventType: z.literal(HttpEventTypeSchema.Values.request), + method: z.string(), + url: z.string() +}); + +export const HttpResponseEventSchema = HttpBaseEventSchema.extend({ + eventType: z.literal(HttpEventTypeSchema.Values.response), + status: z.string() +}); + +export const HttpEventSchema = z.discriminatedUnion("eventType", [HttpRequestEventSchema, HttpResponseEventSchema]); + export const SanitizedSessionSchema = PamSessionsSchema.omit({ encryptedLogsBlob: true }).extend({ - logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) + logs: z.array(z.union([PamSessionCommandLogSchema, HttpEventSchema, TerminalEventSchema])) }); diff --git a/backend/src/ee/services/pam-session/pam-session-types.ts b/backend/src/ee/services/pam-session/pam-session-types.ts index 893f930e5..8f202b672 100644 --- a/backend/src/ee/services/pam-session/pam-session-types.ts +++ b/backend/src/ee/services/pam-session/pam-session-types.ts @@ -1,13 +1,19 @@ import { z } from "zod"; -import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas"; +import { + HttpEventSchema, + PamSessionCommandLogSchema, + SanitizedSessionSchema, + TerminalEventSchema +} from "./pam-session-schemas"; export type TPamSessionCommandLog = z.infer; export type TTerminalEvent = z.infer; +export type THttpEvent = z.infer; export type TPamSanitizedSession = z.infer; // DTOs export type TUpdateSessionLogsDTO = { sessionId: string; - logs: (TPamSessionCommandLog | TTerminalEvent)[]; + logs: (TPamSessionCommandLog | TTerminalEvent | THttpEvent)[]; }; diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 878a35aa1..332c985a9 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -7,18 +7,30 @@ import { PamSessionStatus } from "../enums"; import { TAwsIamAccount, TAwsIamResource } from "./aws-iam-resource"; +import { TKubernetesAccount, TKubernetesResource } from "./kubernetes-resource"; import { TMySQLAccount, TMySQLResource } from "./mysql-resource"; import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; import { TSSHAccount, TSSHResource } from "./ssh-resource"; export * from "./aws-iam-resource"; +export * from "./kubernetes-resource"; export * from "./mysql-resource"; export * from "./postgres-resource"; export * from "./ssh-resource"; -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource; +export type TPamResource = + | TPostgresResource + | TMySQLResource + | TSSHResource + | TAwsIamResource + | TKubernetesResource; -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount; +export type TPamAccount = + | TPostgresAccount + | TMySQLAccount + | TSSHAccount + | TAwsIamAccount + | TKubernetesAccount; export type TPamFolder = { id: string; @@ -44,7 +56,28 @@ export type TTerminalEvent = { elapsedTime: number; // Seconds since session start (for replay) }; -export type TPamSessionLog = TPamCommandLog | TTerminalEvent; +export type THttpRequestEvent = { + timestamp: string; + requestId: string; + eventType: "request"; + headers: Record; + method: string; + url: string; + body?: string; +}; + +export type THttpResponseEvent = { + timestamp: string; + requestId: string; + eventType: "response"; + headers: Record; + status: string; + body?: string; +}; + +export type THttpEvent = THttpRequestEvent | THttpResponseEvent; + +export type TPamSessionLog = TPamCommandLog | TTerminalEvent | THttpEvent; export type TPamSession = { id: string; diff --git a/frontend/src/hooks/api/pam/types/kubernetes-resource.ts b/frontend/src/hooks/api/pam/types/kubernetes-resource.ts new file mode 100644 index 000000000..b7e1501d5 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/kubernetes-resource.ts @@ -0,0 +1,33 @@ +import { PamResourceType } from "../enums"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +export enum KubernetesAuthMethod { + ServiceAccountToken = "service-account-token" +} + +export type TKubernetesConnectionDetails = { + url: string; + sslRejectUnauthorized: boolean; + sslCertificate?: string; +}; + +export type TKubernetesServiceAccountTokenCredentials = { + authMethod: KubernetesAuthMethod.ServiceAccountToken; + serviceAccountToken: string; +}; + +export type TKubernetesCredentials = TKubernetesServiceAccountTokenCredentials; + +// Resources +export type TKubernetesResource = TBasePamResource & { + resourceType: PamResourceType.Kubernetes; +} & { + connectionDetails: TKubernetesConnectionDetails; + rotationAccountCredentials?: TKubernetesCredentials | null; +}; + +// Accounts +export type TKubernetesAccount = TBasePamAccount & { + credentials: TKubernetesCredentials; +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx index eafdfe5f9..faa9ff87d 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -85,6 +85,8 @@ export const PamAccessAccountModal = ({ return `infisical pam db access-account ${fullAccountPath} --project-id ${projectId} --duration ${cliDuration} --domain ${siteURL}`; case PamResourceType.SSH: return `infisical pam ssh access-account ${fullAccountPath} --project-id ${projectId} --duration ${cliDuration} --domain ${siteURL}`; + case PamResourceType.Kubernetes: + return `infisical pam kubernetes access-account ${fullAccountPath} --project-id ${projectId} --duration ${cliDuration} --domain ${siteURL}`; default: return ""; } diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/KubernetesAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/KubernetesAccountForm.tsx new file mode 100644 index 000000000..7778eb97f --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/KubernetesAccountForm.tsx @@ -0,0 +1,121 @@ +import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, ModalClose, TextArea } from "@app/components/v2"; +import { KubernetesAuthMethod, PamResourceType, TKubernetesAccount } from "@app/hooks/api/pam"; +import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; + +import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; +import { rotateAccountFieldsSchema } from "./RotateAccountFields"; + +type Props = { + account?: TKubernetesAccount; + resourceId?: string; + resourceType?: PamResourceType; + onSubmit: (formData: FormData) => Promise; +}; + +const KubernetesServiceAccountTokenCredentialsSchema = z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken), + serviceAccountToken: z.string().trim().min(1, "Service account token is required") +}); + +const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({ + credentials: KubernetesServiceAccountTokenCredentialsSchema +}); + +type FormData = z.infer; + +const KubernetesAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { + const { control } = useFormContext(); + + return ( +
+ ( + +