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.

@@ -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 (
+
+
+
+ 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) => (
+
+ )}
+
+
+ );
+};
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) => (
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 && (
+ (
+ 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
+
+
+ (
+
+ Limited to{" "}
+ {filteredExpiresInOptions[filteredExpiresInOptions.length - 1].label} by
+ organization
+
+ ) : undefined
+ }
+ >
+
+
+ )}
+ />
+ (
+
+ Limited to{" "}
+ {filteredViewLimitOptions[filteredViewLimitOptions.length - 1].label} by
+ organization
+
+ ) : undefined
+ }
+ >
+
+
+ )}
+ />
+
+ {!isPublic && (
+ (
+
+
+
+ )}
+ />
+ )}
+
+
+
+
+
+
+
+
+ );
+
+ if (secretLink === "")
+ return (
+ <>
+
+
+ Shared secret link has been emailed to select users.
+
+
+ >
+ );
+
+ return (
<>
{secretLink}
@@ -265,7 +427,7 @@ export const ShareSecretForm = ({
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
- navigator.clipboard.writeText(secretLink);
+ navigator.clipboard.writeText(secretLink || "");
setCopyTextSecret("Copied");
}}
>
@@ -277,7 +439,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 8eb55b81e..d417821e4 100644
--- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx
+++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx
@@ -95,7 +95,6 @@ export const SecretDetailSidebar = ({
handleSecretShare
}: Props) => {
const {
- register,
control,
watch,
handleSubmit,
@@ -104,7 +103,8 @@ export const SecretDetailSidebar = ({
formState: { isDirty, isSubmitting }
} = useForm({
resolver: zodResolver(formSchema),
- values: secret
+ values: secret,
+ disabled: !secret
});
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp([
@@ -713,14 +713,25 @@ export const SecretDetailSidebar = ({
-
-
-
+ (
+
+
+
+ )}
+ />
{secretReminderRepeatDays && secretReminderRepeatDays > 0 ? (
@@ -921,7 +932,9 @@ export const SecretDetailSidebar = ({
variant="outline_bg"
size="sm"
className="h-8 w-8 rounded-md"
- onClick={() => setValue("value", secretValue)}
+ onClick={() =>
+ setValue("value", secretValue, { shouldDirty: true })
+ }
>