Add k8s stuff

This commit is contained in:
Fang-Pen Lin
2025-12-02 17:03:13 -08:00
parent 1269e7c245
commit 284024d10b
12 changed files with 396 additions and 9 deletions

View File

@@ -1,3 +1,8 @@
import {
CreateKubernetesAccountSchema,
SanitizedKubernetesAccountWithResourceSchema,
UpdateKubernetesAccountSchema
} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas";
import {
CreateMySQLAccountSchema,
SanitizedMySQLAccountWithResourceSchema,
@@ -44,5 +49,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fasti
createAccountSchema: CreateSSHAccountSchema,
updateAccountSchema: UpdateSSHAccountSchema
});
},
[PamResource.Kubernetes]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.Kubernetes,
accountResponseSchema: SanitizedKubernetesAccountWithResourceSchema,
createAccountSchema: CreateKubernetesAccountSchema,
updateAccountSchema: UpdateKubernetesAccountSchema
});
}
};

View File

@@ -1,3 +1,8 @@
import {
CreateKubernetesResourceSchema,
SanitizedKubernetesResourceSchema,
UpdateKubernetesResourceSchema
} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas";
import {
CreateMySQLResourceSchema,
MySQLResourceSchema,
@@ -44,5 +49,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fast
createResourceSchema: CreateSSHResourceSchema,
updateResourceSchema: UpdateSSHResourceSchema
});
},
[PamResource.Kubernetes]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.Kubernetes,
resourceResponseSchema: SanitizedKubernetesResourceSchema,
createResourceSchema: CreateKubernetesResourceSchema,
updateResourceSchema: UpdateKubernetesResourceSchema
});
}
};

View File

@@ -1,6 +1,10 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import {
KubernetesResourceListItemSchema,
SanitizedKubernetesResourceSchema
} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas";
import {
MySQLResourceListItemSchema,
SanitizedMySQLResourceSchema
@@ -22,13 +26,15 @@ import { AuthMode } from "@app/services/auth/auth-type";
const SanitizedResourceSchema = z.union([
SanitizedPostgresResourceSchema,
SanitizedMySQLResourceSchema,
SanitizedSSHResourceSchema
SanitizedSSHResourceSchema,
SanitizedKubernetesResourceSchema
]);
const ResourceOptionsSchema = z.discriminatedUnion("resource", [
PostgresResourceListItemSchema,
MySQLResourceListItemSchema,
SSHResourceListItemSchema
SSHResourceListItemSchema,
KubernetesResourceListItemSchema
]);
export const registerPamResourceRouter = async (server: FastifyZodProvider) => {

View File

@@ -0,0 +1,3 @@
export enum KubernetesAuthMethod {
ServiceAccountToken = "service-account-token"
}

View File

@@ -0,0 +1,215 @@
import axios, { AxiosError } from "axios";
import https from "https";
import { BadRequestError } from "@app/lib/errors";
import { GatewayProxyProtocol, withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
import { logger } from "@app/lib/logger";
import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns";
import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service";
import { PamResource } from "../pam-resource-enums";
import {
TPamResourceFactory,
TPamResourceFactoryRotateAccountCredentials,
TPamResourceFactoryValidateAccountCredentials
} from "../pam-resource-types";
import { KubernetesAuthMethod } from "./kubernetes-resource-enums";
import { TKubernetesAccountCredentials, TKubernetesResourceConnectionDetails } from "./kubernetes-resource-types";
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
export const executeWithGateway = async <T>(
config: {
connectionDetails: TKubernetesResourceConnectionDetails;
resourceType: PamResource;
gatewayId: string;
},
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
operation: (baseUrl: string, httpsAgent?: https.Agent) => Promise<T>
): Promise<T> => {
const { connectionDetails, gatewayId } = config;
const url = new URL(connectionDetails.url);
const [targetHost] = await verifyHostInputValidity(url.hostname, true);
const targetPort = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
gatewayId,
targetHost,
targetPort
});
if (!platformConnectionDetails) {
throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" });
}
let httpsAgent: https.Agent | undefined;
if (connectionDetails.caCertificate) {
httpsAgent = new https.Agent({
ca: connectionDetails.caCertificate,
rejectUnauthorized: !connectionDetails.skipTLSVerify
});
} else if (!connectionDetails.skipTLSVerify) {
httpsAgent = new https.Agent({
rejectUnauthorized: true
});
}
return withGatewayV2Proxy(
async (proxyPort) => {
const protocol = url.protocol === "https:" ? "https" : "http";
const baseUrl = `${protocol}://localhost:${proxyPort}`;
return operation(baseUrl, httpsAgent);
},
{
protocol: GatewayProxyProtocol.Tcp,
relayHost: platformConnectionDetails.relayHost,
gateway: platformConnectionDetails.gateway,
relay: platformConnectionDetails.relay,
httpsAgent
}
);
};
export const kubernetesResourceFactory: TPamResourceFactory<
TKubernetesResourceConnectionDetails,
TKubernetesAccountCredentials
> = (resourceType, connectionDetails, gatewayId, gatewayV2Service) => {
const validateConnection = async () => {
try {
await executeWithGateway(
{ connectionDetails, gatewayId, resourceType },
gatewayV2Service,
async (baseUrl, httpsAgent) => {
// Validate connection by checking API server version
try {
await axios.get(`${baseUrl}/version`, {
headers: {
"Content-Type": "application/json"
},
...(httpsAgent ? { httpsAgent } : {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT
});
} catch (error) {
if (error instanceof AxiosError) {
// If we get a 401/403, it means we reached the API server but need auth - that's fine for connection validation
if (error.response?.status === 401 || error.response?.status === 403) {
logger.info(
{ status: error.response.status },
"[Kubernetes Resource Factory] Kubernetes connection validation succeeded (auth required)"
);
return connectionDetails;
}
throw new BadRequestError({
message: `Unable to connect to Kubernetes API server: ${error.response?.statusText || error.message}`
});
}
throw error;
}
logger.info("[Kubernetes Resource Factory] Kubernetes connection validation succeeded");
return connectionDetails;
}
);
return connectionDetails;
} catch (error) {
throw new BadRequestError({
message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}`
});
}
};
const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<
TKubernetesAccountCredentials
> = async (credentials) => {
try {
await executeWithGateway(
{ connectionDetails, gatewayId, resourceType },
gatewayV2Service,
async (baseUrl, httpsAgent) => {
if (credentials.authMethod === KubernetesAuthMethod.ServiceAccountToken) {
// Validate service account token by making an authenticated API call
try {
await axios.get(`${baseUrl}/api/v1/namespaces/${connectionDetails.namespace}`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.serviceAccountToken}`
},
...(httpsAgent ? { httpsAgent } : {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT
});
logger.info(
{ serviceAccountName: credentials.serviceAccountName, namespace: connectionDetails.namespace },
"[Kubernetes Resource Factory] Kubernetes service account token authentication successful"
);
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 401 || error.response?.status === 403) {
throw new BadRequestError({
message:
"Account credentials invalid. Service account token is not valid or does not have required permissions."
});
}
throw new BadRequestError({
message: `Unable to validate account credentials: ${error.response?.statusText || error.message}`
});
}
throw error;
}
} else {
throw new BadRequestError({
message: `Unsupported Kubernetes auth method: ${(credentials as TKubernetesAccountCredentials).authMethod}`
});
}
}
);
return credentials;
} catch (error) {
if (error instanceof BadRequestError) {
throw error;
}
throw new BadRequestError({
message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
});
}
};
const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<TKubernetesAccountCredentials> = async (
rotationAccountCredentials
) => {
// For Kubernetes, rotation would typically involve creating a new service account token
// This is a placeholder - actual rotation logic would need to be implemented based on requirements
return rotationAccountCredentials;
};
const handleOverwritePreventionForCensoredValues = async (
updatedAccountCredentials: TKubernetesAccountCredentials,
currentCredentials: TKubernetesAccountCredentials
) => {
if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) {
return updatedAccountCredentials;
}
if (
updatedAccountCredentials.authMethod === KubernetesAuthMethod.ServiceAccountToken &&
currentCredentials.authMethod === KubernetesAuthMethod.ServiceAccountToken
) {
if (updatedAccountCredentials.serviceAccountToken === "__INFISICAL_UNCHANGED__") {
return {
...updatedAccountCredentials,
serviceAccountToken: currentCredentials.serviceAccountToken
};
}
}
return updatedAccountCredentials;
};
return {
validateConnection,
validateAccountCredentials,
rotateAccountCredentials,
handleOverwritePreventionForCensoredValues
};
};

View File

@@ -0,0 +1,8 @@
import { KubernetesResourceListItemSchema } from "./kubernetes-resource-schemas";
export const getKubernetesResourceListItem = () => {
return {
name: KubernetesResourceListItemSchema.shape.name.value,
resource: KubernetesResourceListItemSchema.shape.resource.value
};
};

View File

@@ -0,0 +1,94 @@
import { z } from "zod";
import { PamResource } from "../pam-resource-enums";
import {
BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema,
BasePamAccountSchemaWithResource,
BasePamResourceSchema,
BaseUpdatePamAccountSchema,
BaseUpdatePamResourceSchema
} from "../pam-resource-schemas";
import { KubernetesAuthMethod } from "./kubernetes-resource-enums";
export const BaseKubernetesResourceSchema = BasePamResourceSchema.extend({
resourceType: z.literal(PamResource.Kubernetes)
});
export const KubernetesResourceListItemSchema = z.object({
name: z.literal("Kubernetes"),
resource: z.literal(PamResource.Kubernetes)
});
export const KubernetesResourceConnectionDetailsSchema = z.object({
url: z.string().url().trim().max(500),
namespace: z.string().trim().max(255),
skipTLSVerify: z.boolean().optional().default(false),
caCertificate: z.string().trim().max(10000).optional()
});
export const KubernetesServiceAccountTokenCredentialsSchema = z.object({
authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken),
serviceAccountName: z.string().trim().max(255),
serviceAccountToken: z.string().trim().max(10000)
});
export const KubernetesAccountCredentialsSchema = z.discriminatedUnion("authMethod", [
KubernetesServiceAccountTokenCredentialsSchema
]);
export const KubernetesResourceSchema = BaseKubernetesResourceSchema.extend({
connectionDetails: KubernetesResourceConnectionDetailsSchema,
rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional()
});
export const SanitizedKubernetesResourceSchema = BaseKubernetesResourceSchema.extend({
connectionDetails: KubernetesResourceConnectionDetailsSchema,
rotationAccountCredentials: z
.discriminatedUnion("authMethod", [
z.object({
authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken),
serviceAccountName: z.string()
})
])
.nullable()
.optional()
});
export const CreateKubernetesResourceSchema = BaseCreatePamResourceSchema.extend({
connectionDetails: KubernetesResourceConnectionDetailsSchema,
rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional()
});
export const UpdateKubernetesResourceSchema = BaseUpdatePamResourceSchema.extend({
connectionDetails: KubernetesResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional()
});
// Accounts
export const KubernetesAccountSchema = BasePamAccountSchema.extend({
credentials: KubernetesAccountCredentialsSchema
});
export const CreateKubernetesAccountSchema = BaseCreatePamAccountSchema.extend({
credentials: KubernetesAccountCredentialsSchema
});
export const UpdateKubernetesAccountSchema = BaseUpdatePamAccountSchema.extend({
credentials: KubernetesAccountCredentialsSchema.optional()
});
export const SanitizedKubernetesAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
credentials: z.discriminatedUnion("authMethod", [
z.object({
authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken),
serviceAccountName: z.string()
})
])
});
// Sessions
export const KubernetesSessionCredentialsSchema = KubernetesResourceConnectionDetailsSchema.and(
KubernetesAccountCredentialsSchema
);

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
import {
KubernetesAccountCredentialsSchema,
KubernetesAccountSchema,
KubernetesResourceConnectionDetailsSchema,
KubernetesResourceSchema
} from "./kubernetes-resource-schemas";
// Resources
export type TKubernetesResource = z.infer<typeof KubernetesResourceSchema>;
export type TKubernetesResourceConnectionDetails = z.infer<typeof KubernetesResourceConnectionDetailsSchema>;
// Accounts
export type TKubernetesAccount = z.infer<typeof KubernetesAccountSchema>;
export type TKubernetesAccountCredentials = z.infer<typeof KubernetesAccountCredentialsSchema>;

View File

@@ -1,7 +1,8 @@
export enum PamResource {
Postgres = "postgres",
MySQL = "mysql",
SSH = "ssh"
SSH = "ssh",
Kubernetes = "kubernetes"
}
export enum PamResourceOrderBy {

View File

@@ -1,5 +1,6 @@
import { PamResource } from "./pam-resource-enums";
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
import { kubernetesResourceFactory } from "./kubernetes/kubernetes-resource-factory";
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
import { sshResourceFactory } from "./ssh/ssh-resource-factory";
@@ -8,5 +9,6 @@ type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnect
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation,
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation,
[PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation
[PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation,
[PamResource.Kubernetes]: kubernetesResourceFactory as TPamResourceFactoryImplementation
};

View File

@@ -3,12 +3,15 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { decryptAccountCredentials } from "../pam-account/pam-account-fns";
import { getKubernetesResourceListItem } from "./kubernetes/kubernetes-resource-fns";
import { getMySQLResourceListItem } from "./mysql/mysql-resource-fns";
import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types";
import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns";
export const listResourceOptions = () => {
return [getPostgresResourceListItem(), getMySQLResourceListItem()].sort((a, b) => a.name.localeCompare(b.name));
return [getPostgresResourceListItem(), getMySQLResourceListItem(), getKubernetesResourceListItem()].sort((a, b) =>
a.name.localeCompare(b.name)
);
};
// Resource

View File

@@ -1,6 +1,12 @@
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import {
TKubernetesAccount,
TKubernetesAccountCredentials,
TKubernetesResource,
TKubernetesResourceConnectionDetails
} from "./kubernetes/kubernetes-resource-types";
import {
TMySQLAccount,
TMySQLAccountCredentials,
@@ -22,16 +28,21 @@ import {
} from "./ssh/ssh-resource-types";
// Resource types
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TKubernetesResource;
export type TPamResourceConnectionDetails =
| TPostgresResourceConnectionDetails
| TMySQLResourceConnectionDetails
| TSSHResourceConnectionDetails;
| TSSHResourceConnectionDetails
| TKubernetesResourceConnectionDetails;
// Account types
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TKubernetesAccount;
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials;
export type TPamAccountCredentials =
| TPostgresAccountCredentials
| TMySQLAccountCredentials
| TSSHAccountCredentials
| TKubernetesAccountCredentials;
// Resource DTOs
export type TCreateResourceDTO = Pick<