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 6b6a4cbed6..9c7cf161d6 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 0c78b64491..802d430935 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 fcd9840b4a..e3c9cf60c5 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 b6a7532edd..8e4326f3f1 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 3c39a9516e..574b8b7c3d 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 c3baeff0c4..ec8b37c365 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-folder/pam-folder-dal.ts b/backend/src/ee/services/pam-folder/pam-folder-dal.ts index 0b8aa8f60c..a21c401ffb 100644 --- a/backend/src/ee/services/pam-folder/pam-folder-dal.ts +++ b/backend/src/ee/services/pam-folder/pam-folder-dal.ts @@ -71,23 +71,29 @@ export const pamFolderDALFactory = (db: TDbClient) => { const findByPath = async (projectId: string, path: string, tx?: Knex) => { try { const dbInstance = tx || db.replicaNode(); + + const folders = await dbInstance(TableName.PamFolder) + .where(`${TableName.PamFolder}.projectId`, projectId) + .select(selectAllTableCols(TableName.PamFolder)); + const pathSegments = path.split("/").filter(Boolean); + if (pathSegments.length === 0) { + return undefined; + } + + const foldersByParentId = new Map(); + for (const folder of folders) { + const children = foldersByParentId.get(folder.parentId ?? null) ?? []; + children.push(folder); + foldersByParentId.set(folder.parentId ?? null, children); + } let parentId: string | null = null; - let currentFolder: Awaited> | undefined; + let currentFolder: (typeof folders)[0] | undefined; - for await (const segment of pathSegments) { - const query = dbInstance(TableName.PamFolder) - .where(`${TableName.PamFolder}.projectId`, projectId) - .where(`${TableName.PamFolder}.name`, segment); - - if (parentId) { - void query.where(`${TableName.PamFolder}.parentId`, parentId); - } else { - void query.whereNull(`${TableName.PamFolder}.parentId`); - } - - currentFolder = await query.first(); + for (const segment of pathSegments) { + const childFolders: typeof folders = foldersByParentId.get(parentId) || []; + currentFolder = childFolders.find((folder) => folder.name === segment); if (!currentFolder) { return undefined; 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 0000000000..21d7da806c --- /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 0000000000..dddeb37baf --- /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 0000000000..b7d3546c5c --- /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 0000000000..2a0f06d0e5 --- /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 { + BaseCreateGatewayPamResourceSchema, + BaseCreatePamAccountSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema +} 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 = BaseCreateGatewayPamResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateKubernetesResourceSchema = BaseUpdateGatewayPamResourceSchema.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 0000000000..d23163d267 --- /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 bea1667fbe..c8c57b03b2 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 1d1a84f339..bf8d13d664 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 2a27fb76e2..5291e044ac 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 db24931966..b336d15c80 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 893f930e51..8f202b6723 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/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts index cf236b56f5..1718e09fd0 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -214,7 +214,10 @@ export const secretRotationV2DALFactory = ( tx?: Knex ) => { try { - const extendedQuery = baseSecretRotationV2Query({ filter, db, tx, options }) + const { limit, offset = 0, sort, ...queryOptions } = options || {}; + const baseOptions = { ...queryOptions }; + + const subquery = baseSecretRotationV2Query({ filter, db, tx, options: baseOptions }) .join( TableName.SecretRotationV2SecretMapping, `${TableName.SecretRotationV2SecretMapping}.rotationId`, @@ -233,6 +236,7 @@ export const secretRotationV2DALFactory = ( ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .select( + selectAllTableCols(TableName.SecretRotationV2), db.ref("id").withSchema(TableName.SecretV2).as("secretId"), db.ref("key").withSchema(TableName.SecretV2).as("secretKey"), db.ref("version").withSchema(TableName.SecretV2).as("secretVersion"), @@ -252,18 +256,31 @@ export const secretRotationV2DALFactory = ( db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), - db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue"), + db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.SecretRotationV2}."createdAt" DESC) as rank`) ); if (search) { - void extendedQuery.where((query) => { - void query + void subquery.where((qb) => { + void qb .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); }); } - const secretRotations = await extendedQuery; + let secretRotations: Awaited; + if (limit !== undefined) { + const rankOffset = offset + 1; + const queryWithLimit = (tx || db) + .with("inner", subquery) + .select("*") + .from("inner") + .where("inner.rank", ">=", rankOffset) + .andWhere("inner.rank", "<", rankOffset + limit); + secretRotations = (await queryWithLimit) as unknown as Awaited; + } else { + secretRotations = await subquery; + } if (!secretRotations.length) return []; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 8cf9604a43..7dc763730f 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.secretValue, + reminder: rotationReminders[secret.id] ?? null } : secret ) @@ -948,7 +963,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { search, tagSlugs: tags, includeTagsInSearch: true, - includeMetadataInSearch: true + includeMetadataInSearch: true, + excludeRotatedSecrets: includeSecretRotations }); if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { @@ -970,7 +986,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { offset: adjustedOffset, tagSlugs: tags, includeTagsInSearch: true, - includeMetadataInSearch: true + includeMetadataInSearch: true, + excludeRotatedSecrets: includeSecretRotations }) ).secrets; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index d8220e4f54..0753f9640c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -416,6 +416,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { tagSlugs?: string[]; includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; + excludeRotatedSecrets?: boolean; } ) => { try { @@ -481,6 +482,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { ); } + if (filters?.excludeRotatedSecrets) { + void query.whereNull(`${TableName.SecretRotationV2SecretMapping}.secretId`); + } + const secrets = await query; // @ts-expect-error not inferred by knex @@ -594,6 +599,11 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { void bd.whereIn(`${TableName.SecretTag}.slug`, slugs); } }) + .where((bd) => { + if (filters?.excludeRotatedSecrets) { + void bd.whereNull(`${TableName.SecretRotationV2SecretMapping}.secretId`); + } + }) .orderBy( filters?.orderBy === SecretsOrderBy.Name ? "key" : "id", filters?.orderDirection ?? OrderByDirection.ASC 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 d42a26effc..d8d06bd462 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; } @@ -888,6 +888,7 @@ export const secretV2BridgeServiceFactory = ({ | "tagSlugs" | "environment" | "search" + | "excludeRotatedSecrets" >) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -1934,8 +1935,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 +2068,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 +2087,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/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 5e2ffc1a0f..f8613f57a3 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -50,6 +50,7 @@ export type TGetSecretsDTO = { limit?: number; search?: string; keys?: string[]; + excludeRotatedSecrets?: boolean; } & TProjectPermission; export type TGetSecretsMissingReadValuePermissionDTO = Omit< @@ -362,6 +363,7 @@ export type TFindSecretsByFolderIdsFilter = { includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; keys?: string[]; + excludeRotatedSecrets?: boolean; }; export type TGetSecretsRawByFolderMappingsDTO = { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 9ba1df47d7..27690249db 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1154,6 +1154,7 @@ export const secretServiceFactory = ({ | "search" | "includeTagsInSearch" | "includeMetadataInSearch" + | "excludeRotatedSecrets" >) => { const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index d8c778d7e3..be2c8b2149 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -214,6 +214,7 @@ export type TGetSecretsRawDTO = { keys?: string[]; includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; + excludeRotatedSecrets?: boolean; } & TProjectPermission; export type TGetSecretAccessListDTO = { diff --git a/docs/docs.json b/docs/docs.json index 91fedf0bbc..39ebf31a94 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -795,12 +795,6 @@ "documentation/platform/pam/product-reference/session-recording", "documentation/platform/pam/product-reference/credential-rotation" ] - }, - { - "group": "Resources", - "pages": [ - "documentation/platform/pam/resources/aws-iam" - ] } ] } diff --git a/docs/documentation/platform/pam/resources/kubernetes.mdx b/docs/documentation/platform/pam/resources/kubernetes.mdx new file mode 100644 index 0000000000..a92ec51c7d --- /dev/null +++ b/docs/documentation/platform/pam/resources/kubernetes.mdx @@ -0,0 +1,224 @@ +--- +title: "Kubernetes" +sidebarTitle: "Kubernetes" +description: "Learn how to configure Kubernetes cluster access through Infisical PAM for secure, audited, and just-in-time access to your Kubernetes clusters." +--- + +Infisical PAM supports secure, just-in-time access to Kubernetes clusters through service account token authentication. This allows your team to access Kubernetes clusters without sharing long-lived credentials, while maintaining a complete audit trail of who accessed what and when. + +## How It Works + +Kubernetes access in Infisical PAM uses an Infisical Gateway to securely proxy connections to your Kubernetes API server. When a user requests access, Infisical generates a temporary kubeconfig that routes traffic through the Gateway, enabling secure access without exposing your cluster directly. + +```mermaid +sequenceDiagram + participant User + participant CLI as Infisical CLI + participant Infisical + participant Gateway as Infisical Gateway + participant K8s as Kubernetes API Server + + User->>CLI: Request Kubernetes access + CLI->>Infisical: Authenticate & request session + Infisical-->>CLI: Session credentials & Gateway info + CLI->>CLI: Start local proxy + CLI->>Gateway: Establish secure tunnel + User->>CLI: kubectl commands + CLI->>Gateway: Proxy kubectl requests + Gateway->>K8s: Forward with SA token + K8s-->>Gateway: Response + Gateway-->>CLI: Return response + CLI-->>User: kubectl output +``` + +### Key Concepts + +1. **Gateway**: An Infisical Gateway deployed in your network that can reach the Kubernetes API server. The Gateway handles secure communication between users and your cluster. + +2. **Service Account Token**: A Kubernetes service account token that grants access to the cluster. This token is stored securely in Infisical and used by the Gateway to authenticate with the Kubernetes API. + +3. **Local Proxy**: The Infisical CLI starts a local proxy on your machine that intercepts kubectl commands and routes them securely through the Gateway to your cluster. + +4. **Session Tracking**: All access sessions are logged, including when the session was created, who accessed the cluster, session duration, and when it ended. + +### Session Tracking + +Infisical tracks: +- When the session was created +- Who accessed which cluster +- Session duration +- All kubectl commands executed during the session +- When the session ended + + + **Session Logs**: After ending a session (by stopping the proxy), you can view detailed session logs in the Sessions page, including all commands executed during the session. + + +## Prerequisites + +Before configuring Kubernetes access in Infisical PAM, you need: + +1. **Infisical Gateway** - A Gateway deployed in your network with access to the Kubernetes API server +2. **Service Account** - A Kubernetes service account with appropriate RBAC permissions +3. **Infisical CLI** - The Infisical CLI installed on user machines + + + **Gateway Required**: Unlike AWS Console access, Kubernetes access requires an Infisical Gateway to be deployed and registered with your Infisical instance. The Gateway must have network connectivity to your Kubernetes API server. + + +## Create the PAM Resource + +The PAM Resource represents the connection between Infisical and your Kubernetes cluster. + + + + Before creating the resource, ensure you have an Infisical Gateway running and registered with your Infisical instance. The Gateway must have network access to your Kubernetes API server. + + + + 1. Navigate to your PAM project and go to the **Resources** tab + 2. Click **Add Resource** and select **Kubernetes** + 3. Enter a name for the resource (e.g., `production-k8s`, `staging-cluster`) + 4. Enter the **Kubernetes API Server URL** - the URL to your Kubernetes API endpoint (e.g.`https://kubernetes.example.com:6443`) + 5. Select the **Gateway** that has access to this cluster + 6. Configure SSL verification options if needed + + + **SSL Verification**: You may need to disable SSL verification if your Kubernetes API server uses a self-signed certificate or if the certificate's hostname doesn't match the URL you're using to access it. + + + + +## Create a Service Account + +Infisical PAM currently supports service account token authentication for Kubernetes. You'll need to create a service account with appropriate permissions in your cluster. + + + + Create a file named `sa.yaml` with the following content: + + ```yaml sa.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-pam-sa + namespace: kube-system + --- + # Bind the ServiceAccount to the desired ClusterRole + # This example uses cluster-admin - adjust based on your needs + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-pam-binding + subjects: + - kind: ServiceAccount + name: infisical-pam-sa + namespace: kube-system + roleRef: + kind: ClusterRole + name: cluster-admin # Change this to a more restrictive role as needed + apiGroup: rbac.authorization.k8s.io + --- + # Create a static, non-expiring token for the ServiceAccount + apiVersion: v1 + kind: Secret + metadata: + name: infisical-pam-sa-token + namespace: kube-system + annotations: + kubernetes.io/service-account.name: infisical-pam-sa + type: kubernetes.io/service-account-token + ``` + + + **Security Best Practice**: The example above uses `cluster-admin` for simplicity. In production environments, you should create custom ClusterRoles or Roles with the minimum permissions required for each use case. + + + + + Apply the configuration to your cluster: + + ```bash + kubectl apply -f sa.yaml + ``` + + This creates: + - A ServiceAccount named `infisical-pam-sa` in the `kube-system` namespace + - A ClusterRoleBinding that grants the service account its permissions + - A Secret containing a static, non-expiring token for the service account + + + + Get the service account token that you'll use when creating the PAM account: + + ```bash + kubectl -n kube-system get secret infisical-pam-sa-token -o jsonpath='{.data.token}' | base64 -d + ``` + + Copy this token - you'll need it in the next step. + + + +## Create PAM Accounts + +Once you have configured the PAM resource, you'll need to configure a PAM account for your Kubernetes resource. +A PAM Account represents a specific service account that users can request access to. You can create multiple accounts per resource, each with different permission levels. + + + + Go to the **Accounts** tab in your PAM project. + + + + Click **Add Account** and select the Kubernetes resource you created. + + + + Fill in the account details and paste the service account token you retrieved earlier. + + + +## Access Kubernetes Cluster + +Once your resource and accounts are configured, users can request access through the Infisical CLI: + + + + 1. Navigate to the **Accounts** tab in your PAM project + 2. Find the Kubernetes account you want to access + 3. Click the **Access** button + 4. Copy the provided CLI command + + + + + Run the copied command in your terminal. + + The CLI will: + 1. Authenticate with Infisical + 2. Establish a secure connection through the Gateway + 3. Start a local proxy on your machine + 4. Configure kubectl to use the proxy + + + + Once the proxy is running, you can use `kubectl` commands as normal: + + ```bash + kubectl get pods + kubectl get namespaces + kubectl describe deployment my-app + ``` + + All commands are routed securely through the Infisical Gateway to your cluster. + + + + When you're done, stop the proxy by pressing `Ctrl+C` in the terminal where it's running. This will: + - Close the secure tunnel + - End the session + - Log the session details to Infisical + + You can view session logs in the **Sessions** page of your PAM project. + + diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 878a35aa10..332c985a95 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 0000000000..b7e1501d5f --- /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/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 4cef73f46a..519e4265a5 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -170,7 +170,7 @@ export const Navbar = () => { const [isOrgSelectOpen, setIsOrgSelectOpen] = useState(false); const location = useLocation(); - const isBillingPage = location.pathname === "/organization/billing"; + const isBillingPage = location.pathname === `/organizations/${currentOrg.id}/billing`; const isModalIntrusive = Boolean(!isBillingPage && isCardDeclinedMoreThan30Days); diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx index eafdfe5f96..faa9ff87d5 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 0000000000..7778eb97fa --- /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 ( +
+ ( + +