diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-error-handlers.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-error-handlers.ts new file mode 100644 index 00000000000..76fe179b9f1 --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-error-handlers.ts @@ -0,0 +1,200 @@ +import { AxiosError } from "axios"; + +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; + +type ErrorContext = { + host?: string; + port?: number; + kubernetesHost?: string; +}; + +export enum KubernetesAuthErrorContext { + KubernetesHost = "kubernetes-host", + KubernetesApiServer = "kubernetes-api-server", + GatewayProxy = "gateway-proxy" +} + +type ErrorContextConfig = { + serviceName: string; + errorNamePrefix: string; + defaultErrorName: string; + default401Message: string; + default403Message: string; +}; + +const COMMON_KUBERNETES_MESSAGES = { + default401Message: + "Token reviewer JWT is invalid or expired. Please verify the token reviewer JWT is correct and has not expired.", + default403Message: + "Token reviewer JWT does not have permission to perform TokenReviews. Ensure the service account has the 'system:auth-delegator' ClusterRole binding." +} as const; + +const ERROR_CONTEXT_CONFIGS: Record = { + [KubernetesAuthErrorContext.KubernetesHost]: { + serviceName: "Kubernetes host", + errorNamePrefix: "KubernetesHost", + defaultErrorName: "KubernetesHostConnectionError", + ...COMMON_KUBERNETES_MESSAGES + }, + [KubernetesAuthErrorContext.KubernetesApiServer]: { + serviceName: "Kubernetes API server", + errorNamePrefix: "Kubernetes", + defaultErrorName: "KubernetesConnectionError", + ...COMMON_KUBERNETES_MESSAGES + }, + [KubernetesAuthErrorContext.GatewayProxy]: { + serviceName: "gateway proxy", + errorNamePrefix: "Gateway", + defaultErrorName: "GatewayConnectionError", + default401Message: + "Gateway service account is not authorized to perform TokenReviews. Verify the gateway has the 'system:auth-delegator' ClusterRole binding.", + default403Message: + "Gateway service account does not have permission to perform TokenReviews. Ensure it has the 'system:auth-delegator' ClusterRole binding." + } +}; + +/** + * Handles Axios network-level errors (connection refused, DNS failures, timeouts, etc.) + * Returns a BadRequestError with a descriptive message, or null if the error is not a network error. + */ +export const handleAxiosNetworkError = ( + err: AxiosError, + context: ErrorContext, + contextType: KubernetesAuthErrorContext +): BadRequestError | null => { + const { host, kubernetesHost } = context; + const target = host || kubernetesHost || "server"; + const { errorNamePrefix: prefix, serviceName } = ERROR_CONTEXT_CONFIGS[contextType]; + + if (err.code === "ECONNREFUSED") { + return new BadRequestError({ + name: `${prefix}ConnectionRefused`, + message: `Failed to connect to ${serviceName} at ${target}: Connection refused. Verify the host URL and ensure the ${serviceName.toLowerCase()} is accessible.` + }); + } + + if (err.code === "ENOTFOUND") { + return new BadRequestError({ + name: `${prefix}HostNotFound`, + message: `Failed to resolve ${serviceName} hostname: ${target}. Verify the hostname is correct.` + }); + } + + if (err.code === "ETIMEDOUT" || err.code === "ECONNABORTED") { + return new BadRequestError({ + name: `${prefix}ConnectionTimeout`, + message: `Connection to ${serviceName} at ${target} timed out. Verify network connectivity and firewall rules.` + }); + } + + if (err.code === "DEPTH_ZERO_SELF_SIGNED_CERT" || err.code === "SELF_SIGNED_CERT_IN_CHAIN") { + return new BadRequestError({ + name: `${prefix}CertificateError`, + message: `SSL certificate verification failed for ${serviceName} at ${target}. The server uses a self-signed certificate. Please provide the CA certificate in the configuration.` + }); + } + + if (err.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || err.code === "CERT_HAS_EXPIRED") { + return new BadRequestError({ + name: `${prefix}CertificateError`, + message: `SSL certificate verification failed for ${serviceName} at ${target}. Verify the CA certificate is correct and the server certificate is valid.` + }); + } + + return null; +}; + +/** + * Handles Axios HTTP response errors (401, 403, etc.) + * Returns an appropriate error, or null if not an HTTP error. + */ +export const handleAxiosHttpError = ( + err: AxiosError, + contextType: KubernetesAuthErrorContext +): UnauthorizedError | BadRequestError | null => { + if (!err.response) { + return null; + } + + let message = (err.response.data as { message?: string })?.message; + const statusCode = err.response.status; + const { errorNamePrefix: prefix, default401Message, default403Message } = ERROR_CONTEXT_CONFIGS[contextType]; + + if (!message && typeof err.response.data === "string") { + message = err.response.data; + } + + if (statusCode === 401) { + return new UnauthorizedError({ + message: message || default401Message, + name: `${prefix}TokenReviewerUnauthorized` + }); + } + + if (statusCode === 403) { + return new UnauthorizedError({ + message: message || default403Message, + name: `${prefix}TokenReviewerForbidden` + }); + } + + if (message) { + return new BadRequestError({ + message, + name: `${prefix}TokenReviewRequestError` + }); + } + + // Generic HTTP error + return new BadRequestError({ + name: `${prefix}TokenReviewRequestError`, + message: `${prefix} returned HTTP ${statusCode}: ${err.response.statusText || "Unknown error"}` + }); +}; + +/** + * Handles generic Axios errors (fallback when network/HTTP handlers don't match) + */ +export const handleAxiosGenericError = ( + err: AxiosError, + context: ErrorContext, + contextType: KubernetesAuthErrorContext +): BadRequestError => { + const { host, kubernetesHost } = context; + const target = host || kubernetesHost || "server"; + const { defaultErrorName, serviceName } = ERROR_CONTEXT_CONFIGS[contextType]; + + return new BadRequestError({ + name: defaultErrorName, + message: `Failed to communicate with ${serviceName} at ${target}: ${err.message}` + }); +}; + +/** + * Checks if an error is a known error type that should be re-thrown as-is. + */ +export const isKnownError = (err: unknown): boolean => { + return err instanceof UnauthorizedError || err instanceof BadRequestError || err instanceof NotFoundError; +}; + +/** + * Comprehensive Axios error handler that processes network, HTTP, and generic errors. + * Returns an error to throw. + */ +export const handleAxiosError = ( + err: AxiosError, + context: ErrorContext, + contextType: KubernetesAuthErrorContext +): BadRequestError | UnauthorizedError => { + const networkError = handleAxiosNetworkError(err, context, contextType); + if (networkError) { + return networkError; + } + + const httpError = handleAxiosHttpError(err, contextType); + if (httpError) { + return httpError; + } + + return handleAxiosGenericError(err, context, contextType); +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts index 194e69b3c37..e8404719682 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts @@ -1,3 +1,5 @@ +import { BadRequestError } from "@app/lib/errors"; + /** * Extracts the K8s service account name and namespace * from the username in this format: system:serviceaccount:default:infisical-auth @@ -11,5 +13,8 @@ export const extractK8sUsername = (username: string) => { name: parts[3] }; } - throw new Error("Invalid username format"); + throw new BadRequestError({ + name: "KubernetesUsernameParseError", + message: `Invalid Kubernetes service account username format: "${username}". Expected format: system:serviceaccount::` + }); }; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 5d4021fef6c..f619436e7fb 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import axios, { AxiosError } from "axios"; +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import https from "https"; import RE2 from "re2"; @@ -28,6 +28,7 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, @@ -52,6 +53,7 @@ import { TMembershipIdentityDALFactory } from "../membership-identity/membership import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; +import { handleAxiosError, isKnownError, KubernetesAuthErrorContext } from "./identity-kubernetes-auth-error-handlers"; import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; import { IdentityKubernetesAuthTokenReviewMode, @@ -62,6 +64,11 @@ import { TRevokeKubernetesAuthDTO, TUpdateKubernetesAuthDTO } from "./identity-kubernetes-auth-types"; +import { + GatewayRequestExecutor, + validateKubernetesHostConnectivity, + validateTokenReviewerPermissions +} from "./identity-kubernetes-auth-validators"; type TIdentityKubernetesAuthServiceFactoryDep = { identityDAL: Pick; @@ -185,6 +192,70 @@ export const identityKubernetesAuthServiceFactory = ({ return callbackResult; }; + /** + * Supports two modes: + * - Gateway reviewer mode: Gateway uses its own service account (no kubernetesHost option) + * - API mode through gateway: Gateway proxies TCP connection to kubernetesHost (kubernetesHost option provided) + */ + const $createGatewayValidationRequest = ( + gatewayId: string, + options?: { kubernetesHost?: string; caCert?: string } + ): GatewayRequestExecutor => { + const useGatewayServiceAccount = !options?.kubernetesHost; + + let targetHost: string | undefined; + let targetPort: number | undefined; + if (options?.kubernetesHost) { + const parsedUrl = new URL(options.kubernetesHost); + targetHost = parsedUrl.hostname; + targetPort = parsedUrl.port ? Number(parsedUrl.port) : 443; + } + + return async (method: "get" | "post", url: string, body?: object, headers?: Record) => { + let response: AxiosResponse | undefined; + + await $gatewayProxyWrapper( + { + gatewayId, + reviewTokenThroughGateway: useGatewayServiceAccount, + targetHost, + targetPort, + caCert: options?.caCert + }, + async (host: string, port: number, httpsAgent?: https.Agent) => { + const config: AxiosRequestConfig = { + headers: { + "Content-Type": "application/json", + ...(useGatewayServiceAccount + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : headers) + }, + timeout: 10000, + signal: AbortSignal.timeout(10000), + validateStatus: () => true, + ...(httpsAgent ? { httpsAgent } : {}) + }; + + if (method === "get") { + response = await request.get(`${host}:${port}${url}`, config); + } else { + response = await request.post(`${host}:${port}${url}`, body, config); + } + return response.data; + } + ); + + if (!response) { + throw new BadRequestError({ + name: "GatewayConnectionError", + message: "Failed to get response from gateway" + }); + } + + return response; + }; + }; + const login = async ({ identityId, jwt: serviceAccountJwt, subOrganizationName }: TLoginKubernetesAuthDTO) => { const appCfg = getConfig(); const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); @@ -276,28 +347,37 @@ export const identityKubernetesAuthServiceFactory = ({ .catch((err) => { const tokenReviewerJwtSnippet = `${tokenReviewerJwt?.substring?.(0, 10) || ""}...${tokenReviewerJwt?.substring?.(tokenReviewerJwt.length - 10) || ""}`; const serviceAccountJwtSnippet = `${serviceAccountJwt?.substring?.(0, 10) || ""}...${serviceAccountJwt?.substring?.(serviceAccountJwt.length - 10) || ""}`; + if (err instanceof AxiosError) { logger.error( - { response: err.response, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, + { + response: err.response, + host, + port, + tokenReviewerJwtSnippet, + serviceAccountJwtSnippet, + code: err.code + }, "tokenReviewCallbackRaw: Kubernetes token review request error (request error)" ); - if (err.response) { - const { message } = err?.response?.data as unknown as { message?: string }; - - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); - } - } - } else { - logger.error( - { error: err as Error, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, - "tokenReviewCallbackRaw: Kubernetes token review request error (non-request error)" - ); + + throw handleAxiosError(err, { host, port }, KubernetesAuthErrorContext.KubernetesApiServer); + } + + logger.error( + { error: err as Error, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, + "tokenReviewCallbackRaw: Kubernetes token review request error (non-request error)" + ); + + if (isKnownError(err)) { + throw err; } - throw err; + + throw new BadRequestError({ + name: "KubernetesTokenReviewError", + message: (err as Error).message || "Unexpected error during token review", + error: err + }); }); return res.data; @@ -335,23 +415,24 @@ export const identityKubernetesAuthServiceFactory = ({ } ) .catch((err) => { + logger.error( + { error: err as Error, host, port }, + "tokenReviewCallbackThroughGateway: Kubernetes token review request error" + ); + if (err instanceof AxiosError) { - if (err.response) { - let { message } = err?.response?.data as unknown as { message?: string }; - - if (!message && typeof err.response.data === "string") { - message = err.response.data; - } - - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); - } - } + throw handleAxiosError(err, { host, port }, KubernetesAuthErrorContext.GatewayProxy); + } + + if (isKnownError(err)) { + throw err; } - throw err; + + throw new BadRequestError({ + name: "GatewayTokenReviewError", + message: (err as Error).message || "Unexpected error during gateway token review", + error: err + }); }); return res.data; @@ -571,7 +652,18 @@ export const identityKubernetesAuthServiceFactory = ({ "user_agent.original": requestContext.get("userAgent") }); } - throw error; + + if (isKnownError(error)) { + throw error; + } + + logger.error({ error, identityId }, "Unexpected error during Kubernetes auth login"); + + throw new BadRequestError({ + name: "KubernetesAuthLoginError", + message: (error as Error).message || "An unexpected error occurred during Kubernetes authentication", + error + }); } }; @@ -691,6 +783,39 @@ export const identityKubernetesAuthServiceFactory = ({ OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway ); + + if (tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + const gatewayExecutor = $createGatewayValidationRequest(gatewayId); + logger.info({ gatewayId }, "Validating gateway connectivity to Kubernetes"); + await validateKubernetesHostConnectivity({ gatewayExecutor }); + await validateTokenReviewerPermissions({ gatewayExecutor }); + } else if (tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && kubernetesHost) { + // API mode through gateway: gateway proxies requests with user's JWT + const gatewayExecutor = $createGatewayValidationRequest(gatewayId, { + kubernetesHost, + caCert: caCert || undefined + }); + logger.info({ gatewayId, kubernetesHost }, "Validating Kubernetes connectivity through gateway"); + await validateKubernetesHostConnectivity({ gatewayExecutor }); + if (tokenReviewerJwt) { + await validateTokenReviewerPermissions({ gatewayExecutor, tokenReviewerJwt }); + } + } + } else if (tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && kubernetesHost) { + logger.info({ kubernetesHost }, "Validating Kubernetes host connectivity for new auth method"); + await validateKubernetesHostConnectivity({ + kubernetesHost, + caCert: caCert || undefined + }); + + if (tokenReviewerJwt) { + logger.info({ kubernetesHost }, "Validating token reviewer JWT permissions for new auth method"); + await validateTokenReviewerPermissions({ + kubernetesHost, + tokenReviewerJwt, + caCert: caCert || undefined + }); + } } const { encryptor } = await kmsService.createCipherPairWithDataKey({ @@ -850,6 +975,75 @@ export const identityKubernetesAuthServiceFactory = ({ const gatewayIdValue = isGatewayV1 ? gatewayId : null; const gatewayV2IdValue = isGatewayV1 ? null : gatewayId; + const effectiveTokenReviewMode = tokenReviewMode ?? identityKubernetesAuth.tokenReviewMode; + const effectiveKubernetesHost = + kubernetesHost !== undefined ? kubernetesHost : identityKubernetesAuth.kubernetesHost; + const effectiveGatewayId = + gatewayId !== undefined ? gatewayId : (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId); + + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.scopeOrgId + }); + + let effectiveCaCert: string | undefined; + if (caCert !== undefined) { + effectiveCaCert = caCert; + } else if (identityKubernetesAuth.encryptedKubernetesCaCertificate) { + effectiveCaCert = decryptor({ + cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate + }).toString(); + } else { + effectiveCaCert = undefined; + } + + if (effectiveGatewayId) { + if (effectiveTokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + const gatewayExecutor = $createGatewayValidationRequest(effectiveGatewayId); + logger.info( + { gatewayId: effectiveGatewayId }, + "Validating gateway connectivity to Kubernetes for auth method update" + ); + + await validateKubernetesHostConnectivity({ gatewayExecutor }); + await validateTokenReviewerPermissions({ gatewayExecutor }); + } else if (effectiveTokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && effectiveKubernetesHost) { + const gatewayExecutor = $createGatewayValidationRequest(effectiveGatewayId, { + kubernetesHost: effectiveKubernetesHost, + caCert: effectiveCaCert + }); + logger.info( + { gatewayId: effectiveGatewayId, kubernetesHost: effectiveKubernetesHost }, + "Validating Kubernetes connectivity through gateway for auth method update" + ); + + await validateKubernetesHostConnectivity({ gatewayExecutor }); + if (tokenReviewerJwt) { + await validateTokenReviewerPermissions({ gatewayExecutor, tokenReviewerJwt }); + } + } + } else if (effectiveTokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { + if (kubernetesHost) { + logger.info({ kubernetesHost }, "Validating Kubernetes host connectivity for auth method update"); + await validateKubernetesHostConnectivity({ + kubernetesHost, + caCert: effectiveCaCert + }); + } + + if (tokenReviewerJwt && effectiveKubernetesHost) { + logger.info( + { kubernetesHost: effectiveKubernetesHost }, + "Validating token reviewer JWT permissions for auth method update" + ); + await validateTokenReviewerPermissions({ + kubernetesHost: effectiveKubernetesHost, + tokenReviewerJwt, + caCert: effectiveCaCert + }); + } + } + const updateQuery: TIdentityKubernetesAuthsUpdate = { kubernetesHost, tokenReviewMode, @@ -866,11 +1060,6 @@ export const identityKubernetesAuthServiceFactory = ({ : undefined }; - const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identityMembershipOrg.scopeOrgId - }); - if (caCert !== undefined) { updateQuery.encryptedKubernetesCaCertificate = encryptor({ plainText: Buffer.from(caCert) }).cipherTextBlob; } diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-validators.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-validators.ts new file mode 100644 index 00000000000..902c5b1b3b6 --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-validators.ts @@ -0,0 +1,229 @@ +import { AxiosError, AxiosResponse } from "axios"; +import https from "https"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { handleAxiosError, KubernetesAuthErrorContext } from "./identity-kubernetes-auth-error-handlers"; + +export type GatewayRequestExecutor = ( + method: "get" | "post", + url: string, + body?: object, + headers?: Record +) => Promise>; + +/** + * Validates that the Kubernetes host is reachable by making a simple HTTPS request. + * This does not validate credentials, just network connectivity. + * + * Supports two modes: + * - API mode: Direct call to Kubernetes API (default) + * - Gateway mode: Call through gateway using gatewayExecutor + */ +export const validateKubernetesHostConnectivity = async ({ + kubernetesHost, + caCert, + gatewayExecutor +}: { + kubernetesHost?: string; + caCert?: string; + gatewayExecutor?: GatewayRequestExecutor; +}): Promise => { + const isGatewayMode = Boolean(gatewayExecutor); + const logContext = isGatewayMode ? { context: "gateway" } : { kubernetesHost }; + const errorContext = isGatewayMode + ? KubernetesAuthErrorContext.GatewayProxy + : KubernetesAuthErrorContext.KubernetesHost; + + try { + let response: AxiosResponse; + + if (gatewayExecutor) { + response = await gatewayExecutor("get", "/version"); + } else { + if (!kubernetesHost) { + throw new BadRequestError({ + name: "KubernetesHostConnectionError", + message: "Kubernetes host is required for API mode validation" + }); + } + + const httpsAgent = new https.Agent({ + ca: caCert || undefined, + rejectUnauthorized: Boolean(caCert) + }); + + await blockLocalAndPrivateIpAddresses(kubernetesHost); + + response = await request.get(`${kubernetesHost}/version`, { + httpsAgent, + timeout: 10000, + signal: AbortSignal.timeout(10000), + validateStatus: () => true + }); + } + + if (response.status >= 500) { + throw new BadRequestError({ + name: isGatewayMode ? "GatewayConnectionError" : "KubernetesHostConnectionError", + message: `Kubernetes API returned server error: ${response.status} - ${response.statusText}` + }); + } + + logger.info(logContext, "Kubernetes host connectivity validated successfully"); + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + + const error = err as Error; + logger.error({ error, ...logContext }, "Failed to connect to Kubernetes host"); + + if (err instanceof AxiosError) { + throw handleAxiosError(err, { kubernetesHost }, errorContext); + } + + throw new BadRequestError({ + name: isGatewayMode ? "GatewayConnectionError" : "KubernetesHostConnectionError", + message: isGatewayMode + ? `Failed to connect to Kubernetes through gateway: ${error.message}` + : `Failed to connect to Kubernetes host at ${kubernetesHost}: ${error.message}`, + error + }); + } +}; + +/** + * Validates that the token reviewer has the necessary permissions to perform token reviews. + * This is done by making a TokenReview request with a fake token to verify RBAC permissions + * without authenticating a real workload. + * + * Supports three modes: + * - API mode: Direct call to Kubernetes API using tokenReviewerJwt + * - Gateway mode (gateway reviewer): Gateway uses its own service account + * - Gateway mode (API reviewer): Gateway proxies request with user-provided tokenReviewerJwt + */ +export const validateTokenReviewerPermissions = async ({ + kubernetesHost, + tokenReviewerJwt, + caCert, + gatewayExecutor +}: { + kubernetesHost?: string; + tokenReviewerJwt?: string; + caCert?: string; + gatewayExecutor?: GatewayRequestExecutor; +}): Promise => { + const isGatewayMode = Boolean(gatewayExecutor); + const isGatewayWithUserJwt = isGatewayMode && Boolean(tokenReviewerJwt); + const logContext = isGatewayMode ? { context: "gateway" } : { kubernetesHost }; + const errorContext = isGatewayMode + ? KubernetesAuthErrorContext.GatewayProxy + : KubernetesAuthErrorContext.KubernetesApiServer; + + let errorNamePrefix = "TokenReviewer"; + if (isGatewayMode && !isGatewayWithUserJwt) { + errorNamePrefix = "GatewayTokenReview"; + } + + try { + const testToken = "test-token-for-permission-validation"; + const tokenReviewBody = { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: testToken + } + }; + + let response: AxiosResponse; + + if (gatewayExecutor) { + // Gateway mode: optionally pass user JWT if provided (API mode through gateway) + const headers = tokenReviewerJwt ? { Authorization: `Bearer ${tokenReviewerJwt}` } : undefined; + response = await gatewayExecutor("post", "/apis/authentication.k8s.io/v1/tokenreviews", tokenReviewBody, headers); + } else { + // Direct API mode: call Kubernetes API directly + if (!kubernetesHost || !tokenReviewerJwt) { + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: "Kubernetes host and token reviewer JWT are required for API mode validation" + }); + } + + const httpsAgent = new https.Agent({ + ca: caCert || undefined, + rejectUnauthorized: Boolean(caCert) + }); + + await blockLocalAndPrivateIpAddresses(kubernetesHost); + + response = await request.post(`${kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`, tokenReviewBody, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` + }, + httpsAgent, + timeout: 10000, + signal: AbortSignal.timeout(10000), + validateStatus: () => true + }); + } + + if (response.status === 401) { + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: + isGatewayMode && !isGatewayWithUserJwt + ? "Gateway service account is not authorized. Verify the gateway is deployed correctly and has a valid service account." + : "The token reviewer JWT is invalid or expired. Please provide a valid service account token with TokenReview permissions." + }); + } + + if (response.status === 403) { + const errorMessage = + (response.data as { message?: string })?.message || + (isGatewayMode && !isGatewayWithUserJwt + ? "Gateway service account does not have permission to perform TokenReviews." + : "The token reviewer JWT does not have permission to perform TokenReviews."); + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: `${errorMessage}. Ensure the service account has the 'system:auth-delegator' ClusterRole binding.` + }); + } + + if (response.status >= 200 && response.status < 300) { + const data = response.data as { status?: { authenticated?: boolean; error?: string } }; + logger.info( + { ...logContext, authenticated: data?.status?.authenticated }, + "Token reviewer permission validation successful" + ); + return; + } + + const errorMessage = (response.data as { message?: string })?.message || response.statusText; + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: `Unexpected response from Kubernetes API: ${response.status} - ${errorMessage}` + }); + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + + const error = err as Error; + logger.error({ error, ...logContext }, "Failed to validate token reviewer permissions"); + + if (err instanceof AxiosError) { + throw handleAxiosError(err, { kubernetesHost }, errorContext); + } + + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: `Failed to validate token reviewer permissions: ${error.message}` + }); + } +};