Merge branch 'main' into ENG-2797

This commit is contained in:
x032205
2025-05-20 16:48:28 -04:00
38 changed files with 1023 additions and 277 deletions
@@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
}
});
}
}
@@ -0,0 +1,43 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
}
});
}
}
}
+3 -1
View File
@@ -34,7 +34,9 @@ export const OrganizationsSchema = z.object({
kmsProductEnabled: z.boolean().default(true).nullable().optional(), kmsProductEnabled: z.boolean().default(true).nullable().optional(),
sshProductEnabled: z.boolean().default(true).nullable().optional(), sshProductEnabled: z.boolean().default(true).nullable().optional(),
scannerProductEnabled: 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<typeof OrganizationsSchema>; export type TOrganizations = z.infer<typeof OrganizationsSchema>;
+3 -1
View File
@@ -27,7 +27,9 @@ export const SecretSharingSchema = z.object({
password: z.string().nullable().optional(), password: z.string().nullable().optional(),
encryptedSecret: zodBuffer.nullable().optional(), encryptedSecret: zodBuffer.nullable().optional(),
identifier: z.string().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<typeof SecretSharingSchema>; export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
@@ -131,8 +131,8 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
response: { response: {
200: z.object({ 200: z.object({
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), 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),
privateKey: z.string().trim().describe(CERTIFICATES.GET_CERT.privateKey), privateKey: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.privateKey),
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
}) })
} }
@@ -518,7 +518,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
response: { response: {
200: z.object({ 200: z.object({
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), 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) serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
}) })
} }
@@ -114,10 +114,12 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide
CharacterType.Numbers, CharacterType.Numbers,
CharacterType.Colon, CharacterType.Colon,
CharacterType.Period, CharacterType.Period,
CharacterType.ForwardSlash CharacterType.ForwardSlash,
CharacterType.Hyphen
])(val), ])(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), caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert),
@@ -234,11 +236,13 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide
CharacterType.Numbers, CharacterType.Numbers,
CharacterType.Colon, CharacterType.Colon,
CharacterType.Period, CharacterType.Period,
CharacterType.ForwardSlash CharacterType.ForwardSlash,
CharacterType.Hyphen
])(val); ])(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), caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert),
@@ -281,7 +281,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
kmsProductEnabled: z.boolean().optional(), kmsProductEnabled: z.boolean().optional(),
sshProductEnabled: z.boolean().optional(), sshProductEnabled: z.boolean().optional(),
scannerProductEnabled: 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: { response: {
200: z.object({ 200: z.object({
@@ -62,7 +62,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}), }),
body: z.object({ body: z.object({
hashedHex: z.string().min(1).optional(), hashedHex: z.string().min(1).optional(),
password: z.string().optional() password: z.string().optional(),
email: z.string().optional(),
hash: z.string().optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -88,7 +90,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
sharedSecretId: req.params.id, sharedSecretId: req.params.id,
hashedHex: req.body.hashedHex, hashedHex: req.body.hashedHex,
password: req.body.password, password: req.body.password,
orgId: req.permission?.orgId orgId: req.permission?.orgId,
email: req.body.email,
hash: req.body.hash
}); });
if (sharedSecret.secret?.orgId) { if (sharedSecret.secret?.orgId) {
@@ -151,7 +155,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
secretValue: z.string(), secretValue: z.string(),
expiresAt: z.string(), expiresAt: z.string(),
expiresAfterViews: z.number().min(1).optional(), 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: { response: {
200: z.object({ 200: z.object({
@@ -105,7 +105,7 @@ export const buildCertificateChain = async ({
kmsService, kmsService,
kmsId kmsId
}: TBuildCertificateChainDTO) => { }: TBuildCertificateChainDTO) => {
if (!encryptedCertificateChain && (!caCert || !caCertChain)) { if (!encryptedCertificateChain && !caCert) {
return null; return null;
} }
@@ -29,6 +29,7 @@ import {
TGetCertPrivateKeyDTO, TGetCertPrivateKeyDTO,
TRevokeCertDTO TRevokeCertDTO
} from "./certificate-types"; } from "./certificate-types";
import { NotFoundError } from "@app/lib/errors";
type TCertificateServiceFactoryDep = { type TCertificateServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">; certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
@@ -337,18 +338,27 @@ export const certificateServiceFactory = ({
encryptedCertificateChain: certBody.encryptedCertificateChain || undefined encryptedCertificateChain: certBody.encryptedCertificateChain || undefined
}); });
const { certPrivateKey } = await getCertificateCredentials({ let privateKey: string | null = null;
certId: cert.id, try {
projectId: ca.projectId, const { certPrivateKey } = await getCertificateCredentials({
certificateSecretDAL, certId: cert.id,
projectDAL, projectId: ca.projectId,
kmsService certificateSecretDAL,
}); projectDAL,
kmsService
});
privateKey = certPrivateKey;
} catch (e) {
// Skip NotFound errors but throw all others
if (!(e instanceof NotFoundError)) {
throw e;
}
}
return { return {
certificate, certificate,
certificateChain, certificateChain,
privateKey: certPrivateKey, privateKey,
serialNumber, serialNumber,
cert, cert,
ca ca
@@ -79,7 +79,8 @@ export const identityKubernetesAuthServiceFactory = ({
const callbackResult = await withGatewayProxy( const callbackResult = await withGatewayProxy(
async (port) => { 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; return res;
}, },
{ {
@@ -138,11 +139,7 @@ export const identityKubernetesAuthServiceFactory = ({
} }
const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => { const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => {
let baseUrl = `https://${host}`; const baseUrl = port ? `${host}:${port}` : host;
if (port) {
baseUrl += `:${port}`;
}
const res = await axios const res = await axios
.post<TCreateTokenReviewResponse>( .post<TCreateTokenReviewResponse>(
+3 -1
View File
@@ -24,5 +24,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
kmsProductEnabled: true, kmsProductEnabled: true,
sshProductEnabled: true, sshProductEnabled: true,
scannerProductEnabled: true, scannerProductEnabled: true,
shareSecretsProductEnabled: true shareSecretsProductEnabled: true,
maxSharedSecretLifetime: true,
maxSharedSecretViewLimit: true
}); });
+6 -2
View File
@@ -361,7 +361,9 @@ export const orgServiceFactory = ({
kmsProductEnabled, kmsProductEnabled,
sshProductEnabled, sshProductEnabled,
scannerProductEnabled, scannerProductEnabled,
shareSecretsProductEnabled shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
} }
}: TUpdateOrgDTO) => { }: TUpdateOrgDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -469,7 +471,9 @@ export const orgServiceFactory = ({
kmsProductEnabled, kmsProductEnabled,
sshProductEnabled, sshProductEnabled,
scannerProductEnabled, scannerProductEnabled,
shareSecretsProductEnabled shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}); });
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org; return org;
+2
View File
@@ -81,6 +81,8 @@ export type TUpdateOrgDTO = {
sshProductEnabled: boolean; sshProductEnabled: boolean;
scannerProductEnabled: boolean; scannerProductEnabled: boolean;
shareSecretsProductEnabled: boolean; shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null;
}>; }>;
} & TOrgPermission; } & TOrgPermission;
@@ -6,6 +6,7 @@ import { TSecretSharing } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { SecretSharingAccessType } from "@app/lib/types"; import { SecretSharingAccessType } from "@app/lib/types";
import { isUuidV4 } from "@app/lib/validator"; import { isUuidV4 } from "@app/lib/validator";
@@ -60,7 +61,9 @@ export const secretSharingServiceFactory = ({
} }
const fiveMins = 5 * 60 * 1000; 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" }); throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" });
} }
}; };
@@ -76,8 +79,11 @@ export const secretSharingServiceFactory = ({
password, password,
accessType, accessType,
expiresAt, expiresAt,
expiresAfterViews expiresAfterViews,
emails
}: TCreateSharedSecretDTO) => { }: TCreateSharedSecretDTO) => {
const appCfg = getConfig();
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); 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" }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
$validateSharedSecretExpiry(expiresAt); $validateSharedSecretExpiry(expiresAt);
@@ -93,7 +99,46 @@ export const secretSharingServiceFactory = ({
throw new BadRequestError({ message: "Shared secret value too long" }); 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(); 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 encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
const id = crypto.randomBytes(32).toString("hex"); const id = crypto.randomBytes(32).toString("hex");
@@ -112,11 +157,45 @@ export const secretSharingServiceFactory = ({
expiresAfterViews, expiresAfterViews,
userId: actorId, userId: actorId,
orgId, orgId,
accessType accessType,
authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined,
encryptedSalt
}); });
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`; 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 }; return { id: idToReturn };
}; };
@@ -390,8 +469,15 @@ export const secretSharingServiceFactory = ({
}); });
}; };
/** Get's password-less secret. validates all secret's requested (must be fresh). */ /** Gets password-less secret. validates all secret's requested (must be fresh). */
const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => { const getSharedSecretById = async ({
sharedSecretId,
hashedHex,
orgId,
password,
email,
hash
}: TGetActiveSharedSecretByIdDTO) => {
const sharedSecret = isUuidV4(sharedSecretId) const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findOne({ ? await secretSharingDAL.findOne({
id: sharedSecretId, 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 isPasswordProtected = Boolean(sharedSecret.password);
const hasProvidedPassword = Boolean(password); const hasProvidedPassword = Boolean(password);
if (isPasswordProtected) { 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. // 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; let decryptedSecretValue: Buffer | undefined;
if (sharedSecret.encryptedSecret) { if (sharedSecret.encryptedSecret) {
const decryptWithRoot = kmsService.decryptWithRootKey();
decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret); decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret);
} }
@@ -22,6 +22,7 @@ export type TSharedSecretPermission = {
accessType?: SecretSharingAccessType; accessType?: SecretSharingAccessType;
name?: string; name?: string;
password?: string; password?: string;
emails?: string[];
}; };
export type TCreatePublicSharedSecretDTO = { export type TCreatePublicSharedSecretDTO = {
@@ -37,6 +38,10 @@ export type TGetActiveSharedSecretByIdDTO = {
hashedHex?: string; hashedHex?: string;
orgId?: string; orgId?: string;
password?: string; password?: string;
// For secrets shared with specific emails
email?: string;
hash?: string;
}; };
export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & { export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {
+2 -1
View File
@@ -20,6 +20,7 @@ The **Settings** page lets you manage information about your organization includ
- **Slug**: The slug of your organization. - **Slug**: The slug of your organization.
- **Default Organization Member Role**: The role assigned to users when joining your organization unless otherwise specified. - **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. - **Incident Contacts**: Emails that should be alerted if anything abnormal is detected within the organization.
- **Enabled Products**: Products which are enabled for your organization. This setting strictly affects the sidebar UI; disabling a product does not disable its API or routes.
![organization settings general](../../images/platform/organization/organization-settings-general.png) ![organization settings general](../../images/platform/organization/organization-settings-general.png)
@@ -43,7 +44,7 @@ In the **Organization Roles** tab, you can edit current or create new custom rol
<Info> <Info>
Note that Role-Based Access Management (RBAC) is partly a paid feature. Note that Role-Based Access Management (RBAC) is partly a paid feature.
Infisical provides immutable roles like `admin`, `member`, etc. Infisical provides immutable roles like `admin`, `member`, etc.
at the organization and project level for free. at the organization and project level for free.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

After

Width:  |  Height:  |  Size: 993 KiB

@@ -123,6 +123,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
{...props} {...props}
disabled={isDisabled}
className={twMerge( className={twMerge(
"relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80", "relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
isSelected && "bg-primary", isSelected && "bg-primary",
@@ -48,7 +48,7 @@ export const useGetCertBundle = (serialNumber: string) => {
certificate: string; certificate: string;
certificateChain: string; certificateChain: string;
serialNumber: string; serialNumber: string;
privateKey: string; privateKey: string | null;
}>(`/api/v1/pki/certificates/${serialNumber}/bundle`); }>(`/api/v1/pki/certificates/${serialNumber}/bundle`);
return data; return data;
}, },
@@ -118,7 +118,9 @@ export const useUpdateOrg = () => {
kmsProductEnabled, kmsProductEnabled,
sshProductEnabled, sshProductEnabled,
scannerProductEnabled, scannerProductEnabled,
shareSecretsProductEnabled shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}) => { }) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, { return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name, name,
@@ -136,7 +138,9 @@ export const useUpdateOrg = () => {
kmsProductEnabled, kmsProductEnabled,
sshProductEnabled, sshProductEnabled,
scannerProductEnabled, scannerProductEnabled,
shareSecretsProductEnabled shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}); });
}, },
onSuccess: () => { onSuccess: () => {
@@ -26,6 +26,8 @@ export type Organization = {
sshProductEnabled: boolean; sshProductEnabled: boolean;
scannerProductEnabled: boolean; scannerProductEnabled: boolean;
shareSecretsProductEnabled: boolean; shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null;
}; };
export type UpdateOrgDTO = { export type UpdateOrgDTO = {
@@ -46,6 +48,8 @@ export type UpdateOrgDTO = {
sshProductEnabled?: boolean; sshProductEnabled?: boolean;
scannerProductEnabled?: boolean; scannerProductEnabled?: boolean;
shareSecretsProductEnabled?: boolean; shareSecretsProductEnabled?: boolean;
maxSharedSecretViewLimit?: number | null;
maxSharedSecretLifetime?: number;
}; };
export type BillingDetails = { export type BillingDetails = {
@@ -11,10 +11,13 @@ export const secretSharingKeys = {
allSecretRequests: () => ["secretRequests"] as const, allSecretRequests: () => ["secretRequests"] as const,
specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) => specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) =>
[...secretSharingKeys.allSecretRequests(), { offset, limit }] as const, [...secretSharingKeys.allSecretRequests(), { offset, limit }] as const,
getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [ getSecretById: (arg: {
"shared-secret", id: string;
arg hashedHex: string | null;
], password?: string;
email?: string;
hash?: string;
}) => ["shared-secret", arg],
getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const
}; };
@@ -70,20 +73,34 @@ export const useGetSecretRequests = ({
export const useGetActiveSharedSecretById = ({ export const useGetActiveSharedSecretById = ({
sharedSecretId, sharedSecretId,
hashedHex, hashedHex,
password password,
email,
hash
}: { }: {
sharedSecretId: string; sharedSecretId: string;
hashedHex: string | null; hashedHex: string | null;
password?: string; password?: string;
// For secrets shared to specific emails (optional)
email?: string;
hash?: string;
}) => { }) => {
return useQuery({ return useQuery({
queryKey: secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }), queryKey: secretSharingKeys.getSecretById({
id: sharedSecretId,
hashedHex,
password,
email,
hash
}),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.post<TViewSharedSecretResponse>( const { data } = await apiRequest.post<TViewSharedSecretResponse>(
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`, `/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
{ {
...(hashedHex && { hashedHex }), ...(hashedHex && { hashedHex }),
password password,
email,
hash
} }
); );
@@ -32,6 +32,7 @@ export type TCreateSharedSecretRequest = {
expiresAt: Date; expiresAt: Date;
expiresAfterViews?: number; expiresAfterViews?: number;
accessType?: SecretSharingAccessType; accessType?: SecretSharingAccessType;
emails?: string[];
}; };
export type TCreateSecretRequestRequestDTO = { export type TCreateSecretRequestRequestDTO = {
@@ -35,7 +35,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
certificate: string; certificate: string;
certificateChain: string; certificateChain: string;
serialNumber: string; serialNumber: string;
privateKey?: string; privateKey?: string | null;
} }
| undefined = canReadPrivateKey ? bundleData : bodyData; | undefined = canReadPrivateKey ? bundleData : bodyData;
@@ -52,7 +52,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
serialNumber={data.serialNumber} serialNumber={data.serialNumber}
certificate={data.certificate} certificate={data.certificate}
certificateChain={data.certificateChain} certificateChain={data.certificateChain}
privateKey={data.privateKey} privateKey={data.privateKey || undefined}
/> />
) : ( ) : (
<div /> <div />
@@ -30,6 +30,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
allowSecretSharingOutsideOrganization={ allowSecretSharingOutsideOrganization={
currentOrg?.allowSecretSharingOutsideOrganization ?? true currentOrg?.allowSecretSharingOutsideOrganization ?? true
} }
maxSharedSecretLifetime={currentOrg?.maxSharedSecretLifetime}
maxSharedSecretViewLimit={currentOrg?.maxSharedSecretViewLimit}
/> />
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -17,11 +17,11 @@ export const SecretSharingSettingsPage = withPermission(
return ( return (
<> <>
<Helmet> <Helmet>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title> <title>{t("common.head-title", { title: "Secret Share Settings" })}</title>
</Helmet> </Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white"> <div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl"> <div className="w-full max-w-7xl">
<PageHeader title={t("settings.org.title")} /> <PageHeader title="Secret Share Settings" />
<SecretSharingSettingsTabGroup /> <SecretSharingSettingsTabGroup />
</div> </div>
</div> </div>
@@ -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<typeof formSchema>;
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<TForm>({
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 (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Secret Share Limits</p>
</div>
<p className="mb-4 mt-2 text-sm text-gray-400">
These settings establish the maximum limits for all Shared Secret parameters within this
organization. Shared secrets cannot be created with values exceeding these limits.
</p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
<div className="flex max-w-sm gap-4">
<Controller
control={control}
name="maxLifetimeValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The max amount of time that can be set before the secret share link expires."
label="Max Lifetime"
className="w-full"
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? "" : parseInt(val, 10));
}}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="maxLifetimeUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex max-w-sm">
<Controller
control={control}
name="maxViewLimit"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Max Views"
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={!isAllowed}
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem value={String(viewLimitValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isDirty || !isAllowed}
className="mt-4"
>
Save
</Button>
</form>
)}
</OrgPermissionCan>
</div>
);
};
@@ -0,0 +1 @@
export { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";
@@ -1,9 +1,11 @@
import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection";
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone"; import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
export const SecretSharingSettingsGeneralTab = () => { export const SecretSharingSettingsGeneralTab = () => {
return ( return (
<div className="w-full"> <div className="w-full">
<SecretSharingAllowShareToAnyone /> <SecretSharingAllowShareToAnyone />
<OrgSecretShareLimitSection />
</div> </div>
); );
}; };
@@ -79,8 +79,8 @@ export const OrgProductSelectSection = () => {
}; };
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-6 py-5">
<h2 className="text-xl font-semibold text-mineshaft-100">Organization Products</h2> <h2 className="text-xl font-semibold text-mineshaft-100">Enabled Products</h2>
<p className="mb-4 text-gray-400"> <p className="mb-4 text-gray-400">
Select which products are available for your organization. Select which products are available for your organization.
</p> </p>
@@ -96,61 +96,59 @@ export const OrgUserAccessTokenLimitSection = () => {
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}> <OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => ( {(isAllowed) => (
<form onSubmit={handleSubmit(handleUserTokenExpirationSubmit)} autoComplete="off"> <form onSubmit={handleSubmit(handleUserTokenExpirationSubmit)} autoComplete="off">
<div className="flex max-w-md gap-4"> <div className="flex max-w-sm gap-4">
<div className="flex-1"> <Controller
<Controller control={control}
control={control} name="expirationValue"
name="expirationValue" render={({ field, fieldState: { error } }) => (
render={({ field, fieldState: { error } }) => ( <FormControl
<FormControl isError={Boolean(error)}
isError={Boolean(error)} errorText={error?.message}
errorText={error?.message} label="Expiration value"
label="Expiration value" className="w-full"
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="expirationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
> >
<Input {timeUnits.map(({ value, label }) => (
{...field} <SelectItem
type="number" key={value}
min={1} value={value}
step={1} className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
value={field.value} >
onChange={(e) => field.onChange(parseInt(e.target.value, 10))} <div className="ml-3 font-medium">{label}</div>
disabled={!isAllowed} </SelectItem>
/> ))}
</FormControl> </Select>
)} </FormControl>
/> )}
</div> />
<div className="flex-1">
<Controller
control={control}
name="expirationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
</div> </div>
<Button <Button
colorSchema="secondary" colorSchema="secondary"
@@ -91,7 +91,7 @@ export const ShareSecretPage = () => {
Infisical Infisical
</a> </a>
<br /> <br />
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
</p> </p>
</div> </div>
</div> </div>
@@ -6,7 +6,19 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; 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 { useTimedReset } from "@app/hooks";
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api"; import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api";
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing"; import { SecretSharingAccessType } from "@app/hooks/api/secretSharing";
@@ -33,7 +45,24 @@ const schema = z.object({
secret: z.string().min(1), secret: z.string().min(1),
expiresIn: z.string(), expiresIn: z.string(),
viewLimit: 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<typeof schema>; export type FormData = z.infer<typeof schema>;
@@ -42,14 +71,18 @@ type Props = {
isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form
value?: string; value?: string;
allowSecretSharingOutsideOrganization?: boolean; allowSecretSharingOutsideOrganization?: boolean;
maxSharedSecretLifetime?: number;
maxSharedSecretViewLimit?: number | null;
}; };
export const ShareSecretForm = ({ export const ShareSecretForm = ({
isPublic, isPublic,
value, value,
allowSecretSharingOutsideOrganization = true allowSecretSharingOutsideOrganization = true,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}: Props) => { }: Props) => {
const [secretLink, setSecretLink] = useState(""); const [secretLink, setSecretLink] = useState<string | null>(null);
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({ const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
initialState: "Copy to clipboard" initialState: "Copy to clipboard"
}); });
@@ -58,6 +91,15 @@ export const ShareSecretForm = ({
const privateSharedSecretCreator = useCreateSharedSecret(); const privateSharedSecretCreator = useCreateSharedSecret();
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator; 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 { const {
control, control,
reset, reset,
@@ -66,7 +108,10 @@ export const ShareSecretForm = ({
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { 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, secret,
expiresIn, expiresIn,
viewLimit, viewLimit,
accessType accessType,
emails
}: FormData) => { }: FormData) => {
try { try {
const expiresAt = new Date(new Date().getTime() + Number(expiresIn)); const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
const processedEmails = emails ? emails.split(",").map((e) => e.trim()) : undefined;
const { id } = await createSharedSecret.mutateAsync({ const { id } = await createSharedSecret.mutateAsync({
name, name,
password, password,
secretValue: secret, secretValue: secret,
expiresAt, expiresAt,
expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit), 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(); reset();
navigator.clipboard.writeText(link);
setCopyTextSecret("secret");
createNotification({
text: "Shared secret link copied to clipboard.",
type: "success"
});
} catch (error) { } catch (error) {
console.error(error); console.error(error);
createNotification({ createNotification({
@@ -111,152 +169,256 @@ export const ShareSecretForm = ({
} }
}; };
const hasSecretLink = Boolean(secretLink); if (secretLink === null)
return (
return !hasSecretLink ? ( <form onSubmit={handleSubmit(onFormSubmit)}>
<form onSubmit={handleSubmit(onFormSubmit)}> {!isPublic && (
{!isPublic && ( <Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isOptional
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="API Key"
type="text"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</FormControl>
)}
/>
)}
<Controller <Controller
control={control} control={control}
name="name" name="secret"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Name (Optional)" label="Your Secret"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
className="mb-2"
isRequired
> >
<Input <textarea
placeholder="Enter sensitive data to share via an encrypted link..."
{...field} {...field}
placeholder="API Key" className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
type="text" disabled={value !== undefined}
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/> />
</FormControl> </FormControl>
)} )}
/> />
)}
<Controller
control={control}
name="secret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Your Secret"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2"
isRequired
>
<textarea
placeholder="Enter sensitive data to share via an encrypted link..."
{...field}
className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
disabled={value !== undefined}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error)}
errorText={error?.message}
isOptional
>
<Input
{...field}
placeholder="Password"
type="password"
autoComplete="new-password"
autoCorrect="off"
spellCheck="false"
aria-autocomplete="none"
data-form-type="other"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue="3600000"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expires In" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expiresInOptions.map(({ label, value: expiresInValue }) => (
<SelectItem value={String(expiresInValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="viewLimit"
defaultValue="-1"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Max Views" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem value={String(viewLimitValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{!isPublic && (
<Controller <Controller
control={control} control={control}
name="accessType" name="password"
defaultValue={SecretSharingAccessType.Organization} render={({ field, fieldState: { error } }) => (
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( <FormControl
<FormControl label="General Access" errorText={error?.message} isError={Boolean(error)}> label="Password"
<Select isError={Boolean(error)}
defaultValue={field.value} errorText={error?.message}
isOptional
>
<Input
{...field} {...field}
onValueChange={(e) => onChange(e)} placeholder="Password"
className="w-full" type="password"
> autoComplete="new-password"
{allowSecretSharingOutsideOrganization && ( autoCorrect="off"
<SelectItem value={SecretSharingAccessType.Anyone}>Anyone</SelectItem> spellCheck="false"
)} aria-autocomplete="none"
<SelectItem value={SecretSharingAccessType.Organization}> data-form-type="other"
People within your organization />
</SelectItem>
</Select>
</FormControl> </FormControl>
)} )}
/> />
)}
<Button {!isPublic && (
className="mt-4" <Controller
size="sm" control={control}
type="submit" name="accessType"
isLoading={isSubmitting} defaultValue={SecretSharingAccessType.Organization}
isDisabled={isSubmitting} render={({ field: { onChange, ...field }, fieldState: { error } }) => (
> <FormControl
Create Secret Link helperText={
</Button> allowSecretSharingOutsideOrganization ? undefined : (
</form> <span className="text-red-500">Feature enforced by organization</span>
) : ( )
}
errorText={error?.message}
isError={Boolean(error)}
>
<Switch
className={`ml-0 mr-2 bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-primary ${!allowSecretSharingOutsideOrganization ? "opacity-50" : ""}`}
thumbClassName="bg-mineshaft-800"
containerClassName="flex-row-reverse w-fit"
isChecked={
field.value === SecretSharingAccessType.Organization ||
!allowSecretSharingOutsideOrganization
}
isDisabled={!allowSecretSharingOutsideOrganization}
onCheckedChange={(v) =>
onChange(
v ? SecretSharingAccessType.Organization : SecretSharingAccessType.Anyone
)
}
id="org-access-only"
>
Limit access to people within organization
</Switch>
</FormControl>
)}
/>
)}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advance-settings" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Advanced Settings</div>
</AccordionTrigger>
<AccordionContent childrenClassName="p-0">
<Controller
control={control}
name="expiresIn"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Expires In"
errorText={error?.message}
isError={Boolean(error)}
helperText={
expiresInOptions.length !== filteredExpiresInOptions.length ? (
<span className="text-yellow-500">
Limited to{" "}
{filteredExpiresInOptions[filteredExpiresInOptions.length - 1].label} by
organization
</span>
) : undefined
}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expiresInOptions.map(({ label, value: expiresInValue }) => (
<SelectItem
value={String(expiresInValue || "")}
key={label}
isDisabled={!filteredExpiresInOptions.some((v) => v.label === label)}
>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="viewLimit"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Max Views"
errorText={error?.message}
isError={Boolean(error)}
helperText={
viewLimitOptions.length !== filteredViewLimitOptions.length ? (
<span className="text-yellow-500">
Limited to{" "}
{filteredViewLimitOptions[filteredViewLimitOptions.length - 1].label} by
organization
</span>
) : undefined
}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem
value={String(viewLimitValue || "")}
key={label}
isDisabled={!filteredViewLimitOptions.some((v) => v.label === label)}
>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{!isPublic && (
<Controller
control={control}
name="emails"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Authorized Emails"
isOptional
tooltipText="Unique secret links will be emailed to each individual. The secret will only be accessible to those links."
tooltipClassName="max-w-sm"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="[email protected], [email protected]"
autoComplete="off"
/>
</FormControl>
)}
/>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex w-full justify-end">
<Button
className="mt-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Secret Link
</Button>
</div>
</form>
);
if (secretLink === "")
return (
<>
<div className="mt-1 flex w-full items-center justify-center gap-2">
<FontAwesomeIcon icon={faCheck} className="text-green-500" />
<span>Shared secret link has been emailed to select users.</span>
</div>
<Button
className="mt-6 w-full bg-mineshaft-700 py-3 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
onClick={() => setSecretLink(null)}
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
>
Share Another Secret
</Button>
</>
);
return (
<> <>
<div className="mr-2 flex items-center justify-end rounded-md bg-white/[0.05] p-2 text-base text-gray-400"> <div className="mr-2 flex items-center justify-end rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{secretLink}</p> <p className="mr-4 break-all">{secretLink}</p>
@@ -265,7 +427,7 @@ export const ShareSecretForm = ({
colorSchema="secondary" colorSchema="secondary"
className="group relative ml-2" className="group relative ml-2"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(secretLink); navigator.clipboard.writeText(secretLink || "");
setCopyTextSecret("Copied"); setCopyTextSecret("Copied");
}} }}
> >
@@ -277,7 +439,7 @@ export const ShareSecretForm = ({
colorSchema="primary" colorSchema="primary"
variant="outline_bg" variant="outline_bg"
size="sm" size="sm"
onClick={() => setSecretLink("")} onClick={() => setSecretLink(null)}
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />} rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
> >
Share Another Secret Share Another Secret
@@ -175,7 +175,7 @@ export const ViewSecretRequestByIDPage = () => {
Infisical Infisical
</a> </a>
<br /> <br />
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
</p> </p>
</div> </div>
</div> </div>
@@ -38,6 +38,14 @@ export const ViewSharedSecretByIDPage = () => {
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id, from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
select: (el) => el.key 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<string>(); const [password, setPassword] = useState<string>();
const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey); const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey);
@@ -49,7 +57,9 @@ export const ViewSharedSecretByIDPage = () => {
} = useGetActiveSharedSecretById({ } = useGetActiveSharedSecretById({
sharedSecretId: id, sharedSecretId: id,
hashedHex, hashedHex,
password password,
email,
hash
}); });
const navigate = useNavigate(); const navigate = useNavigate();
@@ -57,15 +67,16 @@ export const ViewSharedSecretByIDPage = () => {
const isUnauthorized = const isUnauthorized =
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 401; ((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 401;
const isForbidden =
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 403;
const isInvalidCredential = const isInvalidCredential =
((error as AxiosError)?.response?.data as { message: string })?.message === ((error as AxiosError)?.response?.data as { message: string })?.message ===
"Invalid credentials"; "Invalid credentials";
const isEmailUnauthorized =
((error as AxiosError)?.response?.data as { message: string })?.message ===
"Email not authorized to view secret";
useEffect(() => { 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 // persist current URL in session storage so that we can come back to this after successful login
sessionStorage.setItem( sessionStorage.setItem(
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL, SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
@@ -85,10 +96,10 @@ export const ViewSharedSecretByIDPage = () => {
}); });
} }
if (isForbidden) { if (error) {
createNotification({ createNotification({
type: "error", type: "error",
text: "You do not have access to this shared secret." text: ((error as AxiosError)?.response?.data as { message: string })?.message
}); });
} }
}, [error]); }, [error]);
@@ -195,7 +206,7 @@ export const ViewSharedSecretByIDPage = () => {
Infisical Infisical
</a> </a>
<br /> <br />
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
</p> </p>
</div> </div>
</div> </div>
@@ -7,7 +7,9 @@ import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage"; import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage";
const SharedSecretByIDPageQuerySchema = z.object({ 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")({ export const Route = createFileRoute("/shared/secret/$secretId")({
@@ -95,7 +95,6 @@ export const SecretDetailSidebar = ({
handleSecretShare handleSecretShare
}: Props) => { }: Props) => {
const { const {
register,
control, control,
watch, watch,
handleSubmit, handleSubmit,
@@ -104,7 +103,8 @@ export const SecretDetailSidebar = ({
formState: { isDirty, isSubmitting } formState: { isDirty, isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
values: secret values: secret,
disabled: !secret
}); });
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp([
@@ -713,14 +713,25 @@ export const SecretDetailSidebar = ({
</FormControl> </FormControl>
</div> </div>
</div> </div>
<FormControl label="Comments & Notes"> <Controller
<TextArea control={control}
className="border border-mineshaft-600 bg-bunker-800 text-sm" name="comment"
{...register("comment")} render={({ field, fieldState: { error } }) => (
readOnly={isReadOnly} <FormControl
rows={5} label="Comments & Notes"
/> isError={Boolean(error?.message)}
</FormControl> errorText={error?.message}
className="mb-0"
>
<TextArea
className="border border-mineshaft-600 bg-bunker-800 text-sm"
readOnly={isReadOnly}
rows={5}
{...field}
/>
</FormControl>
)}
/>
<FormControl> <FormControl>
{secretReminderRepeatDays && secretReminderRepeatDays > 0 ? ( {secretReminderRepeatDays && secretReminderRepeatDays > 0 ? (
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
@@ -921,7 +932,9 @@ export const SecretDetailSidebar = ({
variant="outline_bg" variant="outline_bg"
size="sm" size="sm"
className="h-8 w-8 rounded-md" className="h-8 w-8 rounded-md"
onClick={() => setValue("value", secretValue)} onClick={() =>
setValue("value", secretValue, { shouldDirty: true })
}
> >
<FontAwesomeIcon icon={faArrowRotateRight} /> <FontAwesomeIcon icon={faArrowRotateRight} />
</IconButton> </IconButton>