feat: allow k8 dynamic secret multi namespace and show proper error

This commit is contained in:
Sheen Capadngan
2025-06-11 01:11:29 +08:00
parent 3ee8f7aa20
commit f7fb015bd8
15 changed files with 534 additions and 142 deletions
@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
const hasConfigColumn = await knex.schema.hasColumn(TableName.DynamicSecretLease, "config");
if (hasConfigColumn) {
await knex.schema.alterTable(TableName.DynamicSecretLease, (table) => {
table.dropColumn("config");
});
}
}
@@ -16,7 +16,8 @@ export const DynamicSecretLeasesSchema = z.object({
statusDetails: z.string().nullable().optional(), statusDetails: z.string().nullable().optional(),
dynamicSecretId: z.string().uuid(), dynamicSecretId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
config: z.unknown().nullable().optional()
}); });
export type TDynamicSecretLeases = z.infer<typeof DynamicSecretLeasesSchema>; export type TDynamicSecretLeases = z.infer<typeof DynamicSecretLeasesSchema>;
@@ -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 };
}
});
};
+2
View File
@@ -6,6 +6,7 @@ import { registerAssumePrivilegeRouter } from "./assume-privilege-router";
import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router";
import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerCaCrlRouter } from "./certificate-authority-crl-router";
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-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 { registerDynamicSecretRouter } from "./dynamic-secret-router";
import { registerExternalKmsRouter } from "./external-kms-router"; import { registerExternalKmsRouter } from "./external-kms-router";
import { registerGatewayRouter } from "./gateway-router"; import { registerGatewayRouter } from "./gateway-router";
@@ -71,6 +72,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
async (dynamicSecretRouter) => { async (dynamicSecretRouter) => {
await dynamicSecretRouter.register(registerDynamicSecretRouter); await dynamicSecretRouter.register(registerDynamicSecretRouter);
await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" }); await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" });
await dynamicSecretRouter.register(registerKubernetesDynamicSecretLeaseRouter, { prefix: "/leases/kubernetes" });
}, },
{ prefix: "/dynamic-secrets" } { prefix: "/dynamic-secrets" }
); );
@@ -10,6 +10,7 @@ import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal";
import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types"; import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types";
import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models";
import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal";
import { TDynamicSecretLeaseConfig } from "./dynamic-secret-lease-types";
type TDynamicSecretLeaseQueueServiceFactoryDep = { type TDynamicSecretLeaseQueueServiceFactoryDep = {
queueService: TQueueServiceFactory; queueService: TQueueServiceFactory;
@@ -134,10 +135,15 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id))); await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id)));
await Promise.all( await Promise.all(
dynamicSecretLeases.map(({ externalEntityId }) => dynamicSecretLeases.map(({ externalEntityId, config }) =>
selectedProvider.revoke(decryptedStoredInput, externalEntityId, { selectedProvider.revoke(
projectId: folder.projectId decryptedStoredInput,
}) externalEntityId,
{
projectId: folder.projectId
},
config as TDynamicSecretLeaseConfig
)
) )
); );
} }
@@ -29,6 +29,7 @@ import {
TCreateDynamicSecretLeaseDTO, TCreateDynamicSecretLeaseDTO,
TDeleteDynamicSecretLeaseDTO, TDeleteDynamicSecretLeaseDTO,
TDetailsDynamicSecretLeaseDTO, TDetailsDynamicSecretLeaseDTO,
TDynamicSecretLeaseConfig,
TListDynamicSecretLeasesDTO, TListDynamicSecretLeasesDTO,
TRenewDynamicSecretLeaseDTO TRenewDynamicSecretLeaseDTO
} from "./dynamic-secret-lease-types"; } from "./dynamic-secret-lease-types";
@@ -77,7 +78,8 @@ export const dynamicSecretLeaseServiceFactory = ({
actorId, actorId,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
ttl ttl,
config
}: TCreateDynamicSecretLeaseDTO) => { }: TCreateDynamicSecretLeaseDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
@@ -163,7 +165,8 @@ export const dynamicSecretLeaseServiceFactory = ({
expireAt: expireAt.getTime(), expireAt: expireAt.getTime(),
usernameTemplate: dynamicSecretCfg.usernameTemplate, usernameTemplate: dynamicSecretCfg.usernameTemplate,
identity, identity,
metadata: { projectId } metadata: { projectId },
config
}); });
} catch (error: unknown) { } catch (error: unknown) {
if (error && typeof error === "object" && error !== null && "sqlMessage" in error) { if (error && typeof error === "object" && error !== null && "sqlMessage" in error) {
@@ -177,8 +180,10 @@ export const dynamicSecretLeaseServiceFactory = ({
expireAt, expireAt,
version: 1, version: 1,
dynamicSecretId: dynamicSecretCfg.id, dynamicSecretId: dynamicSecretCfg.id,
externalEntityId: entityId externalEntityId: entityId,
config
}); });
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date())); await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date()));
return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data }; return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data };
}; };
@@ -342,7 +347,12 @@ export const dynamicSecretLeaseServiceFactory = ({
) as object; ) as object;
const revokeResponse = await selectedProvider const revokeResponse = await selectedProvider
.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId, { projectId }) .revoke(
decryptedStoredInput,
dynamicSecretLease.externalEntityId,
{ projectId },
dynamicSecretLease.config as TDynamicSecretLeaseConfig
)
.catch(async (err) => { .catch(async (err) => {
// only propogate this error if forced is false // only propogate this error if forced is false
if (!isForced) return { error: err as Error }; if (!isForced) return { error: err as Error };
@@ -10,6 +10,7 @@ export type TCreateDynamicSecretLeaseDTO = {
environmentSlug: string; environmentSlug: string;
ttl?: string; ttl?: string;
projectSlug: string; projectSlug: string;
config?: TDynamicSecretLeaseConfig;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDetailsDynamicSecretLeaseDTO = { export type TDetailsDynamicSecretLeaseDTO = {
@@ -41,3 +42,9 @@ export type TRenewDynamicSecretLeaseDTO = {
ttl?: string; ttl?: string;
projectSlug: string; projectSlug: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDynamicSecretKubernetesLeaseConfig = {
namespace?: string;
};
export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig;
@@ -1,13 +1,14 @@
import axios from "axios"; import axios, { AxiosError } from "axios";
import handlebars from "handlebars"; import handlebars from "handlebars";
import https from "https"; 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 { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; 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 { TGatewayServiceFactory } from "../../gateway/gateway-service";
import { import {
DynamicSecretKubernetesSchema, DynamicSecretKubernetesSchema,
@@ -103,96 +104,127 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const serviceAccountName = generateUsername(); const serviceAccountName = generateUsername();
const roleBindingName = `${serviceAccountName}-role-binding`; const roleBindingName = `${serviceAccountName}-role-binding`;
// 1. Create a test service account const namespaces = providerInputs.namespace.split(",").map((namespace) => namespace.trim());
await axios.post(
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`,
{
metadata: {
name: serviceAccountName,
namespace: providerInputs.namespace
}
},
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
}
);
// 2. Create a test role binding // Test each namespace sequentially instead of in parallel to simplify cleanup
const roleBindingUrl = for await (const namespace of namespaces) {
providerInputs.roleType === KubernetesRoleType.ClusterRole try {
? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings` // 1. Create a test service account
: `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings`; await axios.post(
`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`,
const roleBindingMetadata = {
name: roleBindingName,
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace })
};
await axios.post(
roleBindingUrl,
{
metadata: roleBindingMetadata,
roleRef: {
kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role",
name: providerInputs.role,
apiGroup: "rbac.authorization.k8s.io"
},
subjects: [
{ {
kind: "ServiceAccount", metadata: {
name: serviceAccountName, name: serviceAccountName,
namespace: providerInputs.namespace 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 // 2. Create a test role binding
await axios.post( const roleBindingUrl =
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`, providerInputs.roleType === KubernetesRoleType.ClusterRole
{ ? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings`
spec: { : `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`;
expirationSeconds: 600, // 10 minutes
...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) 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 await axios.delete(`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}`, {
if (providerInputs.roleType === KubernetesRoleType.Role) {
await axios.delete(
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`,
{
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
@@ -202,36 +234,19 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT, timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent 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( throw new Error(`${mainErrorMessage}. ${cleanupInfo}`);
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}`,
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
} }
); }
}; };
const serviceAccountStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { const serviceAccountStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => {
@@ -315,11 +330,13 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const create = async ({ const create = async ({
inputs, inputs,
expireAt, expireAt,
usernameTemplate usernameTemplate,
config
}: { }: {
inputs: unknown; inputs: unknown;
expireAt: number; expireAt: number;
usernameTemplate?: string | null; usernameTemplate?: string | null;
config?: TDynamicSecretKubernetesLeaseConfig;
}) => { }) => {
const providerInputs = await validateProviderInputs(inputs); const providerInputs = await validateProviderInputs(inputs);
@@ -331,14 +348,28 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const baseUrl = port ? `${host}:${port}` : host; const baseUrl = port ? `${host}:${port}` : host;
const serviceAccountName = generateUsername(usernameTemplate); const serviceAccountName = generateUsername(usernameTemplate);
const roleBindingName = `${serviceAccountName}-role-binding`; 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 // 1. Create the service account
await axios.post( await axios.post(
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`, `${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`,
{ {
metadata: { metadata: {
name: serviceAccountName, name: serviceAccountName,
namespace: providerInputs.namespace namespace
} }
}, },
{ {
@@ -358,11 +389,11 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const roleBindingUrl = const roleBindingUrl =
providerInputs.roleType === KubernetesRoleType.ClusterRole providerInputs.roleType === KubernetesRoleType.ClusterRole
? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings` ? `${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 = { const roleBindingMetadata = {
name: roleBindingName, name: roleBindingName,
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace }) ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace })
}; };
await axios.post( await axios.post(
@@ -378,7 +409,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
{ {
kind: "ServiceAccount", kind: "ServiceAccount",
name: serviceAccountName, name: serviceAccountName,
namespace: providerInputs.namespace namespace
} }
] ]
}, },
@@ -397,7 +428,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
// 3. Request a token for the service account // 3. Request a token for the service account
const res = await axios.post<TKubernetesTokenRequest>( const res = await axios.post<TKubernetesTokenRequest>(
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`, `${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}/token`,
{ {
spec: { spec: {
expirationSeconds: Math.floor((expireAt - Date.now()) / 1000), expirationSeconds: Math.floor((expireAt - Date.now()) / 1000),
@@ -425,6 +456,12 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
throw new Error("invalid callback"); 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 baseUrl = port ? `${host}:${port}` : host;
const res = await axios.post<TKubernetesTokenRequest>( const res = await axios.post<TKubernetesTokenRequest>(
@@ -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 providerInputs = await validateProviderInputs(inputs);
const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { 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 baseUrl = port ? `${host}:${port}` : host;
const roleBindingName = `${entityId}-role-binding`; const roleBindingName = `${entityId}-role-binding`;
const namespace = config?.namespace ?? providerInputs.namespace.split(",")[0].trim();
if (providerInputs.roleType === KubernetesRoleType.Role) { if (providerInputs.roleType === KubernetesRoleType.Role) {
await axios.delete( 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: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -552,7 +597,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
} }
// Delete the service account // 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: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
@@ -1,5 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { TDynamicSecretLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types";
export type PasswordRequirements = { export type PasswordRequirements = {
length: number; length: number;
required: { required: {
@@ -329,7 +331,11 @@ export const DynamicSecretKubernetesSchema = z
sslEnabled: z.boolean().default(false), sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesCredentialType.Static), credentialType: z.literal(KubernetesCredentialType.Static),
serviceAccountName: z.string().trim().min(1), 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(), gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)), audiences: z.array(z.string().trim().min(1)),
authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api) authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api)
@@ -340,7 +346,14 @@ export const DynamicSecretKubernetesSchema = z
ca: z.string().optional(), ca: z.string().optional(),
sslEnabled: z.boolean().default(false), sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesCredentialType.Dynamic), 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(), gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)), audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(KubernetesRoleType), roleType: z.nativeEnum(KubernetesRoleType),
@@ -475,10 +488,16 @@ export type TDynamicProviderFns = {
name: string; name: string;
}; };
metadata: { projectId: string }; metadata: { projectId: string };
config?: TDynamicSecretLeaseConfig;
}) => Promise<{ entityId: string; data: unknown }>; }) => Promise<{ entityId: string; data: unknown }>;
validateConnection: (inputs: unknown, metadata: { projectId: string }) => Promise<boolean>; validateConnection: (inputs: unknown, metadata: { projectId: string }) => Promise<boolean>;
validateProviderInputs: (inputs: object, metadata: { projectId: string }) => Promise<unknown>; validateProviderInputs: (inputs: object, metadata: { projectId: string }) => Promise<unknown>;
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: ( renew: (
inputs: unknown, inputs: unknown,
entityId: string, entityId: string,
+8
View File
@@ -1113,6 +1113,14 @@ export const DYNAMIC_SECRET_LEASES = {
leaseId: "The ID of the dynamic secret lease.", leaseId: "The ID of the dynamic secret lease.",
isForced: 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." "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; } as const;
export const SECRET_TAGS = { export const SECRET_TAGS = {
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { DynamicSecretProviders } from "../dynamicSecret/types";
import { dynamicSecretLeaseKeys } from "./queries"; import { dynamicSecretLeaseKeys } from "./queries";
import { import {
TCreateDynamicSecretLeaseDTO, TCreateDynamicSecretLeaseDTO,
@@ -19,6 +20,14 @@ export const useCreateDynamicSecretLease = () => {
TCreateDynamicSecretLeaseDTO TCreateDynamicSecretLeaseDTO
>({ >({
mutationFn: async (dto) => { 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 }>( const { data } = await apiRequest.post<{ lease: TDynamicSecretLease; data: unknown }>(
"/api/v1/dynamic-secrets/leases", "/api/v1/dynamic-secrets/leases",
dto dto
@@ -1,3 +1,5 @@
import { DynamicSecretProviders } from "../dynamicSecret/types";
export enum DynamicSecretLeaseStatus { export enum DynamicSecretLeaseStatus {
FailedDeletion = "Failed to delete" FailedDeletion = "Failed to delete"
} }
@@ -13,12 +15,20 @@ export type TDynamicSecretLease = {
updatedAt: string; updatedAt: string;
}; };
export type TDynamicSecretKubernetesLeaseConfig = {
namespace?: string;
};
export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig;
export type TCreateDynamicSecretLeaseDTO = { export type TCreateDynamicSecretLeaseDTO = {
dynamicSecretName: string; dynamicSecretName: string;
projectSlug: string; projectSlug: string;
ttl?: string; ttl?: string;
path: string; path: string;
environmentSlug: string; environmentSlug: string;
config?: TDynamicSecretLeaseConfig;
provider: DynamicSecretProviders;
}; };
export type TRenewDynamicSecretLeaseDTO = { export type TRenewDynamicSecretLeaseDTO = {
@@ -67,7 +67,14 @@ const formSchema = z
sslEnabled: z.boolean().default(false), sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static), credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static),
serviceAccountName: z.string().trim().min(1), 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(), gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)), audiences: z.array(z.string().trim().min(1)),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api) authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
@@ -78,7 +85,14 @@ const formSchema = z
ca: z.string().optional(), ca: z.string().optional(),
sslEnabled: z.boolean().default(false), sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic), 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(), gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)), audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(RoleType), roleType: z.nativeEnum(RoleType),
@@ -507,7 +521,11 @@ export const KubernetesInputForm = ({
name="provider.namespace" name="provider.namespace"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Namespace" label={
credentialType === KubernetesDynamicSecretCredentialType.Static
? "Namespace"
: "Allowed Namespace(s)"
}
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
> >
@@ -353,12 +353,149 @@ const renderOutputForm = (
return null; 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<typeof kubernetesFormSchema>;
export const CreateKubernetesDynamicSecretLease = ({
onClose,
projectSlug,
dynamicSecretName,
provider,
secretPath,
environment
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TKubernetesForm>({
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 (
<div>
<AnimatePresence>
{!isOutputMode && (
<motion.div
key="lease-input"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<form onSubmit={handleSubmit(handleDynamicSecretLeaseCreate)}>
<Controller
control={control}
name="namespace"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Namespace"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="The Kubernetes namespace to lease the dynamic secret to. If not specified, the first namespace defined in the configuration will be used."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</motion.div>
)}
{isOutputMode && (
<motion.div
key="lease-output"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
{renderOutputForm(
provider,
createDynamicSecretLease.data?.data,
handleLeaseRegeneration
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
const formSchema = z.object({ const formSchema = z.object({
ttl: z ttl: z
.string() .string()
.refine((val) => ms(val) > 0, "TTL must be a positive number") .refine((val) => ms(val) > 0, "TTL must be a positive number")
.optional() .optional()
}); });
type TForm = z.infer<typeof formSchema>; type TForm = z.infer<typeof formSchema>;
type Props = { type Props = {
@@ -404,7 +541,8 @@ export const CreateDynamicSecretLease = ({
projectSlug, projectSlug,
path: secretPath, path: secretPath,
ttl, ttl,
dynamicSecretName dynamicSecretName,
provider
}); });
createNotification({ createNotification({
@@ -433,6 +571,19 @@ export const CreateDynamicSecretLease = ({
} }
}, [provider]); }, [provider]);
if (provider === DynamicSecretProviders.Kubernetes) {
return (
<CreateKubernetesDynamicSecretLease
onClose={onClose}
projectSlug={projectSlug}
dynamicSecretName={dynamicSecretName}
provider={provider}
secretPath={secretPath}
environment={environment}
/>
);
}
const isOutputMode = Boolean(createDynamicSecretLease?.data); const isOutputMode = Boolean(createDynamicSecretLease?.data);
if (isPreloading) { if (isPreloading) {
@@ -65,7 +65,14 @@ const formSchema = z
sslEnabled: z.boolean().default(false), sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static), credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static),
serviceAccountName: z.string().trim().min(1), 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(), gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)), audiences: z.array(z.string().trim().min(1)),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api) authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
@@ -76,7 +83,14 @@ const formSchema = z
ca: z.string().optional(), ca: z.string().optional(),
sslEnabled: z.boolean().default(false), sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic), 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(), gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)), audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(RoleType), roleType: z.nativeEnum(RoleType),
@@ -502,7 +516,11 @@ export const EditDynamicSecretKubernetesForm = ({
name="inputs.namespace" name="inputs.namespace"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Namespace" label={
credentialType === KubernetesDynamicSecretCredentialType.Static
? "Namespace"
: "Allowed Namespace(s)"
}
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
> >