Merge pull request #3615 from Infisical/ENG-2787

feat(org): Shared Secret limits for org
This commit is contained in:
x032205
2025-05-19 16:26:10 -04:00
committed by GitHub
17 changed files with 487 additions and 69 deletions

View File

@@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime");
const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit");
if (!hasLifetimeColumn || !hasViewLimitColumn) {
await knex.schema.alterTable(TableName.Organization, (t) => {
if (!hasLifetimeColumn) {
t.integer("maxSharedSecretLifetime").nullable().defaultTo(2592000); // 30 days in seconds
}
if (!hasViewLimitColumn) {
t.integer("maxSharedSecretViewLimit").nullable();
}
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime");
const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit");
if (hasLifetimeColumn || hasViewLimitColumn) {
await knex.schema.alterTable(TableName.Organization, (t) => {
if (hasLifetimeColumn) {
t.dropColumn("maxSharedSecretLifetime");
}
if (hasViewLimitColumn) {
t.dropColumn("maxSharedSecretViewLimit");
}
});
}
}

View File

@@ -34,7 +34,9 @@ export const OrganizationsSchema = z.object({
kmsProductEnabled: z.boolean().default(true).nullable().optional(),
sshProductEnabled: z.boolean().default(true).nullable().optional(),
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional()
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
maxSharedSecretViewLimit: z.number().nullable().optional()
});
export type TOrganizations = z.infer<typeof OrganizationsSchema>;

View File

@@ -281,7 +281,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
kmsProductEnabled: z.boolean().optional(),
sshProductEnabled: z.boolean().optional(),
scannerProductEnabled: z.boolean().optional(),
shareSecretsProductEnabled: z.boolean().optional()
shareSecretsProductEnabled: z.boolean().optional(),
maxSharedSecretLifetime: z
.number()
.min(300, "Max Shared Secret lifetime cannot be under 5 minutes")
.max(2592000, "Max Shared Secret lifetime cannot exceed 30 days")
.optional(),
maxSharedSecretViewLimit: z
.number()
.min(1, "Max Shared Secret view count cannot be lower than 1")
.max(1000, "Max Shared Secret view count cannot exceed 1000")
.nullable()
.optional()
}),
response: {
200: z.object({

View File

@@ -24,5 +24,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
kmsProductEnabled: true,
sshProductEnabled: true,
scannerProductEnabled: true,
shareSecretsProductEnabled: true
shareSecretsProductEnabled: true,
maxSharedSecretLifetime: true,
maxSharedSecretViewLimit: true
});

View File

@@ -361,7 +361,9 @@ export const orgServiceFactory = ({
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}
}: TUpdateOrgDTO) => {
const appCfg = getConfig();
@@ -469,7 +471,9 @@ export const orgServiceFactory = ({
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
});
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org;

View File

@@ -81,6 +81,8 @@ export type TUpdateOrgDTO = {
sshProductEnabled: boolean;
scannerProductEnabled: boolean;
shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null;
}>;
} & TOrgPermission;

View File

