diff --git a/backend/src/db/migrations/20240718170955_add-access-secret-sharing.ts b/backend/src/db/migrations/20240718170955_add-access-secret-sharing.ts new file mode 100644 index 0000000000..705c8d986a --- /dev/null +++ b/backend/src/db/migrations/20240718170955_add-access-secret-sharing.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { SecretSharingAccessType } from "@app/lib/types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SecretSharing, "accessType"); + if (!hasColumn) { + await knex.schema.table(TableName.SecretSharing, (table) => { + table.string("accessType").notNullable().defaultTo(SecretSharingAccessType.Anyone); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SecretSharing, "accessType"); + if (hasColumn) { + await knex.schema.table(TableName.SecretSharing, (table) => { + table.dropColumn("accessType"); + }); + } +} diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index c8d938861e..4406ad493f 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { SecretSharingAccessType } from "@app/lib/types"; + import { TImmutableDBKeys } from "./models"; export const SecretSharingSchema = z.object({ @@ -16,6 +18,7 @@ export const SecretSharingSchema = z.object({ expiresAt: z.date(), userId: z.string().uuid().nullable().optional(), orgId: z.string().uuid().nullable().optional(), + accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization), createdAt: z.date(), updatedAt: z.date(), expiresAfterViews: z.number().nullable().optional() diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 382762aaaa..4d892b02c5 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -47,3 +47,8 @@ export enum EnforcementLevel { Hard = "hard", Soft = "soft" } + +export enum SecretSharingAccessType { + Anyone = "anyone", + Organization = "organization" +} diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a0df46dc5f..3424e7134a 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -737,7 +737,8 @@ export const registerRoutes = async ( const secretSharingService = secretSharingServiceFactory({ permissionService, - secretSharingDAL + secretSharingDAL, + orgDAL }); const secretApprovalRequestService = secretApprovalRequestServiceFactory({ diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index 4ec2737fb4..d5c6a98842 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { SecretSharingSchema } from "@app/db/schemas"; +import { SecretSharingAccessType } from "@app/lib/types"; import { publicEndpointLimit, publicSecretShareCreationLimit, @@ -55,14 +56,18 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => iv: true, tag: true, expiresAt: true, - expiresAfterViews: true + expiresAfterViews: true, + accessType: true + }).extend({ + orgName: z.string().optional() }) } }, handler: async (req) => { const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex( req.params.id, - req.query.hashedHex + req.query.hashedHex, + req.permission?.orgId ); if (!sharedSecret) return undefined; return { @@ -70,7 +75,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => iv: sharedSecret.iv, tag: sharedSecret.tag, expiresAt: sharedSecret.expiresAt, - expiresAfterViews: sharedSecret.expiresAfterViews + expiresAfterViews: sharedSecret.expiresAfterViews, + accessType: sharedSecret.accessType, + orgName: sharedSecret.orgName }; } }); @@ -104,7 +111,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => tag, hashedHex, expiresAt: new Date(expiresAt), - expiresAfterViews + expiresAfterViews, + accessType: SecretSharingAccessType.Anyone }); return { id: sharedSecret.id }; } @@ -123,7 +131,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => tag: z.string(), hashedHex: z.string(), expiresAt: z.string(), - expiresAfterViews: z.number() + expiresAfterViews: z.number(), + accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization) }), response: { 200: z.object({ @@ -145,7 +154,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => tag, hashedHex, expiresAt: new Date(expiresAt), - expiresAfterViews + expiresAfterViews, + accessType: req.body.accessType }); return { id: sharedSecret.id }; } diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index e40b4ef265..ca6da24e1b 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -1,6 +1,8 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { SecretSharingAccessType } from "@app/lib/types"; +import { TOrgDALFactory } from "../org/org-dal"; import { TSecretSharingDALFactory } from "./secret-sharing-dal"; import { TCreatePublicSharedSecretDTO, @@ -12,13 +14,15 @@ import { type TSecretSharingServiceFactoryDep = { permissionService: Pick; secretSharingDAL: TSecretSharingDALFactory; + orgDAL: TOrgDALFactory; }; export type TSecretSharingServiceFactory = ReturnType; export const secretSharingServiceFactory = ({ permissionService, - secretSharingDAL + secretSharingDAL, + orgDAL }: TSecretSharingServiceFactoryDep) => { const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => { const { @@ -30,6 +34,7 @@ export const secretSharingServiceFactory = ({ encryptedValue, iv, tag, + accessType, hashedHex, expiresAt, expiresAfterViews @@ -62,13 +67,14 @@ export const secretSharingServiceFactory = ({ expiresAt, expiresAfterViews, userId: actorId, - orgId + orgId, + accessType }); return { id: newSharedSecret.id }; }; const createPublicSharedSecret = async (createSharedSecretInput: TCreatePublicSharedSecretDTO) => { - const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = createSharedSecretInput; + const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews, accessType } = createSharedSecretInput; if (new Date(expiresAt) < new Date()) { throw new BadRequestError({ message: "Expiration date cannot be in the past" }); } @@ -92,7 +98,8 @@ export const secretSharingServiceFactory = ({ tag, hashedHex, expiresAt, - expiresAfterViews + expiresAfterViews, + accessType }); return { id: newSharedSecret.id }; }; @@ -105,9 +112,21 @@ export const secretSharingServiceFactory = ({ return userSharedSecrets; }; - const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => { + const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string, orgId?: string) => { const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); if (!sharedSecret) return; + + const orgName = sharedSecret.orgId ? (await orgDAL.findOrgById(sharedSecret.orgId))?.name : ""; + // Support organization level access for secret sharing + if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId) { + return { + ...sharedSecret, + encryptedValue: "", + iv: "", + tag: "", + orgName + }; + } if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) { return; } @@ -118,7 +137,10 @@ export const secretSharingServiceFactory = ({ } await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); } - return sharedSecret; + if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId) { + return { ...sharedSecret, orgName }; + } + return { ...sharedSecret, orgName: undefined }; }; const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => { diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 769bb44798..a9c7dcbd94 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -1,3 +1,5 @@ +import { SecretSharingAccessType } from "@app/lib/types"; + import { ActorAuthMethod, ActorType } from "../auth/auth-type"; export type TSharedSecretPermission = { @@ -6,6 +8,7 @@ export type TSharedSecretPermission = { actorAuthMethod: ActorAuthMethod; actorOrgId: string; orgId: string; + accessType?: SecretSharingAccessType; }; export type TCreatePublicSharedSecretDTO = { @@ -15,6 +18,7 @@ export type TCreatePublicSharedSecretDTO = { hashedHex: string; expiresAt: Date; expiresAfterViews: number; + accessType: SecretSharingAccessType; }; export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO; diff --git a/docs/documentation/platform/secret-sharing.mdx b/docs/documentation/platform/secret-sharing.mdx index 9c08215083..4ff3a326b2 100644 --- a/docs/documentation/platform/secret-sharing.mdx +++ b/docs/documentation/platform/secret-sharing.mdx @@ -21,7 +21,8 @@ With its zero-knowledge architecture, secrets shared via Infisical remain unread zero knowledge architecture. -3. Click on the **Share Secret** button. Set the secret, its expiration time as well as the number of views allowed. It expires as soon as any of the conditions are met. +3. Click on the **Share Secret** button. Set the secret, its expiration time and specify if the secret can be viewed only once. It expires as soon as any of the conditions are met. +Also, specify if the secret can be accessed by anyone or only people within your organization. ![Add View-Bound Sharing Secret](../../images/platform/secret-sharing/create-new-secret.png) diff --git a/docs/images/platform/secret-sharing/create-new-secret.png b/docs/images/platform/secret-sharing/create-new-secret.png index 335fca2b25..03a34e19df 100644 Binary files a/docs/images/platform/secret-sharing/create-new-secret.png and b/docs/images/platform/secret-sharing/create-new-secret.png differ diff --git a/frontend/src/components/v2/EmptyState/EmptyState.tsx b/frontend/src/components/v2/EmptyState/EmptyState.tsx index e285500c1c..9816a3fe2c 100644 --- a/frontend/src/components/v2/EmptyState/EmptyState.tsx +++ b/frontend/src/components/v2/EmptyState/EmptyState.tsx @@ -21,7 +21,7 @@ export const EmptyState = ({ }: Props) => (
diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts index 886b0a82ee..44b2c3193c 100644 --- a/frontend/src/hooks/api/secretSharing/queries.ts +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TSharedSecret, TViewSharedSecretResponse } from "./types"; +import { SecretSharingAccessType, TSharedSecret, TViewSharedSecretResponse } from "./types"; export const useGetSharedSecrets = () => { return useQuery({ @@ -17,7 +17,7 @@ export const useGetSharedSecrets = () => { export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => { return useQuery({ queryFn: async () => { - if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "" }); + if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "", accessType: SecretSharingAccessType.Organization, orgName: "" }); const { data } = await apiRequest.get( `/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}` ); @@ -25,6 +25,8 @@ export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: encryptedValue: data.encryptedValue, iv: data.iv, tag: data.tag, + accessType: data.accessType, + orgName: data.orgName }; } }); diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts index 424e3525c7..69c0d9a650 100644 --- a/frontend/src/hooks/api/secretSharing/types.ts +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -13,14 +13,22 @@ export type TCreateSharedSecretRequest = { hashedHex: string; expiresAt: Date; expiresAfterViews: number; + accessType: SecretSharingAccessType; }; export type TViewSharedSecretResponse = { encryptedValue: string; iv: string; tag: string; + accessType: SecretSharingAccessType; + orgName?: string; }; export type TDeleteSharedSecretRequest = { sharedSecretId: string; }; + +export enum SecretSharingAccessType { + Anyone = "anyone", + Organization = "organization" +} \ No newline at end of file diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx index 4e950dc3f9..47da21693a 100644 --- a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx @@ -1,5 +1,6 @@ import crypto from "crypto"; +import { useEffect, useRef } from "react"; import { Controller } from "react-hook-form"; import { AxiosError } from "axios"; import * as yup from "yup"; @@ -7,13 +8,14 @@ import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto"; import { Button, Checkbox, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; -import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api/secretSharing"; +import { SecretSharingAccessType, useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api/secretSharing"; const schema = yup.object({ value: yup.string().max(10000).required().label("Shared Secret Value"), expiresAfterSingleView: yup.boolean().required().label("Expires After Views"), expiresInValue: yup.number().min(1).required().label("Expiration Value"), - expiresInUnit: yup.string().required().label("Expiration Unit") + expiresInUnit: yup.string().required().label("Expiration Unit"), + accessType: yup.string().required().label("General Access") }); export type FormData = yup.InferType; @@ -35,6 +37,14 @@ export const AddShareSecretForm = ({ setNewSharedSecret: (value: string) => void; isInputDisabled?: boolean; }) => { + const isMounted = useRef(true); + + useEffect(() => { + return () => { + isMounted.current = false; + }; + }, []); + const publicSharedSecretCreator = useCreatePublicSharedSecret(); const privateSharedSecretCreator = useCreateSharedSecret(); const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator; @@ -65,7 +75,8 @@ export const AddShareSecretForm = ({ value, expiresInValue, expiresInUnit, - expiresAfterSingleView + expiresAfterSingleView, + accessType }: FormData) => { try { const key = crypto.randomBytes(16).toString("hex"); @@ -89,18 +100,21 @@ export const AddShareSecretForm = ({ tag, hashedHex, expiresAt, - expiresAfterViews: expiresAfterSingleView ? 1 : 1000 + expiresAfterViews: expiresAfterSingleView ? 1 : 1000, + accessType: accessType as SecretSharingAccessType }); - setNewSharedSecret( - `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent( - hashedHex - )}-${encodeURIComponent(key)}` - ); - createNotification({ - text: "Successfully created a shared secret", - type: "success" - }); + if (isMounted.current) { + setNewSharedSecret( + `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent( + hashedHex + )}-${encodeURIComponent(key)}` + ); + createNotification({ + text: "Successfully created a shared secret", + type: "success" + }); + } } catch (err) { console.error(err); const axiosError = err as AxiosError; @@ -143,7 +157,7 @@ export const AddShareSecretForm = ({ )} />
-
+
@@ -188,8 +202,8 @@ export const AddShareSecretForm = ({
-
-

AND

+
+

AND

-
+ {!isPublic && ( + ( + + + + )} + /> + )} +
diff --git a/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx index 2f468e2b9c..297c20bf8c 100644 --- a/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx +++ b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx @@ -20,12 +20,14 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean const [hashedHex, key] = urlEncodedPublicKey ? urlEncodedPublicKey.toString().split("-") : ["", ""]; - + const publicKey = decodeURIComponent(urlEncodedPublicKey as string); const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex( id as string, hashedHex as string ); + const accessType = data?.accessType; + const orgName = data?.orgName; const decryptedSecret = useMemo(() => { if (data && data.encryptedValue && publicKey) { @@ -87,6 +89,8 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean decryptedSecret={decryptedSecret} isUrlCopied={isUrlCopied} copyUrlToClipboard={copyUrlToClipboard} + accessType={accessType} + orgName={orgName} /> )}
diff --git a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx index f33f958af9..f2218be65c 100644 --- a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx +++ b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx @@ -1,14 +1,17 @@ -import { faCheck, faCopy, faEye, faEyeSlash, faKey } from "@fortawesome/free-solid-svg-icons"; +import { faArrowRight, faCheck, faCopy, faEye, faEyeSlash, faKey } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { EmptyState, IconButton, Td, Tr } from "@app/components/v2"; +import { Button, EmptyState, IconButton, Td, Tr } from "@app/components/v2"; import { useToggle } from "@app/hooks"; +import { SecretSharingAccessType } from "@app/hooks/api/secretSharing/types"; type Props = { isLoading: boolean; decryptedSecret: string; isUrlCopied: boolean; copyUrlToClipboard: () => void; + accessType?: SecretSharingAccessType; + orgName?: string; }; const replaceContentWithDot = (str: string) => { @@ -24,20 +27,49 @@ export const SecretTable = ({ isLoading, decryptedSecret, isUrlCopied, - copyUrlToClipboard + copyUrlToClipboard, + accessType, + orgName }: Props) => { const [isVisible, setIsVisible] = useToggle(false); + const title = orgName + ? (

Someone from {orgName} organization has shared a secret with you

) + : (

You need to be logged in to view this secret

); return (
{isLoading &&
Loading...
} - {!isLoading && !decryptedSecret && ( + {!isLoading && !decryptedSecret && accessType !== SecretSharingAccessType.Organization && ( )} + {!isLoading && !decryptedSecret && accessType === SecretSharingAccessType.Organization && ( + + + + + + + + )} {!isLoading && decryptedSecret && (