Compare commits

..

33 Commits

Author SHA1 Message Date
carlosmonastyrski
852664e2cb feat(invite-users): fix issue where invitations were not sent when the actor was an identity 2025-06-11 16:11:34 -03:00
carlosmonastyrski
baa05714ab Merge pull request #3780 from Infisical/fix/azureClientSecretsManualDeletionCheck
feat(secret-rotation): Azure Client Secrets manually deleted client secrets check
2025-06-11 14:31:21 -03:00
carlosmonastyrski
c487614c38 feat(secret-rotation): fix Azure Client Secrets to check if the client secret has been manually deleted to avoid blocking the process 2025-06-11 13:28:34 -03:00
carlosmonastyrski
a55c8cacea Merge pull request #3778 from Infisical/fix/secretRequestReadIssue
feat(secret-request): hide secret value on missing secret read permission
2025-06-11 12:13:22 -03:00
Sheen
55aa1e87c0 Merge pull request #3767 from Infisical/feat/allow-k8-dynamic-secret-multi-namespace-and-others
feat: allow k8 dynamic secret multi namespace and show proper error
2025-06-11 23:01:00 +08:00
carlosmonastyrski
c5c7adbc42 feat(secret-request): hide secret value on missing secret read permission 2025-06-11 11:43:14 -03:00
Sheen Capadngan
f686882ce6 misc: addressed doc 2025-06-11 22:41:16 +08:00
Maidul Islam
e35417e11b Update kubernetes-helm.mdx 2025-06-11 10:06:45 -04:00
Sheen Capadngan
ff0f4cf46a misc: added support for copying gateway ID 2025-06-11 20:49:10 +08:00
Sheen Capadngan
64093e9175 misc: final revisions 2025-06-11 14:55:41 +08:00
Sheen Capadngan
78fd852588 Merge remote-tracking branch 'origin/main' into feat/allow-k8-dynamic-secret-multi-namespace-and-others 2025-06-11 14:28:15 +08:00
Maidul Islam
0c1f761a9a Merge pull request #3774 from Infisical/akhilmhdh-patch-4
Update aws-iam.mdx
2025-06-10 23:23:16 -04:00
Akhil Mohan
c363f485eb Update aws-iam.mdx 2025-06-11 08:52:35 +05:30
Maidul Islam
433d83641d Merge pull request #3765 from Infisical/help-fix-frontend-cache-issue
disable caching for frontend assets
2025-06-10 19:29:10 -04:00
carlosmonastyrski
35bb7f299c Merge pull request #3773 from Infisical/fix/pitSecretVersionsZeroIssue
feat(pit): improve commit changes condition as some old versions can be zero
2025-06-10 20:17:11 -03:00
carlosmonastyrski
160e2b773b feat(pit): improve commit changes condition as some old versions can be zero 2025-06-10 19:02:02 -03:00
Daniel Hougaard
f0a70e23ac Merge pull request #3772 from Infisical/daniel/full-gateway-auth-2
fix: allow for empty target URLs
2025-06-11 01:56:57 +04:00
Daniel Hougaard
a6271a6187 fix: allow for empty target URLs 2025-06-11 01:45:38 +04:00
Sheen Capadngan
b2fbec740f misc: updated to use new proxy action 2025-06-11 05:11:23 +08:00
Maidul Islam
26bed22b94 fix lint by adding void 2025-06-10 17:05:10 -04:00
Sheen Capadngan
86e5f46d89 Merge remote-tracking branch 'origin/main' into feat/allow-k8-dynamic-secret-multi-namespace-and-others 2025-06-11 04:58:44 +08:00
Sheen Capadngan
720789025c misc: addressed greptile 2025-06-11 04:58:12 +08:00
Daniel Hougaard
811b3d5934 Merge pull request #3769 from Infisical/daniel/full-gateway-auth
feat(gateway): use gateway for full k8s request life-cycle
2025-06-11 00:55:38 +04:00
carlosmonastyrski
dbe7acdc80 Merge pull request #3771 from Infisical/fix/secretRotationIssueCommits
feat(secret-rotation): fix metadata empty objects breaking version co…
2025-06-10 17:48:51 -03:00
carlosmonastyrski
b33985b338 feat(secret-rotation): fix metadata empty objects breaking version comparison 2025-06-10 17:45:58 -03:00
Sheen
c59eddb00a doc: added api reference for k8 lease 2025-06-10 20:19:33 +00:00
Sheen Capadngan
fe40ba497b misc: added flag to CLI 2025-06-11 04:11:51 +08:00
Sheen Capadngan
8b443e0957 misc: url and ssl config not needed when gateway auth 2025-06-11 02:51:22 +08:00
Sheen Capadngan
f7fb015bd8 feat: allow k8 dynamic secret multi namespace and show proper error 2025-06-11 01:11:29 +08:00
carlosmonastyrski
0d7cd357c3 Merge pull request #3766 from Infisical/fix/fixDocsForCliUsageUrlEurope
feat(docs): Added a small note to clarify the usage of the env variable INFISICAL_API_URL for EU users
2025-06-10 13:01:03 -03:00
carlosmonastyrski
e40f65836f feat(docs): Added a small note to clarify the usage of the env variable INFISICAL_API_URL for EU users 2025-06-10 08:25:06 -03:00
Maidul Islam
2d3c63e8b9 fix lint 2025-06-10 03:10:16 -04:00
Maidul Islam
bdb36d6be4 disable caching for frontend assets
This aims to fix the issue where it says

```
TypeError
Cannot read properties of undefined (reading 'component')
```

by telling the browser to not cache any chunks
2025-06-10 02:59:31 -04:00
39 changed files with 1108 additions and 418 deletions

View File

@@ -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");
});
}
}

View File

@@ -16,7 +16,8 @@ export const DynamicSecretLeasesSchema = z.object({
statusDetails: z.string().nullable().optional(),
dynamicSecretId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
config: z.unknown().nullable().optional()
});
export type TDynamicSecretLeases = z.infer<typeof DynamicSecretLeasesSchema>;

View File

@@ -36,7 +36,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path),
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.path)
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug),
config: z.any().optional()
}),
response: {
200: z.object({

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs";
import { daysToMillisecond } from "@app/lib/dates";
import { removeTrailingSlash } from "@app/lib/fn";
import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerKubernetesDynamicSecretLeaseRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.DynamicSecrets],
body: z.object({
dynamicSecretName: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.dynamicSecretName).toLowerCase(),
projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.projectSlug),
ttl: z
.string()
.optional()
.describe(DYNAMIC_SECRET_LEASES.CREATE.ttl)
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be greater than 1min" });
if (valMs > daysToMillisecond(1))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path),
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug),
config: z
.object({
namespace: z.string().min(1).optional().describe(DYNAMIC_SECRET_LEASES.KUBERNETES.CREATE.config.namespace)
})
.optional()
}),
response: {
200: z.object({
lease: DynamicSecretLeasesSchema,
dynamicSecret: SanitizedDynamicSecretSchema,
data: z.unknown()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { data, lease, dynamicSecret } = await server.services.dynamicSecretLease.create({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
name: req.body.dynamicSecretName,
...req.body
});
return { lease, data, dynamicSecret };
}
});
};

View File

