misc: added dynamic credential support and gateway auth

This commit is contained in:
Sheen Capadngan
2025-06-06 21:03:46 +08:00
parent 6847e5bb89
commit 545df3bf28
5 changed files with 989 additions and 164 deletions

View File

@@ -1,13 +1,21 @@
import axios from "axios";
import handlebars from "handlebars";
import https from "https";
import { InternalServerError } from "@app/lib/errors";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
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 { TGatewayServiceFactory } from "../../gateway/gateway-service";
import { DynamicSecretKubernetesSchema, TDynamicProviderFns } from "./models";
import {
DynamicSecretKubernetesSchema,
KubernetesAuthMethod,
KubernetesCredentialType,
KubernetesRoleType,
TDynamicProviderFns
} from "./models";
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
@@ -15,6 +23,16 @@ type TKubernetesProviderDTO = {
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
};
const generateUsername = (usernameTemplate?: string | null) => {
const randomUsername = `dynamic-secret-sa-${alphaNumericNanoId(10).toLowerCase()}`;
if (!usernameTemplate) return randomUsername;
return handlebars.compile(usernameTemplate)({
randomUsername,
unixTimestamp: Math.floor(Date.now() / 100)
});
};
export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs);
@@ -30,20 +48,27 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
gatewayId: string;
targetHost: string;
targetPort: number;
caCert?: string;
reviewTokenThroughGateway: boolean;
enableSsl: boolean;
},
gatewayCallback: (host: string, port: number) => Promise<T>
gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise<T>
): Promise<T> => {
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
const callbackResult = await withGatewayProxy(
async (port) => {
async (port, httpsAgent) => {
// Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server"
const res = await gatewayCallback("https://localhost", port);
const res = await gatewayCallback(
inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost",
port,
httpsAgent
);
return res;
},
{
protocol: GatewayProxyProtocol.Tcp,
protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp,
targetHost: inputs.targetHost,
targetPort: inputs.targetPort,
relayHost,
@@ -54,7 +79,12 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
},
// we always pass this, because its needed for both tcp and http protocol
httpsAgent: new https.Agent({
ca: inputs.caCert,
rejectUnauthorized: inputs.enableSsl
})
}
);
@@ -64,7 +94,169 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const serviceAccountGetCallback = async (host: string, port: number) => {
const serviceAccountDynamicCallback = async (host: string, port: number) => {
if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) {
throw new Error("invalid callback");
}
const baseUrl = port ? `${host}:${port}` : host;
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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
}
);
// 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: [
{
kind: "ServiceAccount",
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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
}
);
// 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 } : {})
}
},
{
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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
}
);
// 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}`,
{
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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
}
);
} 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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
});
}
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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
}
);
};
const serviceAccountStaticCallback = async (host: string, port: number) => {
if (providerInputs.credentialType !== KubernetesCredentialType.Static) {
throw new Error("invalid callback");
}
const baseUrl = port ? `${host}:${port}` : host;
await axios.get(
@@ -72,7 +264,9 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${providerInputs.clusterToken}`
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
@@ -85,23 +279,45 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
};
const url = new URL(providerInputs.url);
const k8sGatewayHost = url.hostname;
const k8sPort = url.port ? Number(url.port) : 443;
const k8sHost = `${url.protocol}//${url.hostname}`;
try {
if (providerInputs.gatewayId) {
const k8sHost = url.hostname;
await $gatewayProxyWrapper(
{
gatewayId: providerInputs.gatewayId,
targetHost: k8sHost,
targetPort: k8sPort
},
serviceAccountGetCallback
);
if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) {
await $gatewayProxyWrapper(
{
gatewayId: providerInputs.gatewayId,
targetHost: k8sHost,
targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled,
caCert: providerInputs.ca,
reviewTokenThroughGateway: true
},
providerInputs.credentialType === KubernetesCredentialType.Static
? serviceAccountStaticCallback
: serviceAccountDynamicCallback
);
} else {
await $gatewayProxyWrapper(
{
gatewayId: providerInputs.gatewayId,
targetHost: k8sGatewayHost,
targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled,
caCert: providerInputs.ca,
reviewTokenThroughGateway: false
},
providerInputs.credentialType === KubernetesCredentialType.Static
? serviceAccountStaticCallback
: serviceAccountDynamicCallback
);
}
} else if (providerInputs.credentialType === KubernetesCredentialType.Static) {
await serviceAccountStaticCallback(k8sHost, k8sPort);
} else {
const k8sHost = `${url.protocol}//${url.hostname}`;
await serviceAccountGetCallback(k8sHost, k8sPort);
await serviceAccountDynamicCallback(k8sHost, k8sPort);
}
return true;
@@ -117,10 +333,128 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
}
};
const create = async ({ inputs, expireAt }: { inputs: unknown; expireAt: number }) => {
const create = async ({
inputs,
expireAt,
usernameTemplate
}: {
inputs: unknown;
expireAt: number;
usernameTemplate?: string | null;
}) => {
const providerInputs = await validateProviderInputs(inputs);
const tokenRequestCallback = async (host: string, port: number) => {
const serviceAccountDynamicCallback = async (host: string, port: number) => {
if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) {
throw new Error("invalid callback");
}
const baseUrl = port ? `${host}:${port}` : host;
const serviceAccountName = generateUsername(usernameTemplate);
const roleBindingName = `${serviceAccountName}-role-binding`;
// 1. Create the 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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
}
);
// 2. Create the 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: [
{
kind: "ServiceAccount",
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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
}
);
// 3. Request a token for the service account
const res = await axios.post<TKubernetesTokenRequest>(
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`,
{
spec: {
expirationSeconds: Math.floor((expireAt - Date.now()) / 1000),
...(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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
}
);
return { ...res.data, serviceAccountName };
};
const tokenRequestStaticCallback = async (host: string, port: number) => {
if (providerInputs.credentialType !== KubernetesCredentialType.Static) {
throw new Error("invalid callback");
}
const baseUrl = port ? `${host}:${port}` : host;
const res = await axios.post<TKubernetesTokenRequest>(
@@ -134,7 +468,9 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${providerInputs.clusterToken}`
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
},
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
timeout: EXTERNAL_REQUEST_TIMEOUT,
@@ -145,7 +481,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
}
);
return res.data;
return { ...res.data, serviceAccountName: providerInputs.serviceAccountName };
};
const url = new URL(providerInputs.url);
@@ -154,19 +490,46 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const k8sPort = url.port ? Number(url.port) : 443;
try {
const tokenData = providerInputs.gatewayId
? await $gatewayProxyWrapper(
let tokenData;
if (providerInputs.gatewayId) {
if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) {
tokenData = await $gatewayProxyWrapper(
{
gatewayId: providerInputs.gatewayId,
targetHost: k8sHost,
targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled,
caCert: providerInputs.ca,
reviewTokenThroughGateway: true
},
providerInputs.credentialType === KubernetesCredentialType.Static
? tokenRequestStaticCallback
: serviceAccountDynamicCallback
);
} else {
tokenData = await $gatewayProxyWrapper(
{
gatewayId: providerInputs.gatewayId,
targetHost: k8sGatewayHost,
targetPort: k8sPort
targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled,
caCert: providerInputs.ca,
reviewTokenThroughGateway: false
},
tokenRequestCallback
)
: await tokenRequestCallback(k8sHost, k8sPort);
providerInputs.credentialType === KubernetesCredentialType.Static
? tokenRequestStaticCallback
: serviceAccountDynamicCallback
);
}
} else {
tokenData =
providerInputs.credentialType === KubernetesCredentialType.Static
? await tokenRequestStaticCallback(k8sHost, k8sPort)
: await serviceAccountDynamicCallback(k8sHost, k8sPort);
}
return {
entityId: providerInputs.serviceAccountName,
entityId: tokenData.serviceAccountName,
data: { TOKEN: tokenData.status.token }
};
} catch (error) {
@@ -181,7 +544,106 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
}
};
const revoke = async (_inputs: unknown, entityId: string) => {
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs);
const serviceAccountDynamicCallback = async (host: string, port: number) => {
if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) {
throw new Error("invalid callback");
}
const baseUrl = port ? `${host}:${port}` : host;
const roleBindingName = `${entityId}-role-binding`;
if (providerInputs.roleType === KubernetesRoleType.Role) {
await axios.delete(
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
}
);
} 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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
});
}
// Delete the service account
await axios.delete(`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${entityId}`, {
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: new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: providerInputs.sslEnabled
})
});
};
if (providerInputs.credentialType === KubernetesCredentialType.Dynamic) {
const url = new URL(providerInputs.url);
const k8sGatewayHost = url.hostname;
const k8sPort = url.port ? Number(url.port) : 443;
const k8sHost = `${url.protocol}//${url.hostname}`;
if (providerInputs.gatewayId) {
if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) {
await $gatewayProxyWrapper(
{
gatewayId: providerInputs.gatewayId,
targetHost: k8sHost,
targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled,
caCert: providerInputs.ca,
reviewTokenThroughGateway: true
},
serviceAccountDynamicCallback
);
} else {
await $gatewayProxyWrapper(
{
gatewayId: providerInputs.gatewayId,
targetHost: k8sGatewayHost,
targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled,
caCert: providerInputs.ca,
reviewTokenThroughGateway: false
},
serviceAccountDynamicCallback
);
}
} else {
await serviceAccountDynamicCallback(k8sHost, k8sPort);
}
}
return { entityId };
};

