mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 01:27:41 +00:00
feat: allow k8 dynamic secret multi namespace and show proper error
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
+21
-3
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
+152
-1
@@ -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) {
|
||||||
|
|||||||
+21
-3
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user