@@ -6,6 +6,7 @@ import { registerAssumePrivilegeRouter } from "./assume-privilege-router";
import { registerAuditLogStreamRouter } from "./audit-log-stream-router";
import { registerCaCrlRouter } from "./certificate-authority-crl-router";
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router";
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
import { registerExternalKmsRouter } from "./external-kms-router";
import { registerGatewayRouter } from "./gateway-router";
@@ -71,6 +72,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
async (dynamicSecretRouter) => {
await dynamicSecretRouter.register(registerDynamicSecretRouter);
await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" });
await dynamicSecretRouter.register(registerKubernetesDynamicSecretLeaseRouter, { prefix: "/leases/kubernetes" });
},
{ prefix: "/dynamic-secrets" }
);

View File

@@ -10,6 +10,7 @@ import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal";
import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types";
import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models";
import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal";
import { TDynamicSecretLeaseConfig } from "./dynamic-secret-lease-types";
type TDynamicSecretLeaseQueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
@@ -134,10 +135,15 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id)));
await Promise.all(
dynamicSecretLeases.map(({ externalEntityId }) =>
selectedProvider.revoke(decryptedStoredInput, externalEntityId, {
projectId: folder.projectId
})
dynamicSecretLeases.map(({ externalEntityId, config }) =>
selectedProvider.revoke(
decryptedStoredInput,
externalEntityId,
{
projectId: folder.projectId
},
config as TDynamicSecretLeaseConfig
)
)
);
}

View File