View File

@@ -31,7 +31,18 @@ export enum LdapCredentialType {
}
export enum KubernetesCredentialType {
Static = "static"
Static = "static",
Dynamic = "dynamic"
}
export enum KubernetesRoleType {
ClusterRole = "cluster-role",
Role = "role"
}
export enum KubernetesAuthMethod {
Gateway = "gateway",
Api = "api"
}
export enum TotpConfigType {
@@ -282,17 +293,50 @@ export const LdapSchema = z.union([
})
]);
export const DynamicSecretKubernetesSchema = z.object({
url: z.string().url().trim().min(1),
gatewayId: z.string().nullable().optional(),
sslEnabled: z.boolean().default(true),
clusterToken: z.string().trim().min(1),
ca: z.string().optional(),
serviceAccountName: z.string().trim().min(1),
credentialType: z.literal(KubernetesCredentialType.Static),
namespace: z.string().trim().min(1),
audiences: z.array(z.string().trim().min(1))
});
export const DynamicSecretKubernetesSchema = z
.discriminatedUnion("credentialType", [
z.object({
url: z.string().url().trim().min(1),
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),
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),
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),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(KubernetesRoleType),
role: z.string().trim().min(1),
authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api)
})
])
.superRefine((data, ctx) => {
if (data.authMethod === KubernetesAuthMethod.Gateway && !data.gatewayId) {
ctx.addIssue({
path: ["gatewayId"],
code: z.ZodIssueCode.custom,
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"
});
}
});
export const DynamicSecretVerticaSchema = z.object({
host: z.string().trim().toLowerCase(),

View File

@@ -267,17 +267,32 @@ export type TDynamicSecretProvider =
}
| {
type: DynamicSecretProviders.Kubernetes;
inputs: {
url: string;
clusterToken: string;
ca?: string;
serviceAccountName: string;
credentialType: "dynamic" | "static";
namespace: string;
gatewayId?: string;
sslEnabled: boolean;
audiences: string[];
};
inputs:
| {
url: string;
clusterToken?: string;
ca?: string;
serviceAccountName: string;
credentialType: "static";
namespace: string;
gatewayId?: string;
sslEnabled: boolean;
audiences: string[];
authMethod: string;
}
| {
url: string;
clusterToken?: string;
ca?: string;
credentialType: "dynamic";
namespace: string;
gatewayId?: string;
sslEnabled: boolean;
audiences: string[];
roleType: string;
role: string;
authMethod: string;
};
}
| {
type: DynamicSecretProviders.Vertica;

View File

@@ -38,46 +38,94 @@ enum CredentialType {
Static = "static"
}
enum RoleType {
ClusterRole = "cluster-role",
Role = "role"
}
export enum AuthMethod {
Api = "api",
Gateway = "gateway"
}
const credentialTypes = [
{
label: "Static",
value: CredentialType.Static
},
{
label: "Dynamic",
value: CredentialType.Dynamic
}
] as const;
const formSchema = z.object({
provider: z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().min(1),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(CredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1))
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const formSchema = z
.object({
provider: z.discriminatedUnion("credentialType", [
z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(CredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
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),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(CredentialType.Dynamic),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(RoleType),
role: z.string().trim().min(1),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
})
]),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: slugSchema(),
environment: z.object({ name: z.string(), slug: z.string() })
});
maxTTL: z
.string()
.optional()
.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 > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: slugSchema(),
environment: z.object({ name: z.string(), slug: z.string() }),
usernameTemplate: z.string().trim().optional()
})
.superRefine((data, ctx) => {
if (data.provider.authMethod === AuthMethod.Gateway && !data.provider.gatewayId) {
ctx.addIssue({
path: ["provider.gatewayId"],
code: z.ZodIssueCode.custom,
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 Manual Token, a cluster token must be provided"
});
}
});
type TForm = z.infer<typeof formSchema> & FieldValues;
@@ -115,8 +163,9 @@ export const KubernetesInputForm = ({
namespace: "",
credentialType: CredentialType.Static,
gatewayId: undefined,
audiences: []
},
audiences: [],
authMethod: AuthMethod.Api
} as const,
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
@@ -130,12 +179,16 @@ export const KubernetesInputForm = ({
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
const sslEnabled = watch("provider.sslEnabled");
const credentialType = watch("provider.credentialType");
const authMethod = watch("provider.authMethod");
const handleCreateDynamicSecret = async (formData: TForm) => {
const { provider, ...rest } = formData;
const { provider, usernameTemplate, ...rest } = formData;
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Kubernetes, inputs: provider },
maxTTL: rest.maxTTL,
@@ -143,7 +196,9 @@ export const KubernetesInputForm = ({
path: secretPath,
defaultTTL: rest.defaultTTL,
projectSlug,
environmentSlug: rest.environment.slug
environmentSlug: rest.environment.slug,
usernameTemplate:
!usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate
});
onCompleted();
@@ -343,20 +398,44 @@ export const KubernetesInputForm = ({
</FormControl>
)}
/>
<Controller
control={control}
name="provider.clusterToken"
name="provider.authMethod"
defaultValue={AuthMethod.Api}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster Token"
label="Auth Method"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Input {...field} type="password" autoComplete="new-password" />
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
<SelectItem value={AuthMethod.Api}>Manual Token (API)</SelectItem>
<SelectItem value={AuthMethod.Gateway}>Gateway</SelectItem>
</Select>
</FormControl>
)}
/>
{authMethod === AuthMethod.Api && (
<Controller
control={control}
name="provider.clusterToken"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster Token"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
)}
<Controller
control={control}
name="provider.credentialType"
@@ -373,12 +452,9 @@ export const KubernetesInputForm = ({
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
{credentialTypes.map((credentialType) => (
<SelectItem
value={credentialType.value}
key={`credential-type-${credentialType.value}`}
>
{credentialType.label}
{credentialTypes.map((ct) => (
<SelectItem value={ct.value} key={`credential-type-${ct.value}`}>
{ct.label}
</SelectItem>
))}
</Select>
@@ -386,21 +462,45 @@ export const KubernetesInputForm = ({
)}
/>
<div className="flex items-center space-x-2">
<div className="flex-1">
<Controller
control={control}
name="provider.serviceAccountName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="new-password" />
</FormControl>
)}
/>
</div>
{credentialType === CredentialType.Static && (
<div className="flex-1">
<Controller
control={control}
name="provider.serviceAccountName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="new-password" />
</FormControl>
)}
/>
</div>
)}
{credentialType === CredentialType.Dynamic && (
<div className="flex-1">
<Controller
control={control}
name="usernameTemplate"
defaultValue="{{randomUsername}}"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</div>
)}
<div className="flex-1">
<Controller
control={control}
@@ -417,6 +517,56 @@ export const KubernetesInputForm = ({
/>
</div>
</div>
{credentialType === CredentialType.Dynamic && (
<div className="flex items-center space-x-2">
<div className="flex-1">
<Controller
control={control}
name="provider.roleType"
defaultValue={RoleType.ClusterRole}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Role Type"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
<SelectItem
value={RoleType.ClusterRole}
key={`role-type-${RoleType.ClusterRole}`}
>
Cluster Role
</SelectItem>
<SelectItem value={RoleType.Role} key={`role-type-${RoleType.Role}`}>
Role
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex-1">
<Controller
control={control}
name="provider.role"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Role"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
)}
<div className="mt-2 w-1/2">
<Controller
control={control}

View File

@@ -36,45 +36,93 @@ enum CredentialType {
Static = "static"
}
enum RoleType {
ClusterRole = "cluster-role",
Role = "role"
}
enum AuthMethod {
Api = "api",
Gateway = "gateway"
}
const credentialTypes = [
{
label: "Static",
value: CredentialType.Static
},
{
label: "Dynamic",
value: CredentialType.Dynamic
}
] as const;
const formSchema = z.object({
inputs: z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().min(1),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(CredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1))
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const formSchema = z
.object({
inputs: z.discriminatedUnion("credentialType", [
z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(CredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
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),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(CredentialType.Dynamic),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(RoleType),
role: z.string().trim().min(1),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
})
]),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
newName: slugSchema().optional()
});
maxTTL: z
.string()
.optional()
.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 > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
newName: slugSchema().optional(),
usernameTemplate: z.string().trim().optional()
})
.superRefine((data, ctx) => {
if (data.inputs.authMethod === AuthMethod.Gateway && !data.inputs.gatewayId) {
ctx.addIssue({
path: ["inputs.gatewayId"],
code: z.ZodIssueCode.custom,
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 Manual Token, a cluster token must be provided"
});
}
});
type TForm = z.infer<typeof formSchema> & FieldValues;
@@ -103,6 +151,7 @@ export const EditDynamicSecretKubernetesForm = ({
values: {
newName: dynamicSecret.name,
defaultTTL: dynamicSecret.defaultTTL,
usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}",
maxTTL: dynamicSecret.maxTTL,
inputs: dynamicSecret.inputs as TForm["inputs"]
}
@@ -110,17 +159,20 @@ export const EditDynamicSecretKubernetesForm = ({
const { fields, append, remove } = useFieldArray({
control,
name: "inputs.audiences" as const
name: "inputs.audiences"
});
const updateDynamicSecret = useUpdateDynamicSecret();
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
const sslEnabled = watch("inputs.sslEnabled");
const credentialType = watch("inputs.credentialType");
const authMethod = watch("inputs.authMethod");
const handleUpdateDynamicSecret = async (formData: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isPending) return;
const isDefaultUsernameTemplate = formData.usernameTemplate === "{{randomUsername}}";
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
@@ -131,9 +183,14 @@ export const EditDynamicSecretKubernetesForm = ({
inputs: formData.inputs,
newName: formData.newName === dynamicSecret.name ? undefined : formData.newName,
defaultTTL: formData.defaultTTL,
maxTTL: formData.maxTTL
maxTTL: formData.maxTTL,
usernameTemplate:
!formData.usernameTemplate || isDefaultUsernameTemplate
? null
: formData.usernameTemplate
}
});
onClose();
createNotification({
type: "success",
@@ -339,17 +396,42 @@ export const EditDynamicSecretKubernetesForm = ({
<Controller
control={control}
name="inputs.clusterToken"
name="inputs.authMethod"
defaultValue={AuthMethod.Api}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster Token"
label="Auth Method"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Input {...field} type="password" autoComplete="new-password" />
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
<SelectItem value={AuthMethod.Api}>Manual Token (API)</SelectItem>
<SelectItem value={AuthMethod.Gateway}>Gateway</SelectItem>
</Select>
</FormControl>
)}
/>
{authMethod === AuthMethod.Api && (
<Controller
control={control}
name="inputs.clusterToken"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster Token"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
)}
<Controller
control={control}
name="inputs.credentialType"
@@ -366,12 +448,9 @@ export const EditDynamicSecretKubernetesForm = ({
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
{credentialTypes.map((credentialType) => (
<SelectItem
value={credentialType.value}
key={`credential-type-${credentialType.value}`}
>
{credentialType.label}
{credentialTypes.map((ct) => (
<SelectItem value={ct.value} key={`credential-type-${ct.value}`}>
{ct.label}
</SelectItem>
))}
</Select>
@@ -379,21 +458,44 @@ export const EditDynamicSecretKubernetesForm = ({
)}
/>
<div className="flex items-center space-x-2">
<div className="flex-1">
<Controller
control={control}
name="inputs.serviceAccountName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="new-password" />
</FormControl>
)}
/>
</div>
{credentialType === CredentialType.Static && (
<div className="flex-1">
<Controller
control={control}
name="inputs.serviceAccountName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="new-password" />
</FormControl>
)}
/>
</div>
)}
{credentialType === CredentialType.Dynamic && (
<div className="flex-1">
<Controller
control={control}
name="usernameTemplate"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</div>
)}
<div className="flex-1">
<Controller
control={control}
@@ -410,6 +512,58 @@ export const EditDynamicSecretKubernetesForm = ({
/>
</div>
</div>
{credentialType === CredentialType.Dynamic && (
<div className="flex items-center space-x-2">
<div className="flex-1">
<Controller
control={control}
name="inputs.roleType"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Role Type"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
<SelectItem
value={RoleType.ClusterRole}
key={`role-type-${RoleType.ClusterRole}`}
>
Cluster Role
</SelectItem>
<SelectItem
value={RoleType.Role}
key={`role-type-${RoleType.Role}`}
>
Role
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex-1">
<Controller
control={control}
name="inputs.role"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Role"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
)}
</div>
</div>
<div className="mt-2 w-1/2">