mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-08 23:18:05 -05:00
Merge pull request #5002 from Infisical/enhancement/k8s-auth-validation-and-errors
improvement(kubernetes-auth): k8s auth validation and errors
This commit is contained in:
@@ -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, ErrorContextConfig> = {
|
||||
[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);
|
||||
};
|
||||
@@ -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:<namespace>:<name>`
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<TIdentityDALFactory, "findById">;
|
||||
@@ -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 <T>(method: "get" | "post", url: string, body?: object, headers?: Record<string, string>) => {
|
||||
let response: AxiosResponse<T> | 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<T>(`${host}:${port}${url}`, config);
|
||||
} else {
|
||||
response = await request.post<T>(`${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);
|
||||
}
|
||||
throw err;
|
||||
|
||||
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 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);
|
||||
}
|
||||
throw err;
|
||||
|
||||
if (isKnownError(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;
|
||||
}
|
||||
|
||||
@@ -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 = <T>(
|
||||
method: "get" | "post",
|
||||
url: string,
|
||||
body?: object,
|
||||
headers?: Record<string, string>
|
||||
) => Promise<AxiosResponse<T>>;
|
||||
|
||||
/**
|
||||
* 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<void> => {
|
||||
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<void> => {
|
||||
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}`
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user