From 3c3c859f12410033c3853a0eb140d4bf7bbacd96 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 17 Nov 2025 23:23:13 -0300 Subject: [PATCH 01/44] Group rotated secrets under rotation row on the UI dashboard --- .../src/server/routes/v1/dashboard-router.ts | 66 ++++++++++++++----- .../SecretDashboardPage.tsx | 23 +++++-- .../components/SecretListView/SecretItem.tsx | 10 ++- .../SecretRotationItem.tsx | 61 ++++++++++++++++- .../SecretRotationListView.tsx | 39 ++++++++++- 5 files changed, 172 insertions(+), 27 deletions(-) diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 8cf9604a4..cf66cd898 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -624,7 +624,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SanitizedTagSchema.array().optional() + tags: SanitizedTagSchema.array().optional(), + reminder: RemindersSchema.extend({ + recipients: z.string().array() + }).nullable() }) .nullable() .array() @@ -743,6 +746,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ReturnType >[number]["secrets"][number] & { isEmpty: boolean; + reminder: Awaited>[string] | null; } > | null)[]; })[] @@ -847,27 +851,38 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ); if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { - secretRotations = ( - await server.services.secretRotationV2.getDashboardSecretRotations( - { - projectId, - search, - orderBy, - orderDirection, - environments: [environment], - secretPath, - limit: remainingLimit, - offset: adjustedOffset - }, - req.permission - ) - ).map((rotation) => ({ + const rawSecretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments: [environment], + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + const allRotationSecretIds = rawSecretRotations + .flatMap((rotation) => rotation.secrets) + .filter((secret) => Boolean(secret)) + .map((secret) => secret.id); + + const rotationReminders = + allRotationSecretIds.length > 0 + ? await server.services.reminder.getRemindersForDashboard(allRotationSecretIds) + : {}; + + secretRotations = rawSecretRotations.map((rotation) => ({ ...rotation, secrets: rotation.secrets.map((secret) => secret ? { ...secret, - isEmpty: !secret.secretValue + isEmpty: secret.secretValueHidden, + reminder: rotationReminders[secret.id] ?? null } : secret ) @@ -978,11 +993,26 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { rawSecrets.map((secret) => secret.id) ); - secrets = rawSecrets.map((secret) => ({ + const rotationSecretIds = + includeSecretRotations && secretRotations?.length + ? new Set( + secretRotations.flatMap((rotation) => rotation.secrets.filter(Boolean).map((secret) => secret.id)) + ) + : new Set(); + + const filteredSecrets = rawSecrets.filter((secret) => !rotationSecretIds.has(secret.id)); + + secrets = filteredSecrets.map((secret) => ({ ...secret, isEmpty: !secret.secretValue, reminder: reminders[secret.id] ?? null })); + + if (includeSecretRotations && secretRotations?.length && totalSecretCount && rotationSecretIds.size > 0) { + const filteredCount = rawSecrets.filter((secret) => !rotationSecretIds.has(secret.id)).length; + const originalCount = rawSecrets.length; + totalSecretCount = Math.max(0, totalSecretCount - (originalCount - filteredCount)); + } } } } catch (error) { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 203a3f6eb..441f69f7c 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -656,12 +656,17 @@ const Page = () => { setDebouncedSearchFilter(""); }; - const getMergedSecretsWithPending = () => { + const getMergedSecretsWithPending = ( + paramSecrets?: (SecretV3RawSanitized | null)[] + ): SecretV3RawSanitized[] => { + const sanitizedParamSecrets = paramSecrets?.filter(Boolean) as + | SecretV3RawSanitized[] + | undefined; if (!isBatchMode || pendingChanges.secrets.length === 0) { - return secrets; + return sanitizedParamSecrets || secrets || []; } - const mergedSecrets = [...(secrets || [])] as (SecretV3RawSanitized & { + const mergedSecrets = [...(sanitizedParamSecrets || secrets || [])] as (SecretV3RawSanitized & { originalKey?: string; })[]; @@ -1041,7 +1046,17 @@ const Page = () => { /> )} {canReadSecretRotations && Boolean(secretRotations?.length) && ( - + )} {canReadSecret && Boolean(mergedSecrets?.length) && ( ( - + void; onViewGeneratedCredentials: () => void; onDelete: () => void; + projectId: string; + secretPath?: string; + tags?: WsTag[]; + isProtectedBranch?: boolean; + usedBySecretSyncs?: UsedBySecretSyncs[]; + importedBy?: { + environment: { name: string; slug: string }; + folders: { + name: string; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; + isImported: boolean; + }[]; + }[]; + colWidth: number; + getMergedSecretsWithPending: ( + paramSecrets?: (SecretV3RawSanitized | null)[] + ) => SecretV3RawSanitized[]; }; export const SecretRotationItem = ({ @@ -35,16 +55,40 @@ export const SecretRotationItem = ({ onEdit, onRotate, onViewGeneratedCredentials, - onDelete + onDelete, + projectId, + secretPath = "/", + tags = [], + isProtectedBranch = false, + usedBySecretSyncs, + importedBy, + colWidth, + getMergedSecretsWithPending }: Props) => { const { name, type, environment, folder, secrets, description } = secretRotation; const { name: rotationType, image } = SECRET_ROTATION_MAP[type]; const [showSecrets, setShowSecrets] = useState(false); + const [isExpanded, setIsExpanded] = useState(true); return ( <> -
+
setIsExpanded(!isExpanded)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setIsExpanded(!isExpanded); + } + }} + role="button" + tabIndex={0} + aria-expanded={isExpanded} + aria-label={`${isExpanded ? "Collapse" : "Expand"} rotation secrets for ${name}`} + >
@@ -198,6 +242,19 @@ export const SecretRotationItem = ({
+ {isExpanded && ( + + )} e.preventDefault()} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx index 1682a79df..1de6aa201 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx @@ -3,15 +3,44 @@ import { EditSecretRotationV2Modal } from "@app/components/secret-rotations-v2/E import { RotateSecretRotationV2Modal } from "@app/components/secret-rotations-v2/RotateSecretRotationV2Modal"; import { ViewSecretRotationV2GeneratedCredentialsModal } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials"; import { usePopUp } from "@app/hooks"; +import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types"; import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { SecretRotationItem } from "./SecretRotationItem"; type Props = { secretRotations?: TSecretRotationV2[]; + projectId: string; + secretPath?: string; + tags?: WsTag[]; + isProtectedBranch?: boolean; + usedBySecretSyncs?: UsedBySecretSyncs[]; + importedBy?: { + environment: { name: string; slug: string }; + folders: { + name: string; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; + isImported: boolean; + }[]; + }[]; + colWidth: number; + getMergedSecretsWithPending: ( + secretParams?: (SecretV3RawSanitized | null)[] + ) => SecretV3RawSanitized[]; }; -export const SecretRotationListView = ({ secretRotations }: Props) => { +export const SecretRotationListView = ({ + secretRotations, + projectId, + secretPath = "/", + tags = [], + isProtectedBranch = false, + usedBySecretSyncs, + importedBy, + colWidth, + getMergedSecretsWithPending +}: Props) => { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "editSecretRotation", "rotateSecretRotation", @@ -31,6 +60,14 @@ export const SecretRotationListView = ({ secretRotations }: Props) => { handlePopUpOpen("viewSecretRotationGeneratedCredentials", secretRotation) } onDelete={() => handlePopUpOpen("deleteSecretRotation", secretRotation)} + colWidth={colWidth} + tags={tags} + projectId={projectId} + secretPath={secretPath} + isProtectedBranch={isProtectedBranch} + importedBy={importedBy} + usedBySecretSyncs={usedBySecretSyncs} + getMergedSecretsWithPending={getMergedSecretsWithPending} /> ))} Date: Tue, 18 Nov 2025 02:07:02 -0300 Subject: [PATCH 02/44] Remove change made while testing --- backend/src/server/routes/v1/dashboard-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index cf66cd898..36331e437 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -881,7 +881,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secret ? { ...secret, - isEmpty: secret.secretValueHidden, + isEmpty: secret.secretValue, reminder: rotationReminders[secret.id] ?? null } : secret From 1b346991427bcc18a9c2b24f6145e81289d9b077 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 18 Nov 2025 10:51:30 -0300 Subject: [PATCH 03/44] Lint fix --- backend/src/server/routes/v1/dashboard-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 36331e437..ca928a1ad 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -881,7 +881,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secret ? { ...secret, - isEmpty: secret.secretValue, + isEmpty: !secret.secretValue, reminder: rotationReminders[secret.id] ?? null } : secret From 5cd6bf8213ab8a31dfd9cb8d9125a310e443ee03 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Wed, 19 Nov 2025 12:43:54 -0300 Subject: [PATCH 04/44] Fix table multiple pending changes --- .../secret-v2-bridge-service.ts | 21 +++++--- .../SecretListView/SecretListView.tsx | 51 +++++++++++-------- .../SecretRotationItem.tsx | 1 + 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 559c86843..1f27d1c7b 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -483,8 +483,8 @@ export const secretV2BridgeServiceFactory = ({ }); if (!sharedSecretToModify) throw new NotFoundError({ message: `Secret with name ${inputSecret.secretName} not found` }); - if (sharedSecretToModify.isRotatedSecret && (inputSecret.newSecretName || inputSecret.secretValue)) - throw new BadRequestError({ message: "Cannot update rotated secret name or value" }); + if (sharedSecretToModify.isRotatedSecret && inputSecret.newSecretName) + throw new BadRequestError({ message: "Cannot update rotated secret name" }); secretId = sharedSecretToModify.id; secret = sharedSecretToModify; } @@ -1934,8 +1934,14 @@ export const secretV2BridgeServiceFactory = ({ if (el.isRotatedSecret) { const input = secretsToUpdateGroupByPath[secretPath].find((i) => i.secretKey === el.key); - if (input && (input.newSecretName || input.secretValue)) - throw new BadRequestError({ message: `Cannot update rotated secret name or value: ${el.key}` }); + if (input) { + if (input.newSecretName) { + delete input.newSecretName; + } + if (input.secretValue !== undefined) { + delete input.secretValue; + } + } } }); @@ -2061,8 +2067,11 @@ export const secretV2BridgeServiceFactory = ({ commitChanges, inputSecrets: secretsToUpdate.map((el) => { const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0]; + const shouldUpdateValue = !originalSecret.isRotatedSecret && typeof el.secretValue !== "undefined"; + const shouldUpdateName = !originalSecret.isRotatedSecret && el.newSecretName; + const encryptedValue = - typeof el.secretValue !== "undefined" + shouldUpdateValue && el.secretValue !== undefined ? { encryptedValue: secretManagerEncryptor({ plainText: Buffer.from(el.secretValue) }).cipherTextBlob, references: secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences @@ -2077,7 +2086,7 @@ export const secretV2BridgeServiceFactory = ({ (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob ), skipMultilineEncoding: el.skipMultilineEncoding, - key: el.newSecretName || el.secretKey, + key: shouldUpdateName ? el.newSecretName : el.secretKey, tags: el.tagIds, secretMetadata: el.secretMetadata, ...encryptedValue diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx index be5dfaaed..b6d358453 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx @@ -52,6 +52,7 @@ type Props = { }[]; }[]; colWidth: number; + excludePendingCreates?: boolean; }; export const SecretListView = ({ @@ -64,7 +65,8 @@ export const SecretListView = ({ isProtectedBranch = false, usedBySecretSyncs, importedBy, - colWidth + colWidth, + excludePendingCreates = false }: Props) => { const queryClient = useQueryClient(); const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ @@ -580,27 +582,32 @@ export const SecretListView = ({ {FontAwesomeSpriteSymbols.map(({ icon, symbol }) => ( ))} - {secrets.map((secret) => ( - - ))} + {secrets + .filter((secret) => { + if (!excludePendingCreates) return true; + return !secret.isPending || secret.pendingAction !== PendingAction.Create; + }) + .map((secret) => ( + + ))} )} From 284024d10bdbce6bf9f4e9a4aa22c2d9d94796a5 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 2 Dec 2025 17:03:13 -0800 Subject: [PATCH 05/44] 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 06/44] 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 07/44] 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 + + + )} + /> + ( + +