From 284024d10bdbce6bf9f4e9a4aa22c2d9d94796a5 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 2 Dec 2025 17:03:13 -0800 Subject: [PATCH 01/31] Add k8s stuff --- .../ee/routes/v1/pam-account-routers/index.ts | 14 ++ .../routes/v1/pam-resource-routers/index.ts | 14 ++ .../pam-resource-router.ts | 10 +- .../kubernetes/kubernetes-resource-enums.ts | 3 + .../kubernetes/kubernetes-resource-factory.ts | 215 ++++++++++++++++++ .../kubernetes/kubernetes-resource-fns.ts | 8 + .../kubernetes/kubernetes-resource-schemas.ts | 94 ++++++++ .../kubernetes/kubernetes-resource-types.ts | 16 ++ .../pam-resource/pam-resource-enums.ts | 3 +- .../pam-resource/pam-resource-factory.ts | 4 +- .../services/pam-resource/pam-resource-fns.ts | 5 +- .../pam-resource/pam-resource-types.ts | 19 +- 12 files changed, 396 insertions(+), 9 deletions(-) create mode 100644 backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts create mode 100644 backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts create mode 100644 backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts create mode 100644 backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts create mode 100644 backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts 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 d3aadd5a4..61a29970e 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -1,3 +1,8 @@ +import { + CreateKubernetesAccountSchema, + SanitizedKubernetesAccountWithResourceSchema, + UpdateKubernetesAccountSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { CreateMySQLAccountSchema, SanitizedMySQLAccountWithResourceSchema, @@ -44,5 +49,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Kubernetes, + accountResponseSchema: SanitizedKubernetesAccountWithResourceSchema, + createAccountSchema: CreateKubernetesAccountSchema, + updateAccountSchema: UpdateKubernetesAccountSchema + }); } }; 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 5dae317da..e26cc5f70 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -1,3 +1,8 @@ +import { + CreateKubernetesResourceSchema, + SanitizedKubernetesResourceSchema, + UpdateKubernetesResourceSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { CreateMySQLResourceSchema, MySQLResourceSchema, @@ -44,5 +49,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Kubernetes, + resourceResponseSchema: SanitizedKubernetesResourceSchema, + createResourceSchema: CreateKubernetesResourceSchema, + updateResourceSchema: UpdateKubernetesResourceSchema + }); } }; 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 3536e7a99..2c97312b4 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 @@ -1,6 +1,10 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + KubernetesResourceListItemSchema, + SanitizedKubernetesResourceSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { MySQLResourceListItemSchema, SanitizedMySQLResourceSchema @@ -22,13 +26,15 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedResourceSchema = z.union([ SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema, - SanitizedSSHResourceSchema + SanitizedSSHResourceSchema, + SanitizedKubernetesResourceSchema ]); const ResourceOptionsSchema = z.discriminatedUnion("resource", [ PostgresResourceListItemSchema, MySQLResourceListItemSchema, - SSHResourceListItemSchema + SSHResourceListItemSchema, + KubernetesResourceListItemSchema ]); export const registerPamResourceRouter = async (server: FastifyZodProvider) => { 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..83bd5a1a0 --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts @@ -0,0 +1,215 @@ +import axios, { AxiosError } from "axios"; +import https from "https"; + +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol, 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); + const targetPort = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 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" }); + } + + let httpsAgent: https.Agent | undefined; + if (connectionDetails.caCertificate) { + httpsAgent = new https.Agent({ + ca: connectionDetails.caCertificate, + rejectUnauthorized: !connectionDetails.skipTLSVerify + }); + } else if (!connectionDetails.skipTLSVerify) { + httpsAgent = new https.Agent({ + rejectUnauthorized: true + }); + } + + 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 () => { + try { + await executeWithGateway( + { connectionDetails, gatewayId, resourceType }, + gatewayV2Service, + async (baseUrl, httpsAgent) => { + // Validate connection by checking API server version + try { + await axios.get(`${baseUrl}/version`, { + headers: { + "Content-Type": "application/json" + }, + ...(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) => { + try { + await executeWithGateway( + { connectionDetails, gatewayId, resourceType }, + gatewayV2Service, + async (baseUrl, httpsAgent) => { + if (credentials.authMethod === KubernetesAuthMethod.ServiceAccountToken) { + // Validate service account token by making an authenticated API call + try { + await axios.get(`${baseUrl}/api/v1/namespaces/${connectionDetails.namespace}`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.serviceAccountToken}` + }, + ...(httpsAgent ? { httpsAgent } : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + }); + + logger.info( + { serviceAccountName: credentials.serviceAccountName, namespace: connectionDetails.namespace }, + "[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: ${(credentials as TKubernetesAccountCredentials).authMethod}` + }); + } + } + ); + 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 = async ( + rotationAccountCredentials + ) => { + // For Kubernetes, rotation would typically involve creating a new service account token + // This is a placeholder - actual rotation logic would need to be implemented based on requirements + return rotationAccountCredentials; + }; + + 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..4d452bc89 --- /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), + namespace: z.string().trim().max(255), + skipTLSVerify: z.boolean().optional().default(false), + caCertificate: z.string().trim().max(10000).optional() +}); + +export const KubernetesServiceAccountTokenCredentialsSchema = z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken), + serviceAccountName: z.string().trim().max(255), + 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), + serviceAccountName: z.string() + }) + ]) + .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), + serviceAccountName: z.string() + }) + ]) +}); + +// 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 e4ec043e1..f9b5ab4bf 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -1,7 +1,8 @@ export enum PamResource { Postgres = "postgres", MySQL = "mysql", - SSH = "ssh" + SSH = "ssh", + Kubernetes = "kubernetes" } export enum PamResourceOrderBy { 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 e2d0a50f8..9fee549e1 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -1,5 +1,6 @@ import { PamResource } from "./pam-resource-enums"; import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; +import { kubernetesResourceFactory } from "./kubernetes/kubernetes-resource-factory"; import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; import { sshResourceFactory } from "./ssh/ssh-resource-factory"; @@ -8,5 +9,6 @@ type TPamResourceFactoryImplementation = TPamResourceFactory = { [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation, [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation, - [PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation + [PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation, + [PamResource.Kubernetes]: kubernetesResourceFactory as TPamResourceFactoryImplementation }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts index cad087d2f..0b5e81706 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-fns.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -3,12 +3,15 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { decryptAccountCredentials } from "../pam-account/pam-account-fns"; +import { getKubernetesResourceListItem } from "./kubernetes/kubernetes-resource-fns"; import { getMySQLResourceListItem } from "./mysql/mysql-resource-fns"; import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; export const listResourceOptions = () => { - return [getPostgresResourceListItem(), getMySQLResourceListItem()].sort((a, b) => a.name.localeCompare(b.name)); + return [getPostgresResourceListItem(), getMySQLResourceListItem(), 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 9da094801..614491588 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -1,6 +1,12 @@ import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { + TKubernetesAccount, + TKubernetesAccountCredentials, + TKubernetesResource, + TKubernetesResourceConnectionDetails +} from "./kubernetes/kubernetes-resource-types"; import { TMySQLAccount, TMySQLAccountCredentials, @@ -22,16 +28,21 @@ import { } from "./ssh/ssh-resource-types"; // Resource types -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource; +export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TKubernetesResource; export type TPamResourceConnectionDetails = | TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails - | TSSHResourceConnectionDetails; + | TSSHResourceConnectionDetails + | TKubernetesResourceConnectionDetails; // Account types -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount; +export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TKubernetesAccount; // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials; +export type TPamAccountCredentials = + | TPostgresAccountCredentials + | TMySQLAccountCredentials + | TSSHAccountCredentials + | TKubernetesAccountCredentials; // Resource DTOs export type TCreateResourceDTO = Pick< From e12f30cd43be424f334c8cca356610bf3bbbe5bc Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 2 Dec 2025 17:48:11 -0800 Subject: [PATCH 02/31] Connect k8s --- .../kubernetes/kubernetes-resource-factory.ts | 21 ++++++++++++++----- .../kubernetes/kubernetes-resource-schemas.ts | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) 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 index 83bd5a1a0..3721116cf 100644 --- a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts @@ -2,8 +2,10 @@ import axios, { AxiosError } from "axios"; import https from "https"; import { BadRequestError } from "@app/lib/errors"; -import { GatewayProxyProtocol, withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +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"; @@ -29,7 +31,15 @@ export const executeWithGateway = async ( const { connectionDetails, gatewayId } = config; const url = new URL(connectionDetails.url); const [targetHost] = await verifyHostInputValidity(url.hostname, true); - const targetPort = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; + + 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, @@ -60,7 +70,7 @@ export const executeWithGateway = async ( return operation(baseUrl, httpsAgent); }, { - protocol: GatewayProxyProtocol.Tcp, + protocol: GatewayProxyProtocol.Http, relayHost: platformConnectionDetails.relayHost, gateway: platformConnectionDetails.gateway, relay: platformConnectionDetails.relay, @@ -126,7 +136,8 @@ export const kubernetesResourceFactory: TPamResourceFactory< { connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (baseUrl, httpsAgent) => { - if (credentials.authMethod === KubernetesAuthMethod.ServiceAccountToken) { + const { authMethod } = credentials; + if (authMethod === KubernetesAuthMethod.ServiceAccountToken) { // Validate service account token by making an authenticated API call try { await axios.get(`${baseUrl}/api/v1/namespaces/${connectionDetails.namespace}`, { @@ -159,7 +170,7 @@ export const kubernetesResourceFactory: TPamResourceFactory< } } else { throw new BadRequestError({ - message: `Unsupported Kubernetes auth method: ${(credentials as TKubernetesAccountCredentials).authMethod}` + message: `Unsupported Kubernetes auth method: ${authMethod as string}` }); } } 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 index 4d452bc89..67bef8be0 100644 --- a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts @@ -24,7 +24,7 @@ export const KubernetesResourceListItemSchema = z.object({ export const KubernetesResourceConnectionDetailsSchema = z.object({ url: z.string().url().trim().max(500), namespace: z.string().trim().max(255), - skipTLSVerify: z.boolean().optional().default(false), + skipTLSVerify: z.boolean(), caCertificate: z.string().trim().max(10000).optional() }); From 5ac4e0a5bb90a9fe940ebbde97e22945de7a9dcf Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 2 Dec 2025 21:18:03 -0800 Subject: [PATCH 03/31] Fix adding res and add missing forms --- .../kubernetes/kubernetes-resource-factory.ts | 6 +- frontend/src/hooks/api/pam/types/index.ts | 6 +- .../api/pam/types/kubernetes-resource.ts | 35 +++++ .../KubernetesResourceForm.tsx | 96 ++++++++++++ .../PamResourceForm/PamResourceForm.tsx | 5 + .../shared/KubernetesResourceFields.tsx | 79 ++++++++++ .../shared/KubernetesRotateAccountFields.tsx | 144 ++++++++++++++++++ .../components/ResourceTypeSelect.tsx | 2 - 8 files changed, 365 insertions(+), 8 deletions(-) create mode 100644 frontend/src/hooks/api/pam/types/kubernetes-resource.ts create mode 100644 frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/KubernetesResourceForm.tsx create mode 100644 frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/KubernetesResourceFields.tsx create mode 100644 frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/KubernetesRotateAccountFields.tsx 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 index 3721116cf..14aefc243 100644 --- a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts @@ -67,10 +67,11 @@ export const executeWithGateway = async ( async (proxyPort) => { const protocol = url.protocol === "https:" ? "https" : "http"; const baseUrl = `${protocol}://localhost:${proxyPort}`; + // const baseUrl = `http://localhost:${proxyPort}`; return operation(baseUrl, httpsAgent); }, { - protocol: GatewayProxyProtocol.Http, + protocol: GatewayProxyProtocol.Tcp, relayHost: platformConnectionDetails.relayHost, gateway: platformConnectionDetails.gateway, relay: platformConnectionDetails.relay, @@ -92,9 +93,6 @@ export const kubernetesResourceFactory: TPamResourceFactory< // Validate connection by checking API server version try { await axios.get(`${baseUrl}/version`, { - headers: { - "Content-Type": "application/json" - }, ...(httpsAgent ? { httpsAgent } : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), timeout: EXTERNAL_REQUEST_TIMEOUT diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 01b87c282..bff8ac25e 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -6,17 +6,19 @@ import { PamResourceType, PamSessionStatus } from "../enums"; +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 "./kubernetes-resource"; export * from "./mysql-resource"; export * from "./postgres-resource"; export * from "./ssh-resource"; -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource; +export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TKubernetesResource; -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount; +export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TKubernetesAccount; export type TPamFolder = { 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..cb7670917 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/kubernetes-resource.ts @@ -0,0 +1,35 @@ +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; + namespace: string; + skipTLSVerify: boolean; + caCertificate?: string; +}; + +export type TKubernetesServiceAccountTokenCredentials = { + authMethod: KubernetesAuthMethod.ServiceAccountToken; + serviceAccountName: string; + 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/PamResourcesPage/components/PamResourceForm/KubernetesResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/KubernetesResourceForm.tsx new file mode 100644 index 000000000..8b650c3b6 --- /dev/null +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/KubernetesResourceForm.tsx @@ -0,0 +1,96 @@ +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 { KubernetesAuthMethod, PamResourceType, TKubernetesResource } from "@app/hooks/api/pam"; +import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; + +import { KubernetesResourceFields } from "./shared/KubernetesResourceFields"; +import { KubernetesRotateAccountFields } from "./shared/KubernetesRotateAccountFields"; +import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields"; + +type Props = { + resource?: TKubernetesResource; + onSubmit: (formData: FormData) => Promise; +}; + +const KubernetesConnectionDetailsSchema = z.object({ + url: z.string().url().trim().max(500), + namespace: z.string().trim().max(255), + skipTLSVerify: z.boolean(), + caCertificate: z.string().trim().max(10000).optional() +}); + +const KubernetesServiceAccountTokenCredentialsSchema = z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken), + serviceAccountName: z.string().trim().max(255), + serviceAccountToken: z.string().trim().max(10000) +}); + +const formSchema = genericResourceFieldsSchema.extend({ + resourceType: z.literal(PamResourceType.Kubernetes), + connectionDetails: KubernetesConnectionDetailsSchema, + rotationAccountCredentials: KubernetesServiceAccountTokenCredentialsSchema.nullable().optional() +}); + +type FormData = z.infer; + +export const KubernetesResourceForm = ({ resource, onSubmit }: Props) => { + const isUpdate = Boolean(resource); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: resource + ? { + ...resource, + rotationAccountCredentials: resource.rotationAccountCredentials + ? { + ...resource.rotationAccountCredentials, + serviceAccountToken: UNCHANGED_PASSWORD_SENTINEL + } + : resource.rotationAccountCredentials + } + : { + resourceType: PamResourceType.Kubernetes, + connectionDetails: { + url: "", + namespace: "default", + skipTLSVerify: false, + caCertificate: undefined + } + } + }); + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ + + +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx index a1cc7cb1b..abc23bde3 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx @@ -9,6 +9,7 @@ import { import { DiscriminativePick } from "@app/types"; import { PamResourceHeader } from "../PamResourceHeader"; +import { KubernetesResourceForm } from "./KubernetesResourceForm"; import { MySQLResourceForm } from "./MySQLResourceForm"; import { PostgresResourceForm } from "./PostgresResourceForm"; import { SSHResourceForm } from "./SSHResourceForm"; @@ -54,6 +55,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) => return ; case PamResourceType.SSH: return ; + case PamResourceType.Kubernetes: + return ; default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -84,6 +87,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => { return ; case PamResourceType.SSH: return ; + case PamResourceType.Kubernetes: + return ; default: throw new Error(`Unhandled resource: ${(resource as any).resourceType}`); } diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/KubernetesResourceFields.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/KubernetesResourceFields.tsx new file mode 100644 index 000000000..28795b663 --- /dev/null +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/KubernetesResourceFields.tsx @@ -0,0 +1,79 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input, Switch, TextArea } from "@app/components/v2"; + +export const KubernetesResourceFields = () => { + const { control, watch } = useFormContext(); + + const skipTLSVerify = watch("connectionDetails.skipTLSVerify"); + + return ( +
+
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + Skip TLS Verification + + + )} + /> + ( + +