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-router.ts b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts index 7c42c7f99..26c27d0d3 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts @@ -36,7 +36,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide 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) + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug), + config: z.any().optional() }), response: { 200: z.object({ 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..f2751c635 --- /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 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.environmentSlug), + 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..45cc06e7c 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, @@ -19,6 +20,9 @@ import { const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; +// This value is just a placeholder. When using gateway auth method, the url is irrelevant. +const GATEWAY_AUTH_DEFAULT_URL = "https://kubernetes.default.svc.cluster.local"; + type TKubernetesProviderDTO = { gatewayService: Pick; }; @@ -36,7 +40,7 @@ const generateUsername = (usernameTemplate?: string | null) => { export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs); - if (!providerInputs.gatewayId) { + if (!providerInputs.gatewayId && providerInputs.url) { await blockLocalAndPrivateIpAddresses(providerInputs.url); } @@ -103,135 +107,173 @@ 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.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT } - ] - }, - { - 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.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + // 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.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + // 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.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + } 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.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + }); } - }, - { - 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 - ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent + timeout: EXTERNAL_REQUEST_TIMEOUT + }); + } 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) => { @@ -247,17 +289,23 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): headers: { "Content-Type": "application/json", ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway - ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent + timeout: EXTERNAL_REQUEST_TIMEOUT } ); }; - const url = new URL(providerInputs.url); + const rawUrl = + providerInputs.authMethod === KubernetesAuthMethod.Gateway ? GATEWAY_AUTH_DEFAULT_URL : providerInputs.url || ""; + const url = new URL(rawUrl); const k8sGatewayHost = url.hostname; const k8sPort = url.port ? Number(url.port) : 443; const k8sHost = `${url.protocol}//${url.hostname}`; @@ -315,11 +363,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,26 +381,44 @@ 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 } }, { headers: { "Content-Type": "application/json", ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway - ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent + timeout: EXTERNAL_REQUEST_TIMEOUT } ); @@ -358,11 +426,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 +446,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): { kind: "ServiceAccount", name: serviceAccountName, - namespace: providerInputs.namespace + namespace } ] }, @@ -386,18 +454,22 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): headers: { "Content-Type": "application/json", ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway - ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent + timeout: EXTERNAL_REQUEST_TIMEOUT } ); // 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), @@ -408,12 +480,16 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): headers: { "Content-Type": "application/json", ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway - ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent + timeout: EXTERNAL_REQUEST_TIMEOUT } ); @@ -425,6 +501,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( @@ -439,19 +521,25 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): headers: { "Content-Type": "application/json", ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway - ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent + timeout: EXTERNAL_REQUEST_TIMEOUT } ); return { ...res.data, serviceAccountName: providerInputs.serviceAccountName }; }; - const url = new URL(providerInputs.url); + const rawUrl = + providerInputs.authMethod === KubernetesAuthMethod.Gateway ? GATEWAY_AUTH_DEFAULT_URL : providerInputs.url || ""; + const url = new URL(rawUrl); const k8sHost = `${url.protocol}//${url.hostname}`; const k8sGatewayHost = url.hostname; const k8sPort = url.port ? Number(url.port) : 443; @@ -511,7 +599,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,19 +616,25 @@ 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", ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway - ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent + timeout: EXTERNAL_REQUEST_TIMEOUT } ); } else { @@ -542,31 +642,44 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): headers: { "Content-Type": "application/json", ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway - ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent + timeout: EXTERNAL_REQUEST_TIMEOUT }); } // 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 - ? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken } + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent + timeout: EXTERNAL_REQUEST_TIMEOUT }); }; if (providerInputs.credentialType === KubernetesCredentialType.Dynamic) { - const url = new URL(providerInputs.url); + const rawUrl = + providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? GATEWAY_AUTH_DEFAULT_URL + : providerInputs.url || ""; + + const url = new URL(rawUrl); const k8sGatewayHost = url.hostname; const k8sPort = url.port ? Number(url.port) : 443; const k8sHost = `${url.protocol}//${url.hostname}`; diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index b2496eebd..32cc46d22 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -1,5 +1,10 @@ +import RE2 from "re2"; import { z } from "zod"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; + +import { TDynamicSecretLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types"; + export type PasswordRequirements = { length: number; required: { @@ -323,24 +328,54 @@ export const LdapSchema = z.union([ export const DynamicSecretKubernetesSchema = z .discriminatedUnion("credentialType", [ z.object({ - url: z.string().url().trim().min(1), + url: z + .string() + .optional() + .refine((val: string | undefined) => !val || new RE2(/^https?:\/\/.+/).test(val), { + message: "Invalid URL. Must start with http:// or https:// (e.g. https://example.com)" + }), 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), + namespace: z + .string() + .trim() + .min(1) + .refine((val) => !val.includes(","), "Namespace must be a single value, not a comma-separated list") + .refine( + (val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), + "Invalid namespace format" + ), 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), + url: z + .string() + .url() + .optional() + .refine((val: string | undefined) => !val || new RE2(/^https?:\/\/.+/).test(val), { + message: "Invalid URL. Must start with http:// or https:// (e.g. https://example.com)" + }), 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), + 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) && + namespaces.every((ns) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(ns)) + ); + }, "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), @@ -356,12 +391,21 @@ export const DynamicSecretKubernetesSchema = z 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" - }); + if (data.authMethod === KubernetesAuthMethod.Api || !data.authMethod) { + if (!data.clusterToken) { + ctx.addIssue({ + path: ["clusterToken"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Token, a cluster token must be provided" + }); + } + if (!data.url) { + ctx.addIssue({ + path: ["url"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Token, a cluster URL must be provided" + }); + } } }); @@ -475,10 +519,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/cli/go.mod b/cli/go.mod index fc7322f61..e6d55eb49 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -14,7 +14,7 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.9.1 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.5.95 + github.com/infisical/go-sdk v0.5.96 github.com/infisical/infisical-kmip v0.3.5 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a @@ -25,6 +25,7 @@ require ( github.com/pion/logging v0.2.3 github.com/pion/turn/v4 v4.0.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/pkg/errors v0.9.1 github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a github.com/quic-go/quic-go v0.50.0 github.com/rs/cors v1.11.0 @@ -106,7 +107,6 @@ require ( github.com/pion/randutil v0.1.0 // indirect github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index aa8dc1f61..2e41c756b 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -292,12 +292,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/infisical/go-sdk v0.5.92 h1:PoCnVndrd6Dbkipuxl9fFiwlD5vCKsabtQo09mo8lUE= -github.com/infisical/go-sdk v0.5.92/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= -github.com/infisical/go-sdk v0.5.94 h1:wKBj+KpJEe+ZzOJ7koXQZDR0dLL9bt0Kqgf/1q+7tG4= -github.com/infisical/go-sdk v0.5.94/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= -github.com/infisical/go-sdk v0.5.95 h1:so0YwPofbT7j6Ao8Xcxee/o3ia33meuEVDU2vWr9yfs= -github.com/infisical/go-sdk v0.5.95/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.96 h1:huky6bQ1Y3oRdPb5MO3Ru868qZaPHUxZ7kP7FPNRn48= +github.com/infisical/go-sdk v0.5.96/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE= github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= diff --git a/cli/packages/cmd/dynamic_secrets.go b/cli/packages/cmd/dynamic_secrets.go index 8761b84ef..45bc323d8 100644 --- a/cli/packages/cmd/dynamic_secrets.go +++ b/cli/packages/cmd/dynamic_secrets.go @@ -232,13 +232,26 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "To fetch dynamic secret root credentials details") } + // for Kubernetes dynamic secrets only + kubernetesNamespace, err := cmd.Flags().GetString("kubernetesNamespace") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + config := map[string]any{} + if kubernetesNamespace != "" { + config["namespace"] = kubernetesNamespace + } + leaseCredentials, _, leaseDetails, err := infisicalClient.DynamicSecrets().Leases().Create(infisicalSdk.CreateDynamicSecretLeaseOptions{ DynamicSecretName: dynamicSecretRootCredential.Name, ProjectSlug: projectDetails.Slug, TTL: ttl, SecretPath: secretsPath, EnvironmentSlug: environmentName, + Config: config, }) + if err != nil { util.HandleError(err, "To lease dynamic secret") } @@ -585,6 +598,10 @@ func init() { dynamicSecretLeaseCreateCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth") dynamicSecretLeaseCreateCmd.Flags().String("ttl", "", "The lease lifetime TTL. If not provided the default TTL of dynamic secret will be used.") dynamicSecretLeaseCreateCmd.Flags().Bool("plain", false, "Print leased credentials without formatting, one per line") + + // Kubernetes specific flags + dynamicSecretLeaseCreateCmd.Flags().String("kubernetesNamespace", "", "The namespace to create the lease in. Only used for Kubernetes dynamic secrets.") + dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseCreateCmd) dynamicSecretLeaseListCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from") diff --git a/docs/api-reference/endpoints/dynamic-secrets/kubernetes/create-lease.mdx b/docs/api-reference/endpoints/dynamic-secrets/kubernetes/create-lease.mdx new file mode 100644 index 000000000..1a4e26709 --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/kubernetes/create-lease.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Kubernetes Lease" +openapi: "POST /api/v1/dynamic-secrets/leases/kubernetes" +--- diff --git a/docs/cli/commands/dynamic-secrets.mdx b/docs/cli/commands/dynamic-secrets.mdx index c345c3e2d..5db90b564 100644 --- a/docs/cli/commands/dynamic-secrets.mdx +++ b/docs/cli/commands/dynamic-secrets.mdx @@ -148,6 +148,22 @@ infisical dynamic-secrets lease create --ttl= +### Provider-specific flags + +The following flags are specific to certain providers or integrations: + + + + The namespace to create the lease in. Only used for Kubernetes dynamic secrets. + + ```bash + # Example + infisical dynamic-secrets lease create --kubernetesNamespace= + ``` + + + + This command is used to list leases for a dynamic secret. diff --git a/docs/documentation/platform/dynamic-secrets/kubernetes.mdx b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx index 713aefae6..87c5b3e89 100644 --- a/docs/documentation/platform/dynamic-secrets/kubernetes.mdx +++ b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx @@ -162,6 +162,12 @@ This feature is ideal for scenarios where you need to: tokens for the target service account. + + When using Gateway authentication, the Gateway will access the Kubernetes API server + using its internal cluster URL (typically https://kubernetes.default.svc) and TLS configuration. + You don't need to specify these values separately in the dynamic secret configuration. + + 1. Deploy the Infisical Gateway in your cluster 2. Set up RBAC permissions for the Gateway's service account: ```yaml rbac.yaml @@ -206,6 +212,7 @@ This feature is ideal for scenarios where you need to: - Automatically clean up service accounts after token expiration - Assign different roles to different users or applications - Maintain strict control over service account permissions + - Support multiple namespaces with a single dynamic secret configuration ### Prerequisites @@ -213,6 +220,16 @@ This feature is ideal for scenarios where you need to: - Cluster access token with permissions to create service accounts and manage RBAC - (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access + ### Namespace Support + + When configuring a dynamic secret, you can specify multiple allowed namespaces as a comma-separated list. During lease creation, you can then specify which namespace to use from this allowed list. This provides flexibility while maintaining security by: + + - Allowing a single dynamic secret configuration to support multiple namespaces + - Restricting service account creation to only the specified allowed namespaces + - Enabling fine-grained control over which namespaces can be used for each lease + + For example, if you configure a dynamic secret with allowed namespaces "default,kube-system,monitoring", you can create leases that use any of these namespaces while preventing access to other namespaces in your cluster. + ### Authentication Setup Choose your authentication method: @@ -318,6 +335,12 @@ This feature is ideal for scenarios where you need to: manage service accounts, their tokens, and RBAC resources. + + When using Gateway authentication, the Gateway will access the Kubernetes API server + using its internal cluster URL (typically https://kubernetes.default.svc) and TLS configuration. + You don't need to specify these values separately in the dynamic secret configuration. + + 1. Deploy the Infisical Gateway in your cluster 2. Set up RBAC permissions for the Gateway's service account: ```yaml rbac.yaml @@ -401,13 +424,13 @@ This feature is ideal for scenarios where you need to: Select a gateway for private cluster access. If not specified, the Internet Gateway will be used. - Kubernetes API server URL (e.g., https://kubernetes.default.svc) + Kubernetes API server URL (e.g., https://kubernetes.default.svc). Not required when using Gateway authentication as the Gateway will use its internal cluster URL. - Whether to enable SSL verification for the Kubernetes API server connection. + Whether to enable SSL verification for the Kubernetes API server connection. Not required when using Gateway authentication as the Gateway will use its internal TLS configuration. - Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA. + Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA. Not required when using Gateway authentication as the Gateway will use its internal TLS configuration. Choose between Token (API) or Gateway authentication. If using Gateway, the Gateway must be deployed in your Kubernetes cluster. @@ -418,18 +441,30 @@ This feature is ideal for scenarios where you need to: Choose between Static (predefined service account) or Dynamic (temporary service accounts with role assignments) - - Name of the service account to generate tokens for (required for Static credentials) - - - 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) - + + + + + Name of the service account to generate tokens for + + + Kubernetes namespace where the service account exists + + + + + + Kubernetes namespace(s) where the service accounts will be created. You can specify multiple namespaces as a comma-separated list (e.g., "default,kube-system"). During lease creation, you can specify which namespace to use from this allowed list. + + + Type of role to assign (ClusterRole or Role) + + + Name of the role to assign to the temporary service account + + + + Optional list of audiences to include in the generated token diff --git a/docs/mint.json b/docs/mint.json index 2443784b9..9270fac17 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -926,6 +926,12 @@ { "group": "Dynamic Secrets", "pages": [ + { + "group": "Kubernetes", + "pages": [ + "api-reference/endpoints/dynamic-secrets/kubernetes/create-lease" + ] + }, "api-reference/endpoints/dynamic-secrets/create", "api-reference/endpoints/dynamic-secrets/update", "api-reference/endpoints/dynamic-secrets/delete", diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 440c534f0..2357a83c0 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -290,7 +290,7 @@ export type TDynamicSecretProvider = type: DynamicSecretProviders.Kubernetes; inputs: | { - url: string; + url?: string; clusterToken?: string; ca?: string; serviceAccountName: string; @@ -302,7 +302,7 @@ export type TDynamicSecretProvider = authMethod: string; } | { - url: string; + url?: string; clusterToken?: string; ca?: string; credentialType: KubernetesDynamicSecretCredentialType.Dynamic; 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/organization/Gateways/GatewayListPage/GatewayListPage.tsx b/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx index 703dca3a7..1552f8548 100644 --- a/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx +++ b/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx @@ -3,6 +3,7 @@ import { Helmet } from "react-helmet"; import { faArrowUpRightFromSquare, faBookOpen, + faCopy, faEdit, faEllipsisV, faInfoCircle, @@ -169,6 +170,12 @@ export const GatewayListPage = withPermission( + } + onClick={() => navigator.clipboard.writeText(el.id)} + > + Copy ID + !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) }), z.object({ - url: z.string().url().trim().min(1), + url: z.string().trim().optional(), 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), + 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), @@ -116,12 +130,21 @@ const formSchema = z 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" - }); + if (data.provider.authMethod === AuthMethod.Api) { + if (!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" + }); + } + if (!data.provider.url) { + ctx.addIssue({ + path: ["provider.url"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Token, a cluster URL must be provided" + }); + } } }); @@ -188,7 +211,13 @@ export const KubernetesInputForm = ({ try { const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; await createDynamicSecret.mutateAsync({ - provider: { type: DynamicSecretProviders.Kubernetes, inputs: provider }, + provider: { + type: DynamicSecretProviders.Kubernetes, + inputs: { + ...provider, + url: provider.url || undefined + } + }, maxTTL: rest.maxTTL, name: rest.name, path: secretPath, @@ -333,69 +362,6 @@ export const KubernetesInputForm = ({ )} - ( - - - - )} - /> - -
- - Enable SSL - - If enabled, you can optionally provide a custom CA certificate. Leave - blank to use the system/public CA. - - } - > - - - - ( - - )} - /> -
- - ( - -