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/migrations/20250517002223_secret-share-to-specific-emails.ts b/backend/src/db/migrations/20250517002223_secret-share-to-specific-emails.ts new file mode 100644 index 000000000..6a02ae4eb --- /dev/null +++ b/backend/src/db/migrations/20250517002223_secret-share-to-specific-emails.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt"); + const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails"); + + if (!hasEncryptedSalt || !hasAuthorizedEmails) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + // These two columns are only needed when secrets are shared with a specific list of emails + + if (!hasEncryptedSalt) { + t.binary("encryptedSalt").nullable(); + } + + if (!hasAuthorizedEmails) { + t.json("authorizedEmails").nullable(); + } + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt"); + const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails"); + + if (hasEncryptedSalt || hasAuthorizedEmails) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (hasEncryptedSalt) { + t.dropColumn("encryptedSalt"); + } + + if (hasAuthorizedEmails) { + t.dropColumn("authorizedEmails"); + } + }); + } + } +} 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/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index 24ea26677..7de34708c 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -27,7 +27,9 @@ export const SecretSharingSchema = z.object({ password: z.string().nullable().optional(), encryptedSecret: zodBuffer.nullable().optional(), identifier: z.string().nullable().optional(), - type: z.string().default("share") + type: z.string().default("share"), + encryptedSalt: zodBuffer.nullable().optional(), + authorizedEmails: z.unknown().nullable().optional() }); export type TSecretSharing = z.infer; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index dad1d9a80..0e4cec8e1 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -131,8 +131,8 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), - certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain), - privateKey: z.string().trim().describe(CERTIFICATES.GET_CERT.privateKey), + certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain), + privateKey: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.privateKey), serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) }) } @@ -518,7 +518,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), - certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain), + certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain), serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) }) } diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index de7927573..d9ef62087 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -114,10 +114,12 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide CharacterType.Numbers, CharacterType.Colon, CharacterType.Period, - CharacterType.ForwardSlash + CharacterType.ForwardSlash, + CharacterType.Hyphen ])(val), { - message: "Kubernetes host must only contain alphabets, numbers, colons, periods, and forward slashes." + message: + "Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes." } ), caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert), @@ -234,11 +236,13 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide CharacterType.Numbers, CharacterType.Colon, CharacterType.Period, - CharacterType.ForwardSlash + CharacterType.ForwardSlash, + CharacterType.Hyphen ])(val); }, { - message: "Kubernetes host must only contain alphabets, numbers, colons, periods, and forward slashes." + message: + "Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes." } ), caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert), diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 1ec34d5a1..b3fceb201 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -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({ diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index 37c8a052f..e712ee138 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -62,7 +62,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }), body: z.object({ hashedHex: z.string().min(1).optional(), - password: z.string().optional() + password: z.string().optional(), + email: z.string().optional(), + hash: z.string().optional() }), response: { 200: z.object({ @@ -88,7 +90,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => sharedSecretId: req.params.id, hashedHex: req.body.hashedHex, password: req.body.password, - orgId: req.permission?.orgId + orgId: req.permission?.orgId, + email: req.body.email, + hash: req.body.hash }); if (sharedSecret.secret?.orgId) { @@ -151,7 +155,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => secretValue: z.string(), expiresAt: z.string(), expiresAfterViews: z.number().min(1).optional(), - accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization) + accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization), + emails: z.string().email().array().max(100).optional() }), response: { 200: z.object({ diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index 961fb27ff..7eeb62d93 100644 --- a/backend/src/services/certificate/certificate-fns.ts +++ b/backend/src/services/certificate/certificate-fns.ts @@ -105,7 +105,7 @@ export const buildCertificateChain = async ({ kmsService, kmsId }: TBuildCertificateChainDTO) => { - if (!encryptedCertificateChain && (!caCert || !caCertChain)) { + if (!encryptedCertificateChain && !caCert) { return null; } diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 73a8caed7..292b5f109 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -29,6 +29,7 @@ import { TGetCertPrivateKeyDTO, TRevokeCertDTO } from "./certificate-types"; +import { NotFoundError } from "@app/lib/errors"; type TCertificateServiceFactoryDep = { certificateDAL: Pick; @@ -337,18 +338,27 @@ export const certificateServiceFactory = ({ encryptedCertificateChain: certBody.encryptedCertificateChain || undefined }); - const { certPrivateKey } = await getCertificateCredentials({ - certId: cert.id, - projectId: ca.projectId, - certificateSecretDAL, - projectDAL, - kmsService - }); + let privateKey: string | null = null; + try { + const { certPrivateKey } = await getCertificateCredentials({ + certId: cert.id, + projectId: ca.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + privateKey = certPrivateKey; + } catch (e) { + // Skip NotFound errors but throw all others + if (!(e instanceof NotFoundError)) { + throw e; + } + } return { certificate, certificateChain, - privateKey: certPrivateKey, + privateKey, serialNumber, cert, ca diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 2da7c3881..a3ec1bdeb 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -79,7 +79,8 @@ export const identityKubernetesAuthServiceFactory = ({ const callbackResult = await withGatewayProxy( async (port) => { - const res = await gatewayCallback("localhost", 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; }, { @@ -138,11 +139,7 @@ export const identityKubernetesAuthServiceFactory = ({ } const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => { - let baseUrl = `https://${host}`; - - if (port) { - baseUrl += `:${port}`; - } + const baseUrl = port ? `${host}:${port}` : host; const res = await axios .post( 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..702078364 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -6,6 +6,7 @@ import { TSecretSharing } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { SecretSharingAccessType } from "@app/lib/types"; import { isUuidV4 } from "@app/lib/validator"; @@ -60,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" }); } }; @@ -76,8 +79,11 @@ export const secretSharingServiceFactory = ({ password, accessType, expiresAt, - expiresAfterViews + expiresAfterViews, + emails }: TCreateSharedSecretDTO) => { + const appCfg = getConfig(); + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); $validateSharedSecretExpiry(expiresAt); @@ -93,7 +99,46 @@ 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; + let encryptedSalt: Buffer | undefined; + const orgEmails = []; + + if (emails && emails.length > 0) { + const allOrgMembers = await orgDAL.findAllOrgMembers(orgId); + + // Check to see that all emails are a part of the organization (if enforced) while also collecting a list of emails which are in the org + for (const email of emails) { + if (allOrgMembers.some((v) => v.user.email === email)) { + orgEmails.push(email); + // If the email is not part of the org, but access type / org settings require it + } else if (!org.allowSecretSharingOutsideOrganization || accessType === SecretSharingAccessType.Organization) { + throw new BadRequestError({ + message: "Organization does not allow sharing secrets to members outside of this organization" + }); + } + } + + // Generate salt for signing email hashes (if emails are provided) + salt = crypto.randomBytes(32).toString("hex"); + encryptedSalt = encryptWithRoot(Buffer.from(salt)); + } + const encryptedSecret = encryptWithRoot(Buffer.from(secretValue)); const id = crypto.randomBytes(32).toString("hex"); @@ -112,11 +157,45 @@ export const secretSharingServiceFactory = ({ expiresAfterViews, userId: actorId, orgId, - accessType + accessType, + authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined, + encryptedSalt }); const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`; + // Loop through recipients and send out emails with unique access links + if (emails && salt) { + const user = await userDAL.findById(actorId); + + if (!user) { + throw new NotFoundError({ message: `User with ID '${actorId}' not found` }); + } + + for await (const email of emails) { + try { + const hmac = crypto.createHmac("sha256", salt).update(email); + const hash = hmac.digest("hex"); + + // Only show the username to emails which are part of the organization + const respondentUsername = orgEmails.includes(email) ? user.username : undefined; + + await smtpService.sendMail({ + recipients: [email], + subjectLine: "A secret has been shared with you", + substitutions: { + name, + respondentUsername, + secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}?email=${encodeURIComponent(email)}&hash=${hash}` + }, + template: SmtpTemplates.SecretRequestCompleted + }); + } catch (e) { + logger.error(e, "Failed to send shared secret URL to a recipient's email."); + } + } + } + return { id: idToReturn }; }; @@ -390,8 +469,15 @@ export const secretSharingServiceFactory = ({ }); }; - /** Get's password-less secret. validates all secret's requested (must be fresh). */ - const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => { + /** Gets password-less secret. validates all secret's requested (must be fresh). */ + const getSharedSecretById = async ({ + sharedSecretId, + hashedHex, + orgId, + password, + email, + hash + }: TGetActiveSharedSecretByIdDTO) => { const sharedSecret = isUuidV4(sharedSecretId) ? await secretSharingDAL.findOne({ id: sharedSecretId, @@ -438,6 +524,32 @@ export const secretSharingServiceFactory = ({ }); } + const decryptWithRoot = kmsService.decryptWithRootKey(); + + if (sharedSecret.authorizedEmails && sharedSecret.encryptedSalt) { + // Verify both params were passed + if (!email || !hash) { + throw new BadRequestError({ + message: "This secret is email protected. Parameters must include email and hash." + }); + + // Verify that email is authorized to view shared secret + } else if (!(sharedSecret.authorizedEmails as string[]).includes(email)) { + throw new UnauthorizedError({ message: "Email not authorized to view secret" }); + + // Verify that hash matches + } else { + const salt = decryptWithRoot(sharedSecret.encryptedSalt).toString(); + const hmac = crypto.createHmac("sha256", salt).update(email); + const rebuiltHash = hmac.digest("hex"); + + if (rebuiltHash !== hash) { + throw new UnauthorizedError({ message: "Email not authorized to view secret" }); + } + } + } + + // Password checks const isPasswordProtected = Boolean(sharedSecret.password); const hasProvidedPassword = Boolean(password); if (isPasswordProtected) { @@ -452,7 +564,6 @@ export const secretSharingServiceFactory = ({ // If encryptedSecret is set, we know that this secret has been encrypted using KMS, and we can therefore do server-side decryption. let decryptedSecretValue: Buffer | undefined; if (sharedSecret.encryptedSecret) { - const decryptWithRoot = kmsService.decryptWithRootKey(); decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret); } diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 835d70eff..049dbb913 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -22,6 +22,7 @@ export type TSharedSecretPermission = { accessType?: SecretSharingAccessType; name?: string; password?: string; + emails?: string[]; }; export type TCreatePublicSharedSecretDTO = { @@ -37,6 +38,10 @@ export type TGetActiveSharedSecretByIdDTO = { hashedHex?: string; orgId?: string; password?: string; + + // For secrets shared with specific emails + email?: string; + hash?: string; }; export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & { diff --git a/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index 3a53484fb..6c3b218ab 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -20,6 +20,7 @@ The **Settings** page lets you manage information about your organization includ - **Slug**: The slug of your organization. - **Default Organization Member Role**: The role assigned to users when joining your organization unless otherwise specified. - **Incident Contacts**: Emails that should be alerted if anything abnormal is detected within the organization. +- **Enabled Products**: Products which are enabled for your organization. This setting strictly affects the sidebar UI; disabling a product does not disable its API or routes. ![organization settings general](../../images/platform/organization/organization-settings-general.png) @@ -43,7 +44,7 @@ In the **Organization Roles** tab, you can edit current or create new custom rol Note that Role-Based Access Management (RBAC) is partly a paid feature. - + Infisical provides immutable roles like `admin`, `member`, etc. at the organization and project level for free. diff --git a/docs/images/platform/organization/organization-settings-general.png b/docs/images/platform/organization/organization-settings-general.png index affcf32ff..9467b6005 100644 Binary files a/docs/images/platform/organization/organization-settings-general.png and b/docs/images/platform/organization/organization-settings-general.png differ diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index e0dc90186..a35dc00d2 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -123,6 +123,7 @@ export const SelectItem = forwardRef( return ( { certificate: string; certificateChain: string; serialNumber: string; - privateKey: string; + privateKey: string | null; }>(`/api/v1/pki/certificates/${serialNumber}/bundle`); return data; }, 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/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts index ace45526a..cfd505ff0 100644 --- a/frontend/src/hooks/api/secretSharing/queries.ts +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -11,10 +11,13 @@ export const secretSharingKeys = { allSecretRequests: () => ["secretRequests"] as const, specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) => [...secretSharingKeys.allSecretRequests(), { offset, limit }] as const, - getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [ - "shared-secret", - arg - ], + getSecretById: (arg: { + id: string; + hashedHex: string | null; + password?: string; + email?: string; + hash?: string; + }) => ["shared-secret", arg], getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const }; @@ -70,20 +73,34 @@ export const useGetSecretRequests = ({ export const useGetActiveSharedSecretById = ({ sharedSecretId, hashedHex, - password + password, + email, + hash }: { sharedSecretId: string; hashedHex: string | null; password?: string; + + // For secrets shared to specific emails (optional) + email?: string; + hash?: string; }) => { return useQuery({ - queryKey: secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }), + queryKey: secretSharingKeys.getSecretById({ + id: sharedSecretId, + hashedHex, + password, + email, + hash + }), queryFn: async () => { const { data } = await apiRequest.post( `/api/v1/secret-sharing/shared/public/${sharedSecretId}`, { ...(hashedHex && { hashedHex }), - password + password, + email, + hash } ); diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts index ab819cfb6..c35228fab 100644 --- a/frontend/src/hooks/api/secretSharing/types.ts +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -32,6 +32,7 @@ export type TCreateSharedSecretRequest = { expiresAt: Date; expiresAfterViews?: number; accessType?: SecretSharingAccessType; + emails?: string[]; }; export type TCreateSecretRequestRequestDTO = { diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx index 54620f1d6..281683d08 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx @@ -35,7 +35,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => { certificate: string; certificateChain: string; serialNumber: string; - privateKey?: string; + privateKey?: string | null; } | undefined = canReadPrivateKey ? bundleData : bodyData; @@ -52,7 +52,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => { serialNumber={data.serialNumber} certificate={data.certificate} certificateChain={data.certificateChain} - privateKey={data.privateKey} + privateKey={data.privateKey || undefined} /> ) : (
diff --git a/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal.tsx b/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal.tsx index 45b974755..a9450993f 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/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx index 8ee37f631..ae1aa3772 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx @@ -17,11 +17,11 @@ export const SecretSharingSettingsPage = withPermission( return ( <> - {t("common.head-title", { title: t("settings.org.title") })} + {t("common.head-title", { title: "Secret Share Settings" })}
- +
diff --git a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx new file mode 100644 index 000000000..e0d88a082 --- /dev/null +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx @@ -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; + +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/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/index.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/index.tsx new file mode 100644 index 000000000..1e83c4be8 --- /dev/null +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/index.tsx @@ -0,0 +1 @@ +export { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection"; diff --git a/frontend/src/pages/organization/SecretSharingSettingsPage/components/SecretSharingSettingsGeneralTab/SecretSharingSettingsGeneralTab.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/components/SecretSharingSettingsGeneralTab/SecretSharingSettingsGeneralTab.tsx index ede3d9fc8..ba849507d 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/components/SecretSharingSettingsGeneralTab/SecretSharingSettingsGeneralTab.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/components/SecretSharingSettingsGeneralTab/SecretSharingSettingsGeneralTab.tsx @@ -1,9 +1,11 @@ +import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection"; import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone"; export const SecretSharingSettingsGeneralTab = () => { return (
+
); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx index 2697aeb7d..e9267d897 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx @@ -79,8 +79,8 @@ export const OrgProductSelectSection = () => { }; return ( -
-

Organization Products

+
+

Enabled Products

Select which products are available for your organization.

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}
+
+ ))} + + + )} + />
diff --git a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx index a2228e46b..e347f1482 100644 --- a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx +++ b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx @@ -6,7 +6,19 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + IconButton, + Input, + Select, + SelectItem, + Switch +} from "@app/components/v2"; import { useTimedReset } from "@app/hooks"; import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api"; import { SecretSharingAccessType } from "@app/hooks/api/secretSharing"; @@ -33,7 +45,24 @@ const schema = z.object({ secret: z.string().min(1), expiresIn: z.string(), viewLimit: z.string(), - accessType: z.nativeEnum(SecretSharingAccessType).optional() + accessType: z.nativeEnum(SecretSharingAccessType).optional(), + emails: z + .string() + .optional() + .refine( + (val) => { + if (!val) return true; + const emails = val + .split(",") + .map((email) => email.trim()) + .filter((email) => email !== ""); + if (emails.length > 100) return false; + return emails.every((email) => z.string().email().safeParse(email).success); + }, + { + message: "Must be a comma-separated list of valid emails (max 100) or empty." + } + ) }); export type FormData = z.infer; @@ -42,14 +71,18 @@ 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(""); + const [secretLink, setSecretLink] = useState(null); const [, isCopyingSecret, setCopyTextSecret] = useTimedReset({ initialState: "Copy to clipboard" }); @@ -58,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, @@ -66,7 +108,10 @@ export const ShareSecretForm = ({ } = useForm({ resolver: zodResolver(schema), defaultValues: { - secret: value || "" + secret: value || "", + viewLimit: filteredViewLimitOptions[filteredViewLimitOptions.length - 1].value.toString(), + expiresIn: + filteredExpiresInOptions[Math.min(filteredExpiresInOptions.length - 1, 2)].value.toString() } }); @@ -76,32 +121,45 @@ export const ShareSecretForm = ({ secret, expiresIn, viewLimit, - accessType + accessType, + emails }: FormData) => { try { const expiresAt = new Date(new Date().getTime() + Number(expiresIn)); + const processedEmails = emails ? emails.split(",").map((e) => e.trim()) : undefined; + const { id } = await createSharedSecret.mutateAsync({ name, password, secretValue: secret, expiresAt, expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit), - accessType + accessType, + emails: processedEmails }); - const link = `${window.location.origin}/shared/secret/${id}`; + if (processedEmails && processedEmails.length > 0) { + setSecretLink(""); + createNotification({ + text: `Shared secret link emailed to ${processedEmails.length} user(s).`, + type: "success" + }); + } else { + const link = `${window.location.origin}/shared/secret/${id}`; + + setSecretLink(link); + + navigator.clipboard.writeText(link); + setCopyTextSecret("secret"); + + createNotification({ + text: "Shared secret link copied to clipboard.", + type: "success" + }); + } - setSecretLink(link); reset(); - - navigator.clipboard.writeText(link); - setCopyTextSecret("secret"); - - createNotification({ - text: "Shared secret link copied to clipboard.", - type: "success" - }); } catch (error) { console.error(error); createNotification({ @@ -111,152 +169,256 @@ export const ShareSecretForm = ({ } }; - const hasSecretLink = Boolean(secretLink); - - return !hasSecretLink ? ( - - {!isPublic && ( + if (secretLink === null) + return ( + + {!isPublic && ( + ( + + + + )} + /> + )} ( - )} /> - )} - ( - -