From f7fb015bd8899a23d967a1caa17b86a360a386c7 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 11 Jun 2025 01:11:29 +0800 Subject: [PATCH] feat: allow k8 dynamic secret multi namespace and show proper error --- ...0143920_add-dynamic-secret-lease-config.ts | 21 ++ .../src/db/schemas/dynamic-secret-leases.ts | 3 +- .../kubernetes-lease-router.ts | 67 ++++ backend/src/ee/routes/v1/index.ts | 2 + .../dynamic-secret-lease-queue.ts | 14 +- .../dynamic-secret-lease-service.ts | 18 +- .../dynamic-secret-lease-types.ts | 7 + .../dynamic-secret/providers/kubernetes.ts | 291 ++++++++++-------- .../dynamic-secret/providers/models.ts | 25 +- backend/src/lib/api-docs/constants.ts | 8 + .../hooks/api/dynamicSecretLease/mutation.ts | 9 + .../src/hooks/api/dynamicSecretLease/types.ts | 10 + .../KubernetesInputForm.tsx | 24 +- .../CreateDynamicSecretLease.tsx | 153 ++++++++- .../EditDynamicSecretKubernetesForm.tsx | 24 +- 15 files changed, 534 insertions(+), 142 deletions(-) create mode 100644 backend/src/db/migrations/20250610143920_add-dynamic-secret-lease-config.ts create mode 100644 backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts diff --git a/backend/src/db/migrations/20250610143920_add-dynamic-secret-lease-config.ts b/backend/src/db/migrations/20250610143920_add-dynamic-secret-lease-config.ts new file mode 100644 index 000000000..30d6f1854 --- /dev/null +++ b/backend/src/db/migrations/20250610143920_add-dynamic-secret-lease-config.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasConfigColumn = await knex.schema.hasColumn(TableName.DynamicSecretLease, "config"); + if (!hasConfigColumn) { + await knex.schema.alterTable(TableName.DynamicSecretLease, (table) => { + table.jsonb("config"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasConfigColumn = await knex.schema.hasColumn(TableName.DynamicSecretLease, "config"); + if (hasConfigColumn) { + await knex.schema.alterTable(TableName.DynamicSecretLease, (table) => { + table.dropColumn("config"); + }); + } +} diff --git a/backend/src/db/schemas/dynamic-secret-leases.ts b/backend/src/db/schemas/dynamic-secret-leases.ts index 8c16bcb55..ef16b1a30 100644 --- a/backend/src/db/schemas/dynamic-secret-leases.ts +++ b/backend/src/db/schemas/dynamic-secret-leases.ts @@ -16,7 +16,8 @@ export const DynamicSecretLeasesSchema = z.object({ statusDetails: z.string().nullable().optional(), dynamicSecretId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + config: z.unknown().nullable().optional() }); export type TDynamicSecretLeases = z.infer; diff --git a/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts b/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts new file mode 100644 index 000000000..926e02aa7 --- /dev/null +++ b/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { DynamicSecretLeasesSchema } from "@app/db/schemas"; +import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs"; +import { daysToMillisecond } from "@app/lib/dates"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { ms } from "@app/lib/ms"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerKubernetesDynamicSecretLeaseRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.DynamicSecrets], + body: z.object({ + dynamicSecretName: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.dynamicSecretName).toLowerCase(), + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.projectSlug), + ttl: z + .string() + .optional() + .describe(DYNAMIC_SECRET_LEASES.CREATE.ttl) + .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 > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.path), + config: z + .object({ + namespace: z.string().min(1).optional().describe(DYNAMIC_SECRET_LEASES.KUBERNETES.CREATE.config.namespace) + }) + .optional() + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema, + dynamicSecret: SanitizedDynamicSecretSchema, + data: z.unknown() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { data, lease, dynamicSecret } = await server.services.dynamicSecretLease.create({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.body.dynamicSecretName, + ...req.body + }); + return { lease, data, dynamicSecret }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 9ba7f734e..8f3b69dfa 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -6,6 +6,7 @@ import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; +import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerExternalKmsRouter } from "./external-kms-router"; import { registerGatewayRouter } from "./gateway-router"; @@ -71,6 +72,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { async (dynamicSecretRouter) => { await dynamicSecretRouter.register(registerDynamicSecretRouter); await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" }); + await dynamicSecretRouter.register(registerKubernetesDynamicSecretLeaseRouter, { prefix: "/leases/kubernetes" }); }, { prefix: "/dynamic-secrets" } ); diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts index c38a8f146..497e94311 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -10,6 +10,7 @@ import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types"; import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; +import { TDynamicSecretLeaseConfig } from "./dynamic-secret-lease-types"; type TDynamicSecretLeaseQueueServiceFactoryDep = { queueService: TQueueServiceFactory; @@ -134,10 +135,15 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id))); await Promise.all( - dynamicSecretLeases.map(({ externalEntityId }) => - selectedProvider.revoke(decryptedStoredInput, externalEntityId, { - projectId: folder.projectId - }) + dynamicSecretLeases.map(({ externalEntityId, config }) => + selectedProvider.revoke( + decryptedStoredInput, + externalEntityId, + { + projectId: folder.projectId + }, + config as TDynamicSecretLeaseConfig + ) ) ); } diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index 561b2170c..4b72ff7e9 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -29,6 +29,7 @@ import { TCreateDynamicSecretLeaseDTO, TDeleteDynamicSecretLeaseDTO, TDetailsDynamicSecretLeaseDTO, + TDynamicSecretLeaseConfig, TListDynamicSecretLeasesDTO, TRenewDynamicSecretLeaseDTO } from "./dynamic-secret-lease-types"; @@ -77,7 +78,8 @@ export const dynamicSecretLeaseServiceFactory = ({ actorId, actorOrgId, actorAuthMethod, - ttl + ttl, + config }: TCreateDynamicSecretLeaseDTO) => { const appCfg = getConfig(); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); @@ -163,7 +165,8 @@ export const dynamicSecretLeaseServiceFactory = ({ expireAt: expireAt.getTime(), usernameTemplate: dynamicSecretCfg.usernameTemplate, identity, - metadata: { projectId } + metadata: { projectId }, + config }); } catch (error: unknown) { if (error && typeof error === "object" && error !== null && "sqlMessage" in error) { @@ -177,8 +180,10 @@ export const dynamicSecretLeaseServiceFactory = ({ expireAt, version: 1, dynamicSecretId: dynamicSecretCfg.id, - externalEntityId: entityId + externalEntityId: entityId, + config }); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date())); return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data }; }; @@ -342,7 +347,12 @@ export const dynamicSecretLeaseServiceFactory = ({ ) as object; const revokeResponse = await selectedProvider - .revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId, { projectId }) + .revoke( + decryptedStoredInput, + dynamicSecretLease.externalEntityId, + { projectId }, + dynamicSecretLease.config as TDynamicSecretLeaseConfig + ) .catch(async (err) => { // only propogate this error if forced is false if (!isForced) return { error: err as Error }; diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts index bf182b349..f6d9f6297 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts @@ -10,6 +10,7 @@ export type TCreateDynamicSecretLeaseDTO = { environmentSlug: string; ttl?: string; projectSlug: string; + config?: TDynamicSecretLeaseConfig; } & Omit; export type TDetailsDynamicSecretLeaseDTO = { @@ -41,3 +42,9 @@ export type TRenewDynamicSecretLeaseDTO = { ttl?: string; projectSlug: string; } & Omit; + +export type TDynamicSecretKubernetesLeaseConfig = { + namespace?: string; +}; + +export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig; diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index cf8f2b3e0..04234c1f8 100644 --- a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -1,13 +1,14 @@ -import axios from "axios"; +import axios, { AxiosError } from "axios"; import handlebars from "handlebars"; import https from "https"; -import { InternalServerError } from "@app/lib/errors"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; 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 { TDynamicSecretKubernetesLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { DynamicSecretKubernetesSchema, @@ -103,96 +104,127 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): 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 - } - ); + const namespaces = providerInputs.namespace.split(",").map((namespace) => namespace.trim()); - // 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: [ + // Test each namespace sequentially instead of in parallel to simplify cleanup + for await (const namespace of namespaces) { + try { + // 1. Create a test service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`, { - kind: "ServiceAccount", - name: serviceAccountName, - namespace: providerInputs.namespace + metadata: { + name: serviceAccountName, + 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 } - ] - }, - { - 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 } : {}) + // 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/${namespace}/rolebindings`; + + const roleBindingMetadata = { + name: roleBindingName, + ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { 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 + } + ] + }, + { + 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/${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/${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 + }); } - }, - { - 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}`, - { + await axios.delete(`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}`, { headers: { "Content-Type": "application/json", ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway @@ -202,36 +234,19 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), timeout: EXTERNAL_REQUEST_TIMEOUT, httpsAgent + }); + } catch (error) { + const cleanupInfo = `You may need to manually clean up the following resources in namespace "${namespace}": Service Account - ${serviceAccountName}, ${providerInputs.roleType === KubernetesRoleType.Role ? "Role" : "Cluster Role"} Binding - ${roleBindingName}.`; + let mainErrorMessage = "Unknown error"; + if (error instanceof AxiosError) { + mainErrorMessage = (error.response?.data as { message: string })?.message; + } else if (error instanceof Error) { + mainErrorMessage = error.message; } - ); - } 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 + throw new Error(`${mainErrorMessage}. ${cleanupInfo}`); } - ); + } }; const serviceAccountStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { @@ -315,11 +330,13 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): const create = async ({ inputs, expireAt, - usernameTemplate + usernameTemplate, + config }: { inputs: unknown; expireAt: number; usernameTemplate?: string | null; + config?: TDynamicSecretKubernetesLeaseConfig; }) => { const providerInputs = await validateProviderInputs(inputs); @@ -331,14 +348,28 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): const baseUrl = port ? `${host}:${port}` : host; const serviceAccountName = generateUsername(usernameTemplate); const roleBindingName = `${serviceAccountName}-role-binding`; + const allowedNamespaces = providerInputs.namespace.split(",").map((namespace) => namespace.trim()); + + if (config?.namespace && !allowedNamespaces?.includes(config?.namespace)) { + throw new BadRequestError({ + message: `Namespace ${config?.namespace} is not allowed. Allowed namespaces: ${allowedNamespaces?.join(", ")}` + }); + } + + const namespace = config?.namespace || allowedNamespaces[0]; + if (!namespace) { + throw new BadRequestError({ + message: "No namespace provided" + }); + } // 1. Create the service account await axios.post( - `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`, + `${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`, { metadata: { name: serviceAccountName, - namespace: providerInputs.namespace + namespace } }, { @@ -358,11 +389,11 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): 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`; + : `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`; const roleBindingMetadata = { name: roleBindingName, - ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace }) + ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace }) }; await axios.post( @@ -378,7 +409,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): { kind: "ServiceAccount", name: serviceAccountName, - namespace: providerInputs.namespace + namespace } ] }, @@ -397,7 +428,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): // 3. Request a token for the service account const res = await axios.post( - `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`, + `${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}/token`, { spec: { expirationSeconds: Math.floor((expireAt - Date.now()) / 1000), @@ -425,6 +456,12 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): throw new Error("invalid callback"); } + if (config?.namespace && config.namespace !== providerInputs.namespace) { + throw new BadRequestError({ + message: `Namespace ${config?.namespace} is not allowed. Allowed namespace: ${providerInputs.namespace}.` + }); + } + const baseUrl = port ? `${host}:${port}` : host; const res = await axios.post( @@ -511,7 +548,13 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): } }; - const revoke = async (inputs: unknown, entityId: string) => { + const revoke = async ( + inputs: unknown, + entityId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _metadata: { projectId: string }, + config?: TDynamicSecretKubernetesLeaseConfig + ) => { const providerInputs = await validateProviderInputs(inputs); const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { @@ -522,9 +565,11 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): const baseUrl = port ? `${host}:${port}` : host; const roleBindingName = `${entityId}-role-binding`; + const namespace = config?.namespace ?? providerInputs.namespace.split(",")[0].trim(); + if (providerInputs.roleType === KubernetesRoleType.Role) { await axios.delete( - `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`, + `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings/${roleBindingName}`, { headers: { "Content-Type": "application/json", @@ -552,7 +597,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): } // Delete the service account - await axios.delete(`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${entityId}`, { + await axios.delete(`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${entityId}`, { headers: { "Content-Type": "application/json", ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index b2496eebd..100f13bb5 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { TDynamicSecretLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types"; + export type PasswordRequirements = { length: number; required: { @@ -329,7 +331,11 @@ export const DynamicSecretKubernetesSchema = z sslEnabled: z.boolean().default(false), credentialType: z.literal(KubernetesCredentialType.Static), serviceAccountName: z.string().trim().min(1), - namespace: z.string().trim().min(1), + namespace: z + .string() + .trim() + .min(1) + .refine((val) => !val.includes(","), "Namespace must be a single value, not a comma-separated list"), gatewayId: z.string().optional(), audiences: z.array(z.string().trim().min(1)), authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api) @@ -340,7 +346,14 @@ export const DynamicSecretKubernetesSchema = z ca: z.string().optional(), sslEnabled: z.boolean().default(false), credentialType: z.literal(KubernetesCredentialType.Dynamic), - namespace: z.string().trim().min(1), + namespace: z + .string() + .trim() + .min(1) + .refine((val) => { + const namespaces = val.split(",").map((ns) => ns.trim()); + return namespaces.length > 0 && namespaces.every((ns) => ns.length > 0); + }, "Must be a valid comma-separated list of namespace values"), gatewayId: z.string().optional(), audiences: z.array(z.string().trim().min(1)), roleType: z.nativeEnum(KubernetesRoleType), @@ -475,10 +488,16 @@ export type TDynamicProviderFns = { name: string; }; metadata: { projectId: string }; + config?: TDynamicSecretLeaseConfig; }) => Promise<{ entityId: string; data: unknown }>; validateConnection: (inputs: unknown, metadata: { projectId: string }) => Promise; validateProviderInputs: (inputs: object, metadata: { projectId: string }) => Promise; - revoke: (inputs: unknown, entityId: string, metadata: { projectId: string }) => Promise<{ entityId: string }>; + revoke: ( + inputs: unknown, + entityId: string, + metadata: { projectId: string }, + config?: TDynamicSecretLeaseConfig + ) => Promise<{ entityId: string }>; renew: ( inputs: unknown, entityId: string, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 642ba453a..caac30556 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1113,6 +1113,14 @@ export const DYNAMIC_SECRET_LEASES = { leaseId: "The ID of the dynamic secret lease.", isForced: "A boolean flag to delete the the dynamic secret from Infisical without trying to remove it from external provider. Used when the dynamic secret got modified externally." + }, + KUBERNETES: { + CREATE: { + config: { + namespace: + "The Kubernetes namespace to create the lease in. If not specified, the first namespace defined in the configuration will be used." + } + } } } as const; export const SECRET_TAGS = { diff --git a/frontend/src/hooks/api/dynamicSecretLease/mutation.ts b/frontend/src/hooks/api/dynamicSecretLease/mutation.ts index 1a95a3ab0..e7051bd2b 100644 --- a/frontend/src/hooks/api/dynamicSecretLease/mutation.ts +++ b/frontend/src/hooks/api/dynamicSecretLease/mutation.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { DynamicSecretProviders } from "../dynamicSecret/types"; import { dynamicSecretLeaseKeys } from "./queries"; import { TCreateDynamicSecretLeaseDTO, @@ -19,6 +20,14 @@ export const useCreateDynamicSecretLease = () => { TCreateDynamicSecretLeaseDTO >({ mutationFn: async (dto) => { + if (dto.provider === DynamicSecretProviders.Kubernetes) { + const { data } = await apiRequest.post<{ lease: TDynamicSecretLease; data: unknown }>( + "/api/v1/dynamic-secrets/leases/kubernetes", + dto + ); + return data; + } + const { data } = await apiRequest.post<{ lease: TDynamicSecretLease; data: unknown }>( "/api/v1/dynamic-secrets/leases", dto diff --git a/frontend/src/hooks/api/dynamicSecretLease/types.ts b/frontend/src/hooks/api/dynamicSecretLease/types.ts index 76bedc8b3..51ee64c4b 100644 --- a/frontend/src/hooks/api/dynamicSecretLease/types.ts +++ b/frontend/src/hooks/api/dynamicSecretLease/types.ts @@ -1,3 +1,5 @@ +import { DynamicSecretProviders } from "../dynamicSecret/types"; + export enum DynamicSecretLeaseStatus { FailedDeletion = "Failed to delete" } @@ -13,12 +15,20 @@ export type TDynamicSecretLease = { updatedAt: string; }; +export type TDynamicSecretKubernetesLeaseConfig = { + namespace?: string; +}; + +export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig; + export type TCreateDynamicSecretLeaseDTO = { dynamicSecretName: string; projectSlug: string; ttl?: string; path: string; environmentSlug: string; + config?: TDynamicSecretLeaseConfig; + provider: DynamicSecretProviders; }; export type TRenewDynamicSecretLeaseDTO = { 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 019b29266..5cbfbf319 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 @@ -67,7 +67,14 @@ const formSchema = z sslEnabled: z.boolean().default(false), credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static), serviceAccountName: z.string().trim().min(1), - namespace: z.string().trim().min(1), + namespace: z + .string() + .trim() + .min(1) + .refine( + (val) => !val.includes(","), + "Namespace must be a single value, not a comma-separated list" + ), gatewayId: z.string().optional(), audiences: z.array(z.string().trim().min(1)), authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api) @@ -78,7 +85,14 @@ const formSchema = z ca: z.string().optional(), sslEnabled: z.boolean().default(false), credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic), - namespace: z.string().trim().min(1), + namespace: z + .string() + .trim() + .min(1) + .refine((val) => { + const namespaces = val.split(",").map((ns) => ns.trim()); + return namespaces.length > 0 && namespaces.every((ns) => ns.length > 0); + }, "Must be a valid comma-separated list of namespace values"), gatewayId: z.string().optional(), audiences: z.array(z.string().trim().min(1)), roleType: z.nativeEnum(RoleType), @@ -507,7 +521,11 @@ export const KubernetesInputForm = ({ name="provider.namespace" render={({ field, fieldState: { error } }) => ( diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index 91180e297..0f56061f5 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -353,12 +353,149 @@ const renderOutputForm = ( return null; }; +const kubernetesFormSchema = z.object({ + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional(), + namespace: z.string().optional() +}); + +type TKubernetesForm = z.infer; + +export const CreateKubernetesDynamicSecretLease = ({ + onClose, + projectSlug, + dynamicSecretName, + provider, + secretPath, + environment +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(kubernetesFormSchema), + defaultValues: { + ttl: "1h" + } + }); + + const createDynamicSecretLease = useCreateDynamicSecretLease(); + + const handleDynamicSecretLeaseCreate = async ({ ttl, namespace }: TKubernetesForm) => { + if (createDynamicSecretLease.isPending) return; + try { + await createDynamicSecretLease.mutateAsync({ + environmentSlug: environment, + projectSlug, + path: secretPath, + ttl, + dynamicSecretName, + config: { + namespace: namespace || undefined + }, + provider + }); + + createNotification({ + type: "success", + text: "Successfully leased dynamic secret" + }); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to lease dynamic secret" + }); + } + }; + + const handleLeaseRegeneration = async (data: { ttl?: string }) => { + handleDynamicSecretLeaseCreate(data); + }; + + const isOutputMode = Boolean(createDynamicSecretLease?.data); + + return ( +
+ + {!isOutputMode && ( + +
+ ( + + + + )} + /> + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + +
+ +
+ )} + {isOutputMode && ( + + {renderOutputForm( + provider, + createDynamicSecretLease.data?.data, + handleLeaseRegeneration + )} + + )} +
+
+ ); +}; + const formSchema = z.object({ ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") .optional() }); + type TForm = z.infer; type Props = { @@ -404,7 +541,8 @@ export const CreateDynamicSecretLease = ({ projectSlug, path: secretPath, ttl, - dynamicSecretName + dynamicSecretName, + provider }); createNotification({ @@ -433,6 +571,19 @@ export const CreateDynamicSecretLease = ({ } }, [provider]); + if (provider === DynamicSecretProviders.Kubernetes) { + return ( + + ); + } + const isOutputMode = Boolean(createDynamicSecretLease?.data); if (isPreloading) { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretKubernetesForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretKubernetesForm.tsx index 64646bc1d..8e31115b9 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretKubernetesForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretKubernetesForm.tsx @@ -65,7 +65,14 @@ const formSchema = z sslEnabled: z.boolean().default(false), credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static), serviceAccountName: z.string().trim().min(1), - namespace: z.string().trim().min(1), + namespace: z + .string() + .trim() + .min(1) + .refine( + (val) => !val.includes(","), + "Namespace must be a single value, not a comma-separated list" + ), gatewayId: z.string().optional(), audiences: z.array(z.string().trim().min(1)), authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api) @@ -76,7 +83,14 @@ const formSchema = z ca: z.string().optional(), sslEnabled: z.boolean().default(false), credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic), - namespace: z.string().trim().min(1), + namespace: z + .string() + .trim() + .min(1) + .refine((val) => { + const namespaces = val.split(",").map((ns) => ns.trim()); + return namespaces.length > 0 && namespaces.every((ns) => ns.length > 0); + }, "Must be a valid comma-separated list of namespace values"), gatewayId: z.string().optional(), audiences: z.array(z.string().trim().min(1)), roleType: z.nativeEnum(RoleType), @@ -502,7 +516,11 @@ export const EditDynamicSecretKubernetesForm = ({ name="inputs.namespace" render={({ field, fieldState: { error } }) => (