diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 737aaadea..3384e3781 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -6,6 +6,7 @@ import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; import { ElasticSearchProvider } from "./elastic-search"; +import { KubernetesProvider } from "./kubernetes"; import { LdapProvider } from "./ldap"; import { DynamicSecretProviders, TDynamicProviderFns } from "./models"; import { MongoAtlasProvider } from "./mongo-atlas"; @@ -38,5 +39,6 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.SapHana]: SapHanaProvider(), [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), [DynamicSecretProviders.Totp]: TotpProvider(), - [DynamicSecretProviders.SapAse]: SapAseProvider() + [DynamicSecretProviders.SapAse]: SapAseProvider(), + [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }) }); diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts new file mode 100644 index 000000000..3f0823ba3 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -0,0 +1,180 @@ +import axios from "axios"; +import https from "https"; + +import { withGatewayProxy } from "@app/lib/gateway"; +import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretKubernetesSchema, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +type TKubernetesProviderDTO = { + gatewayService: Pick; +}; + +export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs); + const [hostIp] = await verifyHostInputValidity(providerInputs.url, Boolean(providerInputs.gatewayId)); + return { ...providerInputs, hostIp }; + }; + + const $gatewayProxyWrapper = async ( + inputs: { + gatewayId: string; + targetHost: string; + targetPort: number; + }, + gatewayCallback: (host: string, port: number) => Promise + ): Promise => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + const callbackResult = await withGatewayProxy( + async (port) => { + // Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server" + const res = await gatewayCallback("https://localhost", port); + return res; + }, + { + targetHost: inputs.targetHost, + targetPort: inputs.targetPort, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + + return callbackResult; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + + const serviceAccountGetCallback = async (host: string, port: number) => { + const baseUrl = port ? `${host}:${port}` : host; + + try { + await axios.get( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${providerInputs.serviceAccountName}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${providerInputs.clusterToken}` + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + return true; + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (axios.isAxiosError(err) && err.response?.data.message) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + throw new Error(`Failed to validate connection: ${err.response.data.message}`); + } + throw err; + } + }; + + const url = new URL(providerInputs.url); + const k8sHost = `${url.protocol}//${url.hostname}`; + const k8sPort = url.port ? Number(url.port) : 443; + + if (providerInputs.gatewayId) { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort + }, + serviceAccountGetCallback + ); + } else { + await serviceAccountGetCallback(k8sHost, k8sPort); + } + + return true; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + + const tokenRequestCallback = async (host: string, port: number) => { + const baseUrl = port ? `${host}:${port}` : host; + + const res = await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${providerInputs.serviceAccountName}/token`, + { + spec: { + expirationSeconds: Math.floor((expireAt - Date.now()) / 1000), + ...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${providerInputs.clusterToken}` + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + + return res.data; + }; + + const url = new URL(providerInputs.url); + const k8sHost = `${url.protocol}//${url.hostname}`; + const k8sPort = url.port ? Number(url.port) : 443; + + const tokenData = providerInputs.gatewayId + ? await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort + }, + tokenRequestCallback + ) + : await tokenRequestCallback(k8sHost, k8sPort); + + return { + entityId: providerInputs.serviceAccountName, + data: { TOKEN: tokenData.status.token } + }; + }; + + const revoke = async (_inputs: unknown, entityId: string) => { + return { entityId }; + }; + + const renew = async (_inputs: unknown, entityId: string) => { + // No renewal necessary + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 0c6eaf151..28baac721 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -29,6 +29,10 @@ export enum LdapCredentialType { Static = "static" } +export enum KubernetesCredentialType { + Static = "static" +} + export enum TotpConfigType { URL = "url", MANUAL = "manual" @@ -277,6 +281,18 @@ export const LdapSchema = z.union([ }) ]); +export const DynamicSecretKubernetesSchema = z.object({ + url: z.string().url().trim().min(1), + gatewayId: z.string().nullable().optional(), + sslEnabled: z.boolean().default(true), + clusterToken: z.string().trim().min(1), + ca: z.string().optional(), + serviceAccountName: z.string().trim().min(1), + credentialType: z.literal(KubernetesCredentialType.Static), + namespace: z.string().trim().min(1), + audiences: z.array(z.string().trim().min(1)) +}); + export const DynamicSecretTotpSchema = z.discriminatedUnion("configType", [ z.object({ configType: z.literal(TotpConfigType.URL), @@ -320,7 +336,8 @@ export enum DynamicSecretProviders { SapHana = "sap-hana", Snowflake = "snowflake", Totp = "totp", - SapAse = "sap-ase" + SapAse = "sap-ase", + Kubernetes = "kubernetes" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -338,7 +355,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }), z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }), z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts index 7a9cb88b5..12edd266f 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -63,6 +63,18 @@ export type TCreateTokenReviewResponse = { status: TCreateTokenReviewSuccessResponse | TCreateTokenReviewErrorResponse; }; +export type TKubernetesTokenRequest = { + apiVersion: "authentication.k8s.io/v1"; + kind: "TokenRequest"; + spec: { + audiences: string[]; + expirationSeconds: number; + }; + status: { + token: string; + }; +}; + export type TRevokeKubernetesAuthDTO = { identityId: string; } & Omit; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 1aedf264f..6aaf301d6 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -31,7 +31,8 @@ export enum DynamicSecretProviders { SapHana = "sap-hana", Snowflake = "snowflake", Totp = "totp", - SapAse = "sap-ase" + SapAse = "sap-ase", + Kubernetes = "kubernetes" } export enum SqlProviders { @@ -261,6 +262,16 @@ export type TDynamicSecretProvider = algorithm?: string; digits?: number; }; + } + | { + type: DynamicSecretProviders.Kubernetes; + inputs: { + url: string; + clusterToken: string; + ca?: string; + serviceAccountName: string; + namespace: string; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index 106a658d4..9d9cca5d3 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -4,6 +4,7 @@ import { SiApachecassandra, SiElasticsearch, SiFiles, + SiKubernetes, SiMongodb, SiRabbitmq, SiSap, @@ -24,6 +25,7 @@ import { AwsIamInputForm } from "./AwsIamInputForm"; import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm"; import { CassandraInputForm } from "./CassandraInputForm"; import { ElasticSearchInputForm } from "./ElasticSearchInputForm"; +import { KubernetesInputForm } from "./KubernetesInputForm"; import { LdapInputForm } from "./LdapInputForm"; import { MongoAtlasInputForm } from "./MongoAtlasInputForm"; import { MongoDBDatabaseInputForm } from "./MongoDBInputForm"; @@ -124,6 +126,11 @@ const DYNAMIC_SECRET_LIST = [ icon: , provider: DynamicSecretProviders.Totp, title: "TOTP" + }, + { + icon: , + provider: DynamicSecretProviders.Kubernetes, + title: "Kubernetes" } ]; @@ -472,6 +479,25 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.Kubernetes && ( + + + + )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/KubernetesInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/KubernetesInputForm.tsx new file mode 100644 index 000000000..b08a3c5b0 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/KubernetesInputForm.tsx @@ -0,0 +1,492 @@ +import { Controller, FieldValues, useFieldArray, useForm } from "react-hook-form"; +import { + faArrowUpRightFromSquare, + faBookOpen, + faQuestionCircle, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + FilterableSelect, + FormControl, + IconButton, + Input, + Select, + SelectItem, + Switch, + TextArea, + Tooltip +} from "@app/components/v2"; +import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext"; +import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types"; +import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; + +enum CredentialType { + Dynamic = "dynamic", + Static = "static" +} + +const credentialTypes = [ + { + label: "Static", + value: CredentialType.Static + } +] as const; + +const formSchema = z.object({ + provider: z.object({ + url: z.string().url().trim().min(1), + clusterToken: z.string().trim().min(1), + ca: z.string().optional(), + sslEnabled: z.boolean().default(true), + credentialType: z.literal(CredentialType.Static), + serviceAccountName: z.string().trim().min(1), + namespace: z.string().trim().min(1), + gatewayId: z.string().optional(), + audiences: z.array(z.string().trim().min(1)) + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"), + environment: z.object({ name: z.string(), slug: z.string() }) +}); + +type TForm = z.infer & FieldValues; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environments: WorkspaceEnv[]; + isSingleEnvironmentMode?: boolean; +}; + +export const KubernetesInputForm = ({ + onCompleted, + onCancel, + secretPath, + projectSlug, + environments, + isSingleEnvironmentMode +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit, + watch + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + url: "", + clusterToken: "", + ca: "", + sslEnabled: true, + serviceAccountName: "", + namespace: "", + credentialType: CredentialType.Static, + gatewayId: undefined, + audiences: [] + }, + environment: isSingleEnvironmentMode ? environments[0] : undefined + } + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: "provider.audiences" + }); + + const createDynamicSecret = useCreateDynamicSecret(); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + + const sslEnabled = watch("provider.sslEnabled"); + + const handleCreateDynamicSecret = async (formData: TForm) => { + const { provider, ...rest } = formData; + // wait till previous request is finished + if (createDynamicSecret.isPending) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.Kubernetes, inputs: provider }, + maxTTL: rest.maxTTL, + name: rest.name, + path: secretPath, + defaultTTL: rest.defaultTTL, + projectSlug, + environmentSlug: rest.environment.slug + }); + + onCompleted(); + } catch { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration + +
+ + Docs + +
+
+
+
+
+
+
+ + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+
+ ( + + + + )} + /> + +
+ + Enable SSL + + If enabled, you can optionally provide a custom CA certificate. Leave + blank to use the system/public CA. + + } + > + + + + ( + + )} + /> +
+ + ( + +