mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 18:41:21 +00:00
feature: server-side pagination/filtering/sorting for secrets overview and main pages
This commit is contained in:
@@ -5,6 +5,8 @@ import { TDbClient } from "@app/db";
|
||||
import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas";
|
||||
import { BadRequestError, DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
|
||||
export type TSecretV2BridgeDALFactory = ReturnType<typeof secretV2BridgeDALFactory>;
|
||||
|
||||
@@ -181,7 +183,16 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findByFolderIds = async (folderIds: string[], userId?: string, tx?: Knex) => {
|
||||
// get unique secret count by folder IDs
|
||||
const countByFolderIds = async (
|
||||
folderIds: string[],
|
||||
userId?: string,
|
||||
tx?: Knex,
|
||||
filters?: {
|
||||
search?: string;
|
||||
tagSlugs?: string[];
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
// check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo)
|
||||
if (userId && !uuidValidate(userId)) {
|
||||
@@ -189,8 +200,70 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
userId = undefined;
|
||||
}
|
||||
|
||||
const secs = await (tx || db.replicaNode())(TableName.SecretV2)
|
||||
const query = (tx || db.replicaNode())(TableName.SecretV2)
|
||||
.whereIn("folderId", folderIds)
|
||||
.where((bd) => {
|
||||
if (filters?.search) {
|
||||
void bd.whereILike("key", `%${filters?.search}%`);
|
||||
}
|
||||
})
|
||||
.where((bd) => {
|
||||
void bd.whereNull("userId").orWhere({ userId: userId || null });
|
||||
})
|
||||
.countDistinct("key");
|
||||
|
||||
// only need to join tags if filtering by tag slugs
|
||||
const slugs = filters?.tagSlugs?.filter(Boolean);
|
||||
if (slugs && slugs.length > 0) {
|
||||
void query
|
||||
.leftJoin(
|
||||
TableName.SecretV2JnTag,
|
||||
`${TableName.SecretV2}.id`,
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.SecretTag,
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
|
||||
`${TableName.SecretTag}.id`
|
||||
)
|
||||
.whereIn("slug", slugs);
|
||||
}
|
||||
|
||||
const secrets = await query;
|
||||
|
||||
return Number(secrets[0]?.count ?? 0);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "get folder secret count" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByFolderIds = async (
|
||||
folderIds: string[],
|
||||
userId?: string,
|
||||
tx?: Knex,
|
||||
filters?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
search?: string;
|
||||
tagSlugs?: string[];
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
// check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo)
|
||||
if (userId && !uuidValidate(userId)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
userId = undefined;
|
||||
}
|
||||
|
||||
const query = (tx || db.replicaNode())(TableName.SecretV2)
|
||||
.whereIn("folderId", folderIds)
|
||||
.where((bd) => {
|
||||
if (filters?.search) {
|
||||
void bd.whereILike("key", `%${filters?.search}%`);
|
||||
}
|
||||
})
|
||||
.where((bd) => {
|
||||
void bd.whereNull("userId").orWhere({ userId: userId || null });
|
||||
})
|
||||
@@ -204,11 +277,37 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
|
||||
`${TableName.SecretTag}.id`
|
||||
)
|
||||
.select(selectAllTableCols(TableName.SecretV2))
|
||||
.select(
|
||||
selectAllTableCols(TableName.SecretV2),
|
||||
db.raw(`DENSE_RANK() OVER (ORDER BY "key" ${filters?.orderDirection ?? OrderByDirection.ASC}) as rank`)
|
||||
)
|
||||
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
|
||||
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
|
||||
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
|
||||
.orderBy("id", "asc");
|
||||
.where((bd) => {
|
||||
const slugs = filters?.tagSlugs?.filter(Boolean);
|
||||
if (slugs && slugs.length > 0) {
|
||||
void bd.whereIn("slug", slugs);
|
||||
}
|
||||
})
|
||||
.orderBy(
|
||||
filters?.orderBy === SecretsOrderBy.Name ? "key" : "id",
|
||||
filters?.orderDirection ?? OrderByDirection.ASC
|
||||
);
|
||||
|
||||
let secs: Awaited<typeof query>;
|
||||
|
||||
if (filters?.limit) {
|
||||
const rankOffset = (filters?.offset ?? 0) + 1; // ranks start at 1
|
||||
secs = await (tx || db)
|
||||
.with("w", query)
|
||||
.select("*")
|
||||
.from<Awaited<typeof query>[number]>("w")
|
||||
.where("w.rank", ">=", rankOffset)
|
||||
.andWhere("w.rank", "<", rankOffset + filters.limit);
|
||||
} else {
|
||||
secs = await query;
|
||||
}
|
||||
|
||||
const data = sqlNestRelationships({
|
||||
data: secs,
|
||||
@@ -384,6 +483,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
findBySecretKeys,
|
||||
upsertSecretReferences,
|
||||
findReferencedSecretReferences,
|
||||
findAllProjectSecretValues
|
||||
findAllProjectSecretValues,
|
||||
countByFolderIds
|
||||
};
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ type TSecretV2BridgeServiceFactoryDep = {
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
folderDAL: Pick<
|
||||
TSecretFolderDALFactory,
|
||||
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find"
|
||||
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv"
|
||||
>;
|
||||
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">;
|
||||
@@ -431,6 +431,175 @@ export const secretV2BridgeServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
// get unique secrets count for multiple envs
|
||||
const getSecretsCountMultiEnv = async ({
|
||||
actorId,
|
||||
path,
|
||||
|
||||
projectId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environments,
|
||||
...params
|
||||
}: Pick<TGetSecretsDTO, "actorId" | "actor" | "path" | "projectId" | "actorOrgId" | "actorAuthMethod" | "search"> & {
|
||||
environments: string[];
|
||||
}) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
// verify user has access to all environments
|
||||
environments.forEach((environment) =>
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
)
|
||||
);
|
||||
|
||||
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
|
||||
if (!folders.length) return 0;
|
||||
|
||||
const count = await secretDAL.countByFolderIds(
|
||||
folders.map((folder) => folder.id),
|
||||
actorId,
|
||||
undefined,
|
||||
params
|
||||
);
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
// get secret count for individual env
|
||||
const getSecretsCount = async ({
|
||||
actorId,
|
||||
path,
|
||||
environment,
|
||||
projectId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
...params
|
||||
}: Pick<
|
||||
TGetSecretsDTO,
|
||||
| "actorId"
|
||||
| "actor"
|
||||
| "path"
|
||||
| "projectId"
|
||||
| "actorOrgId"
|
||||
| "actorAuthMethod"
|
||||
| "tagSlugs"
|
||||
| "environment"
|
||||
| "search"
|
||||
>) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder) return 0;
|
||||
|
||||
const count = await secretDAL.countByFolderIds([folder.id], actorId, undefined, params);
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
// get secrets for multiple envs
|
||||
const getSecretsMultiEnv = async ({
|
||||
actorId,
|
||||
path,
|
||||
environments,
|
||||
projectId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
...params
|
||||
}: Pick<TGetSecretsDTO, "actorId" | "actor" | "path" | "projectId" | "actorOrgId" | "actorAuthMethod" | "search"> & {
|
||||
environments: string[];
|
||||
}) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
let paths: { folderId: string; path: string; environment: string }[] = [];
|
||||
|
||||
// verify user has access to all environments
|
||||
environments.forEach((environment) =>
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
)
|
||||
);
|
||||
|
||||
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
|
||||
|
||||
const data: { [key: string]: typeof decryptedSecrets } = {};
|
||||
|
||||
if (!folders.length) {
|
||||
environments.forEach((env) => {
|
||||
data[env] = [];
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
paths = folders.map((folder) => ({ folderId: folder.id, path, environment: folder.environment.slug }));
|
||||
|
||||
const groupedPaths = groupBy(paths, (p) => p.folderId);
|
||||
|
||||
const secrets = await secretDAL.findByFolderIds(
|
||||
paths.map((p) => p.folderId),
|
||||
actorId,
|
||||
undefined,
|
||||
params
|
||||
);
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const decryptedSecrets = secrets.map((secret) =>
|
||||
reshapeBridgeSecret(
|
||||
projectId,
|
||||
groupedPaths[secret.folderId][0].environment,
|
||||
groupedPaths[secret.folderId][0].path,
|
||||
{
|
||||
...secret,
|
||||
value: secret.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
|
||||
: "",
|
||||
comment: secret.encryptedComment
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString()
|
||||
: ""
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
decryptedSecrets.forEach((secret) => {
|
||||
data[secret.environment] = [...(data[secret.environment] ?? []), secret];
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getSecrets = async ({
|
||||
actorId,
|
||||
path,
|
||||
@@ -441,8 +610,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
includeImports,
|
||||
recursive,
|
||||
tagSlugs = [],
|
||||
expandSecretReferences: shouldExpandSecretReferences
|
||||
expandSecretReferences: shouldExpandSecretReferences,
|
||||
...params
|
||||
}: TGetSecretsDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
@@ -490,7 +659,9 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
const secrets = await secretDAL.findByFolderIds(
|
||||
paths.map((p) => p.folderId),
|
||||
actorId
|
||||
actorId,
|
||||
undefined,
|
||||
params
|
||||
);
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
@@ -509,9 +680,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
: ""
|
||||
})
|
||||
);
|
||||
const filteredSecrets = tagSlugs.length
|
||||
? decryptedSecrets.filter((secret) => Boolean(secret.tags?.find((el) => tagSlugs.includes(el.slug))))
|
||||
: decryptedSecrets;
|
||||
|
||||
const expandSecretReferences = expandSecretReferencesFactory({
|
||||
projectId,
|
||||
folderDAL,
|
||||
@@ -520,7 +689,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
});
|
||||
|
||||
if (shouldExpandSecretReferences) {
|
||||
const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath);
|
||||
const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath);
|
||||
await Promise.allSettled(
|
||||
Object.keys(secretsGroupByPath).map((groupedPath) =>
|
||||
Promise.allSettled(
|
||||
@@ -541,7 +710,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
if (!includeImports) {
|
||||
return {
|
||||
secrets: filteredSecrets
|
||||
secrets: decryptedSecrets
|
||||
};
|
||||
}
|
||||
|
||||
@@ -569,7 +738,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
});
|
||||
|
||||
return {
|
||||
secrets: filteredSecrets,
|
||||
secrets: decryptedSecrets,
|
||||
imports: importedSecrets
|
||||
};
|
||||
};
|
||||
@@ -1416,6 +1585,9 @@ export const secretV2BridgeServiceFactory = ({
|
||||
getSecrets,
|
||||
getSecretVersions,
|
||||
backfillSecretReferences,
|
||||
moveSecrets
|
||||
moveSecrets,
|
||||
getSecretsCount,
|
||||
getSecretsCountMultiEnv,
|
||||
getSecretsMultiEnv
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { SecretType, TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||
|
||||
@@ -21,6 +22,11 @@ export type TGetSecretsDTO = {
|
||||
includeImports?: boolean;
|
||||
recursive?: boolean;
|
||||
tagSlugs?: string[];
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetASecretDTO = {
|
||||
|
||||
Reference in New Issue
Block a user