Compare commits

..

24 Commits

Author SHA1 Message Date
fec55bc9f8 fix greptile recs 2025-05-02 16:40:56 -04:00
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
5d44d58ff4 update postgres reqs 2025-04-30 17:53:41 -04:00
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
c99440ba81 feat(user-auth): use ms library and update docs 2025-04-30 16:49:33 -03:00
6d5a6f42e0 Merge branch 'main' into feat/orgUserAuthTokenExpiration 2025-04-30 15:59:52 -03:00
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
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
98f742a807 Merge pull request #3513 from Infisical/daniel/k8s-hsm-docs
docs: fix hsm kubernetes documentation
2025-04-30 06:10:30 +04:00
03ad5c5db0 Merge pull request #3512 from Infisical/daniel/kms-docs
docs: prerequisite for aws key
2025-04-29 20:39:30 -04:00
e6c4c27a87 docs: added pre-req for aws key 2025-04-30 03:36:07 +04:00
d4ac4f8d8f Update CollapsibleSecretImports.tsx 2025-04-30 03:13:10 +04:00
f0229c5ecf feat(user-auth): fix migration bug for e2e suite 2025-04-29 18:48:08 -03:00
8d711af23b feat(secrets-ui): change secret sync icon color 2025-04-29 18:39:41 -03:00
7bd61d88fc feat(user-auth): improve token refresh logic and default values 2025-04-29 18:28:18 -03:00
c47d76a6c7 feat(secrets-ui): improve warning message table 2025-04-29 14:19:52 -03:00
e959ed7fab feat(secrets-ui): improve warning message and logic for secret-sync on secret imports 2025-04-29 10:15:53 -03:00
4e4b1b689b Merge branch 'main' into feat/improveSecretReferenceWarning 2025-04-29 08:43:35 -03:00
024ed0c0d8 feat(user-auth): add pr suggestions 2025-04-28 18:19:44 -03:00
e99e360339 feat(user-auth): make users auth token expiration customizable for orgs 2025-04-28 17:43:10 -03:00
f345801bd6 feat(secrets-ui): improve types and code quality 2025-04-25 18:17:33 -03:00
4160009913 feat(secrets-ui): add direct reference warning on secrets updates 2025-04-25 17:38:43 -03:00
d5065af7e9 feat(secrets-ui): add secret syncs to referenced secret warning 2025-04-25 15:26:34 -03:00
53 changed files with 850 additions and 151 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

@ -47,21 +47,21 @@ export const buildFindFilter =
if ($in) {
Object.entries($in).forEach(([key, val]) => {
if (val) {
void bd.whereIn(`${tableName ? `${tableName}.` : ""}${key}`, val as never);
void bd.whereIn([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never);
}
});
}
if ($notNull?.length) {
$notNull.forEach((key) => {
void bd.whereNotNull(`${tableName ? `${tableName}.` : ""}${key as string}`);
void bd.whereNotNull([`${tableName ? `${tableName}.` : ""}${key as string}`] as never);
});
}
if ($search) {
Object.entries($search).forEach(([key, val]) => {
if (val) {
void bd.whereILike(`${tableName ? `${tableName}.` : ""}${key}`, val as never);
void bd.whereILike([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never);
}
});
}

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

@ -9,6 +9,9 @@ This guide will walk you through the steps needed to configure external KMS supp
## Prerequisites
- An AWS KMS Key configured as a `Symmetric` key and with `Encrypt and Decrypt` key usage.
![Create AWS KMS Key](/images/platform/kms/aws/aws-kms-key-create.png)
Before you begin, you'll first need to choose a method of authentication with AWS from below.
<Tabs>

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.

After

Width:  |  Height:  |  Size: 170 KiB

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 || ""]}
/>
)