Merge branch 'main' into ENG-4169

This commit is contained in:
Daniel Hougaard
2026-01-06 01:21:51 +01:00
48 changed files with 1867 additions and 638 deletions

View File

@@ -717,7 +717,7 @@ export const oidcConfigServiceFactory = ({
client,
passReqToCallback: true,
usePKCE: supportsPKCE,
params: supportsPKCE ? { code_challenge_method: "S256" } : undefined
params: { prompt: "login", ...(supportsPKCE ? { code_challenge_method: "S256" } : {}) }
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_req: any, tokenSet: TokenSet, cb: any) => {

View File

@@ -47,7 +47,6 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal";
import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal";
import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal";
@@ -61,7 +60,6 @@ import {
AcmeMalformedError,
AcmeOrderNotReadyError,
AcmeServerInternalError,
AcmeUnauthorizedError,
AcmeUnsupportedIdentifierError
} from "./pki-acme-errors";
import { buildUrl, extractAccountIdFromKid, validateDnsIdentifier } from "./pki-acme-fns";
@@ -129,7 +127,6 @@ type TPkiAcmeServiceFactoryDep = {
TKmsServiceFactory,
"decryptWithKmsKey" | "generateKmsKey" | "encryptWithKmsKey" | "createCipherPairWithDataKey"
>;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">;
certificateTemplateV2Service: Pick<TCertificateTemplateV2ServiceFactory, "validateCertificateRequest">;
certificateRequestService: Pick<TCertificateRequestServiceFactory, "createCertificateRequest">;
@@ -152,7 +149,6 @@ export const pkiAcmeServiceFactory = ({
acmeChallengeDAL,
keyStore,
kmsService,
licenseService,
certificateV3Service,
certificateTemplateV2Service,
certificateRequestService,
@@ -169,12 +165,6 @@ export const pkiAcmeServiceFactory = ({
if (profile.enrollmentType !== EnrollmentType.ACME) {
throw new NotFoundError({ message: "Certificate profile is not configured for ACME enrollment" });
}
const orgLicensePlan = await licenseService.getPlan(profile.project!.orgId);
if (!orgLicensePlan.pkiAcme) {
throw new AcmeUnauthorizedError({
message: "Failed to validate ACME profile: Plan restriction. Upgrade plan to continue"
});
}
return profile;
};

View File

@@ -1227,7 +1227,6 @@ export const registerRoutes = async (
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
permissionService,
licenseService,
kmsService,
projectDAL
});
@@ -2340,7 +2339,6 @@ export const registerRoutes = async (
acmeChallengeDAL,
keyStore,
kmsService,
licenseService,
certificateV3Service,
certificateTemplateV2Service,
certificateRequestService,

View File

@@ -5,9 +5,8 @@
import { ForbiddenError } from "@casl/ability";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorType, AuthMethod } from "../auth/auth-type";
import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
@@ -175,10 +174,6 @@ describe("CertificateProfileService", () => {
})
} as unknown as Pick<TPermissionServiceFactory, "getProjectPermission">;
const mockLicenseService = {
getPlan: vi.fn()
} as unknown as Pick<TLicenseServiceFactory, "getPlan">;
const mockKmsService = {
encryptWithKmsKey: vi
.fn()
@@ -258,7 +253,6 @@ describe("CertificateProfileService", () => {
certificateAuthorityDAL: mockCertificateAuthorityDAL,
externalCertificateAuthorityDAL: mockExternalCertificateAuthorityDAL,
permissionService: mockPermissionService,
licenseService: mockLicenseService,
kmsService: mockKmsService,
projectDAL: mockProjectDAL
});
@@ -287,9 +281,6 @@ describe("CertificateProfileService", () => {
id: "project-123",
orgId: "org-123"
});
(mockLicenseService.getPlan as any).mockResolvedValue({
pkiAcme: true
});
(mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
(mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null);
(mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
@@ -423,30 +414,6 @@ describe("CertificateProfileService", () => {
expect(result).toEqual(sampleProfile);
expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
});
it("should throw BadRequestError when plan does not support ACME", async () => {
(mockLicenseService.getPlan as any).mockResolvedValue({
pkiAcme: false
});
await expect(
service.createProfile({
...mockActor,
projectId: "project-123",
data: {
...validProfileData,
enrollmentType: EnrollmentType.ACME,
acmeConfig: {},
apiConfig: undefined,
estConfig: undefined
}
})
).rejects.toThrowError(
new BadRequestError({
message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue"
})
);
});
});
describe("updateProfile", () => {
@@ -756,9 +723,6 @@ describe("CertificateProfileService", () => {
id: "project-123",
orgId: "org-123"
});
(mockLicenseService.getPlan as any).mockResolvedValue({
pkiAcme: true
});
(mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
(mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null);
(mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);

View File

@@ -2,7 +2,6 @@ import { ForbiddenError, subject } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { ActionProjectType } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionCertificateActions,
@@ -233,7 +232,6 @@ type TCertificateProfileServiceFactoryDep = {
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "findById" | "findOne">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
};
@@ -271,7 +269,6 @@ export const certificateProfileServiceFactory = ({
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
permissionService,
licenseService,
kmsService,
projectDAL
}: TCertificateProfileServiceFactoryDep) => {
@@ -309,12 +306,6 @@ export const certificateProfileServiceFactory = ({
if (!project) {
throw new NotFoundError({ message: "Project not found" });
}
const plan = await licenseService.getPlan(project.orgId);
if (!plan.pkiAcme && data.enrollmentType === EnrollmentType.ACME) {
throw new BadRequestError({
message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue"
});
}
// Validate that certificate template exists and belongs to the same project
if (data.certificateTemplateId) {

View File

@@ -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);
};

View File

@@ -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>`
});
};

View File

@@ -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;
}

View File

@@ -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}`
});
}
};