Compare commits

...

26 Commits

Author SHA1 Message Date
Maidul Islam
fec55bc9f8 fix greptile recs 2025-05-02 16:40:56 -04:00
Maidul Islam
47bb3c10fa Add identity-specific-privilege v2 API to docs
Add identity-specific-privilege v2 API to docs
2025-05-02 00:32:17 -04:00
Maidul Islam
5d44d58ff4 update postgres reqs 2025-04-30 17:53:41 -04:00
carlosmonastyrski
ff294dab8d Merge pull request #3507 from Infisical/feat/orgUserAuthTokenExpiration
feat(user-auth): make users auth token expiration customizable for orgs
2025-04-30 18:18:38 -03:00
carlosmonastyrski
c99440ba81 feat(user-auth): use ms library and update docs 2025-04-30 16:49:33 -03:00
carlosmonastyrski
6d5a6f42e0 Merge branch 'main' into feat/orgUserAuthTokenExpiration 2025-04-30 15:59:52 -03:00
x032205
0c027fdc43 Merge pull request #3516 from Infisical/feat/teamcity-root-project
remove _Root filter for projects
2025-04-30 12:07:24 -04:00
x
727a6a7701 remove _Root filter for projects 2025-04-30 10:31:40 -04:00
carlosmonastyrski
7f1f9e7fd0 Merge pull request #3491 from Infisical/feat/improveSecretReferenceWarning
feat(secrets-ui): Add direct reference warning on secrets updates and add secret sync warning on deletion
2025-04-30 08:17:55 -03:00
Daniel Hougaard
98f742a807 Merge pull request #3513 from Infisical/daniel/k8s-hsm-docs
docs: fix hsm kubernetes documentation
2025-04-30 06:10:30 +04:00
Daniel Hougaard
66f1967f88 Update hsm-integration.mdx 2025-04-30 05:37:55 +04:00
Daniel Hougaard
da6cf85c8d fix: remove log output file 2025-04-30 05:37:07 +04:00
Daniel Hougaard
e8b6eb0573 docs: fix hsm kubernetes documentation 2025-04-30 05:09:39 +04:00
Maidul Islam
03ad5c5db0 Merge pull request #3512 from Infisical/daniel/kms-docs
docs: prerequisite for aws key
2025-04-29 20:39:30 -04:00
Daniel Hougaard
d4ac4f8d8f Update CollapsibleSecretImports.tsx 2025-04-30 03:13:10 +04:00
carlosmonastyrski
f0229c5ecf feat(user-auth): fix migration bug for e2e suite 2025-04-29 18:48:08 -03:00
carlosmonastyrski
8d711af23b feat(secrets-ui): change secret sync icon color 2025-04-29 18:39:41 -03:00
carlosmonastyrski
7bd61d88fc feat(user-auth): improve token refresh logic and default values 2025-04-29 18:28:18 -03:00
carlosmonastyrski
c47d76a6c7 feat(secrets-ui): improve warning message table 2025-04-29 14:19:52 -03:00
carlosmonastyrski
e959ed7fab feat(secrets-ui): improve warning message and logic for secret-sync on secret imports 2025-04-29 10:15:53 -03:00
carlosmonastyrski
4e4b1b689b Merge branch 'main' into feat/improveSecretReferenceWarning 2025-04-29 08:43:35 -03:00
carlosmonastyrski
024ed0c0d8 feat(user-auth): add pr suggestions 2025-04-28 18:19:44 -03:00
carlosmonastyrski
e99e360339 feat(user-auth): make users auth token expiration customizable for orgs 2025-04-28 17:43:10 -03:00
carlosmonastyrski
f345801bd6 feat(secrets-ui): improve types and code quality 2025-04-25 18:17:33 -03:00
carlosmonastyrski
4160009913 feat(secrets-ui): add direct reference warning on secrets updates 2025-04-25 17:38:43 -03:00
carlosmonastyrski
d5065af7e9 feat(secrets-ui): add secret syncs to referenced secret warning 2025-04-25 15:26:34 -03:00
51 changed files with 898 additions and 163 deletions

View File

@@ -0,0 +1,27 @@
import { Knex } from "knex";
import { getConfig } from "@app/lib/config/env";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const appCfg = getConfig();
const tokenDuration = appCfg?.JWT_REFRESH_LIFETIME;
if (!(await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration"))) {
await knex.schema.alterTable(TableName.Organization, (t) => {
t.string("userTokenExpiration");
});
if (tokenDuration) {
await knex(TableName.Organization).update({ userTokenExpiration: tokenDuration });
}
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration")) {
await knex.schema.alterTable(TableName.Organization, (t) => {
t.dropColumn("userTokenExpiration");
});
}
}

View File

@@ -28,7 +28,8 @@ export const OrganizationsSchema = z.object({
shouldUseNewPrivilegeSystem: z.boolean().default(true),
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),
privilegeUpgradeInitiatedAt: z.date().nullable().optional(),
bypassOrgAuthEnabled: z.boolean().default(false)
bypassOrgAuthEnabled: z.boolean().default(false),
userTokenExpiration: z.string().nullable().optional()
});
export type TOrganizations = z.infer<typeof OrganizationsSchema>;

View File

@@ -6,4 +6,5 @@ export * from "./array";
export * from "./dates";
export * from "./object";
export * from "./string";
export * from "./time";
export * from "./undefined";

View File

@@ -0,0 +1,21 @@
import ms, { StringValue } from "ms";
const convertToMilliseconds = (exp: string | number): number => {
if (typeof exp === "number") {
return exp * 1000;
}
const result = ms(exp as StringValue);
if (typeof result !== "number") {
throw new Error(`Invalid expiration format: ${exp}`);
}
return result;
};
export const getMinExpiresIn = (exp1: string | number, exp2: string | number): string | number => {
const ms1 = convertToMilliseconds(exp1);
const ms2 = convertToMilliseconds(exp2);
return ms1 <= ms2 ? exp1 : exp2;
};

View File

@@ -1541,6 +1541,7 @@ export const registerRoutes = async (
const secretSyncService = secretSyncServiceFactory({
secretSyncDAL,
secretImportDAL,
permissionService,
appConnectionService,
folderDAL,

View File

@@ -2,6 +2,7 @@ import jwt from "jsonwebtoken";
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
import { getMinExpiresIn } from "@app/lib/fn";
import { authRateLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode, AuthTokenType } from "@app/services/auth/auth-type";
@@ -79,6 +80,18 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => {
handler: async (req) => {
const { decodedToken, tokenVersion } = await server.services.authToken.validateRefreshToken(req.cookies.jid);
const appCfg = getConfig();
let expiresIn: string | number = appCfg.JWT_AUTH_LIFETIME;
if (decodedToken.organizationId) {
const org = await server.services.org.findOrganizationById(
decodedToken.userId,
decodedToken.organizationId,
decodedToken.authMethod,
decodedToken.organizationId
);
if (org && org.userTokenExpiration) {
expiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration);
}
}
const token = jwt.sign(
{
@@ -92,7 +105,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => {
mfaMethod: decodedToken.mfaMethod
},
appCfg.AUTH_SECRET,
{ expiresIn: appCfg.JWT_AUTH_LIFETIME }
{ expiresIn }
);
return { token, organizationId: decodedToken.organizationId };

View File

@@ -154,7 +154,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
secrets: z
.object({
secretId: z.string(),
referencedSecretKey: z.string()
referencedSecretKey: z.string(),
referencedSecretEnv: z.string()
})
.array()
.optional()
@@ -166,6 +167,16 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
})
.array()
.optional(),
usedBySecretSyncs: z
.object({
name: z.string(),
destination: z.string(),
environment: z.string(),
id: z.string(),
path: z.string()
})
.array()
.optional(),
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
@@ -500,6 +511,24 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
const usedBySecretSyncs: { name: string; destination: string; environment: string; id: string; path: string }[] =
[];
for await (const environment of environments) {
const secretSyncs = await server.services.secretSync.listSecretSyncsBySecretPath(
{ projectId, secretPath, environment },
req.permission
);
secretSyncs.forEach((sync) => {
usedBySecretSyncs.push({
name: sync.name,
destination: sync.destination,
environment,
id: sync.id,
path: sync.folder?.path || "/"
});
});
}
return {
folders,
dynamicSecrets,
@@ -512,6 +541,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalSecretCount,
totalSecretRotationCount,
importedByEnvs,
usedBySecretSyncs,
totalCount:
(totalFolderCount ?? 0) +
(totalDynamicSecretCount ?? 0) +
@@ -611,6 +641,16 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
usedBySecretSyncs: z
.object({
name: z.string(),
destination: z.string(),
environment: z.string(),
id: z.string(),
path: z.string()
})
.array()
.optional(),
importedBy: z
.object({
environment: z.object({
@@ -624,7 +664,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
secrets: z
.object({
secretId: z.string(),
referencedSecretKey: z.string()
referencedSecretKey: z.string(),
referencedSecretEnv: z.string()
})
.array()
.optional()
@@ -904,6 +945,18 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
secrets
});
const secretSyncs = await server.services.secretSync.listSecretSyncsBySecretPath(
{ projectId, secretPath, environment },
req.permission
);
const usedBySecretSyncs = secretSyncs.map((sync) => ({
name: sync.name,
destination: sync.destination,
environment: sync.environment?.name || environment,
id: sync.id,
path: sync.folder?.path || "/"
}));
if (secrets?.length || secretRotations?.length) {
const secretCount =
(secrets?.length ?? 0) +
@@ -950,6 +1003,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalSecretCount,
totalSecretRotationCount,
importedBy,
usedBySecretSyncs,
totalCount:
(totalImportCount ?? 0) +
(totalFolderCount ?? 0) +

View File

@@ -1,3 +1,4 @@
import RE2 from "re2";
import { z } from "zod";
import {
@@ -263,7 +264,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
enforceMfa: z.boolean().optional(),
selectedMfaMethod: z.nativeEnum(MfaMethod).optional(),
allowSecretSharingOutsideOrganization: z.boolean().optional(),
bypassOrgAuthEnabled: z.boolean().optional()
bypassOrgAuthEnabled: z.boolean().optional(),
userTokenExpiration: z
.string()
.refine((val) => new RE2(/^\d+[mhdw]$/).test(val), "Must be a number followed by m, h, d, or w")
.refine(
(val) => {
const numericPart = val.slice(0, -1);
return parseInt(numericPart, 10) >= 1;
},
{ message: "Duration value must be at least 1" }
)
.optional()
}),
response: {
200: z.object({

View File

@@ -69,6 +69,5 @@ export const listTeamCityProjects = async (appConnection: TTeamCityConnection) =
}
);
// Filter out the root project. Should not be seen by users.
return resp.data.project.filter((proj) => proj.id !== "_Root");
return resp.data.project;
};

View File

@@ -12,7 +12,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { getUserPrivateKey } from "@app/lib/crypto/srp";
import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { getUserAgentType } from "@app/server/plugins/audit-log";
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
@@ -143,6 +143,17 @@ export const authLoginServiceFactory = ({
);
if (!tokenSession) throw new Error("Failed to create token");
let tokenSessionExpiresIn: string | number = cfg.JWT_AUTH_LIFETIME;
let refreshTokenExpiresIn: string | number = cfg.JWT_REFRESH_LIFETIME;
if (organizationId) {
const org = await orgDAL.findById(organizationId);
if (org && org.userTokenExpiration) {
tokenSessionExpiresIn = getMinExpiresIn(cfg.JWT_AUTH_LIFETIME, org.userTokenExpiration);
refreshTokenExpiresIn = org.userTokenExpiration;
}
}
const accessToken = jwt.sign(
{
authMethod,
@@ -155,7 +166,7 @@ export const authLoginServiceFactory = ({
mfaMethod
},
cfg.AUTH_SECRET,
{ expiresIn: cfg.JWT_AUTH_LIFETIME }
{ expiresIn: tokenSessionExpiresIn }
);
const refreshToken = jwt.sign(
@@ -170,7 +181,7 @@ export const authLoginServiceFactory = ({
mfaMethod
},
cfg.AUTH_SECRET,
{ expiresIn: cfg.JWT_REFRESH_LIFETIME }
{ expiresIn: refreshTokenExpiresIn }
);
return { access: accessToken, refresh: refreshToken };

View File

@@ -10,6 +10,7 @@ import { getConfig } from "@app/lib/config/env";
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp";
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { getMinExpiresIn } from "@app/lib/fn";
import { isDisposableEmail } from "@app/lib/validator";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -46,7 +47,7 @@ type TAuthSignupDep = {
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser" | "findProjectById">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
orgService: Pick<TOrgServiceFactory, "createOrganization">;
orgService: Pick<TOrgServiceFactory, "createOrganization" | "findOrganizationById">;
orgDAL: TOrgDALFactory;
tokenService: TAuthTokenServiceFactory;
smtpService: TSmtpService;
@@ -320,6 +321,17 @@ export const authSignupServiceFactory = ({
projectBotDAL
});
let tokenSessionExpiresIn: string | number = appCfg.JWT_AUTH_LIFETIME;
let refreshTokenExpiresIn: string | number = appCfg.JWT_REFRESH_LIFETIME;
if (organizationId) {
const org = await orgService.findOrganizationById(user.id, organizationId, authMethod, organizationId);
if (org && org.userTokenExpiration) {
tokenSessionExpiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration);
refreshTokenExpiresIn = org.userTokenExpiration;
}
}
const tokenSession = await tokenService.getUserTokenSession({
userAgent,
ip,
@@ -337,7 +349,7 @@ export const authSignupServiceFactory = ({
organizationId
},
appCfg.AUTH_SECRET,
{ expiresIn: appCfg.JWT_AUTH_LIFETIME }
{ expiresIn: tokenSessionExpiresIn }
);
const refreshToken = jwt.sign(
@@ -350,7 +362,7 @@ export const authSignupServiceFactory = ({
organizationId
},
appCfg.AUTH_SECRET,
{ expiresIn: appCfg.JWT_REFRESH_LIFETIME }
{ expiresIn: refreshTokenExpiresIn }
);
return { user: updateduser.info, accessToken, refreshToken, organizationId };

View File

@@ -17,5 +17,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
shouldUseNewPrivilegeSystem: true,
privilegeUpgradeInitiatedByUsername: true,
privilegeUpgradeInitiatedAt: true,
bypassOrgAuthEnabled: true
bypassOrgAuthEnabled: true,
userTokenExpiration: true
});

View File

@@ -170,8 +170,12 @@ export const orgServiceFactory = ({
actorOrgId: string | undefined
) => {
await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
const appCfg = getConfig();
const org = await orgDAL.findOrgById(orgId);
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
if (!org.userTokenExpiration) {
return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME };
}
return org;
};
/*
@@ -350,7 +354,8 @@ export const orgServiceFactory = ({
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
bypassOrgAuthEnabled,
userTokenExpiration
}
}: TUpdateOrgDTO) => {
const appCfg = getConfig();
@@ -451,7 +456,8 @@ export const orgServiceFactory = ({
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
bypassOrgAuthEnabled,
userTokenExpiration
});
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org;

View File

@@ -74,6 +74,7 @@ export type TUpdateOrgDTO = {
selectedMfaMethod: MfaMethod;
allowSecretSharingOutsideOrganization: boolean;
bypassOrgAuthEnabled: boolean;
userTokenExpiration: string;
}>;
} & TOrgPermission;

View File

@@ -171,6 +171,19 @@ export const secretImportDALFactory = (db: TDbClient) => {
}
};
const getFolderImports = async (secretPath: string, environmentId: string, tx?: Knex) => {
try {
const folderImports = await (tx || db.replicaNode())(TableName.SecretImport)
.where({ importPath: secretPath, importEnv: environmentId })
.join(TableName.SecretFolder, `${TableName.SecretImport}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.select(db.ref("id").withSchema(TableName.SecretFolder).as("folderId"));
return folderImports;
} catch (error) {
throw new DatabaseError({ error, name: "get secret imports" });
}
};
const getFolderIsImportedBy = async (
secretPath: string,
environmentId: string,
@@ -203,7 +216,8 @@ export const secretImportDALFactory = (db: TDbClient) => {
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("id").withSchema(TableName.SecretFolder).as("folderId"),
db.ref("secretKey").withSchema(TableName.SecretReferenceV2).as("referencedSecretKey")
db.ref("secretKey").withSchema(TableName.SecretReferenceV2).as("referencedSecretKey"),
db.ref("environment").withSchema(TableName.SecretReferenceV2).as("referencedSecretEnv")
);
const folderResults = folderImports.map(({ envName, envSlug, folderName, folderId }) => ({
@@ -214,13 +228,14 @@ export const secretImportDALFactory = (db: TDbClient) => {
}));
const secretResults = secretReferences.map(
({ envName, envSlug, secretId, folderName, folderId, referencedSecretKey }) => ({
({ envName, envSlug, secretId, folderName, folderId, referencedSecretKey, referencedSecretEnv }) => ({
envName,
envSlug,
secretId,
folderName,
folderId,
referencedSecretKey
referencedSecretKey,
referencedSecretEnv
})
);
@@ -235,6 +250,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
secrets: {
secretId: string;
referencedSecretKey: string;
referencedSecretEnv: string;
}[];
folderId: string;
folderImported: boolean;
@@ -264,7 +280,11 @@ export const secretImportDALFactory = (db: TDbClient) => {
if ("secretId" in item && item.secretId) {
updatedAcc[env].folders[folder].secrets = [
...updatedAcc[env].folders[folder].secrets,
{ secretId: item.secretId, referencedSecretKey: item.referencedSecretKey }
{
secretId: item.secretId,
referencedSecretKey: item.referencedSecretKey,
referencedSecretEnv: item.referencedSecretEnv
}
];
} else {
updatedAcc[env].folders[folder].folderImported = true;
@@ -309,6 +329,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
findLastImportPosition,
updateAllPosition,
getProjectImportCount,
getFolderIsImportedBy
getFolderIsImportedBy,
getFolderImports
};
};

View File

@@ -808,7 +808,7 @@ export const secretImportServiceFactory = ({
actorOrgId,
secrets
}: TGetSecretImportsDTO & {
secrets: { secretKey: string; secretValue: string }[] | undefined;
secrets: { secretKey: string; secretValue: string; id: string }[] | undefined;
}) => {
const { permission } = await permissionService.getProjectPermission({
actor,
@@ -877,7 +877,8 @@ export const secretImportServiceFactory = ({
)
.map((otherSecret) => ({
secretId: secret.secretKey,
referencedSecretKey: otherSecret.secretKey
referencedSecretKey: otherSecret.secretKey,
referencedSecretEnv: environment
}));
}) || [];
if (locallyReferenced.length > 0) {

View File

@@ -56,11 +56,12 @@ export type FolderResult = {
export type SecretResult = {
secretId: string;
referencedSecretKey: string;
referencedSecretEnv: string;
} & FolderResult;
export type FolderInfo = {
folderName: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
folderId: string;
folderImported: boolean;
envSlug?: string;

View File

@@ -23,6 +23,7 @@ import {
TDeleteSecretSyncDTO,
TFindSecretSyncByIdDTO,
TFindSecretSyncByNameDTO,
TListSecretSyncsByFolderId,
TListSecretSyncsByProjectId,
TSecretSync,
TTriggerSecretSyncImportSecretsByIdDTO,
@@ -31,12 +32,14 @@ import {
TUpdateSecretSyncDTO
} from "@app/services/secret-sync/secret-sync-types";
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
import { TSecretSyncDALFactory } from "./secret-sync-dal";
import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps";
import { TSecretSyncQueueFactory } from "./secret-sync-queue";
type TSecretSyncServiceFactoryDep = {
secretSyncDAL: TSecretSyncDALFactory;
secretImportDAL: TSecretImportDALFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
@@ -53,6 +56,7 @@ export type TSecretSyncServiceFactory = ReturnType<typeof secretSyncServiceFacto
export const secretSyncServiceFactory = ({
secretSyncDAL,
folderDAL,
secretImportDAL,
permissionService,
appConnectionService,
projectBotService,
@@ -85,6 +89,37 @@ export const secretSyncServiceFactory = ({
return secretSyncs as TSecretSync[];
};
const listSecretSyncsBySecretPath = async (
{ projectId, secretPath, environment }: TListSecretSyncsByFolderId,
actor: OrgServiceActor
) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId
});
if (permission.cannot(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs)) {
return [];
}
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return [];
const folderImports = await secretImportDAL.getFolderImports(secretPath, folder.envId);
const secretSyncs = await secretSyncDAL.find({
$in: {
folderId: folderImports.map((folderImport) => folderImport.folderId).concat(folder.id)
}
});
return secretSyncs as TSecretSync[];
};
const findSecretSyncById = async ({ destination, syncId }: TFindSecretSyncByIdDTO, actor: OrgServiceActor) => {
const secretSync = await secretSyncDAL.findById(syncId);
@@ -518,6 +553,7 @@ export const secretSyncServiceFactory = ({
return {
listSecretSyncOptions,
listSecretSyncsByProjectId,
listSecretSyncsBySecretPath,
findSecretSyncById,
findSecretSyncByName,
createSecretSync,

View File

@@ -144,6 +144,13 @@ export type TListSecretSyncsByProjectId = {
destination?: SecretSync;
};
export type TListSecretSyncsByFolderId = {
projectId: string;
secretPath: string;
environment: string;
destination?: SecretSync;
};
export type TFindSecretSyncByIdDTO = {
syncId: string;
destination: SecretSync;

View File

@@ -1,4 +1,4 @@
---
title: "Find By Privilege Slug"
title: "Find By Slug"
openapi: "GET /api/v1/additional-privilege/identity/{privilegeSlug}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v2/identity-project-additional-privilege"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v2/identity-project-additional-privilege/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Find By ID"
openapi: "GET /api/v2/identity-project-additional-privilege/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Find By Slug"
openapi: "GET /api/v2/identity-project-additional-privilege/slug/{privilegeSlug}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/identity-project-additional-privilege"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v2/identity-project-additional-privilege/{id}"
---

View File

@@ -268,11 +268,11 @@ For organizations that work with US government agencies, FIPS compliance is almo
<Steps>
<Step title="Create HSM client folder">
When using Kubernetes, you need to mount the path containing the HSM client files. This section covers how to configure your Infisical instance to use an HSM with Kubernetes.
When using Kubernetes, you need to mount the path containing the HSM client files. This section covers how to configure your Infisical instance to use an HSM with Kubernetes. In this example, we are going to be using `/etc/luna-docker`.
```bash
mkdir /etc/hsm-client
mkdir /etc/luna-docker
```
After [setting up your Luna Cloud HSM client](https://thalesdocs.com/gphsm/luna/7/docs/network/Content/install/client_install/add_dpod.htm), you should have a set of files, referred to as the HSM client. You don't need all the files, but for simplicity we recommend copying all the files from the client.
@@ -306,20 +306,60 @@ For organizations that work with US government agencies, FIPS compliance is almo
The most important parts of the client folder is the `Chrystoki.conf` file, and the `libs`, `plugins`, and `jsp` folders. You need to copy these files to the folder you created in the first step.
```bash
cp -r /<path-to-where-your-hsm-client-is-located> /etc/hsm-client
cp -r /<path-to-where-your-luna-client-is-located>/* /etc/luna-docker
```
<Note>
The `/*` wildcard will copy all files and folders within the HSM client. The wildcard is important to ensure that the file structure is inline with the rest of this guide.
</Note>
After copying the files, the `/etc/luna-docker` directory should have the following file structure:
```bash
$ ls -R /etc/luna-docker
Chrystoki.conf etc lock server-certificate.pem
Chrystoki.conf.tmp2E jsp partition-ca-certificate.pem setenv
lch-support-linux-64bit partition-certificate.pem
bin libs plugins
/etc/luna-docker/bin:
64
/etc/luna-docker/bin/64:
ckdemo cmu lunacm multitoken vtl
/etc/luna-docker/etc:
openssl.cnf
/etc/luna-docker/jsp:
64 LunaProvider.jar
/etc/luna-docker/jsp/64:
libLunaAPI.so
/etc/luna-docker/libs:
64
/etc/luna-docker/libs/64:
libCryptoki2.so
/etc/luna-docker/lock:
/etc/luna-docker/plugins:
libcloud.plugin
```
</Step>
<Step title="Update Chrystoki.conf">
The `Chrystoki.conf` file is used to configure the HSM client. You need to update the `Chrystoki.conf` file to point to the correct file paths.
In this example, we will be mounting the `/etc/hsm-client` folder from the host to containers in our deployment's pods at the path `/hsm-client`. This means the contents of `/etc/hsm-client` on the host will be accessible at `/hsm-client` within the containers.
In this example, we will be mounting the `/etc/luna-docker` folder from the host to containers in our deployment's pods at the path `/usr/safenet/lunaclient`. This means the contents of `/etc/luna-docker` on the host will be accessible at `/usr/safenet/lunaclient` within the containers.
An example config file will look like this:
```Chrystoki.conf
Chrystoki2 = {
# This path points to the mounted path, /hsm-client
LibUNIX64 = /hsm-client/libs/64/libCryptoki2.so;
# This path points to the mounted path, /usr/safenet/lunaclient
LibUNIX64 = /usr/safenet/lunaclient/libs/64/libCryptoki2.so;
}
Luna = {
@@ -339,8 +379,8 @@ For organizations that work with US government agencies, FIPS compliance is almo
Misc = {
# Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step.
PluginModuleDir = /hsm-client/plugins;
MutexFolder = /hsm-client/lock;
PluginModuleDir = /usr/safenet/lunaclient/plugins;
MutexFolder = /usr/safenet/lunaclient/lock;
PE1746Enabled = 1;
ToolsDir = /usr/bin;
@@ -353,7 +393,7 @@ For organizations that work with US government agencies, FIPS compliance is almo
LunaSA Client = {
ReceiveTimeout = 20000;
# Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step.
SSLConfigFile = /hsm-client/etc/openssl.cnf;
SSLConfigFile = /usr/safenet/lunaclient/etc/openssl.cnf;
ClientPrivKeyFile = ./etc/ClientNameKey.pem;
ClientCertFile = ./etc/ClientNameCert.pem;
ServerCAFile = ./etc/CAFile.pem;
@@ -441,7 +481,7 @@ For organizations that work with US government agencies, FIPS compliance is almo
```bash
kubectl exec hsm-setup-pod -- mkdir -p /data/ # Create the data directory
kubectl cp ./hsm-client/ hsm-setup-pod:/data/ # Copy the HSM client files into the PVC
kubectl cp /etc/luna-docker/. hsm-setup-pod:/data/ # Copy the HSM client files into the PVC
kubectl exec hsm-setup-pod -- chmod -R 755 /data/ # Set the correct permissions for the HSM client files
```
@@ -456,7 +496,7 @@ For organizations that work with US government agencies, FIPS compliance is almo
Next we need to update the environment variables used for the deployment. If you followed the [setup instructions for Kubernetes deployments](/self-hosting/deployment-options/kubernetes-helm), you should have a Kubernetes secret called `infisical-secrets`.
We need to update the secret with the following environment variables:
- `HSM_LIB_PATH` - The path to the HSM client library _(mapped to `/hsm-client/libs/64/libCryptoki2.so`)_
- `HSM_LIB_PATH` - The path to the HSM client library _(mapped to `/usr/safenet/lunaclient/libs/64/libCryptoki2.so`)_
- `HSM_PIN` - The PIN for the HSM device that you created when setting up your Luna Cloud HSM client
- `HSM_SLOT` - The slot number for the HSM device that you selected when setting up your Luna Cloud HSM client
- `HSM_KEY_LABEL` - The label for the HSM key. If no key is found with the provided key label, the HSM will create a new key with the provided label.
@@ -471,7 +511,7 @@ For organizations that work with US government agencies, FIPS compliance is almo
type: Opaque
stringData:
# ... Other environment variables ...
HSM_LIB_PATH: "/hsm-client/libs/64/libCryptoki2.so" # If you followed this guide, this will be the path of the Luna Cloud HSM client
HSM_LIB_PATH: "/usr/safenet/lunaclient/libs/64/libCryptoki2.so" # If you followed this guide, this will be the path of the Luna Cloud HSM client
HSM_PIN: "<your-hsm-device-pin>"
HSM_SLOT: "<hsm-device-slot>"
HSM_KEY_LABEL: "<your-key-label>"
@@ -487,7 +527,7 @@ For organizations that work with US government agencies, FIPS compliance is almo
<Step title="Updating the Deployment">
After we've successfully configured the PVC and updated our environment variables, we are ready to update the deployment configuration so that the pods it creates can access the HSM client files.
We need to update the Docker image of the deployment to use `infisical/infisical-fips`. The `infisical/infisical-fips` image is a functionally identical image to the `infisical/infisical` image, but it is built with support for HSM encryption.
We need to update the Docker image of the deployment to use `infisical/infisical-fips`. The `infisical/infisical-fips` image is a functionally identical image to the `infisical/infisical` image, but it is built with HSM support.
```yaml
# ... The rest of the values.yaml file ...
@@ -499,8 +539,7 @@ For organizations that work with US government agencies, FIPS compliance is almo
extraVolumeMounts:
- name: hsm-data
mountPath: /hsm-client # The path we will mount the HSM client files to
subPath: ./hsm-client
mountPath: /usr/safenet/lunaclient # The path we will mount the HSM client files to
extraVolumes:
- name: hsm-data

View File

@@ -27,6 +27,10 @@ The **Settings** page lets you manage information about your organization includ
![organization settings auth](../../images/platform/organization/organization-settings-auth.png)
<Tip>
You can adjust the maximum time a user token will remain valid for your organization. After this period, users will be required to re-authenticate. This helps improve security by enforcing regular sign-ins.
</Tip>
## Access Control
The **Access Control** page is where you can manage identities (both people and machines) that are part of your organization.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

After

Width:  |  Height:  |  Size: 352 KiB

View File

@@ -931,12 +931,28 @@
{
"group": "Identity Specific Privilege",
"pages": [
"api-reference/endpoints/identity-specific-privilege/create-permanent",
"api-reference/endpoints/identity-specific-privilege/create-temporary",
"api-reference/endpoints/identity-specific-privilege/update",
"api-reference/endpoints/identity-specific-privilege/delete",
"api-reference/endpoints/identity-specific-privilege/find-by-slug",
"api-reference/endpoints/identity-specific-privilege/list"
{
"group": "V1 (Legacy)",
"pages": [
"api-reference/endpoints/identity-specific-privilege/v1/create-permanent",
"api-reference/endpoints/identity-specific-privilege/v1/create-temporary",
"api-reference/endpoints/identity-specific-privilege/v1/update",
"api-reference/endpoints/identity-specific-privilege/v1/delete",
"api-reference/endpoints/identity-specific-privilege/v1/find-by-slug",
"api-reference/endpoints/identity-specific-privilege/v1/list"
]
},
{
"group": "V2",
"pages": [
"api-reference/endpoints/identity-specific-privilege/v2/create",
"api-reference/endpoints/identity-specific-privilege/v2/update",
"api-reference/endpoints/identity-specific-privilege/v2/delete",
"api-reference/endpoints/identity-specific-privilege/v2/list",
"api-reference/endpoints/identity-specific-privilege/v2/find-by-id",
"api-reference/endpoints/identity-specific-privilege/v2/find-by-slug"
]
}
]
},
{

View File

@@ -73,7 +73,8 @@ The platform utilizes Postgres to persist all of its data and Redis for caching
### PostgreSQL
<Info>
Please note that the database user must have **CREATE** privileges along with ability to create and modify tables. This is needed for Infisical to run schema migrations.
Please note that the database user you create must be granted all privileges on the Infisical database.
This includes the ability to create new schemas, create, update, delete, modify tables and indexes, etc.
</Info>
<ParamField query="DB_CONNECTION_URI" type="string" default="" required>

View File

@@ -19,6 +19,7 @@ type Props = {
formContent?: ReactNode;
children?: ReactNode;
deletionMessage?: ReactNode;
buttonColorSchema?: "danger" | "primary" | "secondary" | "gray" | null;
};
export const DeleteActionModal = ({
@@ -32,6 +33,7 @@ export const DeleteActionModal = ({
buttonText = "Delete",
formContent,
deletionMessage,
buttonColorSchema = "danger",
children
}: Props): JSX.Element => {
const [inputData, setInputData] = useState("");
@@ -67,7 +69,7 @@ export const DeleteActionModal = ({
<div className="mx-2 flex items-center">
<Button
className="mr-4"
colorSchema="danger"
colorSchema={buttonColorSchema}
isDisabled={!(deleteKey === inputData) || isLoading}
onClick={onDelete}
isLoading={isLoading}

View File

@@ -25,9 +25,18 @@ export type DashboardProjectSecretsOverviewResponse = {
totalUniqueFoldersInPage: number;
totalUniqueSecretImportsInPage: number;
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
usedBySecretSyncs?: UsedBySecretSyncs[];
totalUniqueSecretRotationsInPage: number;
};
export type UsedBySecretSyncs = {
name: string;
destination: string;
environment: string;
id: string;
path: string;
};
export type DashboardProjectSecretsDetailsResponse = {
imports?: TSecretImport[];
folders?: TSecretFolder[];
@@ -43,13 +52,14 @@ export type DashboardProjectSecretsDetailsResponse = {
totalSecretRotationCount?: number;
totalCount: number;
importedBy?: ProjectSecretsImportedBy[];
usedBySecretSyncs?: UsedBySecretSyncs[];
};
export type ProjectSecretsImportedBy = {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
};

View File

@@ -111,7 +111,8 @@ export const useUpdateOrg = () => {
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
bypassOrgAuthEnabled,
userTokenExpiration
}) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name,
@@ -122,7 +123,8 @@ export const useUpdateOrg = () => {
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
bypassOrgAuthEnabled,
userTokenExpiration
});
},
onSuccess: () => {

View File

@@ -18,6 +18,7 @@ export type Organization = {
selectedMfaMethod?: MfaMethod;
shouldUseNewPrivilegeSystem: boolean;
allowSecretSharingOutsideOrganization?: boolean;
userTokenExpiration?: string;
userRole: string;
};
@@ -32,6 +33,7 @@ export type UpdateOrgDTO = {
selectedMfaMethod?: MfaMethod;
allowSecretSharingOutsideOrganization?: boolean;
bypassOrgAuthEnabled?: boolean;
userTokenExpiration?: string;
};
export type BillingDetails = {

View File

@@ -23,6 +23,7 @@ import { OrgLDAPSection } from "./OrgLDAPSection";
import { OrgOIDCSection } from "./OrgOIDCSection";
import { OrgScimSection } from "./OrgSCIMSection";
import { OrgSSOSection } from "./OrgSSOSection";
import { OrgUserAccessTokenLimitSection } from "./OrgUserAccessTokenLimitSection";
import { SSOModal } from "./SSOModal";
export const OrgAuthTab = withPermission(
@@ -167,6 +168,7 @@ export const OrgAuthTab = withPermission(
return (
<>
<OrgGenericAuthSection />
<OrgUserAccessTokenLimitSection />
{shouldShowCreateIdentityProviderView ? (
createIdentityProviderView
) : (

View File

@@ -0,0 +1,171 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
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 formSchema = z.object({
expirationValue: z.number().min(1, "Value must be at least 1"),
expirationUnit: z.enum(["m", "h", "d", "w"], {
invalid_type_error: "Please select a valid time unit"
})
});
type TForm = z.infer<typeof formSchema>;
// Function to parse duration string like "30d" into value and unit
const parseDuration = (duration: string): { value: number; unit: string } => {
const match = duration.match(/^(\d+)([mhdw])$/);
if (match) {
return {
value: parseInt(match[1], 10),
unit: match[2]
};
}
// Default to 30 days if invalid format
return { value: 30, unit: "d" };
};
// Function to format value and unit back to duration string
const formatDuration = (value: number, unit: string): string => {
return `${value}${unit}`;
};
export const OrgUserAccessTokenLimitSection = () => {
const { mutateAsync: updateUserTokenExpiration } = useUpdateOrg();
const { currentOrg } = useOrganization();
// Parse the current duration or use default
const currentDuration = parseDuration(currentOrg?.userTokenExpiration || "30d");
const {
control,
formState: { isSubmitting, isDirty },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
expirationValue: currentDuration.value,
expirationUnit: currentDuration.unit as "m" | "h" | "d" | "w"
}
});
if (!currentOrg) return null;
const handleUserTokenExpirationSubmit = async (formData: TForm) => {
try {
const userTokenExpiration = formatDuration(formData.expirationValue, formData.expirationUnit);
await updateUserTokenExpiration({
userTokenExpiration,
orgId: currentOrg.id
});
createNotification({
text: "Successfully updated user token expiration",
type: "success"
});
} catch {
createNotification({
text: "Failed updating user token expiration",
type: "error"
});
}
};
// Units for the dropdown with readable labels
const timeUnits = [
{ value: "m", label: "Minutes" },
{ value: "h", label: "Hours" },
{ value: "d", label: "Days" },
{ value: "w", label: "Weeks" }
];
return (
<div className="mb-6 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">User Token Expiration</p>
</div>
<p className="mb-4 mt-2 text-sm text-gray-400">
This defines the maximum time a user token will be valid. After this time, the user will
need to re-authenticate.
</p>
<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">
<Controller
control={control}
name="expirationValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Expiration value"
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
disabled={!isAllowed}
/>
</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>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isDirty}
className="mt-4"
isDisabled={!isAllowed}
>
Save
</Button>
</form>
)}
</OrgPermissionCan>
</div>
);
};

View File

@@ -74,7 +74,7 @@ import {
useUpdateSecretV3
} from "@app/hooks/api";
import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { DashboardSecretsOrderBy, ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
@@ -274,7 +274,8 @@ export const OverviewPage = () => {
totalUniqueSecretImportsInPage,
totalUniqueDynamicSecretsInPage,
totalUniqueSecretRotationsInPage,
importedByEnvs
importedByEnvs,
usedBySecretSyncs
} = overview ?? {};
const secretImportsShaped = secretImports
@@ -726,6 +727,98 @@ export const OverviewPage = () => {
}
}, [routerSearch.search]);
const selectedKeysCount = Object.keys(selectedEntries.secret).length;
const secretsToDeleteKeys = useMemo(() => {
return Object.values(selectedEntries.secret).flatMap((entries) =>
Object.values(entries).map((secret) => secret.key)
);
}, [selectedEntries]);
const filterAndMergeEnvironments = (
envNames: string[],
envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]
): ProjectSecretsImportedBy[] => {
const filteredEnvs = envs.filter((env) => envNames.includes(env.environment));
if (filteredEnvs.length === 0) return [];
const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy);
const groupedBySlug: Record<string, ProjectSecretsImportedBy[]> = {};
allImportedBy.forEach((item) => {
const { slug } = item.environment;
if (!groupedBySlug[slug]) groupedBySlug[slug] = [];
groupedBySlug[slug].push(item);
});
const mergedImportedBy = Object.values(groupedBySlug).map((group) => {
const { environment } = group[0];
const allFolders = group.flatMap((item) => item.folders);
const foldersByName: Record<string, (typeof allFolders)[number][]> = {};
allFolders.forEach((folder) => {
if (!foldersByName[folder.name]) foldersByName[folder.name] = [];
foldersByName[folder.name].push(folder);
});
const mergedFolders = Object.entries(foldersByName).map(([name, foldersData]) => {
const isImported = foldersData.some((folder) => folder.isImported);
const allSecrets = foldersData.flatMap((folder) => folder.secrets || []);
const uniqueSecrets: {
secretId: string;
referencedSecretKey: string;
referencedSecretEnv: string;
}[] = [];
const secretIds = new Set<string>();
allSecrets
.filter(
(secret) =>
!secretsToDeleteKeys ||
secretsToDeleteKeys.length === 0 ||
secretsToDeleteKeys.includes(secret.referencedSecretKey)
)
.forEach((secret) => {
if (!secretIds.has(secret.secretId)) {
secretIds.add(secret.secretId);
uniqueSecrets.push(secret);
}
});
return {
name,
isImported,
...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {})
};
});
return {
environment,
folders: mergedFolders.filter(
(folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0)
)
};
});
return mergedImportedBy;
};
const importedBy = useMemo(() => {
if (!importedByEnvs) return [];
if (selectedKeysCount === 0) {
return filterAndMergeEnvironments(
visibleEnvs.map(({ slug }) => slug),
importedByEnvs
);
}
return filterAndMergeEnvironments(
Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)),
importedByEnvs
);
}, [importedByEnvs, selectedEntries, selectedKeysCount]);
if (isProjectV3 && visibleEnvs.length > 0 && isOverviewLoading) {
return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
@@ -1044,7 +1137,9 @@ export const OverviewPage = () => {
secretPath={secretPath}
selectedEntries={selectedEntries}
resetSelectedEntries={resetSelectedEntries}
importedByEnvs={importedByEnvs}
importedBy={importedBy}
secretsToDeleteKeys={secretsToDeleteKeys}
usedBySecretSyncs={usedBySecretSyncs}
/>
<div className="thin-scrollbar mt-4">
<TableContainer
@@ -1261,6 +1356,7 @@ export const OverviewPage = () => {
secretKey={key}
getSecretByKey={getSecretByKey}
scrollOffset={debouncedScrollOffset}
importedBy={importedBy}
/>
))}
<SecretNoAccessOverviewTableRow

View File

@@ -29,9 +29,10 @@ import {
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { useToggle } from "@app/hooks";
import { usePopUp, useToggle } from "@app/hooks";
import { SecretType } from "@app/hooks/api/types";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { CollapsibleSecretImports } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports";
type Props = {
defaultValue?: string | null;
@@ -54,6 +55,14 @@ type Props = {
) => Promise<void>;
onSecretDelete: (env: string, key: string, secretId?: string) => Promise<void>;
isRotatedSecret?: boolean;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
}[];
};
export const SecretEditRow = ({
@@ -70,8 +79,13 @@ export const SecretEditRow = ({
secretPath,
isVisible,
secretId,
isRotatedSecret
isRotatedSecret,
importedBy
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"editSecret"
] as const);
const {
handleSubmit,
control,
@@ -115,6 +129,20 @@ export const SecretEditRow = ({
if (isCreatable) {
await onSecretCreate(environment, secretName, value);
} else {
if (
importedBy &&
importedBy.some(({ folders }) =>
folders?.some(({ secrets }) =>
secrets?.some(
({ referencedSecretKey, referencedSecretEnv }) =>
referencedSecretKey === secretName && referencedSecretEnv === environment
)
)
)
) {
handlePopUpOpen("editSecret", { secretValue: value });
return;
}
await onSecretUpdate(
environment,
secretName,
@@ -133,6 +161,18 @@ export const SecretEditRow = ({
}
};
const handleEditSecret = async ({ secretValue }: { secretValue: string }) => {
await onSecretUpdate(
environment,
secretName,
secretValue,
isOverride ? SecretType.Personal : SecretType.Shared,
secretId
);
reset({ value: secretValue });
handlePopUpClose("editSecret");
};
const canReadSecretValue = hasSecretReadValueOrDescribePermission(
permission,
ProjectPermissionSecretActions.ReadValue
@@ -325,6 +365,26 @@ export const SecretEditRow = ({
</>
)}
</div>
<DeleteActionModal
isOpen={popUp.editSecret.isOpen}
deleteKey="confirm"
buttonColorSchema="secondary"
buttonText="Save"
subTitle=""
title="Do you want to edit this secret?"
onChange={(isOpen) => handlePopUpToggle("editSecret", isOpen)}
onDeleteApproved={() => handleEditSecret(popUp?.editSecret?.data)}
formContent={
importedBy &&
importedBy.length > 0 && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={[secretName]}
onlyReferences
/>
)
}
/>
</div>
);
};

View File

@@ -50,6 +50,14 @@ type Props = {
secretName: string
) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined;
scrollOffset: number;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
}[];
};
export const SecretOverviewTableRow = ({
@@ -64,7 +72,8 @@ export const SecretOverviewTableRow = ({
getImportedSecretByKey,
scrollOffset,
onToggleSecretSelect,
isSelected
isSelected,
importedBy
}: Props) => {
const [isFormExpanded, setIsFormExpanded] = useToggle();
const totalCols = environments.length + 1; // secret key row
@@ -266,6 +275,7 @@ export const SecretOverviewTableRow = ({
onSecretUpdate={onSecretUpdate}
environment={slug}
isRotatedSecret={secret?.isRotatedSecret}
importedBy={importedBy}
/>
</td>
</tr>

View File

@@ -15,7 +15,7 @@ import {
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
import { ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types";
import { ProjectSecretsImportedBy, UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import {
SecretType,
SecretV3RawSanitized,
@@ -37,14 +37,18 @@ type Props = {
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
};
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
importedBy?: ProjectSecretsImportedBy[] | null;
usedBySecretSyncs?: UsedBySecretSyncs[];
secretsToDeleteKeys: string[];
};
export const SelectionPanel = ({
secretPath,
resetSelectedEntries,
selectedEntries,
importedByEnvs
importedBy,
secretsToDeleteKeys,
usedBySecretSyncs = []
}: Props) => {
const { permission } = useProjectPermission();
@@ -81,80 +85,11 @@ export const SelectionPanel = ({
)
);
const secretsToDeleteKeys = useMemo(() => {
return Object.values(selectedEntries.secret).flatMap((entries) =>
Object.values(entries).map((secret) => secret.key)
);
}, [selectedEntries]);
const filterAndMergeEnvironments = (
envNames: string[],
envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]
): ProjectSecretsImportedBy[] => {
const filteredEnvs = envs.filter((env) => envNames.includes(env.environment));
if (filteredEnvs.length === 0) return [];
const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy);
const groupedBySlug: Record<string, ProjectSecretsImportedBy[]> = {};
allImportedBy.forEach((item) => {
const { slug } = item.environment;
if (!groupedBySlug[slug]) groupedBySlug[slug] = [];
groupedBySlug[slug].push(item);
});
const mergedImportedBy = Object.values(groupedBySlug).map((group) => {
const { environment } = group[0];
const allFolders = group.flatMap((item) => item.folders);
const foldersByName: Record<string, (typeof allFolders)[number][]> = {};
allFolders.forEach((folder) => {
if (!foldersByName[folder.name]) foldersByName[folder.name] = [];
foldersByName[folder.name].push(folder);
});
const mergedFolders = Object.entries(foldersByName).map(([name, folders]) => {
const isImported = folders.some((folder) => folder.isImported);
const allSecrets = folders.flatMap((folder) => folder.secrets || []);
const uniqueSecrets: { secretId: string; referencedSecretKey: string }[] = [];
const secretIds = new Set<string>();
allSecrets
.filter((secret) => secretsToDeleteKeys.includes(secret.referencedSecretKey))
.forEach((secret) => {
if (!secretIds.has(secret.secretId)) {
secretIds.add(secret.secretId);
uniqueSecrets.push(secret);
}
});
return {
name,
isImported,
...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {})
};
});
return {
environment,
folders: mergedFolders.filter(
(folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0)
)
};
});
return mergedImportedBy;
};
const importedBy = useMemo(() => {
if (selectedKeysCount === 0 || !importedByEnvs) return null;
return filterAndMergeEnvironments(
Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)),
importedByEnvs
);
}, [importedByEnvs, selectedEntries, selectedKeysCount]);
const usedBySecretSyncsFiltered = useMemo(() => {
if (selectedKeysCount === 0 || usedBySecretSyncs.length === 0) return null;
const envs = Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries));
return usedBySecretSyncs.filter((syncItem) => envs.includes(syncItem.environment));
}, [selectedEntries, usedBySecretSyncs, selectedKeysCount]);
const getDeleteModalTitle = () => {
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
@@ -326,11 +261,12 @@ export const SelectionPanel = ({
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
onDeleteApproved={handleBulkDelete}
formContent={
importedBy &&
importedBy.some((element) => element.folders.length > 0) && (
((usedBySecretSyncsFiltered && usedBySecretSyncsFiltered.length > 0) ||
(importedBy && importedBy.some((element) => element.folders.length > 0))) && (
<CollapsibleSecretImports
importedBy={importedBy}
importedBy={importedBy || []}
secretsToDelete={secretsToDeleteKeys}
usedBySecretSyncs={usedBySecretSyncsFiltered}
/>
)
}

View File

@@ -220,6 +220,7 @@ const Page = () => {
totalSecretCount = 0,
totalCount = 0,
importedBy,
usedBySecretSyncs,
totalSecretRotationCount = 0
} = data ?? {};
@@ -441,6 +442,7 @@ const Page = () => {
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
protectedBranchPolicyName={boardPolicy?.name}
importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs}
/>
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="flex flex-col" id="dashboard">
@@ -530,6 +532,7 @@ const Page = () => {
secretPath={secretPath}
isProtectedBranch={isProtectedBranch}
importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs}
/>
)}
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}

View File

@@ -69,6 +69,7 @@ import {
dashboardKeys,
fetchDashboardProjectSecretsByKeys
} from "@app/hooks/api/dashboard/queries";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
@@ -113,11 +114,12 @@ type Props = {
onVisibilityToggle: () => void;
onToggleRowType: (rowType: RowType) => void;
onClickRollbackMode: () => void;
usedBySecretSyncs?: UsedBySecretSyncs[];
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
}[];
@@ -139,7 +141,8 @@ export const ActionBar = ({
onClickRollbackMode,
onToggleRowType,
protectedBranchPolicyName,
importedBy
importedBy,
usedBySecretSyncs
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder",
@@ -1071,11 +1074,12 @@ export const ActionBar = ({
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
onDeleteApproved={handleSecretBulkDelete}
formContent={
importedBy &&
importedBy.length > 0 && (
((importedBy && importedBy.length > 0) ||
(usedBySecretSyncs && usedBySecretSyncs?.length > 0)) && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={Object.values(selectedSecrets).map((s) => s.key)}
usedBySecretSyncs={usedBySecretSyncs}
/>
)
}

View File

@@ -1,13 +1,16 @@
/* eslint-disable no-nested-ternary */
import React, { useMemo } from "react";
import { faFileImport, faKey, faWarning } from "@fortawesome/free-solid-svg-icons";
import { faFileImport, faKey, faSync, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Table, TBody, Td, Th, THead, Tr } from "@app/components/v2";
import { Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
enum ItemType {
Folder = "Folder",
Secret = "Secret"
Secret = "Secret",
SecretSync = "SecretSync"
}
interface FlatItem {
@@ -17,6 +20,8 @@ interface FlatItem {
reference: string;
id: string;
environment: { name: string; slug: string };
tooltipText?: string;
destination?: string;
}
interface CollapsibleSecretImportsProps {
@@ -24,16 +29,20 @@ interface CollapsibleSecretImportsProps {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
}[];
usedBySecretSyncs?: UsedBySecretSyncs[] | null;
secretsToDelete: string[];
onlyReferences?: boolean;
}
export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({
importedBy = [],
secretsToDelete
usedBySecretSyncs = [],
secretsToDelete,
onlyReferences
}) => {
const { currentWorkspace } = useWorkspace();
@@ -51,6 +60,15 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
};
const handlePathClick = (item: FlatItem) => {
if (item.type === ItemType.SecretSync) {
window.open(
`/secret-manager/${currentWorkspace.id}/integrations/secret-syncs/${item.destination}/${item.id}`,
"_blank",
"noopener,noreferrer"
);
return;
}
let pathToNavigate;
if (item.type === ItemType.Folder) {
pathToNavigate = item.path;
@@ -70,7 +88,7 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
importedBy.forEach((env) => {
env.folders.forEach((folder) => {
if (folder.isImported) {
if (folder.isImported && !onlyReferences) {
items.push({
type: ItemType.Folder,
path: folder.name,
@@ -103,7 +121,26 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
});
});
// Add secret sync items
usedBySecretSyncs?.forEach((syncItem) => {
items.push({
type: ItemType.SecretSync,
destination: syncItem.destination,
path: syncItem.path,
id: syncItem.id,
reference: "Secret Sync",
environment: { name: syncItem.environment, slug: "" },
tooltipText: `Currently used by Secret Sync: ${syncItem.name}`
});
});
return items.sort((a, b) => {
if (a.type === ItemType.SecretSync && b.type !== ItemType.SecretSync) return 1;
if (a.type !== ItemType.SecretSync && b.type === ItemType.SecretSync) return -1;
if (a.type === ItemType.SecretSync && b.type === ItemType.SecretSync) {
return a.path.localeCompare(b.path);
}
const envCompare = a.environment.name.localeCompare(b.environment.name);
if (envCompare !== 0) return envCompare;
@@ -119,7 +156,7 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
return aPath.localeCompare(bPath);
});
}, [importedBy]);
}, [importedBy, usedBySecretSyncs, secretsToDelete, onlyReferences]);
const hasImportedItems = importedBy.some((element) => {
if (element.folders && element.folders.length > 0) {
@@ -135,19 +172,33 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
return false;
});
if (!hasImportedItems) {
const hasSecretSyncItems = usedBySecretSyncs && usedBySecretSyncs.length > 0;
if (!hasImportedItems && !hasSecretSyncItems) {
return null;
}
const alertColors = onlyReferences
? {
border: "border-yellow-700/30",
bg: "bg-yellow-900/20",
text: "text-yellow-500"
}
: {
border: "border-red-700/30",
bg: "bg-red-900/20",
text: "text-red-500"
};
return (
<div className="mb-4 w-full">
<div className="mb-4 rounded-md border border-red-700/30 bg-red-900/20">
<div className={`mb-4 rounded-md border ${alertColors.border} ${alertColors.bg}`}>
<div className="flex items-start gap-3 p-4">
<div className="mt-0.5 flex-shrink-0 text-red-500">
<div className={`mt-0.5 flex-shrink-0 ${alertColors.text}`}>
<FontAwesomeIcon icon={faWarning} className="h-5 w-5" aria-hidden="true" />
</div>
<div className="w-full">
<p className="text-sm font-semibold text-red-500">
<p className={`text-sm font-semibold ${alertColors.text}`}>
The following resources will be affected by this change
</p>
</div>
@@ -168,14 +219,36 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
key={item.id}
onClick={() => handlePathClick(item)}
className="cursor-pointer hover:bg-mineshaft-700"
title={`Navigate to ${item.path}`}
title={
item.type === ItemType.SecretSync
? "Navigate to Secret Sync"
: `Navigate to ${item.path}`
}
>
<Td>
<FontAwesomeIcon
icon={item.type === ItemType.Secret ? faKey : faFileImport}
className={`h-4 w-4 ${item.type === ItemType.Secret ? "text-gray-400" : "text-green-700"}`}
aria-hidden="true"
/>
<Tooltip
className="max-w-md"
content={item.tooltipText}
isDisabled={!item.tooltipText}
>
<FontAwesomeIcon
icon={
item.type === ItemType.Secret
? faKey
: item.type === ItemType.Folder
? faFileImport
: faSync
}
className={`h-4 w-4 ${
item.type === ItemType.Secret
? "text-gray-400"
: item.type === ItemType.Folder
? "text-green-700"
: "text-mineshaft-300"
}`}
aria-hidden="true"
/>
</Tooltip>
</Td>
<Td className="px-4">{item.environment.name}</Td>
<Td className="truncate px-4">{truncatePath(item.path)}</Td>

View File

@@ -4,6 +4,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Checkbox,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -31,7 +32,7 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import { usePopUp, useToggle } from "@app/hooks";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { WsTag } from "@app/hooks/api/types";
import { subject } from "@casl/ability";
@@ -55,6 +56,7 @@ import {
SecretActionType,
TFormSchema
} from "./SecretListView.utils";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
const hiddenValue = "******";
@@ -75,6 +77,14 @@ type Props = {
environment: string;
secretPath: string;
handleSecretShare: () => void;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
}[];
};
export const SecretItem = memo(
@@ -90,8 +100,12 @@ export const SecretItem = memo(
onToggleSecretSelect,
environment,
secretPath,
handleSecretShare
handleSecretShare,
importedBy
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"editSecret"
] as const);
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const { isRotatedSecret } = secret;
@@ -213,9 +227,24 @@ export const SecretItem = memo(
};
const handleFormSubmit = async (data: TFormSchema) => {
const hasDirectReferences = importedBy?.some(({ folders }) =>
folders?.some(({ secrets }) =>
secrets?.some(({ referencedSecretKey }) => referencedSecretKey === secret.key)
)
);
if (hasDirectReferences) {
handlePopUpOpen("editSecret", data);
return;
}
await onSaveSecret(secret, { ...secret, ...data }, () => reset());
};
const handleEditSecret = async (data: TFormSchema) => {
await onSaveSecret(secret, { ...secret, ...data }, () => reset());
handlePopUpClose("editSecret");
};
const handleTagSelect = (tag: WsTag) => {
if (selectedTagsGroupById?.[tag.id]) {
const tagPos = selectedTags.findIndex(({ id }) => id === tag.id);
@@ -706,6 +735,26 @@ export const SecretItem = memo(
</AnimatePresence>
</div>
</div>
<DeleteActionModal
isOpen={popUp.editSecret.isOpen}
deleteKey="confirm"
buttonColorSchema="secondary"
buttonText="Save"
subTitle=""
title="Do you want to edit this secret?"
onChange={(isOpen) => handlePopUpToggle("editSecret", isOpen)}
onDeleteApproved={() => handleEditSecret(popUp?.editSecret?.data)}
formContent={
importedBy &&
importedBy.length > 0 && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={[secret.key]}
onlyReferences
/>
)
}
/>
</form>
);
}

View File

@@ -8,6 +8,7 @@ import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
@@ -29,11 +30,12 @@ type Props = {
tags?: WsTag[];
isVisible?: boolean;
isProtectedBranch?: boolean;
usedBySecretSyncs?: UsedBySecretSyncs[];
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
}[];
@@ -47,6 +49,7 @@ export const SecretListView = ({
tags: wsTags = [],
isVisible,
isProtectedBranch = false,
usedBySecretSyncs,
importedBy
}: Props) => {
const queryClient = useQueryClient();
@@ -366,6 +369,7 @@ export const SecretListView = ({
onSaveSecret={handleSaveSecret}
onDeleteSecret={onDeleteSecret}
onDetailViewSecret={onDetailViewSecret}
importedBy={importedBy}
onCreateTag={onCreateTag}
handleSecretShare={() =>
handlePopUpOpen("createSharedSecret", {
@@ -382,10 +386,11 @@ export const SecretListView = ({
onDeleteApproved={handleSecretDelete}
buttonText="Delete Secret"
formContent={
importedBy &&
importedBy.length > 0 && (
((importedBy && importedBy.length > 0) ||
(usedBySecretSyncs && usedBySecretSyncs?.length > 0)) && (
<CollapsibleSecretImports
importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs}
secretsToDelete={[(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key || ""]}
/>
)