@@ -61,7 +61,9 @@ export const secretSharingServiceFactory = ({
}
const fiveMins = 5 * 60 * 1000;
if (expiryTime - currentTime < fiveMins) {
// 1 second buffer
if (expiryTime - currentTime + 1000 < fiveMins) {
throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" });
}
};
@@ -97,6 +99,20 @@ export const secretSharingServiceFactory = ({
throw new BadRequestError({ message: "Shared secret value too long" });
}
// Check lifetime is within org allowance
const expiresAtTimestamp = new Date(expiresAt).getTime();
const lifetime = expiresAtTimestamp - new Date().getTime();
// org.maxSharedSecretLifetime is in seconds
if (org.maxSharedSecretLifetime && lifetime / 1000 > org.maxSharedSecretLifetime) {
throw new BadRequestError({ message: "Secret lifetime exceeds organization limit" });
}
// Check max view count is within org allowance
if (org.maxSharedSecretViewLimit && (!expiresAfterViews || expiresAfterViews > org.maxSharedSecretViewLimit)) {
throw new BadRequestError({ message: "Secret max views parameter exceeds organization limit" });
}
const encryptWithRoot = kmsService.encryptWithRootKey();
let salt: string | undefined;

View File

@@ -123,6 +123,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
return (
<SelectPrimitive.Item
{...props}
disabled={isDisabled}
className={twMerge(
"relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
isSelected && "bg-primary",

View File

@@ -118,7 +118,9 @@ export const useUpdateOrg = () => {
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name,
@@ -136,7 +138,9 @@ export const useUpdateOrg = () => {
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
});
},
onSuccess: () => {

View File

@@ -26,6 +26,8 @@ export type Organization = {
sshProductEnabled: boolean;
scannerProductEnabled: boolean;
shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null;
};
export type UpdateOrgDTO = {
@@ -46,6 +48,8 @@ export type UpdateOrgDTO = {
sshProductEnabled?: boolean;
scannerProductEnabled?: boolean;
shareSecretsProductEnabled?: boolean;
maxSharedSecretViewLimit?: number | null;
maxSharedSecretLifetime?: number;
};
export type BillingDetails = {

View File

@@ -30,6 +30,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
allowSecretSharingOutsideOrganization={
currentOrg?.allowSecretSharingOutsideOrganization ?? true
}
maxSharedSecretLifetime={currentOrg?.maxSharedSecretLifetime}
maxSharedSecretViewLimit={currentOrg?.maxSharedSecretViewLimit}
/>
</ModalContent>
</Modal>

View File

@@ -17,11 +17,11 @@ export const SecretSharingSettingsPage = withPermission(
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
<title>{t("common.head-title", { title: "Secret Share Settings" })}</title>
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader title={t("settings.org.title")} />
<PageHeader title="Secret Share Settings" />
<SecretSharingSettingsTabGroup />
</div>
</div>

View File

@@ -0,0 +1,294 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useEffect } from "react";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds
const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds
// Helper function to convert duration to seconds
const durationToSeconds = (value: number, unit: "m" | "h" | "d"): number => {
switch (unit) {
case "m":
return value * 60;
case "h":
return value * 60 * 60;
case "d":
return value * 60 * 60 * 24;
default:
return 0;
}
};
// Helper function to convert seconds to form lifetime value and unit
const getFormLifetimeFromSeconds = (
totalSeconds: number | null | undefined
): { maxLifetimeValue: number; maxLifetimeUnit: "m" | "h" | "d" } => {
const DEFAULT_LIFETIME_VALUE = 30;
const DEFAULT_LIFETIME_UNIT = "d" as "m" | "h" | "d";
if (totalSeconds == null || totalSeconds <= 0) {
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
}
const secondsInDay = 24 * 60 * 60;
const secondsInHour = 60 * 60;
const secondsInMinute = 60;
if (totalSeconds % secondsInDay === 0) {
const value = totalSeconds / secondsInDay;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "d" };
}
if (totalSeconds % secondsInHour === 0) {
const value = totalSeconds / secondsInHour;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "h" };
}
if (totalSeconds % secondsInMinute === 0) {
const value = totalSeconds / secondsInMinute;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "m" };
}
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
};
const formSchema = z
.object({
maxLifetimeValue: z.number().min(1, "Value must be at least 1"),
maxLifetimeUnit: z.enum(["m", "h", "d"], {
invalid_type_error: "Please select a valid time unit"
}),
maxViewLimit: z.string()
})
.superRefine((data, ctx) => {
const { maxLifetimeValue, maxLifetimeUnit } = data;
const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit);
// Check max limit
if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) {
let message = "Duration exceeds maximum allowed limit";
if (maxLifetimeUnit === "m") {
message = `Maximum allowed minutes is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / 60} (30 days)`;
} else if (maxLifetimeUnit === "h") {
message = `Maximum allowed hours is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (60 * 60)} (30 days)`;
} else if (maxLifetimeUnit === "d") {
message = `Maximum allowed days is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (24 * 60 * 60)}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: ["maxLifetimeValue"]
});
}
// Check min limit
if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) {
const message = `Duration must be at least ${MIN_SHARED_SECRET_LIFETIME_SECONDS / 60} minutes`; // 5 minutes
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: ["maxLifetimeValue"]
});
}
});
type TForm = z.infer<typeof formSchema>;
const viewLimitOptions = [
{ label: "1", value: 1 },
{ label: "Unlimited", value: -1 }
];
export const OrgSecretShareLimitSection = () => {
const { mutateAsync } = useUpdateOrg();
const { currentOrg } = useOrganization();
const getDefaultFormValues = () => {
const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime);
return {
maxLifetimeValue: initialLifetime.maxLifetimeValue,
maxLifetimeUnit: initialLifetime.maxLifetimeUnit,
maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "-1"
};
};
const {
control,
formState: { isSubmitting, isDirty },
handleSubmit,
reset
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: getDefaultFormValues()
});
useEffect(() => {
if (currentOrg) {
reset(getDefaultFormValues());
}
}, [currentOrg, reset]);
const handleFormSubmit = async (formData: TForm) => {
try {
const maxSharedSecretLifetimeSeconds = durationToSeconds(
formData.maxLifetimeValue,
formData.maxLifetimeUnit
);
await mutateAsync({
orgId: currentOrg.id,
maxSharedSecretViewLimit:
formData.maxViewLimit === "-1" ? null : Number(formData.maxViewLimit),
maxSharedSecretLifetime: maxSharedSecretLifetimeSeconds
});
createNotification({
text: "Successfully updated secret share limits",
type: "success"
});
reset(formData);
} catch {
createNotification({
text: "Failed to update secret share limits",
type: "error"
});
}
};
// Units for the dropdown with readable labels
const timeUnits = [
{ value: "m", label: "Minutes" },
{ value: "h", label: "Hours" },
{ value: "d", label: "Days" }
];
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Secret Share Limits</p>
</div>
<p className="mb-4 mt-2 text-sm text-gray-400">
These settings establish the maximum limits for all Shared Secret parameters within this
organization. Shared secrets cannot be created with values exceeding these limits.
</p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
<div className="flex max-w-sm gap-4">
<Controller
control={control}
name="maxLifetimeValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The max amount of time that can be set before the secret share link expires."
label="Max Lifetime"
className="w-full"
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? "" : parseInt(val, 10));
}}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="maxLifetimeUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex max-w-sm">
<Controller
control={control}
name="maxViewLimit"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Max Views"
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={!isAllowed}
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem value={String(viewLimitValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isDirty || !isAllowed}
className="mt-4"
>
Save
</Button>
</form>
)}
</OrgPermissionCan>
</div>
);
};

View File

@@ -0,0 +1 @@
export { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";

View File

@@ -1,9 +1,11 @@
import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection";
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
export const SecretSharingSettingsGeneralTab = () => {
return (
<div className="w-full">
<SecretSharingAllowShareToAnyone />
<OrgSecretShareLimitSection />
</div>
);
};

View File

@@ -96,61 +96,59 @@ export const OrgUserAccessTokenLimitSection = () => {
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<form onSubmit={handleSubmit(handleUserTokenExpirationSubmit)} autoComplete="off">
<div className="flex max-w-md gap-4">
<div className="flex-1">
<Controller
control={control}
name="expirationValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Expiration value"
<div className="flex max-w-sm gap-4">
<Controller
control={control}
name="expirationValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Expiration value"
className="w-full"
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="expirationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
</div>
<div className="flex-1">
<Controller
control={control}
name="expirationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"

View File

@@ -71,12 +71,16 @@ type Props = {
isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form
value?: string;
allowSecretSharingOutsideOrganization?: boolean;
maxSharedSecretLifetime?: number;
maxSharedSecretViewLimit?: number | null;
};
export const ShareSecretForm = ({
isPublic,
value,
allowSecretSharingOutsideOrganization = true
allowSecretSharingOutsideOrganization = true,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}: Props) => {
const [secretLink, setSecretLink] = useState<string | null>(null);
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
@@ -87,6 +91,15 @@ export const ShareSecretForm = ({
const privateSharedSecretCreator = useCreateSharedSecret();
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
// Note: maxSharedSecretLifetime is in seconds
const filteredExpiresInOptions = maxSharedSecretLifetime
? expiresInOptions.filter((v) => v.value / 1000 <= maxSharedSecretLifetime)
: expiresInOptions;
const filteredViewLimitOptions = maxSharedSecretViewLimit
? viewLimitOptions.filter((v) => v.value > 0 && v.value <= maxSharedSecretViewLimit)
: viewLimitOptions;
const {
control,
reset,
@@ -96,8 +109,9 @@ export const ShareSecretForm = ({
resolver: zodResolver(schema),
defaultValues: {
secret: value || "",
viewLimit: "-1",
expiresIn: "3600000"
viewLimit: filteredViewLimitOptions[filteredViewLimitOptions.length - 1].value.toString(),
expiresIn:
filteredExpiresInOptions[Math.min(filteredExpiresInOptions.length - 1, 2)].value.toString()
}
});
@@ -277,6 +291,15 @@ export const ShareSecretForm = ({
label="Expires In"
errorText={error?.message}
isError={Boolean(error)}
helperText={
expiresInOptions.length !== filteredExpiresInOptions.length ? (
<span className="text-yellow-500">
Limited to{" "}
{filteredExpiresInOptions[filteredExpiresInOptions.length - 1].label} by
organization
</span>
) : undefined
}
>
<Select
defaultValue={field.value}
@@ -285,7 +308,11 @@ export const ShareSecretForm = ({
className="w-full"
>
{expiresInOptions.map(({ label, value: expiresInValue }) => (
<SelectItem value={String(expiresInValue || "")} key={label}>
<SelectItem
value={String(expiresInValue || "")}
key={label}
isDisabled={!filteredExpiresInOptions.some((v) => v.label === label)}
>
{label}
</SelectItem>
))}
@@ -301,6 +328,15 @@ export const ShareSecretForm = ({
label="Max Views"
errorText={error?.message}
isError={Boolean(error)}
helperText={
viewLimitOptions.length !== filteredViewLimitOptions.length ? (
<span className="text-yellow-500">
Limited to{" "}
{filteredViewLimitOptions[filteredViewLimitOptions.length - 1].label} by
organization
</span>
) : undefined
}
>
<Select
defaultValue={field.value}
@@ -309,7 +345,11 @@ export const ShareSecretForm = ({
className="w-full"
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem value={String(viewLimitValue || "")} key={label}>
<SelectItem
value={String(viewLimitValue || "")}
key={label}
isDisabled={!filteredViewLimitOptions.some((v) => v.label === label)}
>
{label}
</SelectItem>
))}