diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index 130e0fa92..81bced0bb 100644 --- a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -1,13 +1,21 @@ import axios from "axios"; +import handlebars from "handlebars"; import https from "https"; import { InternalServerError } from "@app/lib/errors"; -import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; -import { DynamicSecretKubernetesSchema, TDynamicProviderFns } from "./models"; +import { + DynamicSecretKubernetesSchema, + KubernetesAuthMethod, + KubernetesCredentialType, + KubernetesRoleType, + TDynamicProviderFns +} from "./models"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; @@ -15,6 +23,16 @@ type TKubernetesProviderDTO = { gatewayService: Pick; }; +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = `dynamic-secret-sa-${alphaNumericNanoId(10).toLowerCase()}`; + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); +}; + export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs); @@ -30,20 +48,27 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): gatewayId: string; targetHost: string; targetPort: number; + caCert?: string; + reviewTokenThroughGateway: boolean; + enableSsl: boolean; }, - gatewayCallback: (host: string, port: number) => Promise + gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise ): Promise => { const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); const callbackResult = await withGatewayProxy( - async (port) => { + async (port, httpsAgent) => { // 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); + const res = await gatewayCallback( + inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost", + port, + httpsAgent + ); return res; }, { - protocol: GatewayProxyProtocol.Tcp, + protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, targetHost: inputs.targetHost, targetPort: inputs.targetPort, relayHost, @@ -54,7 +79,12 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): ca: relayDetails.certChain, cert: relayDetails.certificate, key: relayDetails.privateKey.toString() - } + }, + // we always pass this, because its needed for both tcp and http protocol + httpsAgent: new https.Agent({ + ca: inputs.caCert, + rejectUnauthorized: inputs.enableSsl + }) } ); @@ -64,7 +94,169 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const serviceAccountGetCallback = async (host: string, port: number) => { + const serviceAccountDynamicCallback = async (host: string, port: number) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + const serviceAccountName = generateUsername(); + const roleBindingName = `${serviceAccountName}-role-binding`; + + // 1. Create a test service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`, + { + metadata: { + name: serviceAccountName, + namespace: providerInputs.namespace + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + + // 2. Create a test role binding + const roleBindingUrl = + providerInputs.roleType === KubernetesRoleType.ClusterRole + ? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings` + : `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings`; + + const roleBindingMetadata = { + name: roleBindingName, + ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace }) + }; + + await axios.post( + roleBindingUrl, + { + metadata: roleBindingMetadata, + roleRef: { + kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role", + name: providerInputs.role, + apiGroup: "rbac.authorization.k8s.io" + }, + subjects: [ + { + kind: "ServiceAccount", + name: serviceAccountName, + namespace: providerInputs.namespace + } + ] + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + + // 3. Request a token for the test service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`, + { + spec: { + expirationSeconds: 600, // 10 minutes + ...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + + // 4. Cleanup: delete role binding and service account + if (providerInputs.roleType === KubernetesRoleType.Role) { + await axios.delete( + `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + } else { + await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + }); + } + + await axios.delete( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}`, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + }; + + const serviceAccountStaticCallback = async (host: string, port: number) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Static) { + throw new Error("invalid callback"); + } + const baseUrl = port ? `${host}:${port}` : host; await axios.get( @@ -72,7 +264,9 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${providerInputs.clusterToken}` + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), timeout: EXTERNAL_REQUEST_TIMEOUT, @@ -85,23 +279,45 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): }; const url = new URL(providerInputs.url); + const k8sGatewayHost = url.hostname; const k8sPort = url.port ? Number(url.port) : 443; + const k8sHost = `${url.protocol}//${url.hostname}`; try { if (providerInputs.gatewayId) { - const k8sHost = url.hostname; - - await $gatewayProxyWrapper( - { - gatewayId: providerInputs.gatewayId, - targetHost: k8sHost, - targetPort: k8sPort - }, - serviceAccountGetCallback - ); + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: true + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? serviceAccountStaticCallback + : serviceAccountDynamicCallback + ); + } else { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: false + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? serviceAccountStaticCallback + : serviceAccountDynamicCallback + ); + } + } else if (providerInputs.credentialType === KubernetesCredentialType.Static) { + await serviceAccountStaticCallback(k8sHost, k8sPort); } else { - const k8sHost = `${url.protocol}//${url.hostname}`; - await serviceAccountGetCallback(k8sHost, k8sPort); + await serviceAccountDynamicCallback(k8sHost, k8sPort); } return true; @@ -117,10 +333,128 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): } }; - const create = async ({ inputs, expireAt }: { inputs: unknown; expireAt: number }) => { + const create = async ({ + inputs, + expireAt, + usernameTemplate + }: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + }) => { const providerInputs = await validateProviderInputs(inputs); - const tokenRequestCallback = async (host: string, port: number) => { + const serviceAccountDynamicCallback = async (host: string, port: number) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + const serviceAccountName = generateUsername(usernameTemplate); + const roleBindingName = `${serviceAccountName}-role-binding`; + + // 1. Create the service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`, + { + metadata: { + name: serviceAccountName, + namespace: providerInputs.namespace + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + + // 2. Create the role binding + const roleBindingUrl = + providerInputs.roleType === KubernetesRoleType.ClusterRole + ? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings` + : `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings`; + + const roleBindingMetadata = { + name: roleBindingName, + ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace }) + }; + + await axios.post( + roleBindingUrl, + { + metadata: roleBindingMetadata, + roleRef: { + kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role", + name: providerInputs.role, + apiGroup: "rbac.authorization.k8s.io" + }, + subjects: [ + { + kind: "ServiceAccount", + name: serviceAccountName, + namespace: providerInputs.namespace + } + ] + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + + // 3. Request a token for the service account + const res = await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`, + { + spec: { + expirationSeconds: Math.floor((expireAt - Date.now()) / 1000), + ...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { 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, serviceAccountName }; + }; + + const tokenRequestStaticCallback = async (host: string, port: number) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Static) { + throw new Error("invalid callback"); + } + const baseUrl = port ? `${host}:${port}` : host; const res = await axios.post( @@ -134,7 +468,9 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${providerInputs.clusterToken}` + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), timeout: EXTERNAL_REQUEST_TIMEOUT, @@ -145,7 +481,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): } ); - return res.data; + return { ...res.data, serviceAccountName: providerInputs.serviceAccountName }; }; const url = new URL(providerInputs.url); @@ -154,19 +490,46 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): const k8sPort = url.port ? Number(url.port) : 443; try { - const tokenData = providerInputs.gatewayId - ? await $gatewayProxyWrapper( + let tokenData; + if (providerInputs.gatewayId) { + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + tokenData = await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: true + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? tokenRequestStaticCallback + : serviceAccountDynamicCallback + ); + } else { + tokenData = await $gatewayProxyWrapper( { gatewayId: providerInputs.gatewayId, targetHost: k8sGatewayHost, - targetPort: k8sPort + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: false }, - tokenRequestCallback - ) - : await tokenRequestCallback(k8sHost, k8sPort); + providerInputs.credentialType === KubernetesCredentialType.Static + ? tokenRequestStaticCallback + : serviceAccountDynamicCallback + ); + } + } else { + tokenData = + providerInputs.credentialType === KubernetesCredentialType.Static + ? await tokenRequestStaticCallback(k8sHost, k8sPort) + : await serviceAccountDynamicCallback(k8sHost, k8sPort); + } return { - entityId: providerInputs.serviceAccountName, + entityId: tokenData.serviceAccountName, data: { TOKEN: tokenData.status.token } }; } catch (error) { @@ -181,7 +544,106 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): } }; - const revoke = async (_inputs: unknown, entityId: string) => { + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + + const serviceAccountDynamicCallback = async (host: string, port: number) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + const roleBindingName = `${entityId}-role-binding`; + + if (providerInputs.roleType === KubernetesRoleType.Role) { + await axios.delete( + `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + } else { + await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + }); + } + + // Delete the service account + await axios.delete(`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${entityId}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + }); + }; + + if (providerInputs.credentialType === KubernetesCredentialType.Dynamic) { + const url = new URL(providerInputs.url); + const k8sGatewayHost = url.hostname; + const k8sPort = url.port ? Number(url.port) : 443; + const k8sHost = `${url.protocol}//${url.hostname}`; + + if (providerInputs.gatewayId) { + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: true + }, + serviceAccountDynamicCallback + ); + } else { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: false + }, + serviceAccountDynamicCallback + ); + } + } else { + await serviceAccountDynamicCallback(k8sHost, k8sPort); + } + } + return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 91d26da32..4b2d85fbc 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -31,7 +31,18 @@ export enum LdapCredentialType { } export enum KubernetesCredentialType { - Static = "static" + Static = "static", + Dynamic = "dynamic" +} + +export enum KubernetesRoleType { + ClusterRole = "cluster-role", + Role = "role" +} + +export enum KubernetesAuthMethod { + Gateway = "gateway", + Api = "api" } export enum TotpConfigType { @@ -282,17 +293,50 @@ 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 DynamicSecretKubernetesSchema = z + .discriminatedUnion("credentialType", [ + z.object({ + url: z.string().url().trim().min(1), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + credentialType: z.literal(KubernetesCredentialType.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)), + authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api) + }), + z.object({ + url: z.string().url().trim().min(1), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + credentialType: z.literal(KubernetesCredentialType.Dynamic), + namespace: z.string().trim().min(1), + gatewayId: z.string().optional(), + audiences: z.array(z.string().trim().min(1)), + roleType: z.nativeEnum(KubernetesRoleType), + role: z.string().trim().min(1), + authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api) + }) + ]) + .superRefine((data, ctx) => { + if (data.authMethod === KubernetesAuthMethod.Gateway && !data.gatewayId) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Gateway, a gateway must be selected" + }); + } + if ((data.authMethod === KubernetesAuthMethod.Api || !data.authMethod) && !data.clusterToken) { + ctx.addIssue({ + path: ["clusterToken"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Manual Token, a cluster token must be provided" + }); + } + }); export const DynamicSecretVerticaSchema = z.object({ host: z.string().trim().toLowerCase(), diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index f9aa6d4d0..1de01e803 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -267,17 +267,32 @@ export type TDynamicSecretProvider = } | { type: DynamicSecretProviders.Kubernetes; - inputs: { - url: string; - clusterToken: string; - ca?: string; - serviceAccountName: string; - credentialType: "dynamic" | "static"; - namespace: string; - gatewayId?: string; - sslEnabled: boolean; - audiences: string[]; - }; + inputs: + | { + url: string; + clusterToken?: string; + ca?: string; + serviceAccountName: string; + credentialType: "static"; + namespace: string; + gatewayId?: string; + sslEnabled: boolean; + audiences: string[]; + authMethod: string; + } + | { + url: string; + clusterToken?: string; + ca?: string; + credentialType: "dynamic"; + namespace: string; + gatewayId?: string; + sslEnabled: boolean; + audiences: string[]; + roleType: string; + role: string; + authMethod: string; + }; } | { type: DynamicSecretProviders.Vertica; 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 index ee135c8a8..be36a132a 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/KubernetesInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/KubernetesInputForm.tsx @@ -38,46 +38,94 @@ enum CredentialType { Static = "static" } +enum RoleType { + ClusterRole = "cluster-role", + Role = "role" +} + +export enum AuthMethod { + Api = "api", + Gateway = "gateway" +} + const credentialTypes = [ { label: "Static", value: CredentialType.Static + }, + { + label: "Dynamic", + value: CredentialType.Dynamic } ] 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(false), - 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 formSchema = z + .object({ + provider: z.discriminatedUnion("credentialType", [ + z.object({ + url: z.string().url().trim().min(1), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + 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)), + authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api) + }), + z.object({ + url: z.string().url().trim().min(1), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + credentialType: z.literal(CredentialType.Dynamic), + namespace: z.string().trim().min(1), + gatewayId: z.string().optional(), + audiences: z.array(z.string().trim().min(1)), + roleType: z.nativeEnum(RoleType), + role: z.string().trim().min(1), + authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api) + }) + ]), + 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" }); }), - name: slugSchema(), - environment: z.object({ name: z.string(), slug: z.string() }) -}); + 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: slugSchema(), + environment: z.object({ name: z.string(), slug: z.string() }), + usernameTemplate: z.string().trim().optional() + }) + .superRefine((data, ctx) => { + if (data.provider.authMethod === AuthMethod.Gateway && !data.provider.gatewayId) { + ctx.addIssue({ + path: ["provider.gatewayId"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Gateway, a gateway must be selected" + }); + } + if (data.provider.authMethod === AuthMethod.Api && !data.provider.clusterToken) { + ctx.addIssue({ + path: ["provider.clusterToken"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Manual Token, a cluster token must be provided" + }); + } + }); type TForm = z.infer & FieldValues; @@ -115,8 +163,9 @@ export const KubernetesInputForm = ({ namespace: "", credentialType: CredentialType.Static, gatewayId: undefined, - audiences: [] - }, + audiences: [], + authMethod: AuthMethod.Api + } as const, environment: isSingleEnvironmentMode ? environments[0] : undefined } }); @@ -130,12 +179,16 @@ export const KubernetesInputForm = ({ const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const sslEnabled = watch("provider.sslEnabled"); + const credentialType = watch("provider.credentialType"); + const authMethod = watch("provider.authMethod"); const handleCreateDynamicSecret = async (formData: TForm) => { - const { provider, ...rest } = formData; + const { provider, usernameTemplate, ...rest } = formData; // wait till previous request is finished if (createDynamicSecret.isPending) return; + try { + const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; await createDynamicSecret.mutateAsync({ provider: { type: DynamicSecretProviders.Kubernetes, inputs: provider }, maxTTL: rest.maxTTL, @@ -143,7 +196,9 @@ export const KubernetesInputForm = ({ path: secretPath, defaultTTL: rest.defaultTTL, projectSlug, - environmentSlug: rest.environment.slug + environmentSlug: rest.environment.slug, + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate }); onCompleted(); @@ -343,20 +398,44 @@ export const KubernetesInputForm = ({ )} /> - ( - + )} /> + {authMethod === AuthMethod.Api && ( + ( + + + + )} + /> + )} field.onChange(e)} > - {credentialTypes.map((credentialType) => ( - - {credentialType.label} + {credentialTypes.map((ct) => ( + + {ct.label} ))} @@ -386,21 +462,45 @@ export const KubernetesInputForm = ({ )} />
-
- ( - - - - )} - /> -
+ {credentialType === CredentialType.Static && ( +
+ ( + + + + )} + /> +
+ )} + {credentialType === CredentialType.Dynamic && ( +
+ ( + + + + )} + /> +
+ )}
+ {credentialType === CredentialType.Dynamic && ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ )}
{ - 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 formSchema = z + .object({ + inputs: z.discriminatedUnion("credentialType", [ + z.object({ + url: z.string().url().trim().min(1), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + 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)), + authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api) + }), + z.object({ + url: z.string().url().trim().min(1), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + credentialType: z.literal(CredentialType.Dynamic), + namespace: z.string().trim().min(1), + gatewayId: z.string().optional(), + audiences: z.array(z.string().trim().min(1)), + roleType: z.nativeEnum(RoleType), + role: z.string().trim().min(1), + authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api) + }) + ]), + 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" }); }), - newName: slugSchema().optional() -}); + 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" }); + }), + newName: slugSchema().optional(), + usernameTemplate: z.string().trim().optional() + }) + .superRefine((data, ctx) => { + if (data.inputs.authMethod === AuthMethod.Gateway && !data.inputs.gatewayId) { + ctx.addIssue({ + path: ["inputs.gatewayId"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Gateway, a gateway must be selected" + }); + } + if (data.inputs.authMethod === AuthMethod.Api && !data.inputs.clusterToken) { + ctx.addIssue({ + path: ["inputs.clusterToken"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Manual Token, a cluster token must be provided" + }); + } + }); type TForm = z.infer & FieldValues; @@ -103,6 +151,7 @@ export const EditDynamicSecretKubernetesForm = ({ values: { newName: dynamicSecret.name, defaultTTL: dynamicSecret.defaultTTL, + usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}", maxTTL: dynamicSecret.maxTTL, inputs: dynamicSecret.inputs as TForm["inputs"] } @@ -110,17 +159,20 @@ export const EditDynamicSecretKubernetesForm = ({ const { fields, append, remove } = useFieldArray({ control, - name: "inputs.audiences" as const + name: "inputs.audiences" }); const updateDynamicSecret = useUpdateDynamicSecret(); const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const sslEnabled = watch("inputs.sslEnabled"); + const credentialType = watch("inputs.credentialType"); + const authMethod = watch("inputs.authMethod"); const handleUpdateDynamicSecret = async (formData: TForm) => { // wait till previous request is finished if (updateDynamicSecret.isPending) return; + const isDefaultUsernameTemplate = formData.usernameTemplate === "{{randomUsername}}"; try { await updateDynamicSecret.mutateAsync({ name: dynamicSecret.name, @@ -131,9 +183,14 @@ export const EditDynamicSecretKubernetesForm = ({ inputs: formData.inputs, newName: formData.newName === dynamicSecret.name ? undefined : formData.newName, defaultTTL: formData.defaultTTL, - maxTTL: formData.maxTTL + maxTTL: formData.maxTTL, + usernameTemplate: + !formData.usernameTemplate || isDefaultUsernameTemplate + ? null + : formData.usernameTemplate } }); + onClose(); createNotification({ type: "success", @@ -339,17 +396,42 @@ export const EditDynamicSecretKubernetesForm = ({ ( - + )} /> + {authMethod === AuthMethod.Api && ( + ( + + + + )} + /> + )} field.onChange(e)} > - {credentialTypes.map((credentialType) => ( - - {credentialType.label} + {credentialTypes.map((ct) => ( + + {ct.label} ))} @@ -379,21 +458,44 @@ export const EditDynamicSecretKubernetesForm = ({ )} />
-
- ( - - - - )} - /> -
+ {credentialType === CredentialType.Static && ( +
+ ( + + + + )} + /> +
+ )} + {credentialType === CredentialType.Dynamic && ( +
+ ( + + + + )} + /> +
+ )}
+ {credentialType === CredentialType.Dynamic && ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ )}