mirror of
https://github.com/Infisical/infisical.git
synced 2025-07-02 16:55:02 +00:00
Compare commits
36 Commits
infisical/
...
daniel/age
Author | SHA1 | Date | |
---|---|---|---|
074446df1f | |||
0b6bc4c1f0 | |||
abbe7bbd0c | |||
565340dc50 | |||
36c428f152 | |||
f97826ea82 | |||
0f5cbf055c | |||
b960ee61d7 | |||
0b98a214a7 | |||
599c2226e4 | |||
27486e7600 | |||
979e9efbcb | |||
1097ec64b2 | |||
93fe9929b7 | |||
aca654a993 | |||
b5cf237a4a | |||
6efb630200 | |||
151ede6cbf | |||
931ee1e8da | |||
0401793d38 | |||
0613c12508 | |||
60d3ffac5d | |||
5e192539a1 | |||
021a8ddace | |||
f92aba14cd | |||
fdeefcdfcf | |||
645f70f770 | |||
923feb81f3 | |||
16c51af340 | |||
9fd37ca456 | |||
92bebf7d84 | |||
df053bbae9 | |||
42319f01a7 | |||
0ea9f9b60d | |||
ad50cff184 | |||
8e43d2a994 |
@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
|
||||
if (!hasSecretSharingColumn) {
|
||||
await knex.schema.table(TableName.Project, (table) => {
|
||||
table.boolean("secretSharing").notNullable().defaultTo(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
|
||||
if (hasSecretSharingColumn) {
|
||||
await knex.schema.table(TableName.Project, (table) => {
|
||||
table.dropColumn("secretSharing");
|
||||
});
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -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<typeof OrganizationsSchema>;
|
||||
|
@ -27,7 +27,8 @@ export const ProjectsSchema = z.object({
|
||||
description: z.string().nullable().optional(),
|
||||
type: z.string(),
|
||||
enforceCapitalization: z.boolean().default(false),
|
||||
hasDeleteProtection: z.boolean().default(false).nullable().optional()
|
||||
hasDeleteProtection: z.boolean().default(false).nullable().optional(),
|
||||
secretSharing: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export type TProjects = z.infer<typeof ProjectsSchema>;
|
||||
|
@ -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<typeof SecretSharingSchema>;
|
||||
|
@ -608,7 +608,8 @@ export const PROJECTS = {
|
||||
projectDescription: "An optional description label for the project.",
|
||||
autoCapitalization: "Disable or enable auto-capitalization for the project.",
|
||||
slug: "An optional slug for the project. (must be unique within the organization)",
|
||||
hasDeleteProtection: "Enable or disable delete protection for the project."
|
||||
hasDeleteProtection: "Enable or disable delete protection for the project.",
|
||||
secretSharing: "Enable or disable secret sharing for the project."
|
||||
},
|
||||
GET_KEY: {
|
||||
workspaceId: "The ID of the project to get the key from."
|
||||
|
@ -261,7 +261,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({
|
||||
pitVersionLimit: true,
|
||||
kmsCertificateKeyId: true,
|
||||
auditLogsRetentionDays: true,
|
||||
hasDeleteProtection: true
|
||||
hasDeleteProtection: true,
|
||||
secretSharing: true
|
||||
});
|
||||
|
||||
export const SanitizedTagSchema = SecretTagsSchema.pick({
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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({
|
||||
|
@ -346,7 +346,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
|
||||
})
|
||||
.optional()
|
||||
.describe(PROJECTS.UPDATE.slug)
|
||||
.describe(PROJECTS.UPDATE.slug),
|
||||
secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -366,7 +367,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
description: req.body.description,
|
||||
autoCapitalization: req.body.autoCapitalization,
|
||||
hasDeleteProtection: req.body.hasDeleteProtection,
|
||||
slug: req.body.slug
|
||||
slug: req.body.slug,
|
||||
secretSharing: req.body.secretSharing
|
||||
},
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorId: req.permission.id,
|
||||
|
@ -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({
|
||||
|
@ -105,7 +105,7 @@ export const buildCertificateChain = async ({
|
||||
kmsService,
|
||||
kmsId
|
||||
}: TBuildCertificateChainDTO) => {
|
||||
if (!encryptedCertificateChain && (!caCert || !caCertChain)) {
|
||||
if (!encryptedCertificateChain && !caCert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -29,6 +29,7 @@ import {
|
||||
TGetCertPrivateKeyDTO,
|
||||
TRevokeCertDTO
|
||||
} from "./certificate-types";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
|
||||
type TCertificateServiceFactoryDep = {
|
||||
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
|
||||
@ -337,6 +338,8 @@ export const certificateServiceFactory = ({
|
||||
encryptedCertificateChain: certBody.encryptedCertificateChain || undefined
|
||||
});
|
||||
|
||||
let privateKey: string | null = null;
|
||||
try {
|
||||
const { certPrivateKey } = await getCertificateCredentials({
|
||||
certId: cert.id,
|
||||
projectId: ca.projectId,
|
||||
@ -344,11 +347,18 @@ export const certificateServiceFactory = ({
|
||||
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
|
||||
|
@ -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<TCreateTokenReviewResponse>(
|
||||
|
@ -24,5 +24,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
|
||||
kmsProductEnabled: true,
|
||||
sshProductEnabled: true,
|
||||
scannerProductEnabled: true,
|
||||
shareSecretsProductEnabled: true
|
||||
shareSecretsProductEnabled: true,
|
||||
maxSharedSecretLifetime: true,
|
||||
maxSharedSecretViewLimit: true
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -81,6 +81,8 @@ export type TUpdateOrgDTO = {
|
||||
sshProductEnabled: boolean;
|
||||
scannerProductEnabled: boolean;
|
||||
shareSecretsProductEnabled: boolean;
|
||||
maxSharedSecretLifetime: number;
|
||||
maxSharedSecretViewLimit: number | null;
|
||||
}>;
|
||||
} & TOrgPermission;
|
||||
|
||||
|
@ -658,7 +658,8 @@ export const projectServiceFactory = ({
|
||||
autoCapitalization: update.autoCapitalization,
|
||||
enforceCapitalization: update.autoCapitalization,
|
||||
hasDeleteProtection: update.hasDeleteProtection,
|
||||
slug: update.slug
|
||||
slug: update.slug,
|
||||
secretSharing: update.secretSharing
|
||||
});
|
||||
|
||||
return updatedProject;
|
||||
|
@ -93,6 +93,7 @@ export type TUpdateProjectDTO = {
|
||||
autoCapitalization?: boolean;
|
||||
hasDeleteProtection?: boolean;
|
||||
slug?: string;
|
||||
secretSharing?: boolean;
|
||||
};
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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 & {
|
||||
|
@ -884,6 +884,12 @@ func (tm *AgentManager) MonitorSecretChanges(secretTemplate Template, templateId
|
||||
|
||||
if err != nil {
|
||||
log.Error().Msgf("unable to process template because %v", err)
|
||||
|
||||
// case: if exit-after-auth is true, it should exit the agent once an error on secret fetching occurs with the appropriate exit code (1)
|
||||
// previous behavior would exit after 25 sec with status code 0, even if this step errors
|
||||
if tm.exitAfterAuth {
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
if (existingEtag != currentEtag) || firstRun {
|
||||
|
||||
|
@ -6,9 +6,14 @@ description: "The guide to spending money at Infisical."
|
||||
|
||||
Fairly frequently, you might run into situations when you need to spend company money.
|
||||
|
||||
<Note>
|
||||
Please spend money in a way that you think is in the best interest of the company.
|
||||
</Note>
|
||||
|
||||
# Expensing Meals
|
||||
|
||||
As a perk of working at Infisical, we cover some of your meal expenses.
|
||||
|
||||
HQ team members: meals and unlimited snacks are provided on-site at no cost.
|
||||
|
||||
Remote team members: a food stipend is allocated based on location.
|
||||
|
||||
# Trivial expenses
|
||||
|
||||
@ -18,6 +23,10 @@ This means expenses that are:
|
||||
1. Non-recurring AND less than $75/month in total.
|
||||
2. Recurring AND less than $20/month.
|
||||
|
||||
<Note>
|
||||
Please spend money in a way that you think is in the best interest of the company.
|
||||
</Note>
|
||||
|
||||
## Saving receipts
|
||||
|
||||
Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay.
|
||||
|
@ -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.
|
||||
|
||||

|
||||
|
||||
|
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 (
|
||||
<SelectPrimitive.Item
|
||||
{...props}
|
||||
disabled={isDisabled}
|
||||
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",
|
||||
isSelected && "bg-primary",
|
||||
|
@ -48,7 +48,7 @@ export const useGetCertBundle = (serialNumber: string) => {
|
||||
certificate: string;
|
||||
certificateChain: string;
|
||||
serialNumber: string;
|
||||
privateKey: string;
|
||||
privateKey: string | null;
|
||||
}>(`/api/v1/pki/certificates/${serialNumber}/bundle`);
|
||||
return data;
|
||||
},
|
||||
|
@ -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: () => {
|
||||
|
@ -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 = {
|
||||
|
@ -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<TViewSharedSecretResponse>(
|
||||
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
|
||||
{
|
||||
...(hashedHex && { hashedHex }),
|
||||
password
|
||||
password,
|
||||
email,
|
||||
hash
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -32,6 +32,7 @@ export type TCreateSharedSecretRequest = {
|
||||
expiresAt: Date;
|
||||
expiresAfterViews?: number;
|
||||
accessType?: SecretSharingAccessType;
|
||||
emails?: string[];
|
||||
};
|
||||
|
||||
export type TCreateSecretRequestRequestDTO = {
|
||||
|
@ -277,13 +277,20 @@ export const useUpdateProject = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Workspace, object, UpdateProjectDTO>({
|
||||
mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => {
|
||||
mutationFn: async ({
|
||||
projectID,
|
||||
newProjectName,
|
||||
newProjectDescription,
|
||||
newSlug,
|
||||
secretSharing
|
||||
}) => {
|
||||
const { data } = await apiRequest.patch<{ workspace: Workspace }>(
|
||||
`/api/v1/workspace/${projectID}`,
|
||||
{
|
||||
name: newProjectName,
|
||||
description: newProjectDescription,
|
||||
slug: newSlug
|
||||
slug: newSlug,
|
||||
secretSharing
|
||||
}
|
||||
);
|
||||
return data.workspace;
|
||||
|
@ -37,6 +37,7 @@ export type Workspace = {
|
||||
createdAt: string;
|
||||
roles?: TProjectRole[];
|
||||
hasDeleteProtection: boolean;
|
||||
secretSharing: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceEnv = {
|
||||
@ -73,9 +74,10 @@ export type CreateWorkspaceDTO = {
|
||||
|
||||
export type UpdateProjectDTO = {
|
||||
projectID: string;
|
||||
newProjectName: string;
|
||||
newProjectName?: string;
|
||||
newProjectDescription?: string;
|
||||
newSlug?: string;
|
||||
secretSharing?: boolean;
|
||||
};
|
||||
|
||||
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };
|
||||
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
|
@ -30,6 +30,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
allowSecretSharingOutsideOrganization={
|
||||
currentOrg?.allowSecretSharingOutsideOrganization ?? true
|
||||
}
|
||||
maxSharedSecretLifetime={currentOrg?.maxSharedSecretLifetime}
|
||||
maxSharedSecretViewLimit={currentOrg?.maxSharedSecretViewLimit}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
@ -17,11 +17,11 @@ export const SecretSharingSettingsPage = withPermission(
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
|
||||
<title>{t("common.head-title", { title: "Secret Share Settings" })}</title>
|
||||
</Helmet>
|
||||
<div className="flex w-full justify-center bg-bunker-800 text-white">
|
||||
<div className="w-full max-w-7xl">
|
||||
<PageHeader title={t("settings.org.title")} />
|
||||
<PageHeader title="Secret Share Settings" />
|
||||
<SecretSharingSettingsTabGroup />
|
||||
</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";
|
||||
|
||||
export const SecretSharingSettingsGeneralTab = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SecretSharingAllowShareToAnyone />
|
||||
<OrgSecretShareLimitSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Switch } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useUpdateOrg } from "@app/hooks/api";
|
||||
import axios from "axios";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
|
||||
export const OrgProductSelectSection = () => {
|
||||
const [toggledProducts, setToggledProducts] = useState<{
|
||||
@ -79,8 +79,8 @@ export const OrgProductSelectSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<h2 className="text-xl font-semibold text-mineshaft-100">Organization Products</h2>
|
||||
<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">Enabled Products</h2>
|
||||
<p className="mb-4 text-gray-400">
|
||||
Select which products are available for your organization.
|
||||
</p>
|
||||
|
@ -96,8 +96,7 @@ export const OrgUserAccessTokenLimitSection = () => {
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
|
||||
{(isAllowed) => (
|
||||
<form onSubmit={handleSubmit(handleUserTokenExpirationSubmit)} autoComplete="off">
|
||||
<div className="flex max-w-md gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex max-w-sm gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expirationValue"
|
||||
@ -106,6 +105,7 @@ export const OrgUserAccessTokenLimitSection = () => {
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Expiration value"
|
||||
className="w-full"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
@ -119,8 +119,7 @@ export const OrgUserAccessTokenLimitSection = () => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="expirationUnit"
|
||||
@ -151,7 +150,6 @@ export const OrgUserAccessTokenLimitSection = () => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
|
@ -91,7 +91,7 @@ export const ShareSecretPage = () => {
|
||||
Infisical
|
||||
</a>
|
||||
<br />
|
||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
||||
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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<typeof schema>;
|
||||
@ -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<string | null>(null);
|
||||
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||
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<FormData>({
|
||||
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,24 +121,34 @@ 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
|
||||
});
|
||||
|
||||
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);
|
||||
reset();
|
||||
|
||||
navigator.clipboard.writeText(link);
|
||||
setCopyTextSecret("secret");
|
||||
@ -102,6 +157,9 @@ export const ShareSecretForm = ({
|
||||
text: "Shared secret link copied to clipboard.",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
|
||||
reset();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
@ -111,9 +169,8 @@ export const ShareSecretForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const hasSecretLink = Boolean(secretLink);
|
||||
|
||||
return !hasSecretLink ? (
|
||||
if (secretLink === null)
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
{!isPublic && (
|
||||
<Controller
|
||||
@ -121,7 +178,8 @@ export const ShareSecretForm = ({
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Name (Optional)"
|
||||
label="Name"
|
||||
isOptional
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
@ -180,12 +238,69 @@ export const ShareSecretForm = ({
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPublic && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="accessType"
|
||||
defaultValue={SecretSharingAccessType.Organization}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
helperText={
|
||||
allowSecretSharingOutsideOrganization ? undefined : (
|
||||
<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"
|
||||
defaultValue="3600000"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Expires In" errorText={error?.message} isError={Boolean(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}
|
||||
@ -193,7 +308,11 @@ export const ShareSecretForm = ({
|
||||
className="w-full"
|
||||
>
|
||||
{expiresInOptions.map(({ label, value: expiresInValue }) => (
|
||||
<SelectItem value={String(expiresInValue || "")} key={label}>
|
||||
<SelectItem
|
||||
value={String(expiresInValue || "")}
|
||||
key={label}
|
||||
isDisabled={!filteredExpiresInOptions.some((v) => v.label === label)}
|
||||
>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@ -204,9 +323,21 @@ export const ShareSecretForm = ({
|
||||
<Controller
|
||||
control={control}
|
||||
name="viewLimit"
|
||||
defaultValue="-1"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Max Views" errorText={error?.message} isError={Boolean(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}
|
||||
@ -214,7 +345,11 @@ export const ShareSecretForm = ({
|
||||
className="w-full"
|
||||
>
|
||||
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
||||
<SelectItem value={String(viewLimitValue || "")} key={label}>
|
||||
<SelectItem
|
||||
value={String(viewLimitValue || "")}
|
||||
key={label}
|
||||
isDisabled={!filteredViewLimitOptions.some((v) => v.label === label)}
|
||||
>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@ -222,30 +357,34 @@ export const ShareSecretForm = ({
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPublic && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="accessType"
|
||||
defaultValue={SecretSharingAccessType.Organization}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="General Access" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
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}
|
||||
>
|
||||
{allowSecretSharingOutsideOrganization && (
|
||||
<SelectItem value={SecretSharingAccessType.Anyone}>Anyone</SelectItem>
|
||||
)}
|
||||
<SelectItem value={SecretSharingAccessType.Organization}>
|
||||
People within your organization
|
||||
</SelectItem>
|
||||
</Select>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="user1@example.com, user2@example.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
@ -255,8 +394,31 @@ export const ShareSecretForm = ({
|
||||
>
|
||||
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">
|
||||
<p className="mr-4 break-all">{secretLink}</p>
|
||||
@ -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={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
|
||||
>
|
||||
Share Another Secret
|
||||
|
@ -175,7 +175,7 @@ export const ViewSecretRequestByIDPage = () => {
|
||||
Infisical
|
||||
</a>
|
||||
<br />
|
||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
||||
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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<string>();
|
||||
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
|
||||
</a>
|
||||
<br />
|
||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
||||
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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")({
|
||||
|
@ -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<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: secret
|
||||
values: secret,
|
||||
disabled: !secret
|
||||
});
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp([
|
||||
@ -398,11 +398,19 @@ export const SecretDetailSidebar = ({
|
||||
autoFocus={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content="You don't have permission to view the secret value."
|
||||
isDisabled={!secret?.secretValueHidden}
|
||||
content={
|
||||
!currentWorkspace.secretSharing
|
||||
? "This project does not allow secret sharing."
|
||||
: "You don't have permission to view the secret value."
|
||||
}
|
||||
isDisabled={
|
||||
!secret?.secretValueHidden && currentWorkspace.secretSharing
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isDisabled={secret?.secretValueHidden}
|
||||
isDisabled={
|
||||
secret?.secretValueHidden || !currentWorkspace.secretSharing
|
||||
}
|
||||
className="px-2 py-[0.43rem] font-normal"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faShare} />}
|
||||
@ -705,14 +713,25 @@ export const SecretDetailSidebar = ({
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl label="Comments & Notes">
|
||||
<Controller
|
||||
control={control}
|
||||
name="comment"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Comments & Notes"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 bg-bunker-800 text-sm"
|
||||
{...register("comment")}
|
||||
readOnly={isReadOnly}
|
||||
rows={5}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormControl>
|
||||
{secretReminderRepeatDays && secretReminderRepeatDays > 0 ? (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
@ -913,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 })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateRight} />
|
||||
</IconButton>
|
||||
|
@ -589,7 +589,7 @@ export const SecretItem = memo(
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton
|
||||
isDisabled={secret.secretValueHidden}
|
||||
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
variant="plain"
|
||||
size="md"
|
||||
|
@ -10,6 +10,7 @@ import { DeleteProjectSection } from "../DeleteProjectSection";
|
||||
import { EnvironmentSection } from "../EnvironmentSection";
|
||||
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
|
||||
import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection";
|
||||
import { SecretSharingSection } from "../SecretSharingSection";
|
||||
import { SecretTagsSection } from "../SecretTagsSection";
|
||||
|
||||
export const ProjectGeneralTab = () => {
|
||||
@ -22,6 +23,7 @@ export const ProjectGeneralTab = () => {
|
||||
{isSecretManager && <EnvironmentSection />}
|
||||
{isSecretManager && <SecretTagsSection />}
|
||||
{isSecretManager && <AutoCapitalizationSection />}
|
||||
{isSecretManager && <SecretSharingSection />}
|
||||
{isSecretManager && <PointInTimeVersionLimitSection />}
|
||||
<AuditLogsRetentionSection />
|
||||
{isSecretManager && <BackfillSecretReferenceSecretion />}
|
||||
|
@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Checkbox } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useUpdateProject } from "@app/hooks/api/workspace/queries";
|
||||
|
||||
export const SecretSharingSection = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { mutateAsync: updateProject } = useUpdateProject();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleToggle = async (state: boolean) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (!currentWorkspace?.id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateProject({
|
||||
projectID: currentWorkspace.id,
|
||||
secretSharing: state
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${state ? "enabled" : "disabled"} secret sharing for this project`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update secret sharing for this project",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-3 text-xl font-semibold">Allow Secret Sharing</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
|
||||
{(isAllowed) => (
|
||||
<div className="w-max">
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary"
|
||||
id="secretSharing"
|
||||
isDisabled={!isAllowed || isLoading}
|
||||
isChecked={currentWorkspace?.secretSharing ?? true}
|
||||
onCheckedChange={(state) => handleToggle(state as boolean)}
|
||||
>
|
||||
This feature enables your project members to securely share secrets.
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { SecretSharingSection } from "./SecretSharingSection";
|
Reference in New Issue
Block a user