diff --git a/backend/src/db/migrations/20250516021501_toggle-secret-sharing-on-project.ts b/backend/src/db/migrations/20250516021501_toggle-secret-sharing-on-project.ts
new file mode 100644
index 000000000..2600ae0f0
--- /dev/null
+++ b/backend/src/db/migrations/20250516021501_toggle-secret-sharing-on-project.ts
@@ -0,0 +1,21 @@
+import { Knex } from "knex";
+
+import { TableName } from "../schemas";
+
+export async function up(knex: Knex): Promise {
+ const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
+ if (!hasSecretSharingColumn) {
+ await knex.schema.table(TableName.Project, (table) => {
+ table.boolean("secretSharing").notNullable().defaultTo(true);
+ });
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
+ if (hasSecretSharingColumn) {
+ await knex.schema.table(TableName.Project, (table) => {
+ table.dropColumn("secretSharing");
+ });
+ }
+}
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/projects.ts b/backend/src/db/schemas/projects.ts
index 297601fd0..c1e96e8ce 100644
--- a/backend/src/db/schemas/projects.ts
+++ b/backend/src/db/schemas/projects.ts
@@ -27,7 +27,8 @@ export const ProjectsSchema = z.object({
description: z.string().nullable().optional(),
type: z.string(),
enforceCapitalization: z.boolean().default(false),
- hasDeleteProtection: z.boolean().default(false).nullable().optional()
+ hasDeleteProtection: z.boolean().default(false).nullable().optional(),
+ secretSharing: z.boolean().default(true)
});
export type TProjects = 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/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts
index 51f70010b..3fad36fac 100644
--- a/backend/src/lib/api-docs/constants.ts
+++ b/backend/src/lib/api-docs/constants.ts
@@ -608,7 +608,8 @@ export const PROJECTS = {
projectDescription: "An optional description label for the project.",
autoCapitalization: "Disable or enable auto-capitalization for the project.",
slug: "An optional slug for the project. (must be unique within the organization)",
- hasDeleteProtection: "Enable or disable delete protection for the project."
+ hasDeleteProtection: "Enable or disable delete protection for the project.",
+ secretSharing: "Enable or disable secret sharing for the project."
},
GET_KEY: {
workspaceId: "The ID of the project to get the key from."
diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts
index da300981c..87d82c241 100644
--- a/backend/src/server/routes/sanitizedSchemas.ts
+++ b/backend/src/server/routes/sanitizedSchemas.ts
@@ -261,7 +261,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({
pitVersionLimit: true,
kmsCertificateKeyId: true,
auditLogsRetentionDays: true,
- hasDeleteProtection: true
+ hasDeleteProtection: true,
+ secretSharing: true
});
export const SanitizedTagSchema = SecretTagsSchema.pick({
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/project-router.ts b/backend/src/server/routes/v1/project-router.ts
index e6d9134d3..2e983cb83 100644
--- a/backend/src/server/routes/v1/project-router.ts
+++ b/backend/src/server/routes/v1/project-router.ts
@@ -346,7 +346,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
})
.optional()
- .describe(PROJECTS.UPDATE.slug)
+ .describe(PROJECTS.UPDATE.slug),
+ secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing)
}),
response: {
200: z.object({
@@ -366,7 +367,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
description: req.body.description,
autoCapitalization: req.body.autoCapitalization,
hasDeleteProtection: req.body.hasDeleteProtection,
- slug: req.body.slug
+ slug: req.body.slug,
+ secretSharing: req.body.secretSharing
},
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
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/project/project-service.ts b/backend/src/services/project/project-service.ts
index 8cfa20697..38631a8fa 100644
--- a/backend/src/services/project/project-service.ts
+++ b/backend/src/services/project/project-service.ts
@@ -658,7 +658,8 @@ export const projectServiceFactory = ({
autoCapitalization: update.autoCapitalization,
enforceCapitalization: update.autoCapitalization,
hasDeleteProtection: update.hasDeleteProtection,
- slug: update.slug
+ slug: update.slug,
+ secretSharing: update.secretSharing
});
return updatedProject;
diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts
index 9f74e123c..be052f1cb 100644
--- a/backend/src/services/project/project-types.ts
+++ b/backend/src/services/project/project-types.ts
@@ -93,6 +93,7 @@ export type TUpdateProjectDTO = {
autoCapitalization?: boolean;
hasDeleteProtection?: boolean;
slug?: string;
+ secretSharing?: boolean;
};
} & Omit;
diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts
index e216cb939..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";
@@ -78,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);
@@ -110,6 +114,31 @@ export const secretSharingServiceFactory = ({
}
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");
@@ -128,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 };
};
@@ -406,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,
@@ -454,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) {
@@ -468,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/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx
index a497c3ba9..7c2b13936 100644
--- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx
+++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx
@@ -144,6 +144,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
Key Schema
{" "}
diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx
index c53cef471..50f2836ed 100644
--- a/frontend/src/hooks/api/certificates/queries.tsx
+++ b/frontend/src/hooks/api/certificates/queries.tsx
@@ -48,7 +48,7 @@ export const useGetCertBundle = (serialNumber: string) => {
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/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/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx
index 278b62bc8..c040a1267 100644
--- a/frontend/src/hooks/api/workspace/queries.tsx
+++ b/frontend/src/hooks/api/workspace/queries.tsx
@@ -277,13 +277,20 @@ export const useUpdateProject = () => {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => {
+ mutationFn: async ({
+ projectID,
+ newProjectName,
+ newProjectDescription,
+ newSlug,
+ secretSharing
+ }) => {
const { data } = await apiRequest.patch<{ workspace: Workspace }>(
`/api/v1/workspace/${projectID}`,
{
name: newProjectName,
description: newProjectDescription,
- slug: newSlug
+ slug: newSlug,
+ secretSharing
}
);
return data.workspace;
diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts
index ddcf383fb..382e4189c 100644
--- a/frontend/src/hooks/api/workspace/types.ts
+++ b/frontend/src/hooks/api/workspace/types.ts
@@ -37,6 +37,7 @@ export type Workspace = {
createdAt: string;
roles?: TProjectRole[];
hasDeleteProtection: boolean;
+ secretSharing: boolean;
};
export type WorkspaceEnv = {
@@ -73,9 +74,10 @@ export type CreateWorkspaceDTO = {
export type UpdateProjectDTO = {
projectID: string;
- newProjectName: string;
+ newProjectName?: string;
newProjectDescription?: string;
newSlug?: string;
+ secretSharing?: boolean;
};
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };
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/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx
index b9991d0e5..b649a7ac0 100644
--- a/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx
+++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
+import axios from "axios";
+import { createNotification } from "@app/components/notifications";
import { Switch } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
-import axios from "axios";
-import { createNotification } from "@app/components/notifications";
export const OrgProductSelectSection = () => {
const [toggledProducts, setToggledProducts] = useState<{
@@ -79,7 +79,7 @@ export const OrgProductSelectSection = () => {
};
return (
-
+
Organization Products
Select which products are available for your organization.
diff --git a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgSsoTab.tsx b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgSsoTab.tsx
index 65f97cc2d..ca27b7517 100644
--- a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgSsoTab.tsx
+++ b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgSsoTab.tsx
@@ -1,7 +1,7 @@
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
-import { Button, ContentLoader } from "@app/components/v2";
+import { Button, ContentLoader, EmptyState } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -60,102 +60,108 @@ export const OrgSsoTab = withPermission(
const shouldShowCreateIdentityProviderView =
!isOidcConfigured && !isSamlConfigured && !isLdapConfigured;
- const createIdentityProviderView = (shouldDisplaySection(LoginMethod.SAML) ||
+ const createIdentityProviderView =
+ shouldDisplaySection(LoginMethod.SAML) ||
shouldDisplaySection(LoginMethod.OIDC) ||
- shouldDisplaySection(LoginMethod.LDAP)) && (
- <>
-
-
Connect an Identity Provider
-
- Connect your identity provider to simplify user management
-
- {shouldDisplaySection(LoginMethod.SAML) && (
-
-
SAML
-
+
+ )}
+ {shouldDisplaySection(LoginMethod.LDAP) && (
+
+
LDAP
+
{
+ if (!subscription?.ldap) {
+ handlePopUpOpen("upgradePlan", { feature: "LDAP", plan: "Enterprise" });
+ return;
+ }
+
+ handlePopUpOpen("addLDAP");
+ }}
+ >
+ Connect
+
+
+ )}
+
+
+
+
+ >
+ ) : (
+
+ Single Sign-On (SSO) has been disabled
+ Contact your server administrator
+
+ );
if (areConfigsLoading) {
return
;
diff --git a/frontend/src/pages/public/ShareSecretPage/ShareSecretPage.tsx b/frontend/src/pages/public/ShareSecretPage/ShareSecretPage.tsx
index 186949722..53cd09f65 100644
--- a/frontend/src/pages/public/ShareSecretPage/ShareSecretPage.tsx
+++ b/frontend/src/pages/public/ShareSecretPage/ShareSecretPage.tsx
@@ -91,7 +91,7 @@ export const ShareSecretPage = () => {
Infisical
- 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
+ 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
diff --git a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx
index 7e99bee4b..322aa20ae 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;
@@ -53,7 +82,7 @@ export const ShareSecretForm = ({
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}: Props) => {
- const [secretLink, setSecretLink] = useState("");
+ const [secretLink, setSecretLink] = useState(null);
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset({
initialState: "Copy to clipboard"
});
@@ -79,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()
}
});
@@ -89,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({
@@ -124,156 +169,230 @@ export const ShareSecretForm = ({
}
};
- const hasSecretLink = Boolean(secretLink);
-
- return !hasSecretLink ? (
-
- ) : (
+
+ {!isPublic && (
+ (
+ Feature enforced by organization
+ )
+ }
+ errorText={error?.message}
+ isError={Boolean(error)}
+ >
+
+ onChange(
+ v ? SecretSharingAccessType.Organization : SecretSharingAccessType.Anyone
+ )
+ }
+ id="org-access-only"
+ >
+ Limit access to people within organization
+
+
+ )}
+ />
+ )}
+
+
+
+
+ Advanced Settings
+
+
+ (
+
+
+
+ )}
+ />
+ (
+
+
+
+ )}
+ />
+
+ {!isPublic && (
+ (
+
+
+
+ )}
+ />
+ )}
+
+
+
+
+
+
+ Create Secret Link
+
+
+
+ );
+
+ if (secretLink === "")
+ return (
+ <>
+
+
+ Shared secret link has been emailed to select users.
+
+ setSecretLink(null)}
+ rightIcon={}
+ >
+ Share Another Secret
+
+ >
+ );
+
+ return (
<>
{secretLink}
@@ -282,7 +401,7 @@ export const ShareSecretForm = ({
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
- navigator.clipboard.writeText(secretLink);
+ navigator.clipboard.writeText(secretLink || "");
setCopyTextSecret("Copied");
}}
>
@@ -294,7 +413,7 @@ export const ShareSecretForm = ({
colorSchema="primary"
variant="outline_bg"
size="sm"
- onClick={() => setSecretLink("")}
+ onClick={() => setSecretLink(null)}
rightIcon={
}
>
Share Another Secret
diff --git a/frontend/src/pages/public/ViewSecretRequestByIDPage/ViewSecretRequestByIDPage.tsx b/frontend/src/pages/public/ViewSecretRequestByIDPage/ViewSecretRequestByIDPage.tsx
index bce742358..2bb5c7b0f 100644
--- a/frontend/src/pages/public/ViewSecretRequestByIDPage/ViewSecretRequestByIDPage.tsx
+++ b/frontend/src/pages/public/ViewSecretRequestByIDPage/ViewSecretRequestByIDPage.tsx
@@ -175,7 +175,7 @@ export const ViewSecretRequestByIDPage = () => {
Infisical
- 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
+ 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
diff --git a/frontend/src/pages/public/ViewSharedSecretByIDPage/ViewSharedSecretByIDPage.tsx b/frontend/src/pages/public/ViewSharedSecretByIDPage/ViewSharedSecretByIDPage.tsx
index 5112bd47e..389aee68f 100644
--- a/frontend/src/pages/public/ViewSharedSecretByIDPage/ViewSharedSecretByIDPage.tsx
+++ b/frontend/src/pages/public/ViewSharedSecretByIDPage/ViewSharedSecretByIDPage.tsx
@@ -38,6 +38,14 @@ export const ViewSharedSecretByIDPage = () => {
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
select: (el) => el.key
});
+ const email = useSearch({
+ from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
+ select: (el) => el.email
+ });
+ const hash = useSearch({
+ from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
+ select: (el) => el.hash
+ });
const [password, setPassword] = useState();
const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey);
@@ -49,7 +57,9 @@ export const ViewSharedSecretByIDPage = () => {
} = useGetActiveSharedSecretById({
sharedSecretId: id,
hashedHex,
- password
+ password,
+ email,
+ hash
});
const navigate = useNavigate();
@@ -57,15 +67,16 @@ export const ViewSharedSecretByIDPage = () => {
const isUnauthorized =
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 401;
- const isForbidden =
- ((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 403;
-
const isInvalidCredential =
((error as AxiosError)?.response?.data as { message: string })?.message ===
"Invalid credentials";
+ const isEmailUnauthorized =
+ ((error as AxiosError)?.response?.data as { message: string })?.message ===
+ "Email not authorized to view secret";
+
useEffect(() => {
- if (isUnauthorized && !isInvalidCredential) {
+ if (isUnauthorized && !isInvalidCredential && !isEmailUnauthorized) {
// persist current URL in session storage so that we can come back to this after successful login
sessionStorage.setItem(
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
@@ -85,10 +96,10 @@ export const ViewSharedSecretByIDPage = () => {
});
}
- if (isForbidden) {
+ if (error) {
createNotification({
type: "error",
- text: "You do not have access to this shared secret."
+ text: ((error as AxiosError)?.response?.data as { message: string })?.message
});
}
}, [error]);
@@ -195,7 +206,7 @@ export const ViewSharedSecretByIDPage = () => {
Infisical
- 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
+ 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
diff --git a/frontend/src/pages/public/ViewSharedSecretByIDPage/route.tsx b/frontend/src/pages/public/ViewSharedSecretByIDPage/route.tsx
index 7b98dded3..7cbcb59a2 100644
--- a/frontend/src/pages/public/ViewSharedSecretByIDPage/route.tsx
+++ b/frontend/src/pages/public/ViewSharedSecretByIDPage/route.tsx
@@ -7,7 +7,9 @@ import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage";
const SharedSecretByIDPageQuerySchema = z.object({
- key: z.string().catch("")
+ key: z.string().catch(""),
+ email: z.string().optional(),
+ hash: z.string().optional()
});
export const Route = createFileRoute("/shared/secret/$secretId")({
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx
index da4e89741..8eb55b81e 100644
--- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx
@@ -398,11 +398,19 @@ export const SecretDetailSidebar = ({
autoFocus={false}
/>
}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx
index cb2cf3361..32586e990 100644
--- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx
@@ -589,7 +589,7 @@ export const SecretItem = memo(
)}
{
@@ -22,6 +23,7 @@ export const ProjectGeneralTab = () => {
{isSecretManager && }
{isSecretManager && }
{isSecretManager && }
+ {isSecretManager && }
{isSecretManager && }
{isSecretManager && }
diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/SecretSharingSection/SecretSharingSection.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/SecretSharingSection/SecretSharingSection.tsx
new file mode 100644
index 000000000..837f32d3e
--- /dev/null
+++ b/frontend/src/pages/secret-manager/SettingsPage/components/SecretSharingSection/SecretSharingSection.tsx
@@ -0,0 +1,64 @@
+import { useState } from "react";
+
+import { createNotification } from "@app/components/notifications";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { Checkbox } from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
+import { useUpdateProject } from "@app/hooks/api/workspace/queries";
+
+export const SecretSharingSection = () => {
+ const { currentWorkspace } = useWorkspace();
+ const { mutateAsync: updateProject } = useUpdateProject();
+
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleToggle = async (state: boolean) => {
+ setIsLoading(true);
+
+ try {
+ if (!currentWorkspace?.id) {
+ setIsLoading(false);
+ return;
+ }
+
+ await updateProject({
+ projectID: currentWorkspace.id,
+ secretSharing: state
+ });
+
+ createNotification({
+ text: `Successfully ${state ? "enabled" : "disabled"} secret sharing for this project`,
+ type: "success"
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to update secret sharing for this project",
+ type: "error"
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
Allow Secret Sharing
+
+ {(isAllowed) => (
+
+ handleToggle(state as boolean)}
+ >
+ This feature enables your project members to securely share secrets.
+
+
+ )}
+
+
+ );
+};
diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/SecretSharingSection/index.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/SecretSharingSection/index.tsx
new file mode 100644
index 000000000..716cc511e
--- /dev/null
+++ b/frontend/src/pages/secret-manager/SettingsPage/components/SecretSharingSection/index.tsx
@@ -0,0 +1 @@
+export { SecretSharingSection } from "./SecretSharingSection";