diff --git a/backend/src/db/migrations/20250516192508_secret-sharing-limits-for-org.ts b/backend/src/db/migrations/20250516192508_secret-sharing-limits-for-org.ts new file mode 100644 index 000000000..f68c1c29b --- /dev/null +++ b/backend/src/db/migrations/20250516192508_secret-sharing-limits-for-org.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + 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"); + } + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 6779d5407..fb0728707 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -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; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index e14dacebb..604b5a355 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -281,7 +281,9 @@ 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().optional(), + maxSharedSecretViewLimit: z.number().nullable().optional() }), response: { 200: z.object({ diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 39a1680a9..ae82cd1bc 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -24,5 +24,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ kmsProductEnabled: true, sshProductEnabled: true, scannerProductEnabled: true, - shareSecretsProductEnabled: true + shareSecretsProductEnabled: true, + maxSharedSecretLifetime: true, + maxSharedSecretViewLimit: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index bcbd9e0e5..c966d5ef9 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -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; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 9625934fb..8b2485ac4 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -81,6 +81,8 @@ export type TUpdateOrgDTO = { sshProductEnabled: boolean; scannerProductEnabled: boolean; shareSecretsProductEnabled: boolean; + maxSharedSecretLifetime: number; + maxSharedSecretViewLimit: number | null; }>; } & TOrgPermission; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 9649be722..ed2ad41df 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -93,6 +93,19 @@ 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(); + + 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(); const encryptedSecret = encryptWithRoot(Buffer.from(secretValue)); diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 947353162..cd620bb64 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -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: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index ab015f890..068cfad6d 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -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 = { diff --git a/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal.tsx b/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal.tsx index 45b974755..b176ff653 100644 --- a/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal.tsx +++ b/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal.tsx @@ -30,6 +30,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => { allowSecretSharingOutsideOrganization={ currentOrg?.allowSecretSharingOutsideOrganization ?? true } + maxSharedSecretLifetime={currentOrg.maxSharedSecretLifetime} + maxSharedSecretViewLimit={currentOrg.maxSharedSecretViewLimit} /> diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgSecretShareLimitSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgSecretShareLimitSection.tsx new file mode 100644 index 000000000..cb5c99daa --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgSecretShareLimitSection.tsx @@ -0,0 +1,280 @@ +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 + +// 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); + + 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"] + }); + } + }); + +type TForm = z.infer; + +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({ + 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 ( +
+
+

Secret Share Limits

+
+

+ These settings establish the maximum limits for all Shared Secret parameters within this + organization. Shared secrets cannot be created with values exceeding these limits. +

+ + {(isAllowed) => ( +
+
+ ( + + { + const val = e.target.value; + field.onChange(val === "" ? "" : parseInt(val, 10)); + }} + disabled={!isAllowed} + /> + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgSecurityTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgSecurityTab.tsx index 981681b7d..2d402ab21 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgSecurityTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgSecurityTab.tsx @@ -6,6 +6,7 @@ import { withPermission } from "@app/hoc"; import { OrgGenericAuthSection } from "./OrgGenericAuthSection"; import { OrgUserAccessTokenLimitSection } from "./OrgUserAccessTokenLimitSection"; +import { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection"; export const OrgSecurityTab = withPermission( () => { @@ -28,6 +29,7 @@ export const OrgSecurityTab = withPermission( + ); }, diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgUserAccessTokenLimitSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgUserAccessTokenLimitSection.tsx index bc020d3a1..58a91e90e 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgUserAccessTokenLimitSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgUserAccessTokenLimitSection.tsx @@ -96,61 +96,59 @@ export const OrgUserAccessTokenLimitSection = () => { {(isAllowed) => (
-
-
- ( - + ( + + field.onChange(parseInt(e.target.value, 10))} + disabled={!isAllowed} + /> + + )} + /> + + ( + + field.onChange(parseInt(e.target.value, 10))} - disabled={!isAllowed} - /> - - )} - /> -
-
- ( - - - - )} - /> -
+ {timeUnits.map(({ value, label }) => ( + +
{label}
+
+ ))} + + + )} + />