@@ -29,6 +29,7 @@ import {
TCreateDynamicSecretLeaseDTO,
TDeleteDynamicSecretLeaseDTO,
TDetailsDynamicSecretLeaseDTO,
TDynamicSecretLeaseConfig,
TListDynamicSecretLeasesDTO,
TRenewDynamicSecretLeaseDTO
} from "./dynamic-secret-lease-types";
@@ -77,7 +78,8 @@ export const dynamicSecretLeaseServiceFactory = ({
actorId,
actorOrgId,
actorAuthMethod,
ttl
ttl,
config
}: TCreateDynamicSecretLeaseDTO) => {
const appCfg = getConfig();
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
@@ -163,7 +165,8 @@ export const dynamicSecretLeaseServiceFactory = ({
expireAt: expireAt.getTime(),
usernameTemplate: dynamicSecretCfg.usernameTemplate,
identity,
metadata: { projectId }
metadata: { projectId },
config
});
} catch (error: unknown) {
if (error && typeof error === "object" && error !== null && "sqlMessage" in error) {
@@ -177,8 +180,10 @@ export const dynamicSecretLeaseServiceFactory = ({
expireAt,
version: 1,
dynamicSecretId: dynamicSecretCfg.id,
externalEntityId: entityId
externalEntityId: entityId,
config
});
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date()));
return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data };
};
@@ -342,7 +347,12 @@ export const dynamicSecretLeaseServiceFactory = ({
) as object;
const revokeResponse = await selectedProvider
.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId, { projectId })
.revoke(
decryptedStoredInput,
dynamicSecretLease.externalEntityId,
{ projectId },
dynamicSecretLease.config as TDynamicSecretLeaseConfig
)
.catch(async (err) => {
// only propogate this error if forced is false
if (!isForced) return { error: err as Error };

View File

@@ -10,6 +10,7 @@ export type TCreateDynamicSecretLeaseDTO = {
environmentSlug: string;
ttl?: string;
projectSlug: string;
config?: TDynamicSecretLeaseConfig;
} & Omit<TProjectPermission, "projectId">;
export type TDetailsDynamicSecretLeaseDTO = {
@@ -41,3 +42,9 @@ export type TRenewDynamicSecretLeaseDTO = {
ttl?: string;
projectSlug: string;
} & Omit<TProjectPermission, "projectId">;
export type TDynamicSecretKubernetesLeaseConfig = {
namespace?: string;
};
export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig;

View File

@@ -1,13 +1,14 @@
import axios from "axios";
import axios, { AxiosError } from "axios";
import handlebars from "handlebars";
import https from "https";
import { InternalServerError } from "@app/lib/errors";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types";
import { TDynamicSecretKubernetesLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types";
import { TGatewayServiceFactory } from "../../gateway/gateway-service";
import {
DynamicSecretKubernetesSchema,
@@ -19,6 +20,9 @@ import {
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
// This value is just a placeholder. When using gateway auth method, the url is irrelevant.
const GATEWAY_AUTH_DEFAULT_URL = "https://kubernetes.default.svc.cluster.local";
type TKubernetesProviderDTO = {
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
};
@@ -36,7 +40,7 @@ const generateUsername = (usernameTemplate?: string | null) => {
export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs);
if (!providerInputs.gatewayId) {
if (!providerInputs.gatewayId && providerInputs.url) {
await blockLocalAndPrivateIpAddresses(providerInputs.url);
}
@@ -103,135 +107,173 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const serviceAccountName = generateUsername();
const roleBindingName = `${serviceAccountName}-role-binding`;
// 1. Create a test service account
await axios.post(
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`,
{
metadata: {
name: serviceAccountName,
namespace: providerInputs.namespace
}
},
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
}
);
const namespaces = providerInputs.namespace.split(",").map((namespace) => namespace.trim());
// 2. Create a test role binding
const roleBindingUrl =
providerInputs.roleType === KubernetesRoleType.ClusterRole
? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings`
: `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings`;
const roleBindingMetadata = {
name: roleBindingName,
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace })
};
await axios.post(
roleBindingUrl,
{
metadata: roleBindingMetadata,
roleRef: {
kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role",
name: providerInputs.role,
apiGroup: "rbac.authorization.k8s.io"
},
subjects: [
// Test each namespace sequentially instead of in parallel to simplify cleanup
for await (const namespace of namespaces) {
try {
// 1. Create a test service account
await axios.post(
`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`,
{
kind: "ServiceAccount",
name: serviceAccountName,
namespace: providerInputs.namespace
metadata: {
name: serviceAccountName,
namespace
}
},
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT
}
]
},
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
}
);
);
// 3. Request a token for the test service account
await axios.post(
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`,
{
spec: {
expirationSeconds: 600, // 10 minutes
...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {})
// 2. Create a test role binding
const roleBindingUrl =
providerInputs.roleType === KubernetesRoleType.ClusterRole
? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings`
: `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`;
const roleBindingMetadata = {
name: roleBindingName,
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace })
};
await axios.post(
roleBindingUrl,
{
metadata: roleBindingMetadata,
roleRef: {
kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role",
name: providerInputs.role,
apiGroup: "rbac.authorization.k8s.io"
},
subjects: [
{
kind: "ServiceAccount",
name: serviceAccountName,
namespace
}
]
},
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT
}
);
// 3. Request a token for the test service account
await axios.post(
`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}/token`,
{
spec: {
expirationSeconds: 600, // 10 minutes
...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {})
}
},
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT
}
);
// 4. Cleanup: delete role binding and service account
if (providerInputs.roleType === KubernetesRoleType.Role) {
await axios.delete(
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings/${roleBindingName}`,
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT
}
);
} else {
await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, {
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT
});
}
},
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
}
);
// 4. Cleanup: delete role binding and service account
if (providerInputs.roleType === KubernetesRoleType.Role) {
await axios.delete(
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`,
{
await axios.delete(`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}`, {
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
timeout: EXTERNAL_REQUEST_TIMEOUT
});
} catch (error) {
const cleanupInfo = `You may need to manually clean up the following resources in namespace "${namespace}": Service Account - ${serviceAccountName}, ${providerInputs.roleType === KubernetesRoleType.Role ? "Role" : "Cluster Role"} Binding - ${roleBindingName}.`;
let mainErrorMessage = "Unknown error";
if (error instanceof AxiosError) {
mainErrorMessage = (error.response?.data as { message: string })?.message;
} else if (error instanceof Error) {
mainErrorMessage = error.message;
}
);
} else {
await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, {
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
});
}
await axios.delete(
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}`,
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
throw new Error(`${mainErrorMessage}. ${cleanupInfo}`);
}
);
}
};
const serviceAccountStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => {
@@ -247,17 +289,23 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
timeout: EXTERNAL_REQUEST_TIMEOUT
}
);
};
const url = new URL(providerInputs.url);
const rawUrl =
providerInputs.authMethod === KubernetesAuthMethod.Gateway ? GATEWAY_AUTH_DEFAULT_URL : providerInputs.url || "";
const url = new URL(rawUrl);
const k8sGatewayHost = url.hostname;
const k8sPort = url.port ? Number(url.port) : 443;
const k8sHost = `${url.protocol}//${url.hostname}`;
@@ -315,11 +363,13 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const create = async ({
inputs,
expireAt,
usernameTemplate
usernameTemplate,
config
}: {
inputs: unknown;
expireAt: number;
usernameTemplate?: string | null;
config?: TDynamicSecretKubernetesLeaseConfig;
}) => {
const providerInputs = await validateProviderInputs(inputs);
@@ -331,26 +381,44 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const baseUrl = port ? `${host}:${port}` : host;
const serviceAccountName = generateUsername(usernameTemplate);
const roleBindingName = `${serviceAccountName}-role-binding`;
const allowedNamespaces = providerInputs.namespace.split(",").map((namespace) => namespace.trim());
if (config?.namespace && !allowedNamespaces?.includes(config?.namespace)) {
throw new BadRequestError({
message: `Namespace ${config?.namespace} is not allowed. Allowed namespaces: ${allowedNamespaces?.join(", ")}`
});
}
const namespace = config?.namespace || allowedNamespaces[0];
if (!namespace) {
throw new BadRequestError({
message: "No namespace provided"
});
}
// 1. Create the service account
await axios.post(
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`,
`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`,
{
metadata: {
name: serviceAccountName,
namespace: providerInputs.namespace
namespace
}
},
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
timeout: EXTERNAL_REQUEST_TIMEOUT
}
);
@@ -358,11 +426,11 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const roleBindingUrl =
providerInputs.roleType === KubernetesRoleType.ClusterRole
? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings`
: `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings`;
: `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`;
const roleBindingMetadata = {
name: roleBindingName,
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace })
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace })
};
await axios.post(
@@ -378,7 +446,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
{
kind: "ServiceAccount",
name: serviceAccountName,
namespace: providerInputs.namespace
namespace
}
]
},
@@ -386,18 +454,22 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
timeout: EXTERNAL_REQUEST_TIMEOUT
}
);
// 3. Request a token for the service account
const res = await axios.post<TKubernetesTokenRequest>(
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`,
`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}/token`,
{
spec: {
expirationSeconds: Math.floor((expireAt - Date.now()) / 1000),
@@ -408,12 +480,16 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
timeout: EXTERNAL_REQUEST_TIMEOUT
}
);
@@ -425,6 +501,12 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
throw new Error("invalid callback");
}
if (config?.namespace && config.namespace !== providerInputs.namespace) {
throw new BadRequestError({
message: `Namespace ${config?.namespace} is not allowed. Allowed namespace: ${providerInputs.namespace}.`
});
}
const baseUrl = port ? `${host}:${port}` : host;
const res = await axios.post<TKubernetesTokenRequest>(
@@ -439,19 +521,25 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
timeout: EXTERNAL_REQUEST_TIMEOUT
}
);
return { ...res.data, serviceAccountName: providerInputs.serviceAccountName };
};
const url = new URL(providerInputs.url);
const rawUrl =
providerInputs.authMethod === KubernetesAuthMethod.Gateway ? GATEWAY_AUTH_DEFAULT_URL : providerInputs.url || "";
const url = new URL(rawUrl);
const k8sHost = `${url.protocol}//${url.hostname}`;
const k8sGatewayHost = url.hostname;
const k8sPort = url.port ? Number(url.port) : 443;
@@ -511,7 +599,13 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
}
};
const revoke = async (inputs: unknown, entityId: string) => {
const revoke = async (
inputs: unknown,
entityId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_metadata: { projectId: string },
config?: TDynamicSecretKubernetesLeaseConfig
) => {
const providerInputs = await validateProviderInputs(inputs);
const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => {
@@ -522,19 +616,25 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const baseUrl = port ? `${host}:${port}` : host;
const roleBindingName = `${entityId}-role-binding`;
const namespace = config?.namespace ?? providerInputs.namespace.split(",")[0].trim();
if (providerInputs.roleType === KubernetesRoleType.Role) {
await axios.delete(
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`,
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings/${roleBindingName}`,
{
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
timeout: EXTERNAL_REQUEST_TIMEOUT
}
);
} else {
@@ -542,31 +642,44 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
timeout: EXTERNAL_REQUEST_TIMEOUT
});
}
// Delete the service account
await axios.delete(`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${entityId}`, {
await axios.delete(`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${entityId}`, {
headers: {
"Content-Type": "application/json",
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
...(providerInputs.authMethod === KubernetesAuthMethod.Api
? {
httpsAgent
}
: {}),
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
httpsAgent
timeout: EXTERNAL_REQUEST_TIMEOUT
});
};
if (providerInputs.credentialType === KubernetesCredentialType.Dynamic) {
const url = new URL(providerInputs.url);
const rawUrl =
providerInputs.authMethod === KubernetesAuthMethod.Gateway
? GATEWAY_AUTH_DEFAULT_URL
: providerInputs.url || "";
const url = new URL(rawUrl);
const k8sGatewayHost = url.hostname;
const k8sPort = url.port ? Number(url.port) : 443;
const k8sHost = `${url.protocol}//${url.hostname}`;

View File

@@ -1,5 +1,10 @@
import RE2 from "re2";
import { z } from "zod";
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
import { TDynamicSecretLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types";
export type PasswordRequirements = {
length: number;
required: {
@@ -323,24 +328,54 @@ export const LdapSchema = z.union([
export const DynamicSecretKubernetesSchema = z
.discriminatedUnion("credentialType", [
z.object({
url: z.string().url().trim().min(1),
url: z
.string()
.optional()
.refine((val: string | undefined) => !val || new RE2(/^https?:\/\/.+/).test(val), {
message: "Invalid URL. Must start with http:// or https:// (e.g. https://example.com)"
}),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesCredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
namespace: z
.string()
.trim()
.min(1)
.refine((val) => !val.includes(","), "Namespace must be a single value, not a comma-separated list")
.refine(
(val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val),
"Invalid namespace format"
),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api)
}),
z.object({
url: z.string().url().trim().min(1),
url: z
.string()
.url()
.optional()
.refine((val: string | undefined) => !val || new RE2(/^https?:\/\/.+/).test(val), {
message: "Invalid URL. Must start with http:// or https:// (e.g. https://example.com)"
}),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesCredentialType.Dynamic),
namespace: z.string().trim().min(1),
namespace: z
.string()
.trim()
.min(1)
.refine((val) => {
const namespaces = val.split(",").map((ns) => ns.trim());
return (
namespaces.length > 0 &&
namespaces.every((ns) => ns.length > 0) &&
namespaces.every((ns) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(ns))
);
}, "Must be a valid comma-separated list of namespace values"),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(KubernetesRoleType),
@@ -356,12 +391,21 @@ export const DynamicSecretKubernetesSchema = z
message: "When auth method is set to Gateway, a gateway must be selected"
});
}
if ((data.authMethod === KubernetesAuthMethod.Api || !data.authMethod) && !data.clusterToken) {
ctx.addIssue({
path: ["clusterToken"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Manual Token, a cluster token must be provided"
});
if (data.authMethod === KubernetesAuthMethod.Api || !data.authMethod) {
if (!data.clusterToken) {
ctx.addIssue({
path: ["clusterToken"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster token must be provided"
});
}
if (!data.url) {
ctx.addIssue({
path: ["url"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster URL must be provided"
});
}
}
});
@@ -475,10 +519,16 @@ export type TDynamicProviderFns = {
name: string;
};
metadata: { projectId: string };
config?: TDynamicSecretLeaseConfig;
}) => Promise<{ entityId: string; data: unknown }>;
validateConnection: (inputs: unknown, metadata: { projectId: string }) => Promise<boolean>;
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: (
inputs: unknown,
entityId: string,

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-nested-ternary */
import { ForbiddenError, subject } from "@casl/ability";
import {
@@ -246,7 +247,7 @@ export const secretApprovalRequestServiceFactory = ({
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
const { policy } = secretApprovalRequest;
const { hasRole } = await permissionService.getProjectPermission({
const { hasRole, permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
@@ -262,6 +263,12 @@ export const secretApprovalRequestServiceFactory = ({
throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
}
const hasSecretReadAccess = permission.can(
ProjectPermissionSecretActions.DescribeAndReadValue,
ProjectPermissionSub.Secrets
);
const hiddenSecretValue = "******";
let secrets;
if (shouldUseSecretV2Bridge) {
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
@@ -278,9 +285,9 @@ export const secretApprovalRequestServiceFactory = ({
version: el.version,
secretMetadata: el.secretMetadata as ResourceMetadataDTO,
isRotatedSecret: el.secret?.isRotatedSecret ?? false,
secretValue:
// eslint-disable-next-line no-nested-ternary
el.secret && el.secret.isRotatedSecret
secretValue: !hasSecretReadAccess
? hiddenSecretValue
: el.secret && el.secret.isRotatedSecret
? undefined
: el.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString()
@@ -293,9 +300,11 @@ export const secretApprovalRequestServiceFactory = ({
secretKey: el.secret.key,
id: el.secret.id,
version: el.secret.version,
secretValue: el.secret.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString()
: "",
secretValue: !hasSecretReadAccess
? hiddenSecretValue
: el.secret.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString()
: "",
secretComment: el.secret.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedComment }).toString()
: ""
@@ -306,9 +315,11 @@ export const secretApprovalRequestServiceFactory = ({
secretKey: el.secretVersion.key,
id: el.secretVersion.id,
version: el.secretVersion.version,
secretValue: el.secretVersion.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString()
: "",
secretValue: !hasSecretReadAccess
? hiddenSecretValue
: el.secretVersion.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString()
: "",
secretComment: el.secretVersion.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedComment }).toString()
: "",

View File

@@ -101,10 +101,56 @@ export const azureClientSecretRotationFactory: TRotationFactory<
}
};
/**
* Checks if a credential with the given keyId exists.
*/
const credentialExists = async (keyId: string): Promise<boolean> => {
const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService);
const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/passwordCredentials`;
try {
const { data } = await request.get<{ value: Array<{ keyId: string }> }>(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
}
});
return data.value?.some((credential) => credential.keyId === keyId) || false;
} catch (error: unknown) {
if (error instanceof AxiosError) {
let message;
if (
error.response?.data &&
typeof error.response.data === "object" &&
"error" in error.response.data &&
typeof (error.response.data as AzureErrorResponse).error.message === "string"
) {
message = (error.response.data as AzureErrorResponse).error.message;
}
throw new BadRequestError({
message: `Failed to check credential existence for app ${objectId}: ${
message || error.message || "Unknown error"
}`
});
}
throw new BadRequestError({
message: "Unable to validate connection: verify credentials"
});
}
};
/**
* Revokes a client secret from the Azure app using its keyId.
* First checks if the credential exists before attempting revocation.
*/
const revokeCredential = async (keyId: string) => {
// Check if credential exists before attempting revocation
const exists = await credentialExists(keyId);
if (!exists) {
return; // Credential doesn't exist, nothing to revoke
}
const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService);
const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/removePassword`;

View File

@@ -1113,6 +1113,14 @@ export const DYNAMIC_SECRET_LEASES = {
leaseId: "The ID of the dynamic secret lease.",
isForced:
"A boolean flag to delete the the dynamic secret from Infisical without trying to remove it from external provider. Used when the dynamic secret got modified externally."
},
KUBERNETES: {
CREATE: {
config: {
namespace:
"The Kubernetes namespace to create the lease in. If not specified, the first namespace defined in the configuration will be used."
}
}
}
} as const;
export const SECRET_TAGS = {

View File

@@ -149,8 +149,8 @@ const setupProxyServer = async ({
protocol = GatewayProxyProtocol.Tcp,
httpsAgent
}: {
targetHost: string;
targetPort: number;
targetHost?: string;
targetPort?: number;
relayPort: number;
relayHost: string;
tlsOptions: TGatewayTlsOptions;
@@ -183,27 +183,44 @@ const setupProxyServer = async ({
let command: string;
if (protocol === GatewayProxyProtocol.Http) {
const targetUrl = `${targetHost}:${targetPort}`; // note(daniel): targetHost MUST include the scheme (https|http)
command = `FORWARD-HTTP ${targetUrl}`;
logger.debug(`Using HTTP proxy mode: ${command.trim()}`);
if (!targetHost && !targetPort) {
command = `FORWARD-HTTP`;
logger.debug(`Using HTTP proxy mode, no target URL provided [command=${command.trim()}]`);
} else {
if (!targetHost || targetPort === undefined) {
throw new BadRequestError({
message: `Target host and port are required for HTTP proxy mode with custom target`
});
}
// extract ca certificate from httpsAgent if present
if (httpsAgent && targetHost.startsWith("https://")) {
const agentOptions = httpsAgent.options;
if (agentOptions && agentOptions.ca) {
const caCert = Array.isArray(agentOptions.ca) ? agentOptions.ca.join("\n") : agentOptions.ca;
const caB64 = Buffer.from(caCert as string).toString("base64");
command += ` ca=${caB64}`;
const targetUrl = `${targetHost}:${targetPort}`; // note(daniel): targetHost MUST include the scheme (https|http)
command = `FORWARD-HTTP ${targetUrl}`;
logger.debug(`Using HTTP proxy mode, custom target URL provided [command=${command.trim()}]`);
const rejectUnauthorized = agentOptions.rejectUnauthorized !== false;
command += ` verify=${rejectUnauthorized}`;
// extract ca certificate from httpsAgent if present
if (httpsAgent && targetHost.startsWith("https://")) {
const agentOptions = httpsAgent.options;
if (agentOptions && agentOptions.ca) {
const caCert = Array.isArray(agentOptions.ca) ? agentOptions.ca.join("\n") : agentOptions.ca;
const caB64 = Buffer.from(caCert as string).toString("base64");
command += ` ca=${caB64}`;
logger.debug(`Using HTTP proxy mode [command=${command.trim()}]`);
const rejectUnauthorized = agentOptions.rejectUnauthorized !== false;
command += ` verify=${rejectUnauthorized}`;
logger.debug(`Using HTTP proxy mode, custom target URL provided [command=${command.trim()}]`);
}
}
}
command += "\n";
} else if (protocol === GatewayProxyProtocol.Tcp) {
if (!targetHost || !targetPort) {
throw new BadRequestError({
message: `Target host and port are required for TCP proxy mode`
});
}
// For TCP mode, send FORWARD-TCP with host:port
command = `FORWARD-TCP ${targetHost}:${targetPort}\n`;
logger.debug(`Using TCP proxy mode: ${command.trim()}`);

View File

@@ -15,8 +15,8 @@ export enum GatewayHttpProxyActions {
}
export interface IGatewayProxyOptions {
targetHost: string;
targetPort: number;
targetHost?: string;
targetPort?: number;
relayHost: string;
relayPort: number;
tlsOptions: TGatewayTlsOptions;

View File

@@ -57,9 +57,12 @@ export const registerServeUI = async (
reply.callNotFound();
return;
}
// reference: https://github.com/fastify/fastify-static?tab=readme-ov-file#managing-cache-control-headers
// to avoid ui bundle skew on new deployment
return reply.sendFile("index.html", { maxAge: 0, immutable: false });
// This should help avoid caching any chunks (temp fix)
void reply.header("Cache-Control", "no-cache, no-store, must-revalidate, private, max-age=0");
void reply.header("Pragma", "no-cache");
void reply.header("Expires", "0");
return reply.sendFile("index.html");
}
});
}

View File

@@ -120,7 +120,7 @@ export const folderCommitChangesDALFactory = (db: TDbClient) => {
return docs.map((doc) => {
// Determine if this is a secret or folder change based on populated fields
if (doc.secretKey && doc.secretVersion && doc.secretId) {
if (doc.secretKey && doc.secretVersion !== null && doc.secretId) {
return {
...doc,
resourceType: "secret",
@@ -168,7 +168,7 @@ export const folderCommitChangesDALFactory = (db: TDbClient) => {
);
return docs
.filter((doc) => doc.secretKey && doc.secretVersion && doc.secretId)
.filter((doc) => doc.secretKey && doc.secretVersion !== null && doc.secretId)
.map(
(doc): SecretCommitChange => ({
...doc,
@@ -209,7 +209,7 @@ export const folderCommitChangesDALFactory = (db: TDbClient) => {
);
return docs
.filter((doc) => doc.folderName && doc.folderVersion && doc.folderChangeId)
.filter((doc) => doc.folderName && doc.folderVersion !== null && doc.folderChangeId)
.map(
(doc): FolderCommitChange => ({
...doc,

View File

@@ -815,7 +815,7 @@ export const folderCommitServiceFactory = ({
encryptedComment: version1.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: version1.encryptedComment }).toString()
: "",
metadata: version1.metadata as { key: string; value: string }[],
metadata: Array.isArray(version1.metadata) ? (version1.metadata as { key: string; value: string }[]) : [],
tags: version1.tags.map((tag) => tag.id)
};
const version2Reshaped = {
@@ -826,7 +826,7 @@ export const folderCommitServiceFactory = ({
encryptedComment: version2.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: version2.encryptedComment }).toString()
: "",
metadata: version2.metadata as { key: string; value: string }[],
metadata: Array.isArray(version2.metadata) ? (version2.metadata as { key: string; value: string }[]) : [],
tags: version2.tags.map((tag) => tag.id)
};
return (

View File

@@ -72,8 +72,8 @@ export const identityKubernetesAuthServiceFactory = ({
const $gatewayProxyWrapper = async <T>(
inputs: {
gatewayId: string;
targetHost: string;
targetPort: number;
targetHost?: string;
targetPort?: number;
caCert?: string;
reviewTokenThroughGateway: boolean;
},
@@ -286,8 +286,6 @@ export const identityKubernetesAuthServiceFactory = ({
data = await $gatewayProxyWrapper(
{
gatewayId: identityKubernetesAuth.gatewayId,
targetHost: `/`, // note(daniel): the targetURL will be constructed as `/:0`, which the gateway will handle as a special case, by replacing the /:0, with the internal kubernetes base URL (only when the action header is set to `GatewayHttpProxyActions.UseGatewayK8sServiceAccount`)
targetPort: 0,
reviewTokenThroughGateway: true
},
tokenReviewCallbackThroughGateway

View File

@@ -1211,8 +1211,8 @@ export const orgServiceFactory = ({
subjectLine: "Infisical organization invitation",
recipients: [el.email],
substitutions: {
inviterFirstName: invitingUser.firstName,
inviterUsername: invitingUser.email,
inviterFirstName: invitingUser?.firstName,
inviterUsername: invitingUser?.email,
organizationName: org?.name,
email: el.email,
organizationId: org?.id.toString(),

View File

@@ -5,8 +5,8 @@ import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
interface OrganizationInvitationTemplateProps extends Omit<BaseEmailWrapperProps, "preview" | "title"> {
metadata?: string;
inviterFirstName: string;
inviterUsername: string;
inviterFirstName?: string;
inviterUsername?: string;
organizationName: string;
email: string;
organizationId: string;
@@ -38,11 +38,19 @@ export const OrganizationInvitationTemplate = ({
</Heading>
<Section className="px-[24px] mt-[36px] pt-[12px] pb-[8px] border text-center border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-black text-[14px] leading-[24px]">
<strong>{inviterFirstName}</strong> (
<Link href={`mailto:${inviterUsername}`} className="text-slate-700 no-underline">
{inviterUsername}
</Link>
) has invited you to collaborate on <strong>{organizationName}</strong>.
{inviterFirstName && inviterUsername ? (
<>
<strong>{inviterFirstName}</strong> (
<Link href={`mailto:${inviterUsername}`} className="text-slate-700 no-underline">
{inviterUsername}
</Link>
) has invited you to collaborate on <strong>{organizationName}</strong>.
</>
) : (
<>
You have been invited to collaborate on <strong>{organizationName}</strong>.
</>
)}
</Text>
</Section>
<Section className="text-center mt-[28px]">

View File

@@ -14,7 +14,7 @@ require (
github.com/fatih/semgroup v1.2.0
github.com/gitleaks/go-gitdiff v0.9.1
github.com/h2non/filetype v1.1.3
github.com/infisical/go-sdk v0.5.95
github.com/infisical/go-sdk v0.5.96
github.com/infisical/infisical-kmip v0.3.5
github.com/mattn/go-isatty v0.0.20
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a
@@ -25,6 +25,7 @@ require (
github.com/pion/logging v0.2.3
github.com/pion/turn/v4 v4.0.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pkg/errors v0.9.1
github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a
github.com/quic-go/quic-go v0.50.0
github.com/rs/cors v1.11.0
@@ -106,7 +107,6 @@ require (
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect

View File

@@ -292,12 +292,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/infisical/go-sdk v0.5.92 h1:PoCnVndrd6Dbkipuxl9fFiwlD5vCKsabtQo09mo8lUE=
github.com/infisical/go-sdk v0.5.92/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs=
github.com/infisical/go-sdk v0.5.94 h1:wKBj+KpJEe+ZzOJ7koXQZDR0dLL9bt0Kqgf/1q+7tG4=
github.com/infisical/go-sdk v0.5.94/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs=
github.com/infisical/go-sdk v0.5.95 h1:so0YwPofbT7j6Ao8Xcxee/o3ia33meuEVDU2vWr9yfs=
github.com/infisical/go-sdk v0.5.95/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs=
github.com/infisical/go-sdk v0.5.96 h1:huky6bQ1Y3oRdPb5MO3Ru868qZaPHUxZ7kP7FPNRn48=
github.com/infisical/go-sdk v0.5.96/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs=
github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE=
github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs=
github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo=

View File

@@ -232,13 +232,26 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) {
util.HandleError(err, "To fetch dynamic secret root credentials details")
}
// for Kubernetes dynamic secrets only
kubernetesNamespace, err := cmd.Flags().GetString("kubernetesNamespace")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
config := map[string]any{}
if kubernetesNamespace != "" {
config["namespace"] = kubernetesNamespace
}
leaseCredentials, _, leaseDetails, err := infisicalClient.DynamicSecrets().Leases().Create(infisicalSdk.CreateDynamicSecretLeaseOptions{
DynamicSecretName: dynamicSecretRootCredential.Name,
ProjectSlug: projectDetails.Slug,
TTL: ttl,
SecretPath: secretsPath,
EnvironmentSlug: environmentName,
Config: config,
})
if err != nil {
util.HandleError(err, "To lease dynamic secret")
}
@@ -585,6 +598,10 @@ func init() {
dynamicSecretLeaseCreateCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth")
dynamicSecretLeaseCreateCmd.Flags().String("ttl", "", "The lease lifetime TTL. If not provided the default TTL of dynamic secret will be used.")
dynamicSecretLeaseCreateCmd.Flags().Bool("plain", false, "Print leased credentials without formatting, one per line")
// Kubernetes specific flags
dynamicSecretLeaseCreateCmd.Flags().String("kubernetesNamespace", "", "The namespace to create the lease in. Only used for Kubernetes dynamic secrets.")
dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseCreateCmd)
dynamicSecretLeaseListCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from")

View File

@@ -108,19 +108,17 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) {
return
case "FORWARD-HTTP":
targetURL := ""
argParts := bytes.Split(args, []byte(" "))
if len(argParts) == 0 {
log.Error().Msg("FORWARD-HTTP requires target URL")
return
}
targetURL := string(argParts[0])
// ? note(daniel): special case: if the target URL is "/:0", we don't validate it.
// ? the reason for this is because we want to be able to send requests to the gateway without knowing the actual target URL, and instead let the gateway construct the target URL.
if targetURL != "/:0" && !isValidURL(targetURL) {
log.Error().Msgf("Invalid target URL: %s", targetURL)
return
if len(argParts) == 0 || len(argParts[0]) == 0 {
log.Warn().Msg("FORWARD-HTTP used without a target URL.")
} else {
targetURL = string(argParts[0])
if !isValidURL(targetURL) {
log.Error().Msgf("Invalid target URL: %s", targetURL)
return
}
}
// Parse optional parameters
@@ -208,8 +206,7 @@ func handleHTTPProxy(stream quic.Stream, reader *bufio.Reader, targetURL string,
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", string(token)))
log.Info().Msgf("Injected gateway k8s SA auth token in request to %s", targetURL)
} else if actionHeader == HttpProxyActionUseGatewayK8sServiceAccount {
} else if actionHeader == HttpProxyActionUseGatewayK8sServiceAccount { // will work without a target URL set
// set the ca cert to the pod's k8s service account ca cert:
caCert, err := os.ReadFile(KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH)
if err != nil {
@@ -218,9 +215,7 @@ func handleHTTPProxy(stream quic.Stream, reader *bufio.Reader, targetURL string,
}
caCertPool := x509.NewCertPool()
appendSuccess := caCertPool.AppendCertsFromPEM(caCert)
if !appendSuccess {
if ok := caCertPool.AppendCertsFromPEM(caCert); !ok {
stream.Write([]byte(buildHttpInternalServerError("failed to parse k8s sa ca cert")))
continue
}

View File

@@ -0,0 +1,4 @@
---
title: "Create Kubernetes Lease"
openapi: "POST /api/v1/dynamic-secrets/leases/kubernetes"
---

View File

@@ -148,6 +148,22 @@ infisical dynamic-secrets lease create <dynamic-secret-name> --ttl=<ttl>
</Accordion>
### Provider-specific flags
The following flags are specific to certain providers or integrations:
<Accordion title="Kubernetes">
<Accordion title="--kubernetesNamespace">
The namespace to create the lease in. Only used for Kubernetes dynamic secrets.
```bash
# Example
infisical dynamic-secrets lease create <dynamic-secret-name> --kubernetesNamespace=<namespace>
```
</Accordion>
</Accordion>
</Accordion>
<Accordion title="infisical dynamic-secrets lease list">
This command is used to list leases for a dynamic secret.

View File

@@ -120,6 +120,12 @@ The CLI is designed for a variety of secret management applications ranging from
</Tab>
</Tabs>
<Note>
Starting with CLI version v0.4.0, you can now choose to log in via Infisical Cloud (US/EU) or your own self-hosted instance by simply running `infisical login` and following the on-screen instructions — no need to manually set the `INFISICAL_API_URL` environment variable.
For versions prior to v0.4.0, the CLI defaults to the US Cloud. To connect to the EU Cloud or a self-hosted instance, set the `INFISICAL_API_URL` environment variable to `https://eu.infisical.com` or your custom URL.
</Note>
<Tip>
## Custom Request Headers

View File

@@ -32,7 +32,8 @@ Infisical needs an initial AWS IAM user with the required permissions to create
"iam:ListUserPolicies",
"iam:PutUserPolicy",
"iam:AddUserToGroup",
"iam:RemoveUserFromGroup"
"iam:RemoveUserFromGroup",
"iam:TagUser"
],
"Resource": ["*"]
}

View File

@@ -162,6 +162,12 @@ This feature is ideal for scenarios where you need to:
tokens for the target service account.
</Note>
<Note>
When using Gateway authentication, the Gateway will access the Kubernetes API server
using its internal cluster URL (typically https://kubernetes.default.svc) and TLS configuration.
You don't need to specify these values separately in the dynamic secret configuration.
</Note>
1. Deploy the Infisical Gateway in your cluster
2. Set up RBAC permissions for the Gateway's service account:
```yaml rbac.yaml
@@ -206,6 +212,7 @@ This feature is ideal for scenarios where you need to:
- Automatically clean up service accounts after token expiration
- Assign different roles to different users or applications
- Maintain strict control over service account permissions
- Support multiple namespaces with a single dynamic secret configuration
### Prerequisites
@@ -213,6 +220,16 @@ This feature is ideal for scenarios where you need to:
- Cluster access token with permissions to create service accounts and manage RBAC
- (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access
### Namespace Support
When configuring a dynamic secret, you can specify multiple allowed namespaces as a comma-separated list. During lease creation, you can then specify which namespace to use from this allowed list. This provides flexibility while maintaining security by:
- Allowing a single dynamic secret configuration to support multiple namespaces
- Restricting service account creation to only the specified allowed namespaces
- Enabling fine-grained control over which namespaces can be used for each lease
For example, if you configure a dynamic secret with allowed namespaces "default,kube-system,monitoring", you can create leases that use any of these namespaces while preventing access to other namespaces in your cluster.
### Authentication Setup
Choose your authentication method:
@@ -318,6 +335,12 @@ This feature is ideal for scenarios where you need to:
manage service accounts, their tokens, and RBAC resources.
</Note>
<Note>
When using Gateway authentication, the Gateway will access the Kubernetes API server
using its internal cluster URL (typically https://kubernetes.default.svc) and TLS configuration.
You don't need to specify these values separately in the dynamic secret configuration.
</Note>
1. Deploy the Infisical Gateway in your cluster
2. Set up RBAC permissions for the Gateway's service account:
```yaml rbac.yaml
@@ -401,13 +424,13 @@ This feature is ideal for scenarios where you need to:
Select a gateway for private cluster access. If not specified, the Internet Gateway will be used.
</ParamField>
<ParamField path="Cluster URL" type="string" required>
Kubernetes API server URL (e.g., https://kubernetes.default.svc)
Kubernetes API server URL (e.g., https://kubernetes.default.svc). Not required when using Gateway authentication as the Gateway will use its internal cluster URL.
</ParamField>
<ParamField path="Enable SSL" type="boolean">
Whether to enable SSL verification for the Kubernetes API server connection.
Whether to enable SSL verification for the Kubernetes API server connection. Not required when using Gateway authentication as the Gateway will use its internal TLS configuration.
</ParamField>
<ParamField path="CA" type="string">
Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA.
Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA. Not required when using Gateway authentication as the Gateway will use its internal TLS configuration.
</ParamField>
<ParamField path="Auth Method" type="string" required>
Choose between Token (API) or Gateway authentication. If using Gateway, the Gateway must be deployed in your Kubernetes cluster.
@@ -418,18 +441,30 @@ This feature is ideal for scenarios where you need to:
<ParamField path="Credential Type" type="string" required>
Choose between Static (predefined service account) or Dynamic (temporary service accounts with role assignments)
</ParamField>
<ParamField path="Service Account Name" type="string" required>
Name of the service account to generate tokens for (required for Static credentials)
</ParamField>
<ParamField path="Namespace" type="string" required>
Kubernetes namespace where the service account exists or will be created
</ParamField>
<ParamField path="Role Type" type="string" required>
Type of role to assign (ClusterRole or Role) (required for Dynamic credentials)
</ParamField>
<ParamField path="Role" type="string" required>
Name of the role to assign to the temporary service account (required for Dynamic credentials)
</ParamField>
<Tabs>
<Tab title="Static Credentials Parameters">
<ParamField path="Service Account Name" type="string" required>
Name of the service account to generate tokens for
</ParamField>
<ParamField path="Namespace" type="string" required>
Kubernetes namespace where the service account exists
</ParamField>
</Tab>
<Tab title="Dynamic Credentials Parameters">
<ParamField path="Allowed Namespaces" type="string" required>
Kubernetes namespace(s) where the service accounts will be created. You can specify multiple namespaces as a comma-separated list (e.g., "default,kube-system"). During lease creation, you can specify which namespace to use from this allowed list.
</ParamField>
<ParamField path="Role Type" type="string" required>
Type of role to assign (ClusterRole or Role)
</ParamField>
<ParamField path="Role" type="string" required>
Name of the role to assign to the temporary service account
</ParamField>
</Tab>
</Tabs>
<ParamField path="Audiences" type="array">
Optional list of audiences to include in the generated token
</ParamField>

View File

@@ -926,6 +926,12 @@
{
"group": "Dynamic Secrets",
"pages": [
{
"group": "Kubernetes",
"pages": [
"api-reference/endpoints/dynamic-secrets/kubernetes/create-lease"
]
},
"api-reference/endpoints/dynamic-secrets/create",
"api-reference/endpoints/dynamic-secrets/update",
"api-reference/endpoints/dynamic-secrets/delete",

View File

@@ -29,7 +29,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete
infisical:
image:
repository: infisical/infisical
tag: "v0.46.2-postgres" #<-- update
tag: "<>" #<-- select tag from Dockerhub from the above link
pullPolicy: IfNotPresent
```
<Warning>

View File

@@ -290,7 +290,7 @@ export type TDynamicSecretProvider =
type: DynamicSecretProviders.Kubernetes;
inputs:
| {
url: string;
url?: string;
clusterToken?: string;
ca?: string;
serviceAccountName: string;
@@ -302,7 +302,7 @@ export type TDynamicSecretProvider =
authMethod: string;
}
| {
url: string;
url?: string;
clusterToken?: string;
ca?: string;
credentialType: KubernetesDynamicSecretCredentialType.Dynamic;

View File

@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { DynamicSecretProviders } from "../dynamicSecret/types";
import { dynamicSecretLeaseKeys } from "./queries";
import {
TCreateDynamicSecretLeaseDTO,
@@ -19,6 +20,14 @@ export const useCreateDynamicSecretLease = () => {
TCreateDynamicSecretLeaseDTO
>({
mutationFn: async (dto) => {
if (dto.provider === DynamicSecretProviders.Kubernetes) {
const { data } = await apiRequest.post<{ lease: TDynamicSecretLease; data: unknown }>(
"/api/v1/dynamic-secrets/leases/kubernetes",
dto
);
return data;
}
const { data } = await apiRequest.post<{ lease: TDynamicSecretLease; data: unknown }>(
"/api/v1/dynamic-secrets/leases",
dto

View File

@@ -1,3 +1,5 @@
import { DynamicSecretProviders } from "../dynamicSecret/types";
export enum DynamicSecretLeaseStatus {
FailedDeletion = "Failed to delete"
}
@@ -13,12 +15,20 @@ export type TDynamicSecretLease = {
updatedAt: string;
};
export type TDynamicSecretKubernetesLeaseConfig = {
namespace?: string;
};
export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig;
export type TCreateDynamicSecretLeaseDTO = {
dynamicSecretName: string;
projectSlug: string;
ttl?: string;
path: string;
environmentSlug: string;
config?: TDynamicSecretLeaseConfig;
provider: DynamicSecretProviders;
};
export type TRenewDynamicSecretLeaseDTO = {

View File

@@ -3,6 +3,7 @@ import { Helmet } from "react-helmet";
import {
faArrowUpRightFromSquare,
faBookOpen,
faCopy,
faEdit,
faEllipsisV,
faInfoCircle,
@@ -169,6 +170,12 @@ export const GatewayListPage = withPermission(
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCopy} />}
onClick={() => navigator.clipboard.writeText(el.id)}
>
Copy ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgGatewayPermissionActions.EditGateways}
a={OrgPermissionSubjects.Gateway}

View File

@@ -61,24 +61,38 @@ const formSchema = z
.object({
provider: z.discriminatedUnion("credentialType", [
z.object({
url: z.string().url().trim().min(1),
url: z.string().trim().optional(),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
namespace: z
.string()
.trim()
.min(1)
.refine(
(val) => !val.includes(","),
"Namespace must be a single value, not a comma-separated list"
),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
}),
z.object({
url: z.string().url().trim().min(1),
url: z.string().trim().optional(),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic),
namespace: z.string().trim().min(1),
namespace: z
.string()
.trim()
.min(1)
.refine((val) => {
const namespaces = val.split(",").map((ns) => ns.trim());
return namespaces.length > 0 && namespaces.every((ns) => ns.length > 0);
}, "Must be a valid comma-separated list of namespace values"),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(RoleType),
@@ -116,12 +130,21 @@ const formSchema = z
message: "When auth method is set to Gateway, a gateway must be selected"
});
}
if (data.provider.authMethod === AuthMethod.Api && !data.provider.clusterToken) {
ctx.addIssue({
path: ["provider.clusterToken"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster token must be provided"
});
if (data.provider.authMethod === AuthMethod.Api) {
if (!data.provider.clusterToken) {
ctx.addIssue({
path: ["provider.clusterToken"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster token must be provided"
});
}
if (!data.provider.url) {
ctx.addIssue({
path: ["provider.url"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster URL must be provided"
});
}
}
});
@@ -188,7 +211,13 @@ export const KubernetesInputForm = ({
try {
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Kubernetes, inputs: provider },
provider: {
type: DynamicSecretProviders.Kubernetes,
inputs: {
...provider,
url: provider.url || undefined
}
},
maxTTL: rest.maxTTL,
name: rest.name,
path: secretPath,
@@ -333,69 +362,6 @@ export const KubernetesInputForm = ({
)}
</OrgPermissionCan>
</div>
<Controller
control={control}
name="provider.url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster URL"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mb-2 flex items-center">
<span className="mr-3 flex items-center text-sm text-mineshaft-400">
Enable SSL
<Tooltip
className="ml-1 max-w-md"
content={
<span>
If enabled, you can optionally provide a custom CA certificate. Leave
blank to use the system/public CA.
</span>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</span>
<Controller
name="provider.sslEnabled"
control={control}
render={({ field: { value, onChange } }) => (
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
aria-label="Enable SSL"
/>
)}
/>
</div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA"
isError={Boolean(error?.message)}
errorText={error?.message}
className={sslEnabled ? "" : "opacity-50"}
>
<TextArea
{...field}
placeholder="-----BEGIN CERTIFICATE----- ..."
isDisabled={!sslEnabled}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.authMethod"
@@ -420,6 +386,71 @@ export const KubernetesInputForm = ({
</FormControl>
)}
/>
{authMethod === AuthMethod.Api && (
<>
<Controller
control={control}
name="provider.url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster URL"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mb-2 flex items-center">
<span className="mr-3 flex items-center text-sm text-mineshaft-400">
Enable SSL
<Tooltip
className="ml-1 max-w-md"
content={
<span>
If enabled, you can optionally provide a custom CA certificate. Leave
blank to use the system/public CA.
</span>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</span>
<Controller
name="provider.sslEnabled"
control={control}
render={({ field: { value, onChange } }) => (
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
aria-label="Enable SSL"
/>
)}
/>
</div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA"
isError={Boolean(error?.message)}
errorText={error?.message}
className={sslEnabled ? "" : "opacity-50"}
>
<TextArea
{...field}
placeholder="-----BEGIN CERTIFICATE----- ..."
isDisabled={!sslEnabled}
/>
</FormControl>
)}
/>
</>
)}
{authMethod === AuthMethod.Api && (
<Controller
control={control}
@@ -507,7 +538,11 @@ export const KubernetesInputForm = ({
name="provider.namespace"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Namespace"
label={
credentialType === KubernetesDynamicSecretCredentialType.Static
? "Namespace"
: "Allowed Namespace(s)"
}
isError={Boolean(error?.message)}
errorText={error?.message}
>

View File

@@ -353,12 +353,149 @@ const renderOutputForm = (
return null;
};
const kubernetesFormSchema = z.object({
ttl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.optional(),
namespace: z.string().optional()
});
type TKubernetesForm = z.infer<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({
ttl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
@@ -404,7 +541,8 @@ export const CreateDynamicSecretLease = ({
projectSlug,
path: secretPath,
ttl,
dynamicSecretName
dynamicSecretName,
provider
});
createNotification({
@@ -433,6 +571,19 @@ export const CreateDynamicSecretLease = ({
}
}, [provider]);
if (provider === DynamicSecretProviders.Kubernetes) {
return (
<CreateKubernetesDynamicSecretLease
onClose={onClose}
projectSlug={projectSlug}
dynamicSecretName={dynamicSecretName}
provider={provider}
secretPath={secretPath}
environment={environment}
/>
);
}
const isOutputMode = Boolean(createDynamicSecretLease?.data);
if (isPreloading) {

View File

@@ -59,24 +59,38 @@ const formSchema = z
.object({
inputs: z.discriminatedUnion("credentialType", [
z.object({
url: z.string().url().trim().min(1),
url: z.string().trim().optional(),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
namespace: z
.string()
.trim()
.min(1)
.refine(
(val) => !val.includes(","),
"Namespace must be a single value, not a comma-separated list"
),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
}),
z.object({
url: z.string().url().trim().min(1),
url: z.string().trim().optional(),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic),
namespace: z.string().trim().min(1),
namespace: z
.string()
.trim()
.min(1)
.refine((val) => {
const namespaces = val.split(",").map((ns) => ns.trim());
return namespaces.length > 0 && namespaces.every((ns) => ns.length > 0);
}, "Must be a valid comma-separated list of namespace values"),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(RoleType),
@@ -113,12 +127,21 @@ const formSchema = z
message: "When auth method is set to Gateway, a gateway must be selected"
});
}
if (data.inputs.authMethod === AuthMethod.Api && !data.inputs.clusterToken) {
ctx.addIssue({
path: ["inputs.clusterToken"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster token must be provided"
});
if (data.inputs.authMethod === AuthMethod.Api) {
if (!data.inputs.clusterToken) {
ctx.addIssue({
path: ["inputs.clusterToken"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster token must be provided"
});
}
if (!data.inputs.url) {
ctx.addIssue({
path: ["inputs.url"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster URL must be provided"
});
}
}
});
@@ -178,7 +201,10 @@ export const EditDynamicSecretKubernetesForm = ({
projectSlug,
environmentSlug: environment,
data: {
inputs: formData.inputs,
inputs: {
...formData.inputs,
url: formData.inputs.url || undefined
},
newName: formData.newName === dynamicSecret.name ? undefined : formData.newName,
defaultTTL: formData.defaultTTL,
maxTTL: formData.maxTTL,
@@ -328,70 +354,6 @@ export const EditDynamicSecretKubernetesForm = ({
)}
</OrgPermissionCan>
</div>
<Controller
control={control}
name="inputs.url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster URL"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mb-2 flex items-center">
<span className="mr-3 flex items-center text-sm text-mineshaft-400">
Enable SSL
<Tooltip
className="ml-1 max-w-md"
content={
<span>
If enabled, you can optionally provide a custom CA certificate. Leave
blank to use the system/public CA.
</span>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</span>
<Controller
name="inputs.sslEnabled"
control={control}
render={({ field: { value, onChange } }) => (
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
aria-label="Enable SSL"
/>
)}
/>
</div>
<Controller
control={control}
name="inputs.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA"
isError={Boolean(error?.message)}
errorText={error?.message}
className={sslEnabled ? "" : "opacity-50"}
>
<TextArea
{...field}
placeholder="-----BEGIN CERTIFICATE----- ..."
isDisabled={!sslEnabled}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.authMethod"
@@ -416,6 +378,73 @@ export const EditDynamicSecretKubernetesForm = ({
</FormControl>
)}
/>
{authMethod === AuthMethod.Api && (
<>
<Controller
control={control}
name="inputs.url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster URL"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mb-2 flex items-center">
<span className="mr-3 flex items-center text-sm text-mineshaft-400">
Enable SSL
<Tooltip
className="ml-1 max-w-md"
content={
<span>
If enabled, you can optionally provide a custom CA certificate.
Leave blank to use the system/public CA.
</span>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</span>
<Controller
name="inputs.sslEnabled"
control={control}
render={({ field: { value, onChange } }) => (
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
aria-label="Enable SSL"
/>
)}
/>
</div>
<Controller
control={control}
name="inputs.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA"
isError={Boolean(error?.message)}
errorText={error?.message}
className={sslEnabled ? "" : "opacity-50"}
>
<TextArea
{...field}
placeholder="-----BEGIN CERTIFICATE----- ..."
isDisabled={!sslEnabled}
/>
</FormControl>
)}
/>
</>
)}
{authMethod === AuthMethod.Api && (
<Controller
control={control}
@@ -502,7 +531,11 @@ export const EditDynamicSecretKubernetesForm = ({
name="inputs.namespace"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Namespace"
label={
credentialType === KubernetesDynamicSecretCredentialType.Static
? "Namespace"
: "Allowed Namespace(s)"
}
isError={Boolean(error?.message)}
errorText={error?.message}
>