mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge branch 'main' into ENG-2800
This commit is contained in:
@@ -174,5 +174,28 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
|
||||
return softDeletedPolicy;
|
||||
};
|
||||
|
||||
return { ...accessApprovalPolicyOrm, find, findById, softDeleteById };
|
||||
const findLastValidPolicy = async ({ envId, secretPath }: { envId: string; secretPath: string }, tx?: Knex) => {
|
||||
try {
|
||||
const result = await (tx || db.replicaNode())(TableName.AccessApprovalPolicy)
|
||||
.where(
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
buildFindFilter(
|
||||
{
|
||||
envId,
|
||||
secretPath
|
||||
},
|
||||
TableName.AccessApprovalPolicy
|
||||
)
|
||||
)
|
||||
.orderBy("deletedAt", "desc")
|
||||
.orderByRaw(`"deletedAt" IS NULL`)
|
||||
.first();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindLastValidPolicy" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...accessApprovalPolicyOrm, find, findById, softDeleteById, findLastValidPolicy };
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
||||
| "findOne"
|
||||
| "getCount"
|
||||
>;
|
||||
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find">;
|
||||
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">;
|
||||
accessApprovalRequestReviewerDAL: Pick<
|
||||
TAccessApprovalRequestReviewerDALFactory,
|
||||
"create" | "find" | "findOne" | "transaction"
|
||||
@@ -131,7 +131,7 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
|
||||
if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` });
|
||||
|
||||
const policy = await accessApprovalPolicyDAL.findOne({
|
||||
const policy = await accessApprovalPolicyDAL.findLastValidPolicy({
|
||||
envId: environment.id,
|
||||
secretPath
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AwsIamProvider } from "./aws-iam";
|
||||
import { AzureEntraIDProvider } from "./azure-entra-id";
|
||||
import { CassandraProvider } from "./cassandra";
|
||||
import { ElasticSearchProvider } from "./elastic-search";
|
||||
import { KubernetesProvider } from "./kubernetes";
|
||||
import { LdapProvider } from "./ldap";
|
||||
import { DynamicSecretProviders, TDynamicProviderFns } from "./models";
|
||||
import { MongoAtlasProvider } from "./mongo-atlas";
|
||||
@@ -38,5 +39,6 @@ export const buildDynamicSecretProviders = ({
|
||||
[DynamicSecretProviders.SapHana]: SapHanaProvider(),
|
||||
[DynamicSecretProviders.Snowflake]: SnowflakeProvider(),
|
||||
[DynamicSecretProviders.Totp]: TotpProvider(),
|
||||
[DynamicSecretProviders.SapAse]: SapAseProvider()
|
||||
[DynamicSecretProviders.SapAse]: SapAseProvider(),
|
||||
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService })
|
||||
});
|
||||
|
||||
199
backend/src/ee/services/dynamic-secret/providers/kubernetes.ts
Normal file
199
backend/src/ee/services/dynamic-secret/providers/kubernetes.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import axios from "axios";
|
||||
import https from "https";
|
||||
|
||||
import { InternalServerError } from "@app/lib/errors";
|
||||
import { withGatewayProxy } from "@app/lib/gateway";
|
||||
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";
|
||||
|
||||
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
|
||||
|
||||
type TKubernetesProviderDTO = {
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||
};
|
||||
|
||||
export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => {
|
||||
const validateProviderInputs = async (inputs: unknown) => {
|
||||
const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs);
|
||||
if (!providerInputs.gatewayId) {
|
||||
await blockLocalAndPrivateIpAddresses(providerInputs.url);
|
||||
}
|
||||
|
||||
return providerInputs;
|
||||
};
|
||||
|
||||
const $gatewayProxyWrapper = async <T>(
|
||||
inputs: {
|
||||
gatewayId: string;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
},
|
||||
gatewayCallback: (host: string, port: number) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId);
|
||||
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
|
||||
|
||||
const callbackResult = await withGatewayProxy(
|
||||
async (port) => {
|
||||
// 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);
|
||||
return res;
|
||||
},
|
||||
{
|
||||
targetHost: inputs.targetHost,
|
||||
targetPort: inputs.targetPort,
|
||||
relayHost,
|
||||
relayPort: Number(relayPort),
|
||||
identityId: relayDetails.identityId,
|
||||
orgId: relayDetails.orgId,
|
||||
tlsOptions: {
|
||||
ca: relayDetails.certChain,
|
||||
cert: relayDetails.certificate,
|
||||
key: relayDetails.privateKey.toString()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return callbackResult;
|
||||
};
|
||||
|
||||
const validateConnection = async (inputs: unknown) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
|
||||
const serviceAccountGetCallback = async (host: string, port: number) => {
|
||||
const baseUrl = port ? `${host}:${port}` : host;
|
||||
|
||||
await axios.get(
|
||||
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${providerInputs.serviceAccountName}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
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 url = new URL(providerInputs.url);
|
||||
const k8sPort = url.port ? Number(url.port) : 443;
|
||||
|
||||
try {
|
||||
if (providerInputs.gatewayId) {
|
||||
const k8sHost = url.hostname;
|
||||
|
||||
await $gatewayProxyWrapper(
|
||||
{
|
||||
gatewayId: providerInputs.gatewayId,
|
||||
targetHost: k8sHost,
|
||||
targetPort: k8sPort
|
||||
},
|
||||
serviceAccountGetCallback
|
||||
);
|
||||
} else {
|
||||
const k8sHost = `${url.protocol}//${url.hostname}`;
|
||||
await serviceAccountGetCallback(k8sHost, k8sPort);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
let errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) {
|
||||
errorMessage = (error.response?.data as { message: string }).message;
|
||||
}
|
||||
|
||||
throw new InternalServerError({
|
||||
message: `Failed to validate connection: ${errorMessage}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const create = async (inputs: unknown, expireAt: number) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
|
||||
const tokenRequestCallback = async (host: string, port: number) => {
|
||||
const baseUrl = port ? `${host}:${port}` : host;
|
||||
|
||||
const res = await axios.post<TKubernetesTokenRequest>(
|
||||
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${providerInputs.serviceAccountName}/token`,
|
||||
{
|
||||
spec: {
|
||||
expirationSeconds: Math.floor((expireAt - Date.now()) / 1000),
|
||||
...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
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;
|
||||
};
|
||||
|
||||
const url = new URL(providerInputs.url);
|
||||
const k8sHost = `${url.protocol}//${url.hostname}`;
|
||||
const k8sGatewayHost = url.hostname;
|
||||
const k8sPort = url.port ? Number(url.port) : 443;
|
||||
|
||||
try {
|
||||
const tokenData = providerInputs.gatewayId
|
||||
? await $gatewayProxyWrapper(
|
||||
{
|
||||
gatewayId: providerInputs.gatewayId,
|
||||
targetHost: k8sGatewayHost,
|
||||
targetPort: k8sPort
|
||||
},
|
||||
tokenRequestCallback
|
||||
)
|
||||
: await tokenRequestCallback(k8sHost, k8sPort);
|
||||
|
||||
return {
|
||||
entityId: providerInputs.serviceAccountName,
|
||||
data: { TOKEN: tokenData.status.token }
|
||||
};
|
||||
} catch (error) {
|
||||
let errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) {
|
||||
errorMessage = (error.response?.data as { message: string }).message;
|
||||
}
|
||||
|
||||
throw new InternalServerError({
|
||||
message: `Failed to create dynamic secret: ${errorMessage}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (_inputs: unknown, entityId: string) => {
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
const renew = async (_inputs: unknown, entityId: string) => {
|
||||
// No renewal necessary
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
return {
|
||||
validateProviderInputs,
|
||||
validateConnection,
|
||||
create,
|
||||
revoke,
|
||||
renew
|
||||
};
|
||||
};
|
||||
@@ -29,6 +29,10 @@ export enum LdapCredentialType {
|
||||
Static = "static"
|
||||
}
|
||||
|
||||
export enum KubernetesCredentialType {
|
||||
Static = "static"
|
||||
}
|
||||
|
||||
export enum TotpConfigType {
|
||||
URL = "url",
|
||||
MANUAL = "manual"
|
||||
@@ -277,6 +281,18 @@ 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 DynamicSecretTotpSchema = z.discriminatedUnion("configType", [
|
||||
z.object({
|
||||
configType: z.literal(TotpConfigType.URL),
|
||||
@@ -320,7 +336,8 @@ export enum DynamicSecretProviders {
|
||||
SapHana = "sap-hana",
|
||||
Snowflake = "snowflake",
|
||||
Totp = "totp",
|
||||
SapAse = "sap-ase"
|
||||
SapAse = "sap-ase",
|
||||
Kubernetes = "kubernetes"
|
||||
}
|
||||
|
||||
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||
@@ -338,7 +355,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema })
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema })
|
||||
]);
|
||||
|
||||
export type TDynamicProviderFns = {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal"
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { OrgPermissionBillingActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { BillingPlanRows, BillingPlanTableHead } from "./licence-enums";
|
||||
import { TLicenseDALFactory } from "./license-dal";
|
||||
@@ -288,7 +288,7 @@ export const licenseServiceFactory = ({
|
||||
billingCycle
|
||||
}: TOrgPlansTableDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
const { data } = await licenseServerCloudApi.request.get(
|
||||
`/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}`
|
||||
);
|
||||
@@ -310,8 +310,10 @@ export const licenseServiceFactory = ({
|
||||
success_url
|
||||
}: TStartOrgTrialDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionBillingActions.ManageBilling,
|
||||
OrgPermissionSubjects.Billing
|
||||
);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -338,8 +340,10 @@ export const licenseServiceFactory = ({
|
||||
actorOrgId
|
||||
}: TCreateOrgPortalSession) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionBillingActions.ManageBilling,
|
||||
OrgPermissionSubjects.Billing
|
||||
);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -385,7 +389,7 @@ export const licenseServiceFactory = ({
|
||||
|
||||
const getOrgBillingInfo = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -413,7 +417,7 @@ export const licenseServiceFactory = ({
|
||||
// returns org current plan feature table
|
||||
const getOrgPlanTable = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -484,7 +488,7 @@ export const licenseServiceFactory = ({
|
||||
|
||||
const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -509,7 +513,10 @@ export const licenseServiceFactory = ({
|
||||
email
|
||||
}: TUpdateOrgBillingDetailsDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionBillingActions.ManageBilling,
|
||||
OrgPermissionSubjects.Billing
|
||||
);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -529,7 +536,7 @@ export const licenseServiceFactory = ({
|
||||
|
||||
const getOrgPmtMethods = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPmtMethodsDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -556,7 +563,10 @@ export const licenseServiceFactory = ({
|
||||
cancel_url
|
||||
}: TAddOrgPmtMethodDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionBillingActions.ManageBilling,
|
||||
OrgPermissionSubjects.Billing
|
||||
);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -585,7 +595,10 @@ export const licenseServiceFactory = ({
|
||||
pmtMethodId
|
||||
}: TDelOrgPmtMethodDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionBillingActions.ManageBilling,
|
||||
OrgPermissionSubjects.Billing
|
||||
);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -602,7 +615,7 @@ export const licenseServiceFactory = ({
|
||||
|
||||
const getOrgTaxIds = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgTaxIdDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -620,7 +633,10 @@ export const licenseServiceFactory = ({
|
||||
|
||||
const addOrgTaxId = async ({ actorId, actor, actorAuthMethod, actorOrgId, orgId, type, value }: TAddOrgTaxIdDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionBillingActions.ManageBilling,
|
||||
OrgPermissionSubjects.Billing
|
||||
);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -641,7 +657,10 @@ export const licenseServiceFactory = ({
|
||||
|
||||
const delOrgTaxId = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId, taxId }: TDelOrgTaxIdDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionBillingActions.ManageBilling,
|
||||
OrgPermissionSubjects.Billing
|
||||
);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -658,7 +677,7 @@ export const licenseServiceFactory = ({
|
||||
|
||||
const getOrgTaxInvoices = async ({ actorId, actor, actorOrgId, actorAuthMethod, orgId }: TOrgInvoiceDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
@@ -675,7 +694,7 @@ export const licenseServiceFactory = ({
|
||||
|
||||
const getOrgLicenses = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgLicensesDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) {
|
||||
|
||||
@@ -700,7 +700,6 @@ export const oidcConfigServiceFactory = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(_req: any, tokenSet: TokenSet, cb: any) => {
|
||||
const claims = tokenSet.claims();
|
||||
logger.info(`User OIDC claims received for [orgId=${org.id}] [claims=${JSON.stringify(claims)}]`);
|
||||
if (!claims.email || !claims.given_name) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid request. Missing email or first name"
|
||||
|
||||
@@ -67,6 +67,11 @@ export enum OrgPermissionGroupActions {
|
||||
RemoveMembers = "remove-members"
|
||||
}
|
||||
|
||||
export enum OrgPermissionBillingActions {
|
||||
Read = "read",
|
||||
ManageBilling = "manage-billing"
|
||||
}
|
||||
|
||||
export enum OrgPermissionSubjects {
|
||||
Workspace = "workspace",
|
||||
Role = "role",
|
||||
@@ -107,7 +112,7 @@ export type OrgPermissionSet =
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
|
||||
| [OrgPermissionGroupActions, OrgPermissionSubjects.Groups]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||
| [OrgPermissionBillingActions, OrgPermissionSubjects.Billing]
|
||||
| [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
||||
@@ -298,10 +303,8 @@ const buildAdminPermission = () => {
|
||||
can(OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups);
|
||||
can(OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups);
|
||||
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing);
|
||||
|
||||
can(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
|
||||
can(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity);
|
||||
@@ -362,7 +365,7 @@ const buildMemberPermission = () => {
|
||||
can(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.Role);
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings);
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount);
|
||||
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning);
|
||||
|
||||
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
||||
import net from "node:net";
|
||||
|
||||
import quicDefault, * as quicModule from "@infisical/quic";
|
||||
import axios from "axios";
|
||||
|
||||
import { BadRequestError } from "../errors";
|
||||
import { logger } from "../logger";
|
||||
@@ -378,7 +379,12 @@ export const withGatewayProxy = async <T>(
|
||||
logger.error(new Error(proxyErrorMessage), "Failed to proxy");
|
||||
}
|
||||
logger.error(err, "Failed to do gateway");
|
||||
throw new BadRequestError({ message: proxyErrorMessage || (err as Error)?.message });
|
||||
let errorMessage = proxyErrorMessage || (err as Error)?.message;
|
||||
if (axios.isAxiosError(err) && (err.response?.data as { message?: string })?.message) {
|
||||
errorMessage = (err.response?.data as { message: string }).message;
|
||||
}
|
||||
|
||||
throw new BadRequestError({ message: errorMessage });
|
||||
} finally {
|
||||
// Ensure cleanup happens regardless of success or failure
|
||||
await cleanup();
|
||||
|
||||
@@ -311,7 +311,6 @@ export const certificateAuthorityServiceFactory = ({
|
||||
}
|
||||
|
||||
const updatedCa = await internalCertificateAuthorityService.updateCaById({
|
||||
...configuration,
|
||||
isInternal: true,
|
||||
enableDirectIssuance,
|
||||
caId: certificateAuthority.id,
|
||||
|
||||
@@ -55,8 +55,4 @@ export const CreateInternalCertificateAuthoritySchema = GenericCreateCertificate
|
||||
configuration: InternalCertificateAuthorityConfigurationSchema
|
||||
});
|
||||
|
||||
export const UpdateInternalCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(
|
||||
CaType.INTERNAL
|
||||
).extend({
|
||||
configuration: InternalCertificateAuthorityConfigurationSchema.optional()
|
||||
});
|
||||
export const UpdateInternalCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(CaType.INTERNAL);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import https from "https";
|
||||
import jwt from "jsonwebtoken";
|
||||
import RE2 from "re2";
|
||||
|
||||
import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas";
|
||||
import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal";
|
||||
@@ -185,7 +186,13 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const [k8sHost, k8sPort] = identityKubernetesAuth.kubernetesHost.split(":");
|
||||
let { kubernetesHost } = identityKubernetesAuth;
|
||||
|
||||
if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) {
|
||||
kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, "");
|
||||
}
|
||||
|
||||
const [k8sHost, k8sPort] = kubernetesHost.split(":");
|
||||
|
||||
const data = identityKubernetesAuth.gatewayId
|
||||
? await $gatewayProxyWrapper(
|
||||
|
||||
@@ -63,6 +63,18 @@ export type TCreateTokenReviewResponse = {
|
||||
status: TCreateTokenReviewSuccessResponse | TCreateTokenReviewErrorResponse;
|
||||
};
|
||||
|
||||
export type TKubernetesTokenRequest = {
|
||||
apiVersion: "authentication.k8s.io/v1";
|
||||
kind: "TokenRequest";
|
||||
spec: {
|
||||
audiences: string[];
|
||||
expirationSeconds: number;
|
||||
};
|
||||
status: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TRevokeKubernetesAuthDTO = {
|
||||
identityId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
@@ -137,6 +137,15 @@ export const pkiSubscriberServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
const ca = await certificateAuthorityDAL.findById(caId);
|
||||
if (!ca) {
|
||||
throw new NotFoundError({ message: `CA with ID '${caId}' not found` });
|
||||
}
|
||||
|
||||
if (ca.projectId !== projectId) {
|
||||
throw new BadRequestError({ message: "CA does not belong to the project" });
|
||||
}
|
||||
|
||||
const newSubscriber = await pkiSubscriberDAL.create({
|
||||
caId,
|
||||
projectId,
|
||||
@@ -245,6 +254,17 @@ export const pkiSubscriberServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (caId) {
|
||||
const ca = await certificateAuthorityDAL.findById(caId);
|
||||
if (!ca) {
|
||||
throw new NotFoundError({ message: `CA with ID '${caId}' not found` });
|
||||
}
|
||||
|
||||
if (ca.projectId !== projectId) {
|
||||
throw new BadRequestError({ message: "CA does not belong to the project" });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSubscriber = await pkiSubscriberDAL.updateById(subscriber.id, {
|
||||
caId,
|
||||
name,
|
||||
|
||||
243
docs/documentation/platform/dynamic-secrets/kubernetes.mdx
Normal file
243
docs/documentation/platform/dynamic-secrets/kubernetes.mdx
Normal file
@@ -0,0 +1,243 @@
|
||||
---
|
||||
title: "Kubernetes"
|
||||
description: "Learn how to dynamically generate Kubernetes service account tokens."
|
||||
---
|
||||
|
||||
The Infisical Kubernetes dynamic secret allows you to generate short-lived service account tokens on demand.
|
||||
|
||||
## Overview
|
||||
|
||||
The Kubernetes dynamic secret feature enables you to generate short-lived service account tokens for your Kubernetes clusters. This is particularly useful for:
|
||||
|
||||
- **Secure Access Management**: Instead of using long-lived service account tokens, you can generate short-lived tokens that automatically expire, reducing the risk of token exposure.
|
||||
- **Temporary Access**: Generate tokens with specific TTLs (Time To Live) for temporary access to your Kubernetes clusters.
|
||||
- **Audit Trail**: Each token generation is tracked, providing better visibility into who accessed your cluster and when.
|
||||
- **Integration with Private Clusters**: Seamlessly work with private Kubernetes clusters using Infisical's Gateway feature.
|
||||
|
||||
<Note>
|
||||
Kubernetes service account tokens cannot be revoked once issued. This is why
|
||||
it's important to use short TTLs and carefully manage token generation. The
|
||||
tokens will automatically expire after their TTL period.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Kubernetes service account tokens are JWTs (JSON Web Tokens) with a fixed
|
||||
expiration time. Once a token is generated, its lifetime cannot be extended.
|
||||
If you need longer access, you'll need to generate a new token.
|
||||
</Note>
|
||||
|
||||
This feature is ideal for scenarios where you need to:
|
||||
|
||||
- Provide temporary access to developers or CI/CD pipelines
|
||||
- Rotate service account tokens frequently
|
||||
- Maintain a secure audit trail of cluster access
|
||||
- Manage access to multiple Kubernetes clusters
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Kubernetes cluster with a service account
|
||||
- Cluster access token with permissions to create service account tokens
|
||||
- (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access
|
||||
|
||||
## RBAC Configuration
|
||||
|
||||
Before you can start generating dynamic service account tokens, you'll need to configure the appropriate permissions in your Kubernetes cluster. This involves setting up Role-Based Access Control (RBAC) to allow the creation and management of service account tokens.
|
||||
|
||||
The RBAC configuration serves a crucial security purpose: it creates a dedicated service account with minimal permissions that can only create and manage service account tokens. This follows the principle of least privilege, ensuring that the token generation process is secure and controlled.
|
||||
|
||||
The following RBAC configuration creates the necessary permissions for generating service account tokens:
|
||||
|
||||
```yaml rbac.yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: tokenrequest
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- "serviceaccounts/token"
|
||||
- "serviceaccounts"
|
||||
verbs:
|
||||
- "create"
|
||||
- "get"
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: tokenrequest
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: tokenrequest
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: infisical-token-requester
|
||||
namespace: default
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -f rbac.yaml
|
||||
```
|
||||
|
||||
This configuration:
|
||||
|
||||
1. Creates a `ClusterRole` named `tokenrequest` that allows:
|
||||
- Creating and getting service account tokens
|
||||
- Getting service account information
|
||||
2. Creates a `ClusterRoleBinding` that binds the role to a service account named `infisical-token-requester` in the `default` namespace
|
||||
|
||||
You can customize the service account name and namespace according to your needs.
|
||||
|
||||
## Obtaining the Cluster Token
|
||||
|
||||
After setting up the RBAC configuration, you need to obtain a token for the service account that will be used to create dynamic secrets. Here's how to get the token:
|
||||
|
||||
1. Create a service account in your Kubernetes cluster that will be used to create service account tokens:
|
||||
|
||||
```yaml infisical-service-account.yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: infisical-token-requester
|
||||
namespace: default
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -f infisical-service-account.yaml
|
||||
```
|
||||
|
||||
2. Create a long-lived service account token using this configuration file:
|
||||
|
||||
```yaml service-account-token.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
type: kubernetes.io/service-account-token
|
||||
metadata:
|
||||
name: infisical-token-requester-token
|
||||
annotations:
|
||||
kubernetes.io/service-account.name: "infisical-token-requester"
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -f service-account-token.yaml
|
||||
```
|
||||
|
||||
3. Link the secret to the service account:
|
||||
|
||||
```bash
|
||||
kubectl patch serviceaccount infisical-token-requester -p '{"secrets": [{"name": "infisical-token-requester-token"}]}' -n default
|
||||
```
|
||||
|
||||
4. Retrieve the token:
|
||||
|
||||
```bash
|
||||
kubectl get secret infisical-token-requester-token -n default -o=jsonpath='{.data.token}' | base64 --decode
|
||||
```
|
||||
|
||||
This token will be used as the "Cluster Token" in the dynamic secret configuration.
|
||||
|
||||
## Obtaining the Cluster URL
|
||||
|
||||
The cluster URL is the address of your Kubernetes API server. The simplest way to find it is to use the `kubectl cluster-info` command:
|
||||
|
||||
```bash
|
||||
kubectl cluster-info
|
||||
```
|
||||
|
||||
This command works for all Kubernetes environments (managed services like GKE, EKS, AKS, or self-hosted clusters) and will show you the Kubernetes control plane address, which is your cluster URL.
|
||||
|
||||
<Note>
|
||||
Make sure the cluster URL is accessible from where you're running Infisical.
|
||||
If you're using a private cluster, you'll need to configure a [Gateway](/documentation/platform/gateways/overview) to
|
||||
access it.
|
||||
</Note>
|
||||
|
||||
## Set up Dynamic Secrets with Kubernetes
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Secret Overview Dashboard">
|
||||
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
|
||||
</Step>
|
||||
<Step title="Click on the 'Add Dynamic Secret' button">
|
||||

|
||||
</Step>
|
||||
<Step title="Select Kubernetes">
|
||||

|
||||
</Step>
|
||||
<Step title="Provide the inputs for dynamic secret parameters">
|
||||
<ParamField path="Secret Name" type="string" required>
|
||||
Name by which you want the secret to be referenced
|
||||
</ParamField>
|
||||
<ParamField path="Default TTL" type="string" required>
|
||||
Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated)
|
||||
</ParamField>
|
||||
<ParamField path="Max TTL" type="string" required>
|
||||
Maximum time-to-live for a generated secret
|
||||
</ParamField>
|
||||
<ParamField path="Gateway" type="string">
|
||||
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)
|
||||
</ParamField>
|
||||
<ParamField path="Enable SSL" type="boolean">
|
||||
Whether to enable SSL verification for the Kubernetes API server connection.
|
||||
</ParamField>
|
||||
<ParamField path="CA" type="string">
|
||||
Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA.
|
||||
</ParamField>
|
||||
<ParamField path="Cluster Token" type="string" required>
|
||||
Token with permissions to create service account tokens
|
||||
</ParamField>
|
||||
<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>
|
||||
<ParamField path="Audiences" type="array">
|
||||
Optional list of audiences to include in the generated token
|
||||
</ParamField>
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
<Step title="Click 'Submit'">
|
||||
After submitting the form, you will see a dynamic secret created in the dashboard.
|
||||
</Step>
|
||||
<Step title="Generate dynamic secrets">
|
||||
Once you've successfully configured the dynamic secret, you're ready to generate on-demand service account tokens.
|
||||
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item.
|
||||
Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section.
|
||||
|
||||

|
||||

|
||||
|
||||
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
|
||||
|
||||

|
||||
|
||||
<Tip>
|
||||
Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret.
|
||||
</Tip>
|
||||
|
||||
Once you click the `Submit` button, a new secret lease will be generated and the service account token will be shown to you.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Audit or Revoke Leases
|
||||
|
||||
Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard.
|
||||
This will allow you to see the lease details and delete the lease ahead of its expiration time.
|
||||
|
||||
<Note>
|
||||
While you can delete the lease from Infisical, the actual Kubernetes service
|
||||
account token cannot be revoked. The token will remain valid until its TTL
|
||||
expires. This is why it's crucial to use appropriate TTL values when
|
||||
generating tokens.
|
||||
</Note>
|
||||
|
||||

|
||||
Binary file not shown.
|
After Width: | Height: | Size: 619 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 477 KiB |
BIN
docs/images/platform/dynamic-secrets/kubernetes-lease-value.png
Normal file
BIN
docs/images/platform/dynamic-secrets/kubernetes-lease-value.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 606 KiB |
@@ -10,9 +10,7 @@ We value reports that help identify vulnerabilities that affect the integrity of
|
||||
### How to Report
|
||||
|
||||
- Send reports to **security@infisical.com** with clear steps to reproduce, impact, and (if possible) a proof-of-concept.
|
||||
- We will acknowledge receipt within 3 business days for reports that are clearly written, technically sound, and plausibly within scope.
|
||||
- We'll provide an initial assessment or next steps within 5 business days.
|
||||
- **Please note**: We do not respond to spam, auto generated reports, inaccurate claims, or submissions that are clearly out of scope.
|
||||
- You will receive follow ups from our team if we deam your report to be a legitimate vulnerability or need further clarification. We do not respond to spam, auto generated reports, inaccurate claims, or submissions that are clearly out of scope.
|
||||
|
||||
|
||||
### What's in Scope?
|
||||
@@ -29,7 +27,7 @@ Bounties are based on severity, impact, and exploitability, as well as whether t
|
||||
| --- | --- | --- |
|
||||
| **Critical** | Full unauthorized access to secrets, authentication bypass, cross-tenant access, RCE, full compromise, etc | $2,000 - $5,000 |
|
||||
| **High** | Privilege escalation, project-level access without authorization, persistent DoS | $750 - $2,000 |
|
||||
| **Medium** | Info disclosure, scoped DoS (e.g. ReDoS with auth), or minor access control issues | $250 - $1,000 |
|
||||
| **Medium** | Info disclosure, scoped DoS (e.g. ReDoS with auth), or minor access control issues | $100 - $1,000 |
|
||||
| **Low / Informational** | Missing headers, CSP warnings, theoretical flaws, self-hosting misconfigurations | Recognition only |
|
||||
|
||||
|
||||
|
||||
@@ -142,12 +142,10 @@ Below is a comprehensive list of all available organization-level subjects and t
|
||||
|
||||
#### Subject: `billing`
|
||||
|
||||
| Action | Description |
|
||||
| -------- | ------------------------------------------------ |
|
||||
| `read` | View billing information and subscription status |
|
||||
| `create` | Set up new payment methods or subscriptions |
|
||||
| `edit` | Modify billing details or subscription plans |
|
||||
| `delete` | Remove payment methods or cancel subscriptions |
|
||||
| Action | Description |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| `read` | View billing information and subscription status |
|
||||
| `manage-billing` | Manage billing details and subscription plans |
|
||||
|
||||
### Templates & Automation
|
||||
|
||||
|
||||
@@ -217,7 +217,8 @@
|
||||
"documentation/platform/dynamic-secrets/sap-ase",
|
||||
"documentation/platform/dynamic-secrets/sap-hana",
|
||||
"documentation/platform/dynamic-secrets/snowflake",
|
||||
"documentation/platform/dynamic-secrets/totp"
|
||||
"documentation/platform/dynamic-secrets/totp",
|
||||
"documentation/platform/dynamic-secrets/kubernetes"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@lexical/react": "^0.29.0",
|
||||
"@lottiefiles/dotlottie-react": "^0.12.0",
|
||||
"@lottiefiles/dotlottie-web": "^0.38.2",
|
||||
"@octokit/rest": "^21.0.2",
|
||||
"@peculiar/x509": "^1.12.3",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@lexical/react": "^0.29.0",
|
||||
"@lottiefiles/dotlottie-react": "^0.12.0",
|
||||
"@lottiefiles/dotlottie-web": "^0.38.2",
|
||||
"@octokit/rest": "^21.0.2",
|
||||
"@peculiar/x509": "^1.12.3",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
|
||||
@@ -2,6 +2,7 @@ export { useOrgPermission } from "./OrgPermissionContext";
|
||||
export type { TOrgPermission } from "./types";
|
||||
export {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionBillingActions,
|
||||
OrgPermissionGroupActions,
|
||||
OrgPermissionIdentityActions,
|
||||
OrgPermissionSubjects
|
||||
|
||||
@@ -7,6 +7,11 @@ export enum OrgPermissionActions {
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum OrgPermissionBillingActions {
|
||||
Read = "read",
|
||||
ManageBilling = "manage-billing"
|
||||
}
|
||||
|
||||
export enum OrgGatewayPermissionActions {
|
||||
// is there a better word for this. This mean can an identity be a gateway
|
||||
CreateGateways = "create-gateways",
|
||||
@@ -100,7 +105,7 @@ export type OrgPermissionSet =
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
|
||||
| [OrgPermissionGroupActions, OrgPermissionSubjects.Groups]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||
| [OrgPermissionBillingActions, OrgPermissionSubjects.Billing]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
||||
|
||||
@@ -2,6 +2,7 @@ export { useOrganization } from "./OrganizationContext";
|
||||
export type { TOrgPermission } from "./OrgPermissionContext";
|
||||
export {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionBillingActions,
|
||||
OrgPermissionGroupActions,
|
||||
OrgPermissionIdentityActions,
|
||||
OrgPermissionSubjects,
|
||||
|
||||
@@ -31,10 +31,14 @@ export const useUpdateCa = () => {
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: ({ projectId, type }) => {
|
||||
onSuccess: ({ projectId, type }, { caName }) => {
|
||||
caKeys.getCaByNameAndProjectId(caName, projectId);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: caKeys.listCasByTypeAndProjectId(type, projectId)
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: caKeys.getCaByNameAndProjectId(caName, projectId)
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -31,7 +31,8 @@ export enum DynamicSecretProviders {
|
||||
SapHana = "sap-hana",
|
||||
Snowflake = "snowflake",
|
||||
Totp = "totp",
|
||||
SapAse = "sap-ase"
|
||||
SapAse = "sap-ase",
|
||||
Kubernetes = "kubernetes"
|
||||
}
|
||||
|
||||
export enum SqlProviders {
|
||||
@@ -261,6 +262,20 @@ export type TDynamicSecretProvider =
|
||||
algorithm?: string;
|
||||
digits?: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: DynamicSecretProviders.Kubernetes;
|
||||
inputs: {
|
||||
url: string;
|
||||
clusterToken: string;
|
||||
ca?: string;
|
||||
serviceAccountName: string;
|
||||
credentialType: "dynamic" | "static";
|
||||
namespace: string;
|
||||
gatewayId?: string;
|
||||
sslEnabled: boolean;
|
||||
audiences: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type TCreateDynamicSecretDTO = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { setWasmUrl } from "@lottiefiles/dotlottie-react";
|
||||
import lottieWasmUrl from "@lottiefiles/dotlottie-web/dist/dotlottie-player.wasm?url";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import NProgress from "nprogress";
|
||||
|
||||
@@ -22,6 +24,9 @@ import "./translation";
|
||||
// have a look at the Quick start guide
|
||||
// for passing in lng and translations on init/
|
||||
|
||||
// Configure Lottie player to use local WASM file
|
||||
setWasmUrl(lottieWasmUrl);
|
||||
|
||||
// Create a new router instance
|
||||
NProgress.configure({ showSpinner: false });
|
||||
|
||||
|
||||
@@ -188,11 +188,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
name,
|
||||
type: CaType.INTERNAL,
|
||||
status,
|
||||
enableDirectIssuance,
|
||||
configuration: {
|
||||
...configuration,
|
||||
maxPathLength: Number(configuration.maxPathLength)
|
||||
}
|
||||
enableDirectIssuance
|
||||
});
|
||||
} else {
|
||||
// create
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { OrgPermissionBillingActions, OrgPermissionSubjects } from "@app/context";
|
||||
|
||||
import { BillingTabGroup } from "./components";
|
||||
|
||||
@@ -24,7 +24,7 @@ export const BillingPage = () => {
|
||||
</div>
|
||||
<OrgPermissionCan
|
||||
passThrough={false}
|
||||
I={OrgPermissionActions.Read}
|
||||
I={OrgPermissionBillingActions.Read}
|
||||
a={OrgPermissionSubjects.Billing}
|
||||
>
|
||||
<BillingTabGroup />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button } from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionBillingActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useSubscription
|
||||
@@ -112,7 +112,7 @@ export const PreviewSection = () => {
|
||||
Get unlimited members, projects, RBAC, smart alerts, and so much more.
|
||||
</p>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Billing}>
|
||||
<OrgPermissionCan I={OrgPermissionBillingActions.ManageBilling} a={OrgPermissionSubjects.Billing}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => handleUpgradeBtnClick()}
|
||||
@@ -156,7 +156,7 @@ export const PreviewSection = () => {
|
||||
}`}
|
||||
</p>
|
||||
{isInfisicalCloud() && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
|
||||
<OrgPermissionCan I={OrgPermissionBillingActions.ManageBilling} a={OrgPermissionSubjects.Billing}>
|
||||
{(isAllowed) => (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api";
|
||||
|
||||
const schema = z
|
||||
@@ -74,7 +74,7 @@ export const CompanyNameSection = () => {
|
||||
name="name"
|
||||
/>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
|
||||
<OrgPermissionCan I={OrgPermissionBillingActions.ManageBilling} a={OrgPermissionSubjects.Billing}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api";
|
||||
|
||||
const schema = z
|
||||
@@ -75,7 +75,7 @@ export const InvoiceEmailSection = () => {
|
||||
name="email"
|
||||
/>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
|
||||
<OrgPermissionCan I={OrgPermissionBillingActions.ManageBilling} a={OrgPermissionSubjects.Billing}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useAddOrgPmtMethod } from "@app/hooks/api";
|
||||
|
||||
import { PmtMethodsTable } from "./PmtMethodsTable";
|
||||
@@ -27,7 +27,7 @@ export const PmtMethodsSection = () => {
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-8 flex items-center">
|
||||
<h2 className="flex-1 text-xl font-semibold text-white">Payment methods</h2>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Billing}>
|
||||
<OrgPermissionCan I={OrgPermissionBillingActions.ManageBilling} a={OrgPermissionSubjects.Billing}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={handleAddPmtMethodBtnClick}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteOrgPmtMethod, useGetOrgPmtMethods } from "@app/hooks/api";
|
||||
|
||||
@@ -75,7 +75,7 @@ export const PmtMethodsTable = () => {
|
||||
<Td>{`${exp_month}/${exp_year}`}</Td>
|
||||
<Td>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
I={OrgPermissionBillingActions.ManageBilling}
|
||||
a={OrgPermissionSubjects.Billing}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { OrgPermissionBillingActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { TaxIDModal } from "./TaxIDModal";
|
||||
@@ -18,7 +18,7 @@ export const TaxIDSection = () => {
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-8 flex items-center">
|
||||
<h2 className="flex-1 text-xl font-semibold text-white">Tax ID</h2>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
|
||||
<OrgPermissionCan I={OrgPermissionBillingActions.ManageBilling} a={OrgPermissionSubjects.Billing}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => handlePopUpOpen("addTaxID")}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useDeleteOrgTaxId, useGetOrgTaxIds } from "@app/hooks/api";
|
||||
|
||||
const taxIDTypeLabelMap: { [key: string]: string } = {
|
||||
@@ -103,7 +103,7 @@ export const TaxIDTable = () => {
|
||||
<Td>{value}</Td>
|
||||
<Td>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
I={OrgPermissionBillingActions.ManageBilling}
|
||||
a={OrgPermissionSubjects.Billing}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { OrgPermissionBillingActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { isInfisicalCloud } from "@app/helpers/platform";
|
||||
import { withPermission } from "@app/hoc";
|
||||
|
||||
@@ -47,5 +47,5 @@ export const BillingTabGroup = withPermission(
|
||||
</Tabs>
|
||||
);
|
||||
},
|
||||
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Billing }
|
||||
{ action: OrgPermissionBillingActions.Read, subject: OrgPermissionSubjects.Billing }
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { OrgPermissionSubjects } from "@app/context";
|
||||
import {
|
||||
OrgGatewayPermissionActions,
|
||||
OrgPermissionAppConnectionActions,
|
||||
OrgPermissionBillingActions,
|
||||
OrgPermissionGroupActions,
|
||||
OrgPermissionIdentityActions,
|
||||
OrgPermissionKmipActions,
|
||||
@@ -21,6 +22,13 @@ const generalPermissionSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const billingPermissionSchema = z
|
||||
.object({
|
||||
[OrgPermissionBillingActions.Read]: z.boolean().optional(),
|
||||
[OrgPermissionBillingActions.ManageBilling]: z.boolean().optional()
|
||||
})
|
||||
.optional();
|
||||
|
||||
const appConnectionsPermissionSchema = z
|
||||
.object({
|
||||
[OrgPermissionAppConnectionActions.Read]: z.boolean().optional(),
|
||||
@@ -113,7 +121,7 @@ export const formSchema = z.object({
|
||||
scim: generalPermissionSchema,
|
||||
[OrgPermissionSubjects.GithubOrgSync]: generalPermissionSchema,
|
||||
ldap: generalPermissionSchema,
|
||||
billing: generalPermissionSchema,
|
||||
billing: billingPermissionSchema,
|
||||
identity: identityPermissionSchema,
|
||||
"organization-admin-console": adminConsolePermissionSchmea,
|
||||
[OrgPermissionSubjects.Kms]: generalPermissionSchema,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
|
||||
import { OrgPermissionBillingActions } from "@app/context/OrgPermissionContext/types";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
import { TFormSchema } from "../OrgRoleModifySection.utils";
|
||||
|
||||
const PERMISSION_ACTIONS = [
|
||||
{ action: OrgPermissionBillingActions.Read, label: "View bills" },
|
||||
{ action: OrgPermissionBillingActions.ManageBilling, label: "Manage billing" }
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
isEditable: boolean;
|
||||
setValue: UseFormSetValue<TFormSchema>;
|
||||
control: Control<TFormSchema>;
|
||||
};
|
||||
|
||||
enum Permission {
|
||||
NoAccess = "no-access",
|
||||
ReadOnly = "read-only",
|
||||
FullAccess = "full-access",
|
||||
Custom = "custom"
|
||||
}
|
||||
|
||||
export const OrgPermissionBillingRow = ({ isEditable, control, setValue }: Props) => {
|
||||
const [isRowExpanded, setIsRowExpanded] = useToggle();
|
||||
const [isCustom, setIsCustom] = useToggle();
|
||||
|
||||
const rule = useWatch({
|
||||
control,
|
||||
name: "permissions.billing"
|
||||
});
|
||||
|
||||
const selectedPermissionCategory = useMemo(() => {
|
||||
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
|
||||
const totalActions = PERMISSION_ACTIONS.length;
|
||||
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
|
||||
|
||||
if (isCustom) return Permission.Custom;
|
||||
if (score === 0) return Permission.NoAccess;
|
||||
if (score === totalActions) return Permission.FullAccess;
|
||||
if (score === 1 && rule?.[OrgPermissionBillingActions.Read]) return Permission.ReadOnly;
|
||||
return Permission.Custom;
|
||||
}, [rule, isCustom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
|
||||
else setIsCustom.off();
|
||||
}, [selectedPermissionCategory]);
|
||||
|
||||
const handlePermissionChange = (val: Permission) => {
|
||||
if (val === Permission.Custom) {
|
||||
setIsRowExpanded.on();
|
||||
setIsCustom.on();
|
||||
return;
|
||||
}
|
||||
setIsCustom.off();
|
||||
|
||||
switch (val) {
|
||||
case Permission.NoAccess:
|
||||
setValue(
|
||||
"permissions.billing",
|
||||
{
|
||||
[OrgPermissionBillingActions.Read]: false,
|
||||
[OrgPermissionBillingActions.ManageBilling]: false
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
case Permission.ReadOnly:
|
||||
setValue(
|
||||
"permissions.billing",
|
||||
{
|
||||
[OrgPermissionBillingActions.Read]: true,
|
||||
[OrgPermissionBillingActions.ManageBilling]: false
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
case Permission.FullAccess:
|
||||
setValue(
|
||||
"permissions.billing",
|
||||
{
|
||||
[OrgPermissionBillingActions.Read]: true,
|
||||
[OrgPermissionBillingActions.ManageBilling]: true
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
default:
|
||||
setValue(
|
||||
"permissions.billing",
|
||||
{
|
||||
[OrgPermissionBillingActions.Read]: false,
|
||||
[OrgPermissionBillingActions.ManageBilling]: false
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
onClick={() => setIsRowExpanded.toggle()}
|
||||
>
|
||||
<Td>
|
||||
<FontAwesomeIcon icon={isRowExpanded ? faChevronDown : faChevronRight} />
|
||||
</Td>
|
||||
<Td>Billing</Td>
|
||||
<Td>
|
||||
<Select
|
||||
value={selectedPermissionCategory}
|
||||
className="w-40 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={handlePermissionChange}
|
||||
isDisabled={!isEditable}
|
||||
>
|
||||
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
|
||||
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
|
||||
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
|
||||
<SelectItem value={Permission.Custom}>Custom</SelectItem>
|
||||
</Select>
|
||||
</Td>
|
||||
</Tr>
|
||||
{isRowExpanded && (
|
||||
<Tr>
|
||||
<Td
|
||||
colSpan={3}
|
||||
className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && "border-mineshaft-500 p-8"}`}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{PERMISSION_ACTIONS.map(({ action, label }) => {
|
||||
return (
|
||||
<Controller
|
||||
name={`permissions.billing.${action}`}
|
||||
key={`permissions.billing.${action}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(e) => {
|
||||
if (!isEditable) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update default role"
|
||||
});
|
||||
return;
|
||||
}
|
||||
field.onChange(e);
|
||||
}}
|
||||
id={`permissions.billing.${action}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -38,13 +38,6 @@ const MEMBERS_PERMISSIONS = [
|
||||
{ action: "delete", label: "Remove members" }
|
||||
] as const;
|
||||
|
||||
const BILLING_PERMISSIONS = [
|
||||
{ action: "read", label: "View bills" },
|
||||
{ action: "create", label: "Add payment methods" },
|
||||
{ action: "edit", label: "Edit payments" },
|
||||
{ action: "delete", label: "Remove payments" }
|
||||
] as const;
|
||||
|
||||
const PROJECT_TEMPLATES_PERMISSIONS = [
|
||||
{ action: "read", label: "View & Apply" },
|
||||
{ action: "create", label: "Create" },
|
||||
@@ -52,18 +45,16 @@ const PROJECT_TEMPLATES_PERMISSIONS = [
|
||||
{ action: "delete", label: "Remove" }
|
||||
] as const;
|
||||
|
||||
const getPermissionList = (option: string) => {
|
||||
switch (option) {
|
||||
case "secret-scanning":
|
||||
return SECRET_SCANNING_PERMISSIONS;
|
||||
case "billing":
|
||||
return BILLING_PERMISSIONS;
|
||||
case "incident-contact":
|
||||
return INCIDENT_CONTACTS_PERMISSIONS;
|
||||
const getPermissionList = (formName: Props["formName"]) => {
|
||||
switch (formName) {
|
||||
case "member":
|
||||
return MEMBERS_PERMISSIONS;
|
||||
case OrgPermissionSubjects.ProjectTemplates:
|
||||
return PROJECT_TEMPLATES_PERMISSIONS;
|
||||
case "secret-scanning":
|
||||
return SECRET_SCANNING_PERMISSIONS;
|
||||
case "incident-contact":
|
||||
return INCIDENT_CONTACTS_PERMISSIONS;
|
||||
default:
|
||||
return PERMISSIONS;
|
||||
}
|
||||
@@ -74,7 +65,7 @@ type Props = {
|
||||
title: string;
|
||||
formName: keyof Omit<
|
||||
Exclude<TFormSchema["permissions"], undefined>,
|
||||
"workspace" | "organization-admin-console" | "kmip" | "gateway" | "secret-share"
|
||||
"workspace" | "organization-admin-console" | "kmip" | "gateway" | "secret-share" | "billing"
|
||||
>;
|
||||
setValue: UseFormSetValue<TFormSchema>;
|
||||
control: Control<TFormSchema>;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TFormSchema
|
||||
} from "../OrgRoleModifySection.utils";
|
||||
import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow";
|
||||
import { OrgPermissionBillingRow } from "./OrgPermissionBillingRow";
|
||||
import { OrgGatewayPermissionRow } from "./OrgPermissionGatewayRow";
|
||||
import { OrgPermissionGroupRow } from "./OrgPermissionGroupRow";
|
||||
import { OrgPermissionIdentityRow } from "./OrgPermissionIdentityRow";
|
||||
@@ -27,10 +28,6 @@ const SIMPLE_PERMISSION_OPTIONS = [
|
||||
title: "User Management",
|
||||
formName: "member"
|
||||
},
|
||||
{
|
||||
title: "Usage & Billing",
|
||||
formName: "billing"
|
||||
},
|
||||
{
|
||||
title: "Role Management",
|
||||
formName: "role"
|
||||
@@ -186,6 +183,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
|
||||
setValue={setValue}
|
||||
isEditable={isCustomRole}
|
||||
/>
|
||||
<OrgPermissionBillingRow
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
isEditable={isCustomRole}
|
||||
/>
|
||||
<OrgPermissionSecretShareRow
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
|
||||
@@ -49,7 +49,7 @@ const formSchema = z
|
||||
environment: z.object({ slug: z.string(), name: z.string() }),
|
||||
name: z.string().optional(),
|
||||
secretPath: z.string().optional(),
|
||||
approvals: z.number().min(1),
|
||||
approvals: z.number().min(1).default(1),
|
||||
userApprovers: z
|
||||
.object({ type: z.literal(ApproverType.User), id: z.string() })
|
||||
.array()
|
||||
@@ -67,7 +67,7 @@ const formSchema = z
|
||||
.array()
|
||||
.default([]),
|
||||
policyType: z.nativeEnum(PolicyType),
|
||||
enforcementLevel: z.nativeEnum(EnforcementLevel),
|
||||
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
|
||||
allowedSelfApprovals: z.boolean().default(true)
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SiApachecassandra,
|
||||
SiElasticsearch,
|
||||
SiFiles,
|
||||
SiKubernetes,
|
||||
SiMongodb,
|
||||
SiRabbitmq,
|
||||
SiSap,
|
||||
@@ -24,6 +25,7 @@ import { AwsIamInputForm } from "./AwsIamInputForm";
|
||||
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
||||
import { CassandraInputForm } from "./CassandraInputForm";
|
||||
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
||||
import { KubernetesInputForm } from "./KubernetesInputForm";
|
||||
import { LdapInputForm } from "./LdapInputForm";
|
||||
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
|
||||
import { MongoDBDatabaseInputForm } from "./MongoDBInputForm";
|
||||
@@ -124,6 +126,11 @@ const DYNAMIC_SECRET_LIST = [
|
||||
icon: <FontAwesomeIcon icon={faClock} size="lg" />,
|
||||
provider: DynamicSecretProviders.Totp,
|
||||
title: "TOTP"
|
||||
},
|
||||
{
|
||||
icon: <SiKubernetes size="1.5rem" />,
|
||||
provider: DynamicSecretProviders.Kubernetes,
|
||||
title: "Kubernetes"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -472,6 +479,25 @@ export const CreateDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === WizardSteps.ProviderInputs &&
|
||||
selectedProvider === DynamicSecretProviders.Kubernetes && (
|
||||
<motion.div
|
||||
key="dynamic-kubernetes-step"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<KubernetesInputForm
|
||||
onCompleted={handleFormReset}
|
||||
onCancel={handleFormReset}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
environments={environments}
|
||||
isSingleEnvironmentMode={isSingleEnvironmentMode}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
import { Controller, FieldValues, useFieldArray, useForm } from "react-hook-form";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faBookOpen,
|
||||
faQuestionCircle,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext";
|
||||
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
|
||||
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
|
||||
enum CredentialType {
|
||||
Dynamic = "dynamic",
|
||||
Static = "static"
|
||||
}
|
||||
|
||||
const credentialTypes = [
|
||||
{
|
||||
label: "Static",
|
||||
value: CredentialType.Static
|
||||
}
|
||||
] 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 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: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
|
||||
environment: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema> & FieldValues;
|
||||
|
||||
type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
secretPath: string;
|
||||
projectSlug: string;
|
||||
environments: WorkspaceEnv[];
|
||||
isSingleEnvironmentMode?: boolean;
|
||||
};
|
||||
|
||||
export const KubernetesInputForm = ({
|
||||
onCompleted,
|
||||
onCancel,
|
||||
secretPath,
|
||||
projectSlug,
|
||||
environments,
|
||||
isSingleEnvironmentMode
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
watch
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
provider: {
|
||||
url: "",
|
||||
clusterToken: "",
|
||||
ca: "",
|
||||
sslEnabled: false,
|
||||
serviceAccountName: "",
|
||||
namespace: "",
|
||||
credentialType: CredentialType.Static,
|
||||
gatewayId: undefined,
|
||||
audiences: []
|
||||
},
|
||||
environment: isSingleEnvironmentMode ? environments[0] : undefined
|
||||
}
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "provider.audiences"
|
||||
});
|
||||
|
||||
const createDynamicSecret = useCreateDynamicSecret();
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
const sslEnabled = watch("provider.sslEnabled");
|
||||
|
||||
const handleCreateDynamicSecret = async (formData: TForm) => {
|
||||
const { provider, ...rest } = formData;
|
||||
// wait till previous request is finished
|
||||
if (createDynamicSecret.isPending) return;
|
||||
try {
|
||||
await createDynamicSecret.mutateAsync({
|
||||
provider: { type: DynamicSecretProviders.Kubernetes, inputs: provider },
|
||||
maxTTL: rest.maxTTL,
|
||||
name: rest.name,
|
||||
path: secretPath,
|
||||
defaultTTL: rest.defaultTTL,
|
||||
projectSlug,
|
||||
environmentSlug: rest.environment.slug
|
||||
});
|
||||
|
||||
onCompleted();
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-secret" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
defaultValue="1h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
defaultValue="24h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/kubernetes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="mb-1 ml-2 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
Docs
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-xxs"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<div>
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</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.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"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Credential Type"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="w-full"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
className="w-full"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
{credentialTypes.map((credentialType) => (
|
||||
<SelectItem
|
||||
value={credentialType.value}
|
||||
key={`credential-type-${credentialType.value}`}
|
||||
>
|
||||
{credentialType.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.namespace"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.audiences"
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Audiences"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-center space-x-2">
|
||||
<Input
|
||||
{...control.register(`provider.audiences.${index}`)}
|
||||
placeholder="Enter audience"
|
||||
className="flex-grow"
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => remove(index)}
|
||||
variant="outline_bg"
|
||||
ariaLabel="Remove audience"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline_bg" onClick={() => append("")} type="button">
|
||||
Add Audience
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{!isSingleEnvironmentMode && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
options={environments}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select the environment to create secret in..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
menuPlacement="top"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -320,6 +320,20 @@ const renderOutputForm = (
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === DynamicSecretProviders.Kubernetes) {
|
||||
const { TOKEN } = data as { TOKEN: string };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<OutputDisplay
|
||||
label="Service Account JWT"
|
||||
value={TOKEN}
|
||||
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === DynamicSecretProviders.Totp) {
|
||||
const { TOTP, TIME_REMAINING } = data as {
|
||||
TOTP: string;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
||||
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
||||
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||
import { EditDynamicSecretKubernetesForm } from "./EditDynamicSecretKubernetesForm";
|
||||
import { EditDynamicSecretLdapForm } from "./EditDynamicSecretLdapForm";
|
||||
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
|
||||
import { EditDynamicSecretMongoDBForm } from "./EditDynamicSecretMongoDBForm";
|
||||
@@ -312,6 +313,23 @@ export const EditDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{dynamicSecretDetails?.type === DynamicSecretProviders.Kubernetes && (
|
||||
<motion.div
|
||||
key="kubernetes-edit"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<EditDynamicSecretKubernetesForm
|
||||
onClose={onClose}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecret={dynamicSecretDetails}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import { Controller, FieldValues, useFieldArray, useForm } from "react-hook-form";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faBookOpen,
|
||||
faQuestionCircle,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext";
|
||||
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
|
||||
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
|
||||
enum CredentialType {
|
||||
Dynamic = "dynamic",
|
||||
Static = "static"
|
||||
}
|
||||
|
||||
const credentialTypes = [
|
||||
{
|
||||
label: "Static",
|
||||
value: CredentialType.Static
|
||||
}
|
||||
] 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 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: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema> & FieldValues;
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||
secretPath: string;
|
||||
projectSlug: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
export const EditDynamicSecretKubernetesForm = ({
|
||||
onClose,
|
||||
dynamicSecret,
|
||||
environment,
|
||||
secretPath,
|
||||
projectSlug
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
watch
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
newName: dynamicSecret.name,
|
||||
defaultTTL: dynamicSecret.defaultTTL,
|
||||
maxTTL: dynamicSecret.maxTTL,
|
||||
inputs: dynamicSecret.inputs as TForm["inputs"]
|
||||
}
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "inputs.audiences" as const
|
||||
});
|
||||
|
||||
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
|
||||
const sslEnabled = watch("inputs.sslEnabled");
|
||||
|
||||
const handleUpdateDynamicSecret = async (formData: TForm) => {
|
||||
// wait till previous request is finished
|
||||
if (updateDynamicSecret.isPending) return;
|
||||
try {
|
||||
await updateDynamicSecret.mutateAsync({
|
||||
name: dynamicSecret.name,
|
||||
path: secretPath,
|
||||
projectSlug,
|
||||
environmentSlug: environment,
|
||||
data: {
|
||||
inputs: formData.inputs,
|
||||
newName: formData.newName === dynamicSecret.name ? undefined : formData.newName,
|
||||
defaultTTL: formData.defaultTTL,
|
||||
maxTTL: formData.maxTTL
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated dynamic secret"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : "Failed to update dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="newName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-secret" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
defaultValue="1h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
defaultValue="24h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/kubernetes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="mb-1 ml-2 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
Docs
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-xxs"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<div>
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</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.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"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Credential Type"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="w-full"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
className="w-full"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
{credentialTypes.map((credentialType) => (
|
||||
<SelectItem
|
||||
value={credentialType.value}
|
||||
key={`credential-type-${credentialType.value}`}
|
||||
>
|
||||
{credentialType.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.namespace"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.audiences"
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Audiences"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-center space-x-2">
|
||||
<Input
|
||||
{...control.register(`inputs.audiences.${index}`)}
|
||||
placeholder="Enter audience"
|
||||
className="flex-grow"
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => remove(index)}
|
||||
variant="outline_bg"
|
||||
ariaLabel="Remove audience"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline_bg" onClick={() => append("")} type="button">
|
||||
Add Audience
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user