diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index 130e0fa92..cf8f2b3e0 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,151 @@ 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, httpsAgent?: https.Agent) => { + 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 + } + ); + + // 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 + } + ); + + // 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 + } + ); + + // 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 + } + ); + } 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 + }); + } + + 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 + } + ); + }; + + const serviceAccountStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Static) { + throw new Error("invalid callback"); + } + const baseUrl = port ? `${host}:${port}` : host; await axios.get( @@ -72,36 +246,57 @@ 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, - httpsAgent: new https.Agent({ - ca: providerInputs.ca, - rejectUnauthorized: providerInputs.sslEnabled - }) + httpsAgent } ); }; 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 +312,119 @@ 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, httpsAgent?: https.Agent) => { + 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 + } + ); + + // 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 + } + ); + + // 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 + } + ); + + return { ...res.data, serviceAccountName }; + }; + + const tokenRequestStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Static) { + throw new Error("invalid callback"); + } + const baseUrl = port ? `${host}:${port}` : host; const res = await axios.post( @@ -134,18 +438,17 @@ 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, - httpsAgent: new https.Agent({ - ca: providerInputs.ca, - rejectUnauthorized: providerInputs.sslEnabled - }) + httpsAgent } ); - return res.data; + return { ...res.data, serviceAccountName: providerInputs.serviceAccountName }; }; const url = new URL(providerInputs.url); @@ -154,19 +457,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 +511,97 @@ 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, httpsAgent?: https.Agent) => { + 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 + } + ); + } 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 + }); + } + + // 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 + }); + }; + + 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 fe6830cb7..b2496eebd 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -36,7 +36,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 { @@ -309,17 +320,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/docs/documentation/platform/dynamic-secrets/kubernetes.mdx b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx index d6d051ff7..713aefae6 100644 --- a/docs/documentation/platform/dynamic-secrets/kubernetes.mdx +++ b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx @@ -33,125 +33,6 @@ This feature is ideal for scenarios where you need to: - Maintain a secure audit trail of cluster access - Manage access to multiple Kubernetes clusters -## Prerequisites - -- A Kubernetes cluster with a service account -- Cluster access token with permissions to create service account tokens -- (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access - -## RBAC Configuration - -Before you can start generating dynamic service account tokens, you'll need to configure the appropriate permissions in your Kubernetes cluster. This involves setting up Role-Based Access Control (RBAC) to allow the creation and management of service account tokens. - -The RBAC configuration serves a crucial security purpose: it creates a dedicated service account with minimal permissions that can only create and manage service account tokens. This follows the principle of least privilege, ensuring that the token generation process is secure and controlled. - -The following RBAC configuration creates the necessary permissions for generating service account tokens: - -```yaml rbac.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: tokenrequest -rules: - - apiGroups: [""] - resources: - - "serviceaccounts/token" - - "serviceaccounts" - verbs: - - "create" - - "get" ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: tokenrequest -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: tokenrequest -subjects: - - kind: ServiceAccount - name: infisical-token-requester - namespace: default -``` - -```bash -kubectl apply -f rbac.yaml -``` - -This configuration: - -1. Creates a `ClusterRole` named `tokenrequest` that allows: - - Creating and getting service account tokens - - Getting service account information -2. Creates a `ClusterRoleBinding` that binds the role to a service account named `infisical-token-requester` in the `default` namespace - -You can customize the service account name and namespace according to your needs. - -## Obtaining the Cluster Token - -After setting up the RBAC configuration, you need to obtain a token for the service account that will be used to create dynamic secrets. Here's how to get the token: - -1. Create a service account in your Kubernetes cluster that will be used to create service account tokens: - -```yaml infisical-service-account.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: infisical-token-requester - namespace: default -``` - -```bash -kubectl apply -f infisical-service-account.yaml -``` - -2. Create a long-lived service account token using this configuration file: - -```yaml service-account-token.yaml -apiVersion: v1 -kind: Secret -type: kubernetes.io/service-account-token -metadata: - name: infisical-token-requester-token - annotations: - kubernetes.io/service-account.name: "infisical-token-requester" -``` - -```bash -kubectl apply -f service-account-token.yaml -``` - -3. Link the secret to the service account: - -```bash -kubectl patch serviceaccount infisical-token-requester -p '{"secrets": [{"name": "infisical-token-requester-token"}]}' -n default -``` - -4. Retrieve the token: - -```bash -kubectl get secret infisical-token-requester-token -n default -o=jsonpath='{.data.token}' | base64 --decode -``` - -This token will be used as the "Cluster Token" in the dynamic secret configuration. - -## Obtaining the Cluster URL - -The cluster URL is the address of your Kubernetes API server. The simplest way to find it is to use the `kubectl cluster-info` command: - -```bash -kubectl cluster-info -``` - -This command works for all Kubernetes environments (managed services like GKE, EKS, AKS, or self-hosted clusters) and will show you the Kubernetes control plane address, which is your cluster URL. - - - Make sure the cluster URL is accessible from where you're running Infisical. - If you're using a private cluster, you'll need to configure a [Gateway](/documentation/platform/gateways/overview) to - access it. - - ## Set up Dynamic Secrets with Kubernetes @@ -164,6 +45,348 @@ This command works for all Kubernetes environments (managed services like GKE, E ![Dynamic Secret Modal](/images/platform/dynamic-secrets/dynamic-secret-modal-kubernetes.png) + + Before proceeding with the setup, you'll need to make two key decisions: + + 1. **Credential Type**: How you want to manage service accounts + - **Static**: Use an existing service account with predefined permissions + - **Dynamic**: Create temporary service accounts with specific role assignments + + 2. **Authentication Method**: How you want to authenticate with the cluster + - **Token (API)**: Use a service account token for direct API access + - **Gateway**: Use an Infisical Gateway deployed in your cluster + + + + Static credentials generate service account tokens for a predefined service account. This is useful when you want to: + - Generate tokens for an existing service account + - Maintain consistent permissions across token generations + - Use a service account that already has the necessary RBAC permissions + + ### Prerequisites + + - A Kubernetes cluster with a service account + - Cluster access token with permissions to create service account tokens + - (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access + + ### Authentication Setup + + Choose your authentication method: + + + + This method uses a service account token to authenticate with the Kubernetes cluster. It's suitable when: + - You want to use a specific service account token that you've created + - You're working with a public cluster or have network access to the cluster's API server + - You want to explicitly control which service account is used for operations + + + With Token (API) authentication, Infisical uses the provided service account token + to make API calls to your Kubernetes cluster. This token must have the necessary + permissions to generate tokens for the target service account. + + + 1. Create a service account: + ```yaml infisical-service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-token-requester + namespace: default + ``` + + ```bash + kubectl apply -f infisical-service-account.yaml + ``` + + 2. Set up RBAC permissions: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: tokenrequest + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tokenrequest + subjects: + - kind: ServiceAccount + name: infisical-token-requester + namespace: default + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + 3. Create and obtain the token: + ```yaml service-account-token.yaml + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-token-requester-token + annotations: + kubernetes.io/service-account.name: "infisical-token-requester" + ``` + + ```bash + kubectl apply -f service-account-token.yaml + kubectl patch serviceaccount infisical-token-requester -p '{"secrets": [{"name": "infisical-token-requester-token"}]}' -n default + kubectl get secret infisical-token-requester-token -n default -o=jsonpath='{.data.token}' | base64 --decode + ``` + + + This method uses an Infisical Gateway deployed in your Kubernetes cluster. It's ideal when: + - You want to avoid storing static service account tokens + - You prefer to use the Gateway's pre-configured service account + - You want centralized management of cluster operations + + + With Gateway authentication, Infisical communicates with the Gateway, which then + uses its own service account to make API calls to the Kubernetes API server. + The Gateway's service account must have the necessary permissions to generate + tokens for the target service account. + + + 1. Deploy the Infisical Gateway in your cluster + 2. Set up RBAC permissions for the Gateway's service account: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: tokenrequest + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tokenrequest + subjects: + - kind: ServiceAccount + name: infisical-gateway + namespace: infisical + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + + + + + + Dynamic credentials create a temporary service account, assign it to a defined role/cluster-role, and generate a service account token. This is useful when you want to: + - Create temporary service accounts with specific permissions + - Automatically clean up service accounts after token expiration + - Assign different roles to different users or applications + - Maintain strict control over service account permissions + + ### Prerequisites + + - A Kubernetes cluster with a service account + - Cluster access token with permissions to create service accounts and manage RBAC + - (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access + + ### Authentication Setup + + Choose your authentication method: + + + + This method uses a service account token to authenticate with the Kubernetes cluster. It's suitable when: + - You want to use a specific service account token that you've created + - You're working with a public cluster or have network access to the cluster's API server + - You want to explicitly control which service account is used for operations + + + With Token (API) authentication, Infisical uses the provided service account token + to make API calls to your Kubernetes cluster. This token must have the necessary + permissions to create and manage service accounts, their tokens, and RBAC resources. + + + 1. Create a service account: + ```yaml service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-token-requester + namespace: default + --- + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-token-requester-token + annotations: + kubernetes.io/service-account.name: "infisical-token-requester" + ``` + + ```bash + kubectl apply -f service-account.yaml + ``` + + 2. Set up RBAC permissions: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + - "delete" + - apiGroups: ["rbac.authorization.k8s.io"] + resources: + - "rolebindings" + - "clusterrolebindings" + verbs: + - "create" + - "delete" + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: tokenrequest + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tokenrequest + subjects: + - kind: ServiceAccount + name: infisical-token-requester + namespace: default + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-dynamic-role-binding-sa + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-dynamic-role + subjects: + - kind: ServiceAccount + name: infisical-token-requester + namespace: default + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + + This method uses an Infisical Gateway deployed in your Kubernetes cluster. It's ideal when: + - You want to avoid storing static service account tokens + - You prefer to use the Gateway's pre-configured service account + - You want centralized management of cluster operations + + + With Gateway authentication, Infisical communicates with the Gateway, which then + uses its own service account to make API calls to the Kubernetes API server. + The Gateway's service account must have the necessary permissions to create and + manage service accounts, their tokens, and RBAC resources. + + + 1. Deploy the Infisical Gateway in your cluster + 2. Set up RBAC permissions for the Gateway's service account: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + - "delete" + - apiGroups: ["rbac.authorization.k8s.io"] + resources: + - "rolebindings" + - "clusterrolebindings" + verbs: + - "create" + - "delete" + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: tokenrequest + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tokenrequest + subjects: + - kind: ServiceAccount + name: infisical-gateway + namespace: infisical + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-dynamic-role-binding-sa + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-dynamic-role + subjects: + - kind: ServiceAccount + name: infisical-gateway + namespace: infisical + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + + + + In Kubernetes RBAC, a service account can only create role bindings for resources that it has access to. + This means that if you want to create dynamic service accounts with access to certain resources, + the service account creating these bindings (either the token requester or Gateway service account) + must also have access to those same resources. For example, if you want to create dynamic service + accounts that can access secrets, the token requester service account must also have access to secrets. + + + + + + Name by which you want the secret to be referenced @@ -186,48 +409,62 @@ This command works for all Kubernetes environments (managed services like GKE, E Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA. + + Choose between Token (API) or Gateway authentication. If using Gateway, the Gateway must be deployed in your Kubernetes cluster. + - Token with permissions to create service account tokens + Token with permissions to create service accounts and manage RBAC (required when using Token authentication) + + + Choose between Static (predefined service account) or Dynamic (temporary service accounts with role assignments) - Name of the service account to generate tokens for + Name of the service account to generate tokens for (required for Static credentials) - Kubernetes namespace where the service account exists + Kubernetes namespace where the service account exists or will be created + + + Type of role to assign (ClusterRole or Role) (required for Dynamic credentials) + + + Name of the role to assign to the temporary service account (required for Dynamic credentials) Optional list of audiences to include in the generated token - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png) After submitting the form, you will see a dynamic secret created in the dashboard. - - Once you've successfully configured the dynamic secret, you're ready to generate on-demand service account tokens. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. - Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. - - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) - - When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. - - ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) - - - Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. - - - Once you click the `Submit` button, a new secret lease will be generated and the service account token will be shown to you. - - ![Provision Lease](/images/platform/dynamic-secrets/kubernetes-lease-value.png) - - +## Generate and Manage Tokens + +Once you've successfully configured the dynamic secret, you're ready to generate on-demand service account tokens. +To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. +Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + +![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) +![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + +When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + +![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when + configuring the dynamic secret. + + +Once you click the `Submit` button, a new secret lease will be generated and the service account token will be shown to you. + +![Provision Lease](/images/platform/dynamic-secrets/kubernetes-lease-value.png) + ## Audit or Revoke Leases Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png new file mode 100644 index 000000000..dffbedad3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png new file mode 100644 index 000000000..cc5e56001 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png deleted file mode 100644 index 011dfadc7..000000000 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png and /dev/null differ diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 9b105cd64..440c534f0 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -37,6 +37,11 @@ export enum DynamicSecretProviders { Vertica = "vertica" } +export enum KubernetesDynamicSecretCredentialType { + Static = "static", + Dynamic = "dynamic" +} + export enum SqlProviders { Postgres = "postgres", MySql = "mysql2", @@ -283,17 +288,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: KubernetesDynamicSecretCredentialType.Static; + namespace: string; + gatewayId?: string; + sslEnabled: boolean; + audiences: string[]; + authMethod: string; + } + | { + url: string; + clusterToken?: string; + ca?: string; + credentialType: KubernetesDynamicSecretCredentialType.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..019b29266 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 @@ -29,55 +29,101 @@ import { 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 { + DynamicSecretProviders, + KubernetesDynamicSecretCredentialType +} from "@app/hooks/api/dynamicSecret/types"; import { WorkspaceEnv } from "@app/hooks/api/types"; import { slugSchema } from "@app/lib/schemas"; -enum CredentialType { - Dynamic = "dynamic", - Static = "static" +enum RoleType { + ClusterRole = "cluster-role", + Role = "role" +} + +export enum AuthMethod { + Api = "api", + Gateway = "gateway" } const credentialTypes = [ { label: "Static", - value: CredentialType.Static + value: KubernetesDynamicSecretCredentialType.Static + }, + { + label: "Dynamic", + value: KubernetesDynamicSecretCredentialType.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(KubernetesDynamicSecretCredentialType.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(KubernetesDynamicSecretCredentialType.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 Token, a cluster token must be provided" + }); + } + }); type TForm = z.infer & FieldValues; @@ -113,10 +159,11 @@ export const KubernetesInputForm = ({ sslEnabled: false, serviceAccountName: "", namespace: "", - credentialType: CredentialType.Static, + credentialType: KubernetesDynamicSecretCredentialType.Static, gatewayId: undefined, - audiences: [] - }, + audiences: [], + authMethod: AuthMethod.Api + } as const, environment: isSingleEnvironmentMode ? environments[0] : undefined } }); @@ -130,12 +177,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 +194,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 +396,45 @@ export const KubernetesInputForm = ({ )} /> - ( - + )} /> + {authMethod === AuthMethod.Api && ( + ( + + + + )} + /> + )} @@ -386,21 +462,45 @@ export const KubernetesInputForm = ({ )} />
-
- ( - - - - )} - /> -
+ {credentialType === KubernetesDynamicSecretCredentialType.Static && ( +
+ ( + + + + )} + /> +
+ )} + {credentialType === KubernetesDynamicSecretCredentialType.Dynamic && ( +
+ ( + + + + )} + /> +
+ )}
+ {credentialType === KubernetesDynamicSecretCredentialType.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(KubernetesDynamicSecretCredentialType.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(KubernetesDynamicSecretCredentialType.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 Token, a cluster token must be provided" + }); + } + }); type TForm = z.infer & FieldValues; @@ -103,6 +149,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 +157,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 +181,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 +394,43 @@ export const EditDynamicSecretKubernetesForm = ({ ( - + )} /> + {authMethod === AuthMethod.Api && ( + ( + + + + )} + /> + )} @@ -379,21 +458,44 @@ export const EditDynamicSecretKubernetesForm = ({ )} />
-
- ( - - - - )} - /> -
+ {credentialType === KubernetesDynamicSecretCredentialType.Static && ( +
+ ( + + + + )} + /> +
+ )} + {credentialType === KubernetesDynamicSecretCredentialType.Dynamic && ( +
+ ( + + + + )} + /> +
+ )}
+ {credentialType === KubernetesDynamicSecretCredentialType.Dynamic && ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ )}