Merge remote-tracking branch 'origin/main' into feat/azureDevopsSecretSync

This commit is contained in:
carlosmonastyrski
2025-06-11 19:20:26 -03:00
336 changed files with 19380 additions and 2390 deletions
@@ -11,8 +11,10 @@ import { AppConnection } from "../app-connection-enums";
import { GcpConnectionMethod } from "./gcp-connection-enums";
import {
GCPApp,
GCPGetProjectLocationsRes,
GCPGetProjectsRes,
GCPGetServiceRes,
GCPLocation,
TGcpConnection,
TGcpConnectionConfig
} from "./gcp-connection-types";
@@ -145,6 +147,45 @@ export const getGcpSecretManagerProjects = async (appConnection: TGcpConnection)
return projects;
};
export const getGcpSecretManagerProjectLocations = async (projectId: string, appConnection: TGcpConnection) => {
const accessToken = await getGcpConnectionAuthToken(appConnection);
let gcpLocations: GCPLocation[] = [];
const pageSize = 100;
let pageToken: string | undefined;
let hasMorePages = true;
while (hasMorePages) {
const params = new URLSearchParams({
pageSize: String(pageSize),
...(pageToken ? { pageToken } : {})
});
// eslint-disable-next-line no-await-in-loop
const { data } = await request.get<GCPGetProjectLocationsRes>(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${projectId}/locations`,
{
params,
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
gcpLocations = gcpLocations.concat(data.locations);
if (!data.nextPageToken) {
hasMorePages = false;
}
pageToken = data.nextPageToken;
}
return gcpLocations.sort((a, b) => a.displayName.localeCompare(b.displayName));
};
export const validateGcpConnectionCredentials = async (appConnection: TGcpConnectionConfig) => {
// Check if provided service account email suffix matches organization ID.
// We do this to mitigate confused deputy attacks in multi-tenant instances
@@ -1,8 +1,8 @@
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { getGcpSecretManagerProjects } from "./gcp-connection-fns";
import { TGcpConnection } from "./gcp-connection-types";
import { getGcpSecretManagerProjectLocations, getGcpSecretManagerProjects } from "./gcp-connection-fns";
import { TGcpConnection, TGetGCPProjectLocationsDTO } from "./gcp-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
@@ -23,7 +23,23 @@ export const gcpConnectionService = (getAppConnection: TGetAppConnectionFunc) =>
}
};
const listSecretManagerProjectLocations = async (
{ connectionId, projectId }: TGetGCPProjectLocationsDTO,
actor: OrgServiceActor
) => {
const appConnection = await getAppConnection(AppConnection.GCP, connectionId, actor);
try {
const locations = await getGcpSecretManagerProjectLocations(projectId, appConnection);
return locations;
} catch (error) {
return [];
}
};
return {
listSecretManagerProjects
listSecretManagerProjects,
listSecretManagerProjectLocations
};
};
@@ -38,6 +38,22 @@ export type GCPGetProjectsRes = {
nextPageToken?: string;
};
export type GCPLocation = {
name: string;
locationId: string;
displayName: string;
};
export type GCPGetProjectLocationsRes = {
locations: GCPLocation[];
nextPageToken?: string;
};
export type TGetGCPProjectLocationsDTO = {
projectId: string;
connectionId: string;
};
export type GCPGetServiceRes = {
name: string;
parent: string;
@@ -397,7 +397,7 @@ export const authLoginServiceFactory = ({
// Check if the user actually has access to the specified organization.
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId);
const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited");
const selectedOrg = await orgDAL.findById(organizationId);
if (!hasOrganizationMembership) {
@@ -10,6 +10,7 @@ import { chunkArray } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { TProjectDALFactory } from "../project/project-dal";
@@ -18,6 +19,7 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectEnvServiceFactory } from "../project-env/project-env-service";
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal";
import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { fnSecretBulkInsert, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns";
@@ -42,6 +44,8 @@ export type TImportDataIntoInfisicalDTO = {
projectService: Pick<TProjectServiceFactory, "createProject">;
projectEnvService: Pick<TProjectEnvServiceFactory, "createEnvironment">;
secretV2BridgeService: Pick<TSecretV2BridgeServiceFactory, "createManySecret">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
folderVersionDAL: Pick<TSecretFolderVersionDALFactory, "create">;
input: TImportInfisicalDataCreate;
};
@@ -507,6 +511,8 @@ export const importDataIntoInfisicalFn = async ({
secretVersionTagDAL,
folderDAL,
resourceMetadataDAL,
folderVersionDAL,
folderCommitService,
input: { data, actor, actorId, actorOrgId, actorAuthMethod }
}: TImportDataIntoInfisicalDTO) => {
// Import data to infisical
@@ -599,6 +605,36 @@ export const importDataIntoInfisicalFn = async ({
tx
);
const newFolderVersion = await folderVersionDAL.create(
{
name: newFolder.name,
envId: newFolder.envId,
version: newFolder.version,
folderId: newFolder.id
},
tx
);
await folderCommitService.createCommit(
{
actor: {
type: actor,
metadata: {
id: actorId
}
},
message: "Changed by external migration",
folderId: parentEnv.rootFolderId,
changes: [
{
type: CommitType.ADD,
folderVersionId: newFolderVersion.id
}
]
},
tx
);
originalToNewFolderId.set(folder.id, {
folderId: newFolder.id,
projectId: parentEnv.projectId
@@ -772,6 +808,7 @@ export const importDataIntoInfisicalFn = async ({
secretVersionDAL,
secretTagDAL,
secretVersionTagDAL,
folderCommitService,
actor: {
type: actor,
actorId
@@ -3,6 +3,7 @@ import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectServiceFactory } from "../project/project-service";
@@ -10,6 +11,7 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectEnvServiceFactory } from "../project-env/project-env-service";
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal";
import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bridge-service";
@@ -36,6 +38,8 @@ export type TExternalMigrationQueueFactoryDep = {
projectService: Pick<TProjectServiceFactory, "createProject">;
projectEnvService: Pick<TProjectEnvServiceFactory, "createEnvironment">;
secretV2BridgeService: Pick<TSecretV2BridgeServiceFactory, "createManySecret">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
folderVersionDAL: Pick<TSecretFolderVersionDALFactory, "create">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
};
@@ -56,6 +60,8 @@ export const externalMigrationQueueFactory = ({
secretTagDAL,
secretVersionTagDAL,
folderDAL,
folderCommitService,
folderVersionDAL,
resourceMetadataDAL
}: TExternalMigrationQueueFactoryDep) => {
const startImport = async (dto: {
@@ -114,6 +120,8 @@ export const externalMigrationQueueFactory = ({
projectService,
projectEnvService,
secretV2BridgeService,
folderCommitService,
folderVersionDAL,
resourceMetadataDAL
});
@@ -0,0 +1,118 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import {
TableName,
TFolderCheckpointResources,
TFolderCheckpoints,
TSecretFolderVersions,
TSecretVersionsV2
} from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderCheckpointResourcesDALFactory = ReturnType<typeof folderCheckpointResourcesDALFactory>;
export type ResourceWithCheckpointInfo = TFolderCheckpointResources & {
folderCommitId: string;
};
export const folderCheckpointResourcesDALFactory = (db: TDbClient) => {
const folderCheckpointResourcesOrm = ormify(db, TableName.FolderCheckpointResources);
const findByCheckpointId = async (
folderCheckpointId: string,
tx?: Knex
): Promise<
(TFolderCheckpointResources & {
referencedSecretId?: string;
referencedFolderId?: string;
folderName?: string;
folderVersion?: string;
secretKey?: string;
secretVersion?: string;
})[]
> => {
try {
const docs = await (tx || db.replicaNode())<TFolderCheckpointResources>(TableName.FolderCheckpointResources)
.where({ folderCheckpointId })
.leftJoin<TSecretVersionsV2>(
TableName.SecretVersionV2,
`${TableName.FolderCheckpointResources}.secretVersionId`,
`${TableName.SecretVersionV2}.id`
)
.leftJoin<TSecretFolderVersions>(
TableName.SecretFolderVersion,
`${TableName.FolderCheckpointResources}.folderVersionId`,
`${TableName.SecretFolderVersion}.id`
)
.select(selectAllTableCols(TableName.FolderCheckpointResources))
.select(
db.ref("secretId").withSchema(TableName.SecretVersionV2).as("referencedSecretId"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("referencedFolderId"),
db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderName"),
db.ref("version").withSchema(TableName.SecretFolderVersion).as("folderVersion"),
db.ref("key").withSchema(TableName.SecretVersionV2).as("secretKey"),
db.ref("version").withSchema(TableName.SecretVersionV2).as("secretVersion")
);
return docs.map((doc) => ({
...doc,
folderVersion: doc.folderVersion?.toString(),
secretVersion: doc.secretVersion?.toString()
}));
} catch (error) {
throw new DatabaseError({ error, name: "FindByCheckpointId" });
}
};
const findBySecretVersionId = async (secretVersionId: string, tx?: Knex): Promise<ResourceWithCheckpointInfo[]> => {
try {
const docs = await (tx || db.replicaNode())<
TFolderCheckpointResources & Pick<TFolderCheckpoints, "folderCommitId" | "createdAt">
>(TableName.FolderCheckpointResources)
.where({ secretVersionId })
.select(selectAllTableCols(TableName.FolderCheckpointResources))
.join(
TableName.FolderCheckpoint,
`${TableName.FolderCheckpointResources}.folderCheckpointId`,
`${TableName.FolderCheckpoint}.id`
)
.select(
db.ref("folderCommitId").withSchema(TableName.FolderCheckpoint),
db.ref("createdAt").withSchema(TableName.FolderCheckpoint)
);
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindBySecretVersionId" });
}
};
const findByFolderVersionId = async (folderVersionId: string, tx?: Knex): Promise<ResourceWithCheckpointInfo[]> => {
try {
const docs = await (tx || db.replicaNode())<
TFolderCheckpointResources & Pick<TFolderCheckpoints, "folderCommitId" | "createdAt">
>(TableName.FolderCheckpointResources)
.where({ folderVersionId })
.select(selectAllTableCols(TableName.FolderCheckpointResources))
.join(
TableName.FolderCheckpoint,
`${TableName.FolderCheckpointResources}.folderCheckpointId`,
`${TableName.FolderCheckpoint}.id`
)
.select(
db.ref("folderCommitId").withSchema(TableName.FolderCheckpoint),
db.ref("createdAt").withSchema(TableName.FolderCheckpoint)
);
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindByFolderVersionId" });
}
};
return {
...folderCheckpointResourcesOrm,
findByCheckpointId,
findBySecretVersionId,
findByFolderVersionId
};
};
@@ -0,0 +1,129 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TFolderCheckpoints, TFolderCommits } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderCheckpointDALFactory = ReturnType<typeof folderCheckpointDALFactory>;
type CheckpointWithCommitInfo = TFolderCheckpoints & {
actorMetadata: unknown;
actorType: string;
message?: string | null;
commitDate: Date;
folderId: string;
};
export const folderCheckpointDALFactory = (db: TDbClient) => {
const folderCheckpointOrm = ormify(db, TableName.FolderCheckpoint);
const findByCommitId = async (folderCommitId: string, tx?: Knex): Promise<TFolderCheckpoints | undefined> => {
try {
const doc = await (tx || db.replicaNode())<TFolderCheckpoints>(TableName.FolderCheckpoint)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ folderCommitId }, TableName.FolderCheckpoint))
.select(selectAllTableCols(TableName.FolderCheckpoint))
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindByCommitId" });
}
};
const findByFolderId = async (folderId: string, limit?: number, tx?: Knex): Promise<CheckpointWithCommitInfo[]> => {
try {
let query = (tx || db.replicaNode())(TableName.FolderCheckpoint)
.join<TFolderCommits>(
TableName.FolderCommit,
`${TableName.FolderCheckpoint}.folderCommitId`,
`${TableName.FolderCommit}.id`
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ folderId }, TableName.FolderCommit))
.select(selectAllTableCols(TableName.FolderCheckpoint))
.select(
db.ref("actorMetadata").withSchema(TableName.FolderCommit),
db.ref("actorType").withSchema(TableName.FolderCommit),
db.ref("message").withSchema(TableName.FolderCommit),
db.ref("createdAt").withSchema(TableName.FolderCommit).as("commitDate"),
db.ref("folderId").withSchema(TableName.FolderCommit)
)
.orderBy(`${TableName.FolderCheckpoint}.createdAt`, "desc");
if (limit !== undefined) {
query = query.limit(limit);
}
return await query;
} catch (error) {
throw new DatabaseError({ error, name: "FindByFolderId" });
}
};
const findLatestByFolderId = async (folderId: string, tx?: Knex): Promise<CheckpointWithCommitInfo | undefined> => {
try {
const doc = await (tx || db.replicaNode())(TableName.FolderCheckpoint)
.join<TFolderCommits>(
TableName.FolderCommit,
`${TableName.FolderCheckpoint}.folderCommitId`,
`${TableName.FolderCommit}.id`
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ folderId }, TableName.FolderCommit))
.select(selectAllTableCols(TableName.FolderCheckpoint))
.select(
db.ref("actorMetadata").withSchema(TableName.FolderCommit),
db.ref("actorType").withSchema(TableName.FolderCommit),
db.ref("message").withSchema(TableName.FolderCommit),
db.ref("createdAt").withSchema(TableName.FolderCommit).as("commitDate"),
db.ref("folderId").withSchema(TableName.FolderCommit)
)
.orderBy(`${TableName.FolderCheckpoint}.createdAt`, "desc")
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestByFolderId" });
}
};
const findNearestCheckpoint = async (
folderCommitId: bigint,
folderId: string,
tx?: Knex
): Promise<(CheckpointWithCommitInfo & { commitId: bigint }) | undefined> => {
try {
// Get the checkpoint with the highest commitId that's still less than or equal to our commit
const nearestCheckpoint = await (tx || db.replicaNode())(TableName.FolderCheckpoint)
.join<TFolderCommits>(
TableName.FolderCommit,
`${TableName.FolderCheckpoint}.folderCommitId`,
`${TableName.FolderCommit}.id`
)
.where(`${TableName.FolderCommit}.folderId`, "=", folderId)
.where(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId.toString())
.select(selectAllTableCols(TableName.FolderCheckpoint))
.select(
db.ref("actorMetadata").withSchema(TableName.FolderCommit),
db.ref("actorType").withSchema(TableName.FolderCommit),
db.ref("message").withSchema(TableName.FolderCommit),
db.ref("commitId").withSchema(TableName.FolderCommit),
db.ref("createdAt").withSchema(TableName.FolderCommit).as("commitDate"),
db.ref("folderId").withSchema(TableName.FolderCommit)
)
.orderBy(`${TableName.FolderCommit}.commitId`, "desc")
.first();
return nearestCheckpoint;
} catch (error) {
throw new DatabaseError({ error, name: "FindNearestCheckpoint" });
}
};
return {
...folderCheckpointOrm,
findByCommitId,
findByFolderId,
findLatestByFolderId,
findNearestCheckpoint
};
};
@@ -0,0 +1,233 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import {
TableName,
TFolderCommitChanges,
TFolderCommits,
TProjectEnvironments,
TSecretFolderVersions,
TSecretVersionsV2
} from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderCommitChangesDALFactory = ReturnType<typeof folderCommitChangesDALFactory>;
// Base type with common fields
type BaseCommitChangeInfo = TFolderCommitChanges & {
actorMetadata: unknown;
actorType: string;
message?: string | null;
folderId: string;
createdAt: Date;
};
// Secret-specific change
export type SecretCommitChange = BaseCommitChangeInfo & {
resourceType: "secret";
secretKey: string;
changeType: string;
secretVersionId?: string | null;
secretVersion: string;
secretId: string;
versions?: {
secretKey: string;
secretComment: string;
skipMultilineEncoding?: boolean | null;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
metadata?: unknown;
tags?: string[] | null;
secretReminderRecipients?: string[] | null;
secretValue: string;
}[];
};
// Folder-specific change
export type FolderCommitChange = BaseCommitChangeInfo & {
resourceType: "folder";
folderName: string;
folderVersion: string;
folderChangeId: string;
versions?: {
version: string;
name?: string;
}[];
};
// Discriminated union
export type CommitChangeWithCommitInfo = SecretCommitChange | FolderCommitChange;
// Type guards
export const isSecretCommitChange = (change: CommitChangeWithCommitInfo): change is SecretCommitChange =>
change.resourceType === "secret";
export const isFolderCommitChange = (change: CommitChangeWithCommitInfo): change is FolderCommitChange =>
change.resourceType === "folder";
export const folderCommitChangesDALFactory = (db: TDbClient) => {
const folderCommitChangesOrm = ormify(db, TableName.FolderCommitChanges);
const findByCommitId = async (
folderCommitId: string,
projectId: string,
tx?: Knex
): Promise<CommitChangeWithCommitInfo[]> => {
try {
const docs = await (tx || db.replicaNode())<TFolderCommitChanges>(TableName.FolderCommitChanges)
.where(buildFindFilter({ folderCommitId }, TableName.FolderCommitChanges))
.leftJoin<TFolderCommits>(
TableName.FolderCommit,
`${TableName.FolderCommitChanges}.folderCommitId`,
`${TableName.FolderCommit}.id`
)
.leftJoin<TSecretVersionsV2>(
TableName.SecretVersionV2,
`${TableName.FolderCommitChanges}.secretVersionId`,
`${TableName.SecretVersionV2}.id`
)
.leftJoin<TSecretFolderVersions>(
TableName.SecretFolderVersion,
`${TableName.FolderCommitChanges}.folderVersionId`,
`${TableName.SecretFolderVersion}.id`
)
.leftJoin<TProjectEnvironments>(
TableName.Environment,
`${TableName.FolderCommit}.envId`,
`${TableName.Environment}.id`
)
.where((qb) => {
if (projectId) {
void qb.where(`${TableName.Environment}.projectId`, "=", projectId);
}
})
.select(selectAllTableCols(TableName.FolderCommitChanges))
.select(
db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderName"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderChangeId"),
db.ref("version").withSchema(TableName.SecretFolderVersion).as("folderVersion"),
db.ref("key").withSchema(TableName.SecretVersionV2).as("secretKey"),
db.ref("version").withSchema(TableName.SecretVersionV2).as("secretVersion"),
db.ref("secretId").withSchema(TableName.SecretVersionV2),
db.ref("actorMetadata").withSchema(TableName.FolderCommit),
db.ref("actorType").withSchema(TableName.FolderCommit),
db.ref("message").withSchema(TableName.FolderCommit),
db.ref("createdAt").withSchema(TableName.FolderCommit),
db.ref("folderId").withSchema(TableName.FolderCommit)
);
return docs.map((doc) => {
// Determine if this is a secret or folder change based on populated fields
if (doc.secretKey && doc.secretVersion !== null && doc.secretId) {
return {
...doc,
resourceType: "secret",
secretKey: doc.secretKey,
secretVersion: doc.secretVersion.toString(),
secretId: doc.secretId
} as SecretCommitChange;
}
return {
...doc,
resourceType: "folder",
folderName: doc.folderName,
folderVersion: doc.folderVersion.toString(),
folderChangeId: doc.folderChangeId
} as FolderCommitChange;
});
} catch (error) {
throw new DatabaseError({ error, name: "FindByCommitId" });
}
};
const findBySecretVersionId = async (secretVersionId: string, tx?: Knex): Promise<SecretCommitChange[]> => {
try {
const docs = await (tx || db.replicaNode())<
TFolderCommitChanges &
Pick<TFolderCommits, "actorMetadata" | "actorType" | "message" | "createdAt" | "folderId">
>(TableName.FolderCommitChanges)
.where(buildFindFilter({ secretVersionId }, TableName.FolderCommitChanges))
.select(selectAllTableCols(TableName.FolderCommitChanges))
.join(TableName.FolderCommit, `${TableName.FolderCommitChanges}.folderCommitId`, `${TableName.FolderCommit}.id`)
.leftJoin<TSecretVersionsV2>(
TableName.SecretVersionV2,
`${TableName.FolderCommitChanges}.secretVersionId`,
`${TableName.SecretVersionV2}.id`
)
.select(
db.ref("actorMetadata").withSchema(TableName.FolderCommit),
db.ref("actorType").withSchema(TableName.FolderCommit),
db.ref("message").withSchema(TableName.FolderCommit),
db.ref("createdAt").withSchema(TableName.FolderCommit),
db.ref("folderId").withSchema(TableName.FolderCommit),
db.ref("key").withSchema(TableName.SecretVersionV2).as("secretKey"),
db.ref("version").withSchema(TableName.SecretVersionV2).as("secretVersion"),
db.ref("secretId").withSchema(TableName.SecretVersionV2)
);
return docs
.filter((doc) => doc.secretKey && doc.secretVersion !== null && doc.secretId)
.map(
(doc): SecretCommitChange => ({
...doc,
resourceType: "secret",
secretKey: doc.secretKey,
secretVersion: doc.secretVersion.toString(),
secretId: doc.secretId
})
);
} catch (error) {
throw new DatabaseError({ error, name: "FindBySecretVersionId" });
}
};
const findByFolderVersionId = async (folderVersionId: string, tx?: Knex): Promise<FolderCommitChange[]> => {
try {
const docs = await (tx || db.replicaNode())<
TFolderCommitChanges &
Pick<TFolderCommits, "actorMetadata" | "actorType" | "message" | "createdAt" | "folderId">
>(TableName.FolderCommitChanges)
.where(buildFindFilter({ folderVersionId }, TableName.FolderCommitChanges))
.select(selectAllTableCols(TableName.FolderCommitChanges))
.join(TableName.FolderCommit, `${TableName.FolderCommitChanges}.folderCommitId`, `${TableName.FolderCommit}.id`)
.leftJoin<TSecretFolderVersions>(
TableName.SecretFolderVersion,
`${TableName.FolderCommitChanges}.folderVersionId`,
`${TableName.SecretFolderVersion}.id`
)
.select(
db.ref("actorMetadata").withSchema(TableName.FolderCommit),
db.ref("actorType").withSchema(TableName.FolderCommit),
db.ref("message").withSchema(TableName.FolderCommit),
db.ref("createdAt").withSchema(TableName.FolderCommit),
db.ref("folderId").withSchema(TableName.FolderCommit),
db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderName"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderChangeId"),
db.ref("version").withSchema(TableName.SecretFolderVersion).as("folderVersion")
);
return docs
.filter((doc) => doc.folderName && doc.folderVersion !== null && doc.folderChangeId)
.map(
(doc): FolderCommitChange => ({
...doc,
resourceType: "folder",
folderName: doc.folderName,
folderVersion: doc.folderVersion!.toString(),
folderChangeId: doc.folderChangeId
})
);
} catch (error) {
throw new DatabaseError({ error, name: "FindByFolderVersionId" });
}
};
return {
...folderCommitChangesOrm,
findByCommitId,
findBySecretVersionId,
findByFolderVersionId
};
};
@@ -0,0 +1,513 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import {
TableName,
TFolderCommitChanges,
TFolderCommits,
TProjectEnvironments,
TSecretFolderVersions,
TSecretVersionsV2
} from "@app/db/schemas";
import { DatabaseError, NotFoundError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderCommitDALFactory = ReturnType<typeof folderCommitDALFactory>;
export const folderCommitDALFactory = (db: TDbClient) => {
const folderCommitOrm = ormify(db, TableName.FolderCommit);
const { delete: deleteOp, deleteById, ...restOfOrm } = folderCommitOrm;
const findByFolderId = async (folderId: string, tx?: Knex): Promise<TFolderCommits[]> => {
try {
const trx = tx || db.replicaNode();
// First, get all folder commits
const folderCommits = await trx(TableName.FolderCommit)
.where({ folderId })
.select("*")
.orderBy("createdAt", "desc");
if (folderCommits.length === 0) return [];
// Get all commit IDs
const commitIds = folderCommits.map((commit) => commit.id);
// Then get all related changes
const changes = await trx(TableName.FolderCommitChanges).whereIn("folderCommitId", commitIds).select("*");
const changesMap = changes.reduce(
(acc, change) => {
const { folderCommitId } = change;
if (!acc[folderCommitId]) acc[folderCommitId] = [];
acc[folderCommitId].push(change);
return acc;
},
{} as Record<string, TFolderCommitChanges[]>
);
return folderCommits.map((commit) => ({
...commit,
changes: changesMap[commit.id] || []
}));
} catch (error) {
throw new DatabaseError({ error, name: "FindByFolderId" });
}
};
const findLatestCommit = async (
folderId: string,
projectId?: string,
tx?: Knex
): Promise<TFolderCommits | undefined> => {
try {
const doc = await (tx || db.replicaNode())(TableName.FolderCommit)
.where({ folderId })
.leftJoin(TableName.Environment, `${TableName.FolderCommit}.envId`, `${TableName.Environment}.id`)
.where((qb) => {
if (projectId) {
void qb.where(`${TableName.Environment}.projectId`, "=", projectId);
}
})
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc")
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestCommit" });
}
};
const findLatestCommitByFolderIds = async (folderIds: string[], tx?: Knex): Promise<TFolderCommits[] | undefined> => {
try {
// First get max commitId for each folderId
const maxCommitIdSubquery = (tx || db.replicaNode())(TableName.FolderCommit)
.select("folderId")
.max("commitId as maxCommitId")
.whereIn("folderId", folderIds)
.groupBy("folderId");
// Join with main table to get complete records for each max commitId
const docs = await (tx || db.replicaNode())(TableName.FolderCommit)
.select(selectAllTableCols(TableName.FolderCommit))
// eslint-disable-next-line func-names
.join<TFolderCommits>(maxCommitIdSubquery.as("latest"), function () {
this.on(`${TableName.FolderCommit}.folderId`, "=", "latest.folderId").andOn(
`${TableName.FolderCommit}.commitId`,
"=",
"latest.maxCommitId"
);
});
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestCommitByFolderIds" });
}
};
const findLatestEnvCommit = async (envId: string, tx?: Knex): Promise<TFolderCommits | undefined> => {
try {
const doc = await (tx || db.replicaNode())(TableName.FolderCommit)
.where(`${TableName.FolderCommit}.envId`, "=", envId)
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc")
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestCommit" });
}
};
const findMultipleLatestCommits = async (folderIds: string[], tx?: Knex): Promise<TFolderCommits[]> => {
try {
const knexInstance = tx || db.replicaNode();
// Get the latest commitId for each folderId
const subquery = knexInstance(TableName.FolderCommit)
.whereIn("folderId", folderIds)
.groupBy("folderId")
.select("folderId")
.max("commitId as maxCommitId");
// Then fetch the complete rows matching those latest commits
const docs = await knexInstance(TableName.FolderCommit)
// eslint-disable-next-line func-names
.innerJoin<TFolderCommits>(subquery.as("latest"), function () {
this.on(`${TableName.FolderCommit}.folderId`, "=", "latest.folderId").andOn(
`${TableName.FolderCommit}.commitId`,
"=",
"latest.maxCommitId"
);
})
.select(selectAllTableCols(TableName.FolderCommit));
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindMultipleLatestCommits" });
}
};
const getNumberOfCommitsSince = async (folderId: string, folderCommitId: string, tx?: Knex): Promise<number> => {
try {
const referencedCommit = await (tx || db.replicaNode())(TableName.FolderCommit)
.where({ id: folderCommitId })
.select("commitId")
.first();
if (referencedCommit?.commitId) {
const doc = await (tx || db.replicaNode())(TableName.FolderCommit)
.where({ folderId })
.where("commitId", ">", referencedCommit.commitId)
.count();
return Number(doc?.[0].count);
}
return 0;
} catch (error) {
throw new DatabaseError({ error, name: "getNumberOfCommitsSince" });
}
};
const getEnvNumberOfCommitsSince = async (envId: string, folderCommitId: string, tx?: Knex): Promise<number> => {
try {
const referencedCommit = await (tx || db.replicaNode())(TableName.FolderCommit)
.where({ id: folderCommitId })
.select("commitId")
.first();
if (referencedCommit?.commitId) {
const doc = await (tx || db.replicaNode())(TableName.FolderCommit)
.where(`${TableName.FolderCommit}.envId`, "=", envId)
.where("commitId", ">", referencedCommit.commitId)
.count();
return Number(doc?.[0].count);
}
return 0;
} catch (error) {
throw new DatabaseError({ error, name: "getNumberOfCommitsSince" });
}
};
const findCommitsToRecreate = async (
folderId: string,
targetCommitNumber: bigint,
checkpointCommitNumber: bigint,
tx?: Knex
): Promise<
(TFolderCommits & {
changes: (TFolderCommitChanges & {
referencedSecretId?: string;
referencedFolderId?: string;
folderName?: string;
folderVersion?: string;
secretKey?: string;
secretVersion?: string;
})[];
})[]
> => {
try {
// First get all the commits in the range
const commits = await (tx || db.replicaNode())(TableName.FolderCommit)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ folderId }, TableName.FolderCommit))
.andWhere(`${TableName.FolderCommit}.commitId`, ">", checkpointCommitNumber.toString())
.andWhere(`${TableName.FolderCommit}.commitId`, "<=", targetCommitNumber.toString())
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy(`${TableName.FolderCommit}.commitId`, "asc");
// If no commits found, return empty array
if (!commits.length) {
return [];
}
// Get all the commit IDs
const commitIds = commits.map((commit) => commit.id);
// Get all changes for these commits in a single query
const allChanges = await (tx || db.replicaNode())(TableName.FolderCommitChanges)
.whereIn(`${TableName.FolderCommitChanges}.folderCommitId`, commitIds)
.leftJoin<TSecretVersionsV2>(
TableName.SecretVersionV2,
`${TableName.FolderCommitChanges}.secretVersionId`,
`${TableName.SecretVersionV2}.id`
)
.leftJoin<TSecretFolderVersions>(
TableName.SecretFolderVersion,
`${TableName.FolderCommitChanges}.folderVersionId`,
`${TableName.SecretFolderVersion}.id`
)
.select(selectAllTableCols(TableName.FolderCommitChanges))
.select(
db.ref("secretId").withSchema(TableName.SecretVersionV2).as("referencedSecretId"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("referencedFolderId"),
db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderName"),
db.ref("version").withSchema(TableName.SecretFolderVersion).as("folderVersion"),
db.ref("key").withSchema(TableName.SecretVersionV2).as("secretKey"),
db.ref("version").withSchema(TableName.SecretVersionV2).as("secretVersion")
);
// Organize changes by commit ID
const changesByCommitId = allChanges.reduce(
(acc, change) => {
if (!acc[change.folderCommitId]) {
acc[change.folderCommitId] = [];
}
acc[change.folderCommitId].push(change);
return acc;
},
{} as Record<string, TFolderCommitChanges[]>
);
// Attach changes to each commit
return commits.map((commit) => ({
...commit,
changes: changesByCommitId[commit.id] || []
}));
} catch (error) {
throw new DatabaseError({ error, name: "FindCommitsToRecreate" });
}
};
const findLatestCommitBetween = async ({
folderId,
startCommitId,
endCommitId,
tx
}: {
folderId: string;
startCommitId?: string;
endCommitId: string;
tx?: Knex;
}): Promise<TFolderCommits | undefined> => {
try {
const doc = await (tx || db.replicaNode())(TableName.FolderCommit)
.where("commitId", "<=", endCommitId)
.where({ folderId })
.where((qb) => {
if (startCommitId) {
void qb.where("commitId", ">=", startCommitId);
}
})
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc")
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestCommitBetween" });
}
};
const findAllCommitsBetween = async ({
envId,
startCommitId,
endCommitId,
tx
}: {
envId?: string;
startCommitId?: string;
endCommitId?: string;
tx?: Knex;
}): Promise<TFolderCommits[]> => {
try {
const docs = await (tx || db.replicaNode())(TableName.FolderCommit)
.where((qb) => {
if (envId) {
void qb.where(`${TableName.FolderCommit}.envId`, "=", envId);
}
if (startCommitId) {
void qb.where("commitId", ">=", startCommitId);
}
if (endCommitId) {
void qb.where("commitId", "<=", endCommitId);
}
})
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc");
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestCommitBetween" });
}
};
const findAllFolderCommitsAfter = async ({
envId,
startCommitId,
tx
}: {
envId?: string;
startCommitId?: string;
tx?: Knex;
}): Promise<TFolderCommits[]> => {
try {
const docs = await (tx || db.replicaNode())(TableName.FolderCommit)
.where((qb) => {
if (envId) {
void qb.where(`${TableName.FolderCommit}.envId`, "=", envId);
}
if (startCommitId) {
void qb.where("commitId", ">=", startCommitId);
}
})
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc");
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestCommitBetween" });
}
};
const findPreviousCommitTo = async (
folderId: string,
commitId: string,
tx?: Knex
): Promise<TFolderCommits | undefined> => {
try {
const doc = await (tx || db.replicaNode())(TableName.FolderCommit)
.where({ folderId })
.where("commitId", "<=", commitId)
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc")
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindPreviousCommitTo" });
}
};
const findById = async (id: string, tx?: Knex, projectId?: string): Promise<TFolderCommits> => {
try {
const doc = await (tx || db.replicaNode())(TableName.FolderCommit)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ id }, TableName.FolderCommit))
.leftJoin<TProjectEnvironments>(
TableName.Environment,
`${TableName.FolderCommit}.envId`,
`${TableName.Environment}.id`
)
.where((qb) => {
if (projectId) {
void qb.where(`${TableName.Environment}.projectId`, "=", projectId);
}
})
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc")
.first();
if (!doc) {
throw new NotFoundError({
message: `Folder commit not found for ID ${id}`
});
}
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindById" });
}
};
const findByFolderIdPaginated = async (
folderId: string,
options: {
offset?: number;
limit?: number;
search?: string;
sort?: "asc" | "desc";
} = {},
tx?: Knex
): Promise<{
commits: TFolderCommits[];
total: number;
hasMore: boolean;
}> => {
try {
const { offset = 0, limit = 20, search, sort = "desc" } = options;
const trx = tx || db.replicaNode();
// Build base query
let baseQuery = trx(TableName.FolderCommit).where({ folderId });
// Add search functionality
if (search) {
baseQuery = baseQuery.where((qb) => {
void qb.whereILike("message", `%${search}%`);
});
}
// Get total count
const totalResult = await baseQuery.clone().count("*", { as: "count" }).first();
const total = Number(totalResult?.count || 0);
// Get paginated commits
const folderCommits = await baseQuery.select("*").orderBy("createdAt", sort).limit(limit).offset(offset);
if (folderCommits.length === 0) {
return { commits: [], total, hasMore: false };
}
// Get all commit IDs for changes
const commitIds = folderCommits.map((commit) => commit.id);
// Get all related changes
const changes = await trx(TableName.FolderCommitChanges).whereIn("folderCommitId", commitIds).select("*");
const changesMap = changes.reduce(
(acc, change) => {
const { folderCommitId } = change;
if (!acc[folderCommitId]) acc[folderCommitId] = [];
acc[folderCommitId].push(change);
return acc;
},
{} as Record<string, TFolderCommitChanges[]>
);
const commitsWithChanges = folderCommits.map((commit) => ({
...commit,
changes: changesMap[commit.id] || []
}));
const hasMore = offset + limit < total;
return {
commits: commitsWithChanges,
total,
hasMore
};
} catch (error) {
throw new DatabaseError({ error, name: "FindByFolderIdPaginated" });
}
};
const findCommitBefore = async (
folderId: string,
commitId: bigint,
tx?: Knex
): Promise<TFolderCommits | undefined> => {
try {
const doc = await (tx || db.replicaNode())(TableName.FolderCommit)
.where({ folderId })
.where("commitId", "<", commitId.toString())
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc")
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindCommitBefore" });
}
};
return {
...restOfOrm,
findByFolderId,
findLatestCommit,
getNumberOfCommitsSince,
findCommitsToRecreate,
findMultipleLatestCommits,
findAllCommitsBetween,
findLatestCommitBetween,
findLatestEnvCommit,
getEnvNumberOfCommitsSince,
findLatestCommitByFolderIds,
findAllFolderCommitsAfter,
findPreviousCommitTo,
findById,
findByFolderIdPaginated,
findCommitBefore
};
};
@@ -0,0 +1,282 @@
import { Knex } from "knex";
import { TSecretFolders } from "@app/db/schemas";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TFolderTreeCheckpointDALFactory } from "../folder-tree-checkpoint/folder-tree-checkpoint-dal";
import { TFolderTreeCheckpointResourcesDALFactory } from "../folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TFolderCommitDALFactory } from "./folder-commit-dal";
// Define types for job data
type TCreateFolderTreeCheckpointDTO = {
envId: string;
failedToAcquireLockCount?: number;
folderCommitId?: string;
};
type TFolderCommitQueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "getItem" | "deleteItem">;
folderTreeCheckpointDAL: Pick<
TFolderTreeCheckpointDALFactory,
"create" | "findLatestByEnvId" | "findNearestCheckpoint"
>;
folderTreeCheckpointResourcesDAL: Pick<
TFolderTreeCheckpointResourcesDALFactory,
"insertMany" | "findByTreeCheckpointId"
>;
folderCommitDAL: Pick<
TFolderCommitDALFactory,
"findLatestEnvCommit" | "getEnvNumberOfCommitsSince" | "findMultipleLatestCommits" | "findById"
>;
folderDAL: Pick<TSecretFolderDALFactory, "findByEnvId">;
};
export type TFolderCommitQueueServiceFactory = ReturnType<typeof folderCommitQueueServiceFactory>;
export const folderCommitQueueServiceFactory = ({
queueService,
keyStore,
folderTreeCheckpointDAL,
folderTreeCheckpointResourcesDAL,
folderCommitDAL,
folderDAL
}: TFolderCommitQueueServiceFactoryDep) => {
const appCfg = getConfig();
// Helper function to calculate delay for requeuing
const getRequeueDelay = (failureCount?: number) => {
if (!failureCount) return 0;
const baseDelay = 5000;
const maxDelay = 30000;
const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay);
const jitter = delay * (0.5 + Math.random() * 0.5);
return jitter;
};
const scheduleTreeCheckpoint = async (payload: TCreateFolderTreeCheckpointDTO) => {
const { envId, failedToAcquireLockCount = 0 } = payload;
// Create a unique jobId for each retry to prevent conflicts
const jobId =
failedToAcquireLockCount > 0 ? `${envId}-retry-${failedToAcquireLockCount}-${Date.now()}` : `${envId}`;
await queueService.queue(QueueName.FolderTreeCheckpoint, QueueJobs.CreateFolderTreeCheckpoint, payload, {
jobId,
delay: getRequeueDelay(failedToAcquireLockCount),
backoff: {
type: "exponential",
delay: 3000
},
removeOnFail: {
count: 3
},
removeOnComplete: true
});
};
// Sort folders by hierarchy (copied from the source code)
const sortFoldersByHierarchy = (folders: TSecretFolders[]) => {
const childrenMap = new Map<string, TSecretFolders[]>();
const allFolderIds = new Set<string>();
folders.forEach((folder) => {
if (folder.id) allFolderIds.add(folder.id);
});
folders.forEach((folder) => {
if (folder.parentId) {
const children = childrenMap.get(folder.parentId) || [];
children.push(folder);
childrenMap.set(folder.parentId, children);
}
});
const rootFolders = folders.filter((folder) => !folder.parentId || !allFolderIds.has(folder.parentId));
const result = [];
let currentLevel = rootFolders;
while (currentLevel.length > 0) {
result.push(...currentLevel);
const nextLevel = [];
for (const folder of currentLevel) {
if (folder.id) {
const children = childrenMap.get(folder.id) || [];
nextLevel.push(...children);
}
}
currentLevel = nextLevel;
}
return result;
};
const createFolderTreeCheckpoint = async (jobData: TCreateFolderTreeCheckpointDTO, tx?: Knex) => {
const { envId, folderCommitId, failedToAcquireLockCount = 0 } = jobData;
logger.info(`Folder tree checkpoint creation started [envId=${envId}] [attempt=${failedToAcquireLockCount + 1}]`);
// First, try to clear any stale locks before attempting to acquire
if (failedToAcquireLockCount > 1) {
try {
await keyStore.deleteItem(KeyStorePrefixes.FolderTreeCheckpoint(envId));
logger.info(`Cleared potential stale lock for envId ${envId} before attempt ${failedToAcquireLockCount + 1}`);
} catch (error) {
// This is fine if it fails, we'll still try to acquire the lock
logger.info(`No stale lock found for envId ${envId}`);
}
}
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>> | undefined;
try {
// Attempt to acquire the lock with a shorter timeout for first attempts
const timeout = failedToAcquireLockCount > 3 ? 60 * 1000 : 15 * 1000;
logger.info(`Attempting to acquire lock for envId=${envId} with timeout ${timeout}ms`);
lock = await keyStore.acquireLock([KeyStorePrefixes.FolderTreeCheckpoint(envId)], timeout);
logger.info(`Successfully acquired lock for envId=${envId}`);
} catch (e) {
logger.info(
`Failed to acquire lock for folder tree checkpoint [envId=${envId}] [attempt=${failedToAcquireLockCount + 1}]`
);
// Requeue with incremented failure count if under max attempts
if (failedToAcquireLockCount < 10) {
// Force a delay between retries
const nextRetryCount = failedToAcquireLockCount + 1;
logger.info(`Scheduling retry #${nextRetryCount} for folder tree checkpoint [envId=${envId}]`);
// Create a new job with incremented counter
await scheduleTreeCheckpoint({
envId,
folderCommitId,
failedToAcquireLockCount: nextRetryCount
});
} else {
// Max retries reached
logger.error(`Maximum lock acquisition attempts (10) reached for envId ${envId}. Giving up.`);
// Try to force-clear the lock for next time
try {
await keyStore.deleteItem(KeyStorePrefixes.FolderTreeCheckpoint(envId));
} catch (clearError) {
logger.error(clearError, `Failed to clear lock after maximum retries for envId=${envId}`);
}
}
return;
}
if (!lock) {
logger.error(`Lock is undefined after acquisition for envId=${envId}. This should never happen.`);
return;
}
try {
logger.info(`Processing tree checkpoint data for envId=${envId}`);
const latestTreeCheckpoint = await folderTreeCheckpointDAL.findLatestByEnvId(envId, tx);
let latestCommit;
if (folderCommitId) {
latestCommit = await folderCommitDAL.findById(folderCommitId, tx);
} else {
latestCommit = await folderCommitDAL.findLatestEnvCommit(envId, tx);
}
if (!latestCommit) {
logger.info(`Latest commit ID not found for envId ${envId}`);
return;
}
const latestCommitId = latestCommit.id;
if (latestTreeCheckpoint) {
const commitsSinceLastCheckpoint = await folderCommitDAL.getEnvNumberOfCommitsSince(
envId,
latestTreeCheckpoint.folderCommitId,
tx
);
if (commitsSinceLastCheckpoint < Number(appCfg.PIT_TREE_CHECKPOINT_WINDOW)) {
logger.info(
`Commits since last checkpoint ${commitsSinceLastCheckpoint} is less than ${appCfg.PIT_TREE_CHECKPOINT_WINDOW}`
);
return;
}
}
const folders = await folderDAL.findByEnvId(envId, tx);
const sortedFolders = sortFoldersByHierarchy(folders);
const filteredFoldersIds = sortedFolders.filter((folder) => !folder.isReserved).map((folder) => folder.id);
const folderCommits = await folderCommitDAL.findMultipleLatestCommits(filteredFoldersIds, tx);
const folderTreeCheckpoint = await folderTreeCheckpointDAL.create(
{
folderCommitId: latestCommitId
},
tx
);
await folderTreeCheckpointResourcesDAL.insertMany(
folderCommits.map((folderCommit) => ({
folderTreeCheckpointId: folderTreeCheckpoint.id,
folderId: folderCommit.folderId,
folderCommitId: folderCommit.id
})),
tx
);
logger.info(`Folder tree checkpoint created successfully: ${folderTreeCheckpoint.id}`);
} catch (error) {
logger.error(error, `Error processing folder tree checkpoint [envId=${envId}]`);
throw error;
} finally {
// Always release the lock
try {
if (lock) {
await lock.release();
logger.info(`Released lock for folder tree checkpoint [envId=${envId}]`);
} else {
logger.error(`No lock to release for envId=${envId}. This should never happen.`);
}
} catch (releaseError) {
logger.error(releaseError, `Error releasing lock for folder tree checkpoint [envId=${envId}]`);
// Try to force delete the lock if release fails
try {
await keyStore.deleteItem(KeyStorePrefixes.FolderTreeCheckpoint(envId));
logger.info(`Force deleted lock after release failure for envId=${envId}`);
} catch (deleteError) {
logger.error(deleteError, `Failed to force delete lock after release failure for envId=${envId}`);
}
}
}
};
queueService.start(QueueName.FolderTreeCheckpoint, async (job) => {
try {
if (job.name === QueueJobs.CreateFolderTreeCheckpoint) {
const jobData = job.data as TCreateFolderTreeCheckpointDTO;
await createFolderTreeCheckpoint(jobData);
}
} catch (error) {
logger.error(error, "Error creating folder tree checkpoint:");
throw error;
}
});
return {
scheduleTreeCheckpoint: (envId: string) => scheduleTreeCheckpoint({ envId }),
createFolderTreeCheckpoint: (envId: string, folderCommitId?: string, tx?: Knex) =>
createFolderTreeCheckpoint({ envId, folderCommitId }, tx)
};
};
@@ -0,0 +1,143 @@
import { z } from "zod";
// Base schema shared by both secret and folder changes
const baseChangeSchema = z.object({
id: z.string(),
folderCommitId: z.string(),
changeType: z.string(),
isUpdate: z.boolean().optional(),
createdAt: z.union([z.string(), z.date()]),
updatedAt: z.union([z.string(), z.date()]),
actorMetadata: z
.union([
z.object({
id: z.string().optional(),
name: z.string().optional()
}),
z.unknown()
])
.optional(),
actorType: z.string(),
message: z.string().nullable().optional(),
folderId: z.string()
});
// Secret-specific versions schema
const secretVersionSchema = z.object({
secretKey: z.string(),
secretComment: z.string(),
skipMultilineEncoding: z.boolean().nullable().optional(),
tags: z.array(z.string()).nullable().optional(),
metadata: z.unknown().nullable().optional(),
secretValue: z.string()
});
// Folder-specific versions schema
const folderVersionSchema = z.object({
version: z.string().optional(),
name: z.string().optional(),
description: z.string().optional().nullable()
});
// Secret commit change schema
const secretCommitChangeSchema = baseChangeSchema.extend({
resourceType: z.literal("secret"),
secretVersionId: z.string().optional().nullable(),
secretKey: z.string(),
secretVersion: z.union([z.string(), z.number()]),
secretId: z.string(),
versions: z.array(secretVersionSchema).optional()
});
// Folder commit change schema
const folderCommitChangeSchema = baseChangeSchema.extend({
resourceType: z.literal("folder"),
folderVersionId: z.string().optional().nullable(),
folderName: z.string(),
folderChangeId: z.string(),
folderVersion: z.union([z.string(), z.number()]),
versions: z.array(folderVersionSchema).optional()
});
// Discriminated union for commit changes
export const commitChangeSchema = z.discriminatedUnion("resourceType", [
secretCommitChangeSchema,
folderCommitChangeSchema
]);
// Commit schema
const commitSchema = z.object({
id: z.string(),
commitId: z.string(),
actorMetadata: z
.union([
z.object({
id: z.string().optional(),
name: z.string().optional()
}),
z.unknown()
])
.optional(),
actorType: z.string(),
message: z.string().nullable().optional(),
folderId: z.string(),
envId: z.string(),
createdAt: z.union([z.string(), z.date()]),
updatedAt: z.union([z.string(), z.date()]),
isLatest: z.boolean().default(false),
changes: z.array(commitChangeSchema).optional()
});
// Response schema
export const commitChangesResponseSchema = z.object({
changes: commitSchema
});
// Base resource change schema for comparison results
const baseResourceChangeSchema = z.object({
id: z.string(),
versionId: z.string(),
oldVersionId: z.string().optional(),
changeType: z.enum(["add", "delete", "update", "create"]),
commitId: z.union([z.string(), z.bigint()]),
createdAt: z.union([z.string(), z.date()]).optional(),
parentId: z.string().optional(),
isUpdate: z.boolean().optional(),
fromVersion: z.union([z.string(), z.number()]).optional()
});
// Secret resource change schema
const secretResourceChangeSchema = baseResourceChangeSchema.extend({
type: z.literal("secret"),
secretKey: z.string(),
secretVersion: z.union([z.string(), z.number()]),
secretId: z.string(),
versions: z
.array(
z.object({
secretKey: z.string().optional(),
secretComment: z.string().optional(),
skipMultilineEncoding: z.boolean().nullable().optional(),
secretReminderRepeatDays: z.number().nullable().optional(),
tags: z.array(z.string()).nullable().optional(),
metadata: z.unknown().nullable().optional(),
secretReminderNote: z.string().nullable().optional(),
secretValue: z.string().optional()
})
)
.optional()
});
// Folder resource change schema
const folderResourceChangeSchema = baseResourceChangeSchema.extend({
type: z.literal("folder"),
folderName: z.string(),
folderVersion: z.union([z.string(), z.number()]),
versions: z.array(folderVersionSchema).optional()
});
// Discriminated union for resource changes
export const resourceChangeSchema = z.discriminatedUnion("type", [
secretResourceChangeSchema,
folderResourceChangeSchema
]);
@@ -0,0 +1,671 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/return-await */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { Knex } from "knex";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectType, TSecretFolderVersions, TSecretVersionsV2 } from "@app/db/schemas";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { ActorType } from "../auth/auth-type";
import {
ChangeType,
CommitType,
folderCommitServiceFactory,
ResourceChange,
TFolderCommitServiceFactory
} from "./folder-commit-service";
// Mock config
vi.mock("@app/lib/config/env", () => ({
getConfig: () => ({
PIT_CHECKPOINT_WINDOW: 5,
PIT_TREE_CHECKPOINT_WINDOW: 10
})
}));
// Mock logger
vi.mock("@app/lib/logger", () => ({
logger: {
info: vi.fn(),
error: vi.fn()
}
}));
describe("folderCommitServiceFactory", () => {
// Properly type the mock functions
type TransactionCallback<T> = (trx: Knex) => Promise<T>;
// Mock dependencies
const mockFolderCommitDAL = {
create: vi.fn().mockResolvedValue({}),
findById: vi.fn().mockResolvedValue({}),
findByFolderId: vi.fn().mockResolvedValue([]),
findLatestCommit: vi.fn().mockResolvedValue({}),
transaction: vi.fn().mockImplementation(<T>(callback: TransactionCallback<T>) => callback({} as Knex)),
getNumberOfCommitsSince: vi.fn().mockResolvedValue(0),
getEnvNumberOfCommitsSince: vi.fn().mockResolvedValue(0),
findCommitsToRecreate: vi.fn().mockResolvedValue([]),
findMultipleLatestCommits: vi.fn().mockResolvedValue([]),
findLatestCommitBetween: vi.fn().mockResolvedValue({}),
findAllCommitsBetween: vi.fn().mockResolvedValue([]),
findLatestEnvCommit: vi.fn().mockResolvedValue({}),
findLatestCommitByFolderIds: vi.fn().mockResolvedValue({})
};
const mockKmsService = {
createCipherPairWithDataKey: vi.fn().mockResolvedValue({})
};
const mockFolderCommitChangesDAL = {
create: vi.fn().mockResolvedValue({}),
findByCommitId: vi.fn().mockResolvedValue([]),
insertMany: vi.fn().mockResolvedValue([])
};
const mockFolderCheckpointDAL = {
create: vi.fn().mockResolvedValue({}),
findByFolderId: vi.fn().mockResolvedValue([]),
findLatestByFolderId: vi.fn().mockResolvedValue(null),
findNearestCheckpoint: vi.fn().mockResolvedValue({})
};
const mockFolderCheckpointResourcesDAL = {
insertMany: vi.fn().mockResolvedValue([]),
findByCheckpointId: vi.fn().mockResolvedValue([])
};
const mockFolderTreeCheckpointDAL = {
create: vi.fn().mockResolvedValue({}),
findByProjectId: vi.fn().mockResolvedValue([]),
findLatestByProjectId: vi.fn().mockResolvedValue({}),
findNearestCheckpoint: vi.fn().mockResolvedValue({}),
findLatestByEnvId: vi.fn().mockResolvedValue({})
};
const mockFolderTreeCheckpointResourcesDAL = {
insertMany: vi.fn().mockResolvedValue([]),
findByTreeCheckpointId: vi.fn().mockResolvedValue([])
};
const mockUserDAL = {
findById: vi.fn().mockResolvedValue({})
};
const mockIdentityDAL = {
findById: vi.fn().mockResolvedValue({})
};
const mockFolderDAL = {
findByParentId: vi.fn().mockResolvedValue([]),
findByProjectId: vi.fn().mockResolvedValue([]),
deleteById: vi.fn().mockResolvedValue({}),
create: vi.fn().mockResolvedValue({}),
updateById: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
find: vi.fn().mockResolvedValue([]),
findById: vi.fn().mockResolvedValue({}),
findByEnvId: vi.fn().mockResolvedValue([]),
findFoldersByRootAndIds: vi.fn().mockResolvedValue([])
};
const mockFolderVersionDAL = {
findLatestFolderVersions: vi.fn().mockResolvedValue({}),
findById: vi.fn().mockResolvedValue({}),
deleteById: vi.fn().mockResolvedValue({}),
create: vi.fn().mockResolvedValue({}),
updateById: vi.fn().mockResolvedValue({}),
find: vi.fn().mockResolvedValue({}), // Changed from [] to {} to match Object.values() expectation
findByIdsWithLatestVersion: vi.fn().mockResolvedValue({})
};
const mockSecretVersionV2BridgeDAL = {
findLatestVersionByFolderId: vi.fn().mockResolvedValue([]),
findById: vi.fn().mockResolvedValue({}),
deleteById: vi.fn().mockResolvedValue({}),
create: vi.fn().mockResolvedValue({}),
updateById: vi.fn().mockResolvedValue({}),
find: vi.fn().mockResolvedValue([]),
findByIdsWithLatestVersion: vi.fn().mockResolvedValue({}),
findLatestVersionMany: vi.fn().mockResolvedValue({})
};
const mockSecretV2BridgeDAL = {
deleteById: vi.fn().mockResolvedValue({}),
create: vi.fn().mockResolvedValue({}),
updateById: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
insertMany: vi.fn().mockResolvedValue([]),
invalidateSecretCacheByProjectId: vi.fn().mockResolvedValue({})
};
const mockProjectDAL = {
findById: vi.fn().mockResolvedValue({}),
findProjectByEnvId: vi.fn().mockResolvedValue({})
};
const mockFolderCommitQueueService = {
scheduleTreeCheckpoint: vi.fn().mockResolvedValue({}),
createFolderTreeCheckpoint: vi.fn().mockResolvedValue({})
};
const mockPermissionService = {
getProjectPermission: vi.fn().mockResolvedValue({})
};
const mockSecretTagDAL = {
findSecretTagsByVersionId: vi.fn().mockResolvedValue([]),
saveTagsToSecretV2: vi.fn().mockResolvedValue([]),
findSecretTagsBySecretId: vi.fn().mockResolvedValue([]),
deleteTagsToSecretV2: vi.fn().mockResolvedValue([]),
saveTagsToSecretVersionV2: vi.fn().mockResolvedValue([])
};
const mockResourceMetadataDAL = {
find: vi.fn().mockResolvedValue([]),
insertMany: vi.fn().mockResolvedValue([]),
delete: vi.fn().mockResolvedValue([])
};
let folderCommitService: TFolderCommitServiceFactory;
beforeEach(() => {
vi.clearAllMocks();
folderCommitService = folderCommitServiceFactory({
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
folderCommitDAL: mockFolderCommitDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
folderCommitChangesDAL: mockFolderCommitChangesDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
folderCheckpointDAL: mockFolderCheckpointDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
folderCheckpointResourcesDAL: mockFolderCheckpointResourcesDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
folderTreeCheckpointDAL: mockFolderTreeCheckpointDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
folderTreeCheckpointResourcesDAL: mockFolderTreeCheckpointResourcesDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
userDAL: mockUserDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
identityDAL: mockIdentityDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
folderDAL: mockFolderDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
folderVersionDAL: mockFolderVersionDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
secretVersionV2BridgeDAL: mockSecretVersionV2BridgeDAL,
projectDAL: mockProjectDAL,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
secretV2BridgeDAL: mockSecretV2BridgeDAL,
folderCommitQueueService: mockFolderCommitQueueService,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
permissionService: mockPermissionService,
kmsService: mockKmsService,
secretTagDAL: mockSecretTagDAL,
resourceMetadataDAL: mockResourceMetadataDAL
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe("createCommit", () => {
it("should successfully create a commit with user actor", async () => {
// Arrange
const userData = { id: "user-id", username: "testuser" };
const folderData = { id: "folder-id", envId: "env-id" };
const commitData = { id: "commit-id", folderId: "folder-id" };
mockUserDAL.findById.mockResolvedValue(userData);
mockFolderDAL.findById.mockResolvedValue(folderData);
mockFolderCommitDAL.create.mockResolvedValue(commitData);
mockFolderCheckpointDAL.findLatestByFolderId.mockResolvedValue(null);
mockFolderCommitDAL.findLatestCommit.mockResolvedValue({ id: "latest-commit-id" });
mockFolderDAL.findByParentId.mockResolvedValue([]);
mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]);
const data = {
actor: {
type: ActorType.USER,
metadata: { id: userData.id }
},
message: "Test commit",
folderId: folderData.id,
changes: [
{
type: CommitType.ADD,
secretVersionId: "secret-version-1"
}
]
};
// Act
const result = await folderCommitService.createCommit(data);
// Assert
expect(mockUserDAL.findById).toHaveBeenCalledWith(userData.id, undefined);
expect(mockFolderDAL.findById).toHaveBeenCalledWith(folderData.id, undefined);
expect(mockFolderCommitDAL.create).toHaveBeenCalledWith(
expect.objectContaining({
actorType: ActorType.USER,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
actorMetadata: expect.objectContaining({ name: userData.username }),
message: data.message,
folderId: data.folderId,
envId: folderData.envId
}),
undefined
);
expect(mockFolderCommitChangesDAL.insertMany).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
folderCommitId: commitData.id,
changeType: data.changes[0].type,
secretVersionId: data.changes[0].secretVersionId
})
]),
undefined
);
expect(mockFolderCommitQueueService.scheduleTreeCheckpoint).toHaveBeenCalledWith(folderData.envId);
expect(result).toEqual(commitData);
});
it("should successfully create a commit with identity actor", async () => {
// Arrange
const identityData = { id: "identity-id", name: "testidentity" };
const folderData = { id: "folder-id", envId: "env-id" };
const commitData = { id: "commit-id", folderId: "folder-id" };
mockIdentityDAL.findById.mockResolvedValue(identityData);
mockFolderDAL.findById.mockResolvedValue(folderData);
mockFolderCommitDAL.create.mockResolvedValue(commitData);
mockFolderCheckpointDAL.findLatestByFolderId.mockResolvedValue(null);
mockFolderCommitDAL.findLatestCommit.mockResolvedValue({ id: "latest-commit-id" });
mockFolderDAL.findByParentId.mockResolvedValue([]);
mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]);
// Mock folderVersionDAL.find to return an object with folder version data
mockFolderVersionDAL.find.mockResolvedValue({
"folder-version-1": {
id: "folder-version-1",
folderId: "sub-folder-id",
envId: "env-id",
name: "Test Folder",
version: 1
}
});
const data = {
actor: {
type: ActorType.IDENTITY,
metadata: { id: identityData.id }
},
message: "Test commit",
folderId: folderData.id,
changes: [
{
type: CommitType.ADD,
folderVersionId: "folder-version-1"
}
],
omitIgnoreFilter: true
};
// Act
const result = await folderCommitService.createCommit(data);
// Assert
expect(mockIdentityDAL.findById).toHaveBeenCalledWith(identityData.id, undefined);
expect(mockFolderDAL.findById).toHaveBeenCalledWith(folderData.id, undefined);
expect(mockFolderCommitDAL.create).toHaveBeenCalledWith(
expect.objectContaining({
actorType: ActorType.IDENTITY,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
actorMetadata: expect.objectContaining({ name: identityData.name }),
message: data.message,
folderId: data.folderId,
envId: folderData.envId
}),
undefined
);
expect(mockFolderCommitChangesDAL.insertMany).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
folderCommitId: commitData.id,
changeType: data.changes[0].type,
folderVersionId: data.changes[0].folderVersionId
})
]),
undefined
);
expect(mockFolderCommitQueueService.scheduleTreeCheckpoint).toHaveBeenCalledWith(folderData.envId);
expect(result).toEqual(commitData);
});
it("should throw NotFoundError when folder does not exist", async () => {
// Arrange
mockFolderDAL.findById.mockResolvedValue(null);
const data = {
actor: {
type: ActorType.PLATFORM
},
message: "Test commit",
folderId: "non-existent-folder",
changes: []
};
// Act & Assert
await expect(folderCommitService.createCommit(data)).rejects.toThrow(NotFoundError);
expect(mockFolderDAL.findById).toHaveBeenCalledWith("non-existent-folder", undefined);
});
});
describe("addCommitChange", () => {
it("should successfully add a change to an existing commit", async () => {
// Arrange
const commitData = { id: "commit-id", folderId: "folder-id" };
const changeData = { id: "change-id", folderCommitId: "commit-id" };
mockFolderCommitDAL.findById.mockResolvedValue(commitData);
mockFolderCommitChangesDAL.create.mockResolvedValue(changeData);
const data = {
folderCommitId: commitData.id,
changeType: CommitType.ADD,
secretVersionId: "secret-version-1"
};
// Act
const result = await folderCommitService.addCommitChange(data);
// Assert
expect(mockFolderCommitDAL.findById).toHaveBeenCalledWith(commitData.id, undefined);
expect(mockFolderCommitChangesDAL.create).toHaveBeenCalledWith(data, undefined);
expect(result).toEqual(changeData);
});
it("should throw BadRequestError when neither secretVersionId nor folderVersionId is provided", async () => {
// Arrange
const data = {
folderCommitId: "commit-id",
changeType: CommitType.ADD
};
// Act & Assert
await expect(folderCommitService.addCommitChange(data)).rejects.toThrow(BadRequestError);
});
it("should throw NotFoundError when commit does not exist", async () => {
// Arrange
mockFolderCommitDAL.findById.mockResolvedValue(null);
const data = {
folderCommitId: "non-existent-commit",
changeType: CommitType.ADD,
secretVersionId: "secret-version-1"
};
// Act & Assert
await expect(folderCommitService.addCommitChange(data)).rejects.toThrow(NotFoundError);
expect(mockFolderCommitDAL.findById).toHaveBeenCalledWith("non-existent-commit", undefined);
});
});
// Note: reconstructFolderState is an internal function not exposed in the public API
// We'll test it indirectly through compareFolderStates
describe("compareFolderStates", () => {
it("should mark all resources as creates when currentCommitId is not provided", async () => {
// Arrange
const targetCommitId = "target-commit-id";
const targetCommit = { id: targetCommitId, commitId: 1, folderId: "folder-id" };
mockFolderCommitDAL.findById.mockResolvedValue(targetCommit);
// Mock how compareFolderStates would process the results internally
mockFolderCheckpointDAL.findNearestCheckpoint.mockResolvedValue({ id: "checkpoint-id", commitId: "hash-0" });
mockFolderCheckpointResourcesDAL.findByCheckpointId.mockResolvedValue([
{ secretVersionId: "secret-version-1", referencedSecretId: "secret-1" },
{ folderVersionId: "folder-version-1", referencedFolderId: "folder-1" }
]);
mockFolderCommitDAL.findCommitsToRecreate.mockResolvedValue([]);
mockProjectDAL.findProjectByEnvId.mockResolvedValue({
id: "project-id",
name: "test-project",
type: ProjectType.SecretManager
});
// Act
const result = await folderCommitService.compareFolderStates({
targetCommitId
});
// Assert
expect(mockFolderCommitDAL.findById).toHaveBeenCalledWith(targetCommitId, undefined);
// Verify we get resources marked as create
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
changeType: "create",
commitId: targetCommit.commitId
})
])
);
});
});
describe("createFolderCheckpoint", () => {
it("should successfully create a checkpoint when force is true", async () => {
// Arrange
const folderCommitId = "commit-id";
const folderId = "folder-id";
const checkpointData = { id: "checkpoint-id", folderCommitId };
mockFolderDAL.findByParentId.mockResolvedValue([{ id: "subfolder-id" }]);
mockFolderVersionDAL.findLatestFolderVersions.mockResolvedValue({ "subfolder-id": { id: "folder-version-1" } });
mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([{ id: "secret-version-1" }]);
mockFolderCheckpointDAL.create.mockResolvedValue(checkpointData);
// Act
const result = await folderCommitService.createFolderCheckpoint({
folderId,
folderCommitId,
force: true
});
// Assert
expect(mockFolderCheckpointDAL.create).toHaveBeenCalledWith({ folderCommitId }, undefined);
expect(mockFolderCheckpointResourcesDAL.insertMany).toHaveBeenCalled();
expect(result).toBe(folderCommitId);
});
});
describe("deepRollbackFolder", () => {
it("should throw NotFoundError when commit doesn't exist", async () => {
// Arrange
const targetCommitId = "non-existent-commit";
const envId = "env-id";
const actorId = "user-id";
const actorType = ActorType.USER;
const projectId = "project-id";
// Mock the transaction to properly handle the error
mockFolderCommitDAL.transaction.mockImplementation(async (callback) => {
return await callback({} as Knex);
});
// Mock findById to return null inside the transaction
mockFolderCommitDAL.findById.mockResolvedValue(null);
// Act & Assert
await expect(
folderCommitService.deepRollbackFolder(targetCommitId, envId, actorId, actorType, projectId)
).rejects.toThrow(NotFoundError);
});
});
describe("createFolderTreeCheckpoint", () => {
it("should create a tree checkpoint when checkpoint window is exceeded", async () => {
// Arrange
const envId = "env-id";
const folderCommitId = "commit-id";
const latestCommit = { id: folderCommitId };
const latestTreeCheckpoint = { id: "tree-checkpoint-id", folderCommitId: "old-commit-id" };
const folders = [
{ id: "folder-1", isReserved: false },
{ id: "folder-2", isReserved: false },
{ id: "folder-3", isReserved: true } // Reserved folders should be filtered out
];
const folderCommits = [
{ folderId: "folder-1", id: "commit-1" },
{ folderId: "folder-2", id: "commit-2" }
];
const treeCheckpoint = { id: "new-tree-checkpoint-id" };
mockFolderCommitDAL.findLatestEnvCommit.mockResolvedValue(latestCommit);
mockFolderTreeCheckpointDAL.findLatestByEnvId.mockResolvedValue(latestTreeCheckpoint);
mockFolderCommitDAL.getEnvNumberOfCommitsSince.mockResolvedValue(15); // More than PIT_TREE_CHECKPOINT_WINDOW (10)
mockFolderDAL.findByEnvId.mockResolvedValue(folders);
mockFolderCommitDAL.findMultipleLatestCommits.mockResolvedValue(folderCommits);
mockFolderTreeCheckpointDAL.create.mockResolvedValue(treeCheckpoint);
// Act
await folderCommitService.createFolderTreeCheckpoint(envId);
// Assert
expect(mockFolderCommitDAL.findLatestEnvCommit).toHaveBeenCalledWith(envId, undefined);
expect(mockFolderTreeCheckpointDAL.create).toHaveBeenCalledWith({ folderCommitId }, undefined);
});
});
describe("applyFolderStateDifferences", () => {
it("should process changes correctly", async () => {
// Arrange
const folderId = "folder-id";
const projectId = "project-id";
const actorId = "user-id";
const actorType = ActorType.USER;
const differences = [
{
id: "secret-1",
versionId: "v1",
changeType: ChangeType.CREATE,
commitId: BigInt(1)
} as ResourceChange,
{
id: "folder-1",
versionId: "v2",
changeType: ChangeType.UPDATE,
commitId: BigInt(1),
folderName: "Test Folder",
folderVersion: "v2"
} as ResourceChange
];
const secretVersions = {
"secret-1": {
id: "secret-version-1",
createdAt: new Date(),
updatedAt: new Date(),
type: "shared",
folderId: "folder-1",
secretId: "secret-1",
version: 1,
key: "SECRET_KEY",
encryptedValue: Buffer.from("encrypted"),
encryptedComment: Buffer.from("comment"),
skipMultilineEncoding: false,
userId: "user-1",
envId: "env-1",
metadata: {}
} as TSecretVersionsV2
};
const folderVersions = {
"folder-1": {
folderId: "folder-1",
version: 1,
name: "Test Folder",
envId: "env-1"
} as TSecretFolderVersions
};
// Mock folder lookup for the folder being processed
mockFolderDAL.findById.mockImplementation((id) => {
if (id === folderId) {
return Promise.resolve({ id: folderId, envId: "env-1" });
}
return Promise.resolve(null);
});
// Mock latest commit lookup
mockFolderCommitDAL.findLatestCommit.mockImplementation((id) => {
if (id === folderId) {
return Promise.resolve({ id: "latest-commit-id", folderId });
}
return Promise.resolve(null);
});
// Make sure findByParentId returns an array, not undefined
mockFolderDAL.findByParentId.mockResolvedValue([]);
// Make sure other required functions return appropriate values
mockFolderCheckpointDAL.findLatestByFolderId.mockResolvedValue(null);
mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]);
// These mocks need to return objects with an id field
mockSecretVersionV2BridgeDAL.findByIdsWithLatestVersion.mockResolvedValue(Object.values(secretVersions));
mockFolderVersionDAL.findByIdsWithLatestVersion.mockResolvedValue(Object.values(folderVersions));
mockSecretV2BridgeDAL.insertMany.mockResolvedValue([{ id: "new-secret-1" }]);
mockSecretVersionV2BridgeDAL.create.mockResolvedValue({ id: "new-secret-version-1" });
mockFolderDAL.updateById.mockResolvedValue({ id: "updated-folder-1" });
mockFolderVersionDAL.create.mockResolvedValue({ id: "new-folder-version-1" });
mockFolderCommitDAL.create.mockResolvedValue({ id: "new-commit-id" });
mockSecretVersionV2BridgeDAL.findLatestVersionMany.mockResolvedValue([
{
id: "secret-version-1",
createdAt: new Date(),
updatedAt: new Date(),
type: "shared",
folderId: "folder-1",
secretId: "secret-1",
version: 1,
key: "SECRET_KEY",
encryptedValue: Buffer.from("encrypted"),
encryptedComment: Buffer.from("comment"),
skipMultilineEncoding: false,
userId: "user-1",
envId: "env-1",
metadata: {}
}
]);
// Mock transaction
mockFolderCommitDAL.transaction.mockImplementation(<T>(callback: TransactionCallback<T>) => callback({} as Knex));
// Act
const result = await folderCommitService.applyFolderStateDifferences({
differences,
actorInfo: {
actorType,
actorId,
message: "Applying changes"
},
folderId,
projectId,
reconstructNewFolders: false
});
// Assert
expect(mockFolderCommitDAL.create).toHaveBeenCalled();
expect(mockSecretV2BridgeDAL.invalidateSecretCacheByProjectId).toHaveBeenCalledWith(projectId);
// Check that we got the right counts
expect(result.totalChanges).toEqual(2);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TFolderTreeCheckpointResources } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderTreeCheckpointResourcesDALFactory = ReturnType<typeof folderTreeCheckpointResourcesDALFactory>;
type TFolderTreeCheckpointResourcesWithCommitId = TFolderTreeCheckpointResources & {
commitId: bigint;
};
export const folderTreeCheckpointResourcesDALFactory = (db: TDbClient) => {
const folderTreeCheckpointResourcesOrm = ormify(db, TableName.FolderTreeCheckpointResources);
const findByTreeCheckpointId = async (
folderTreeCheckpointId: string,
tx?: Knex
): Promise<TFolderTreeCheckpointResourcesWithCommitId[]> => {
try {
const docs = await (tx || db.replicaNode())<TFolderTreeCheckpointResources>(
TableName.FolderTreeCheckpointResources
)
.join(
TableName.FolderCommit,
`${TableName.FolderTreeCheckpointResources}.folderCommitId`,
`${TableName.FolderCommit}.id`
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ folderTreeCheckpointId }, TableName.FolderTreeCheckpointResources))
.select(selectAllTableCols(TableName.FolderTreeCheckpointResources))
.select(db.ref("commitId").withSchema(TableName.FolderCommit).as("commitId"));
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindByTreeCheckpointId" });
}
};
return {
...folderTreeCheckpointResourcesOrm,
findByTreeCheckpointId
};
};
@@ -0,0 +1,79 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TFolderCommits, TFolderTreeCheckpoints } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderTreeCheckpointDALFactory = ReturnType<typeof folderTreeCheckpointDALFactory>;
type TreeCheckpointWithCommitInfo = TFolderTreeCheckpoints & {
commitId: bigint;
};
export const folderTreeCheckpointDALFactory = (db: TDbClient) => {
const folderTreeCheckpointOrm = ormify(db, TableName.FolderTreeCheckpoint);
const findByCommitId = async (folderCommitId: string, tx?: Knex): Promise<TFolderTreeCheckpoints | undefined> => {
try {
const doc = await (tx || db.replicaNode())<TFolderTreeCheckpoints>(TableName.FolderTreeCheckpoint)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ folderCommitId }, TableName.FolderTreeCheckpoint))
.select(selectAllTableCols(TableName.FolderTreeCheckpoint))
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindByCommitId" });
}
};
const findNearestCheckpoint = async (
folderCommitId: bigint,
envId: string,
tx?: Knex
): Promise<TreeCheckpointWithCommitInfo | undefined> => {
try {
const nearestCheckpoint = await (tx || db.replicaNode())(TableName.FolderTreeCheckpoint)
.join<TFolderCommits>(
TableName.FolderCommit,
`${TableName.FolderTreeCheckpoint}.folderCommitId`,
`${TableName.FolderCommit}.id`
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(`${TableName.FolderCommit}.envId`, "=", envId)
.andWhere(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId.toString())
.select(selectAllTableCols(TableName.FolderTreeCheckpoint))
.select(db.ref("commitId").withSchema(TableName.FolderCommit))
.orderBy(`${TableName.FolderCommit}.commitId`, "desc")
.first();
return nearestCheckpoint;
} catch (error) {
throw new DatabaseError({ error, name: "FindNearestCheckpoint" });
}
};
const findLatestByEnvId = async (envId: string, tx?: Knex): Promise<TFolderTreeCheckpoints | undefined> => {
try {
const doc = await (tx || db.replicaNode())<TFolderTreeCheckpoints>(TableName.FolderTreeCheckpoint)
.join<TFolderCommits>(
TableName.FolderCommit,
`${TableName.FolderTreeCheckpoint}.folderCommitId`,
`${TableName.FolderCommit}.id`
)
.where(`${TableName.FolderCommit}.envId`, "=", envId)
.orderBy(`${TableName.FolderTreeCheckpoint}.createdAt`, "desc")
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestByEnvId" });
}
};
return {
...folderTreeCheckpointOrm,
findByCommitId,
findNearestCheckpoint,
findLatestByEnvId
};
};
@@ -1,6 +1,7 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas";
import { TListProjectGroupUsersDTO } from "@app/ee/services/group/group-types";
import {
constructPermissionErrorMessage,
validatePrivilegeChangeOperation
@@ -42,7 +43,7 @@ type TGroupProjectServiceFactoryDep = {
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany" | "transaction">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
projectBotDAL: TProjectBotDALFactory;
groupDAL: Pick<TGroupDALFactory, "findOne">;
groupDAL: Pick<TGroupDALFactory, "findOne" | "findAllGroupPossibleMembers">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getProjectPermissionByRole">;
};
@@ -471,11 +472,54 @@ export const groupProjectServiceFactory = ({
return groupMembership;
};
const listProjectGroupUsers = async ({
id,
projectId,
offset,
limit,
username,
actor,
actorId,
actorAuthMethod,
actorOrgId,
search,
filter
}: TListProjectGroupUsersDTO) => {
const project = await projectDAL.findById(projectId);
if (!project) {
throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.Any
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups);
const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({
orgId: project.orgId,
groupId: id,
offset,
limit,
username,
search,
filter
});
return { users: members, totalCount };
};
return {
addGroupToProject,
updateGroupInProject,
removeGroupFromProject,
listGroupsInProject,
getGroupInProject
getGroupInProject,
listProjectGroupUsers
};
};
@@ -11,5 +11,9 @@ export type TIdentityAccessTokenJwtPayload = {
oidc?: {
claims: Record<string, string>;
};
kubernetes?: {
namespace: string;
name: string;
};
};
};
@@ -20,8 +20,9 @@ import {
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
import { withGatewayProxy } from "@app/lib/gateway";
import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger";
import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
@@ -33,6 +34,7 @@ import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/su
import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal";
import { extractK8sUsername } from "./identity-kubernetes-auth-fns";
import {
IdentityKubernetesAuthTokenReviewMode,
TAttachKubernetesAuthDTO,
TCreateTokenReviewResponse,
TGetKubernetesAuthDTO,
@@ -70,21 +72,27 @@ export const identityKubernetesAuthServiceFactory = ({
const $gatewayProxyWrapper = async <T>(
inputs: {
gatewayId: string;
targetHost: string;
targetPort: number;
targetHost?: string;
targetPort?: number;
caCert?: string;
reviewTokenThroughGateway: boolean;
},
gatewayCallback: (host: string, port: number) => Promise<T>
gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise<T>
): Promise<T> => {
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
const callbackResult = await withGatewayProxy(
async (port) => {
// Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server"
const res = await gatewayCallback("https://localhost", port);
async (port, httpsAgent) => {
const res = await gatewayCallback(
inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost",
port,
httpsAgent
);
return res;
},
{
protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp,
targetHost: inputs.targetHost,
targetPort: inputs.targetPort,
relayHost,
@@ -95,7 +103,16 @@ export const identityKubernetesAuthServiceFactory = ({
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
},
// only needed for TCP protocol, because the gateway as reviewer will use the pod's CA cert for auth directly
...(!inputs.reviewTokenThroughGateway
? {
httpsAgent: new https.Agent({
ca: inputs.caCert,
rejectUnauthorized: Boolean(inputs.caCert)
})
}
: {})
}
);
@@ -129,22 +146,36 @@ export const identityKubernetesAuthServiceFactory = ({
caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString();
}
let tokenReviewerJwt = "";
if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) {
tokenReviewerJwt = decryptor({
cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt
}).toString();
} else {
// if no token reviewer is provided means the incoming token has to act as reviewer
tokenReviewerJwt = serviceAccountJwt;
}
const tokenReviewCallbackRaw = async (host = identityKubernetesAuth.kubernetesHost, port?: number) => {
logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API");
let { kubernetesHost } = identityKubernetesAuth;
if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) {
kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, "");
}
if (!host || !identityKubernetesAuth.kubernetesHost) {
throw new BadRequestError({
message: "Kubernetes host is required when token review mode is set to API"
});
}
let tokenReviewerJwt = "";
if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) {
tokenReviewerJwt = decryptor({
cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt
}).toString();
} else {
// if no token reviewer is provided means the incoming token has to act as reviewer
tokenReviewerJwt = serviceAccountJwt;
}
let servername = identityKubernetesAuth.kubernetesHost;
if (servername.startsWith("https://") || servername.startsWith("http://")) {
servername = new RE2("^https?:\\/\\/").replace(servername, "");
}
// get the last colon index, if it has a port, remove it, including the colon
const lastColonIndex = servername.lastIndexOf(":");
if (lastColonIndex !== -1) {
servername = servername.substring(0, lastColonIndex);
}
const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => {
const baseUrl = port ? `${host}:${port}` : host;
const res = await axios
@@ -165,11 +196,10 @@ export const identityKubernetesAuthServiceFactory = ({
},
signal: AbortSignal.timeout(10000),
timeout: 10000,
// if ca cert, rejectUnauthorized: true
httpsAgent: new https.Agent({
ca: caCert,
rejectUnauthorized: Boolean(caCert),
servername: kubernetesHost
servername
})
}
)
@@ -192,18 +222,110 @@ export const identityKubernetesAuthServiceFactory = ({
return res.data;
};
const [k8sHost, k8sPort] = kubernetesHost.split(":");
const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => {
logger.info(
{
host,
port
},
"tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway"
);
const data = identityKubernetesAuth.gatewayId
? await $gatewayProxyWrapper(
const res = await axios
.post<TCreateTokenReviewResponse>(
`${host}:${port}/apis/authentication.k8s.io/v1/tokenreviews`,
{
gatewayId: identityKubernetesAuth.gatewayId,
targetHost: k8sHost,
targetPort: k8sPort ? Number(k8sPort) : 443
apiVersion: "authentication.k8s.io/v1",
kind: "TokenReview",
spec: {
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {})
}
},
tokenReviewCallback
{
headers: {
"Content-Type": "application/json",
"x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount
},
signal: AbortSignal.timeout(10000),
timeout: 10000
}
)
: await tokenReviewCallback();
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
let { message } = err?.response?.data as unknown as { message?: string };
if (!message && typeof err.response.data === "string") {
message = err.response.data;
}
if (message) {
throw new UnauthorizedError({
message,
name: "KubernetesTokenReviewRequestError"
});
}
}
}
throw err;
});
return res.data;
};
let data: TCreateTokenReviewResponse | undefined;
if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) {
if (!identityKubernetesAuth.gatewayId) {
throw new BadRequestError({
message: "Gateway ID is required when token review mode is set to Gateway"
});
}
data = await $gatewayProxyWrapper(
{
gatewayId: identityKubernetesAuth.gatewayId,
reviewTokenThroughGateway: true
},
tokenReviewCallbackThroughGateway
);
} else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) {
if (!identityKubernetesAuth.kubernetesHost) {
throw new BadRequestError({
message: "Kubernetes host is required when token review mode is set to API"
});
}
let { kubernetesHost } = identityKubernetesAuth;
if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) {
kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, "");
}
const [k8sHost, k8sPort] = kubernetesHost.split(":");
data = identityKubernetesAuth.gatewayId
? await $gatewayProxyWrapper(
{
gatewayId: identityKubernetesAuth.gatewayId,
targetHost: k8sHost,
targetPort: k8sPort ? Number(k8sPort) : 443,
reviewTokenThroughGateway: false
},
tokenReviewCallbackRaw
)
: await tokenReviewCallbackRaw();
} else {
throw new BadRequestError({
message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}`
});
}
if (!data) {
throw new BadRequestError({
message: "Failed to review token"
});
}
if ("error" in data.status)
throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" });
@@ -278,7 +400,13 @@ export const identityKubernetesAuthServiceFactory = ({
{
identityId: identityKubernetesAuth.identityId,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
identityAuth: {
kubernetes: {
namespace: targetNamespace,
name: targetName
}
}
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
@@ -298,6 +426,7 @@ export const identityKubernetesAuthServiceFactory = ({
kubernetesHost,
caCert,
tokenReviewerJwt,
tokenReviewMode,
allowedNamespaces,
allowedNames,
allowedAudience,
@@ -384,6 +513,7 @@ export const identityKubernetesAuthServiceFactory = ({
{
identityId: identityMembershipOrg.identityId,
kubernetesHost,
tokenReviewMode,
allowedNamespaces,
allowedNames,
allowedAudience,
@@ -410,6 +540,7 @@ export const identityKubernetesAuthServiceFactory = ({
kubernetesHost,
caCert,
tokenReviewerJwt,
tokenReviewMode,
allowedNamespaces,
allowedNames,
allowedAudience,
@@ -492,6 +623,7 @@ export const identityKubernetesAuthServiceFactory = ({
const updateQuery: TIdentityKubernetesAuthsUpdate = {
kubernetesHost,
tokenReviewMode,
allowedNamespaces,
allowedNames,
allowedAudience,
@@ -5,11 +5,17 @@ export type TLoginKubernetesAuthDTO = {
jwt: string;
};
export enum IdentityKubernetesAuthTokenReviewMode {
Api = "api",
Gateway = "gateway"
}
export type TAttachKubernetesAuthDTO = {
identityId: string;
kubernetesHost: string;
kubernetesHost: string | null;
caCert: string;
tokenReviewerJwt?: string;
tokenReviewMode: IdentityKubernetesAuthTokenReviewMode;
allowedNamespaces: string;
allowedNames: string;
allowedAudience: string;
@@ -23,9 +29,10 @@ export type TAttachKubernetesAuthDTO = {
export type TUpdateKubernetesAuthDTO = {
identityId: string;
kubernetesHost?: string;
kubernetesHost?: string | null;
caCert?: string;
tokenReviewerJwt?: string | null;
tokenReviewMode?: IdentityKubernetesAuthTokenReviewMode;
allowedNamespaces?: string;
allowedNames?: string;
allowedAudience?: string;
+3 -2
View File
@@ -212,7 +212,7 @@ export const orgDALFactory = (db: TDbClient) => {
// special query
const findAllOrgsByUserId = async (
userId: string
): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string })[]> => {
): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string; userStatus: string })[]> => {
try {
const org = (await db
.replicaNode()(TableName.OrgMembership)
@@ -234,6 +234,7 @@ export const orgDALFactory = (db: TDbClient) => {
})
.select(selectAllTableCols(TableName.Organization))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("userRole"))
.select(db.ref("status").withSchema(TableName.OrgMembership).as("userStatus"))
.select(
db.raw(`
CASE
@@ -242,7 +243,7 @@ export const orgDALFactory = (db: TDbClient) => {
ELSE ''
END as "orgAuthMethod"
`)
)) as (TOrganizations & { orgAuthMethod: string; userRole: string })[];
)) as (TOrganizations & { orgAuthMethod: string; userRole: string; userStatus: string })[];
return org;
} catch (error) {
+19 -11
View File
@@ -183,7 +183,9 @@ export const orgServiceFactory = ({
* */
const findAllOrganizationOfUser = async (userId: string) => {
const orgs = await orgDAL.findAllOrgsByUserId(userId);
return orgs;
// Filter out orgs where the membership object is an invitation
return orgs.filter((org) => org.userStatus !== "invited");
};
/*
* Get all workspace members
@@ -835,16 +837,22 @@ export const orgServiceFactory = ({
// if the user doesn't exist we create the user with the email
if (!inviteeUser) {
inviteeUser = await userDAL.create(
{
isAccepted: false,
email: inviteeEmail,
username: inviteeEmail,
authMethods: [AuthMethod.EMAIL],
isGhost: false
},
tx
);
// TODO(carlos): will be removed once the function receives usernames instead of emails
const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx);
if (usersByEmail?.length === 1) {
[inviteeUser] = usersByEmail;
} else {
inviteeUser = await userDAL.create(
{
isAccepted: false,
email: inviteeEmail,
username: inviteeEmail,
authMethods: [AuthMethod.EMAIL],
isGhost: false
},
tx
);
}
}
const inviteeUserId = inviteeUser?.id;
+12 -1
View File
@@ -12,7 +12,7 @@ import {
TProjectsUpdate
} from "@app/db/schemas";
import { BadRequestError, DatabaseError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
import { ActorType } from "../auth/auth-type";
import { Filter, ProjectFilterType, SearchProjectSortBy } from "./project-types";
@@ -475,6 +475,16 @@ export const projectDALFactory = (db: TDbClient) => {
return { docs, totalCount: Number(docs?.[0]?.count ?? 0) };
};
const findProjectByEnvId = async (envId: string, tx?: Knex) => {
const project = await (tx || db.replicaNode())(TableName.Project)
.leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ id: envId }, TableName.Environment))
.select(selectAllTableCols(TableName.Project))
.first();
return project;
};
const countOfOrgProjects = async (orgId: string | null, tx?: Knex) => {
try {
const doc = await (tx || db.replicaNode())(TableName.Project)
@@ -504,6 +514,7 @@ export const projectDALFactory = (db: TDbClient) => {
checkProjectUpgradeStatus,
getProjectFromSplitId,
searchProjects,
findProjectByEnvId,
countOfOrgProjects
};
};
+18 -12
View File
@@ -30,7 +30,7 @@ import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-
import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
@@ -165,7 +165,7 @@ type TProjectServiceFactoryDep = {
sshHostGroupDAL: Pick<TSshHostGroupDALFactory, "find" | "findSshHostGroupsWithLoginMappings">;
permissionService: TPermissionServiceFactory;
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "invalidateGetPlan">;
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
smtpService: Pick<TSmtpService, "sendMail">;
orgDAL: Pick<TOrgDALFactory, "findOne">;
@@ -259,16 +259,17 @@ export const projectServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
const plan = await licenseService.getPlan(organization.id);
if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) {
// case: limit imposed on number of workspaces allowed
// case: number of workspaces used exceeds the number of workspaces allowed
throw new BadRequestError({
message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces."
});
}
const results = await (trx || projectDAL).transaction(async (tx) => {
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateProject(organization.id)]);
const plan = await licenseService.getPlan(organization.id);
if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) {
// case: limit imposed on number of workspaces allowed
// case: number of workspaces used exceeds the number of workspaces allowed
throw new BadRequestError({
message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces."
});
}
const ghostUser = await orgService.addGhostUser(organization.id, tx);
if (kmsKeyId) {
@@ -493,6 +494,10 @@ export const projectServiceFactory = ({
);
}
// no need to invalidate if there was no limit
if (plan.workspaceLimit) {
await licenseService.invalidateGetPlan(organization.id);
}
return {
...project,
environments: envs,
@@ -666,7 +671,8 @@ export const projectServiceFactory = ({
enforceCapitalization: update.autoCapitalization,
hasDeleteProtection: update.hasDeleteProtection,
slug: update.slug,
secretSharing: update.secretSharing
secretSharing: update.secretSharing,
showSnapshotsLegacy: update.showSnapshotsLegacy
});
return updatedProject;
@@ -94,6 +94,7 @@ export type TUpdateProjectDTO = {
hasDeleteProtection?: boolean;
slug?: string;
secretSharing?: boolean;
showSnapshotsLegacy?: boolean;
};
} & Omit<TProjectPermission, "projectId">;
@@ -488,6 +488,75 @@ export const secretFolderDALFactory = (db: TDbClient) => {
}
};
const findFoldersByRootAndIds = async ({ rootId, folderIds }: { rootId: string; folderIds: string[] }, tx?: Knex) => {
try {
// First, get all descendant folders of rootId
const descendants = await (tx || db.replicaNode())
.withRecursive("descendants", (qb) =>
qb
.select(
selectAllTableCols(TableName.SecretFolder),
db.raw("0 as depth"),
db.raw(`'/' as path`),
db.ref(`${TableName.Environment}.slug`).as("environment")
)
.from(TableName.SecretFolder)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.where(`${TableName.SecretFolder}.id`, rootId)
.union((un) => {
void un
.select(
selectAllTableCols(TableName.SecretFolder),
db.raw("descendants.depth + 1 as depth"),
db.raw(
`CONCAT(
CASE WHEN descendants.path = '/' THEN '' ELSE descendants.path END,
CASE WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' ELSE CONCAT('/', secret_folders.name) END
)`
),
db.ref("descendants.environment")
)
.from(TableName.SecretFolder)
.where(`${TableName.SecretFolder}.isReserved`, false)
.join("descendants", `${TableName.SecretFolder}.parentId`, "descendants.id");
})
)
.select<(TSecretFolders & { path: string; depth: number; environment: string })[]>("*")
.from("descendants")
.whereIn(`id`, folderIds)
.orderBy("depth")
.orderBy(`name`);
return descendants;
} catch (error) {
throw new DatabaseError({ error, name: "FindFoldersByRootAndIds" });
}
};
const findByParentId = async (parentId: string, tx?: Knex) => {
try {
const folders = await (tx || db.replicaNode())(TableName.SecretFolder)
.where({ parentId })
.andWhere({ isReserved: false })
.select(selectAllTableCols(TableName.SecretFolder));
return folders;
} catch (error) {
throw new DatabaseError({ error, name: "findByParentId" });
}
};
const findByEnvId = async (envId: string, tx?: Knex) => {
try {
const folders = await (tx || db.replicaNode())(TableName.SecretFolder)
.where({ envId })
.andWhere({ isReserved: false })
.select(selectAllTableCols(TableName.SecretFolder));
return folders;
} catch (error) {
throw new DatabaseError({ error, name: "findByEnvId" });
}
};
return {
...secretFolderOrm,
update,
@@ -499,6 +568,9 @@ export const secretFolderDALFactory = (db: TDbClient) => {
findClosestFolder,
findByProjectId,
findByMultiEnv,
findByEnvsDeep
findByEnvsDeep,
findByParentId,
findByEnvId,
findFoldersByRootAndIds
};
};
@@ -10,6 +10,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
import { ChangeType, CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TSecretFolderDALFactory } from "./secret-folder-dal";
@@ -29,7 +30,8 @@ type TSecretFolderServiceFactoryDep = {
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
folderDAL: TSecretFolderDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs" | "find">;
folderVersionDAL: TSecretFolderVersionDALFactory;
folderVersionDAL: Pick<TSecretFolderVersionDALFactory, "findLatestFolderVersions" | "create" | "insertMany" | "find">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
};
@@ -41,6 +43,7 @@ export const secretFolderServiceFactory = ({
permissionService,
projectEnvDAL,
folderVersionDAL,
folderCommitService,
projectDAL
}: TSecretFolderServiceFactoryDep) => {
const createFolder = async ({
@@ -111,15 +114,33 @@ export const secretFolderServiceFactory = ({
});
parentFolderId = newFolders.at(-1)?.id as string;
const docs = await folderDAL.insertMany(newFolders, tx);
await folderVersionDAL.insertMany(
const folderVersions = await folderVersionDAL.insertMany(
docs.map((doc) => ({
name: doc.name,
envId: doc.envId,
version: doc.version,
folderId: doc.id
folderId: doc.id,
description: doc.description
})),
tx
);
await folderCommitService.createCommit(
{
actor: {
type: actor,
metadata: {
id: actorId
}
},
message: "Folder created",
folderId: parentFolderId,
changes: folderVersions.map((fv) => ({
type: CommitType.ADD,
folderVersionId: fv.id
}))
},
tx
);
}
}
@@ -127,12 +148,32 @@ export const secretFolderServiceFactory = ({
{ name, envId: env.id, version: 1, parentId: parentFolderId, description },
tx
);
await folderVersionDAL.create(
const folderVersion = await folderVersionDAL.create(
{
name: doc.name,
envId: doc.envId,
version: doc.version,
folderId: doc.id
folderId: doc.id,
description: doc.description
},
tx
);
await folderCommitService.createCommit(
{
actor: {
type: actor,
metadata: {
id: actorId
}
},
message: "Folder created",
folderId: parentFolderId,
changes: [
{
type: CommitType.ADD,
folderVersionId: folderVersion.id
}
]
},
tx
);
@@ -225,12 +266,33 @@ export const secretFolderServiceFactory = ({
{ name, description },
tx
);
await folderVersionDAL.create(
const folderVersion = await folderVersionDAL.create(
{
name: doc.name,
envId: doc.envId,
version: doc.version,
folderId: doc.id
folderId: doc.id,
description: doc.description
},
tx
);
await folderCommitService.createCommit(
{
actor: {
type: actor,
metadata: {
id: actorId
}
},
message: "Folder updated",
folderId: parentFolder.id,
changes: [
{
type: CommitType.ADD,
isUpdate: true,
folderVersionId: folderVersion.id
}
]
},
tx
);
@@ -321,12 +383,33 @@ export const secretFolderServiceFactory = ({
{ name, description },
tx
);
await folderVersionDAL.create(
const folderVersion = await folderVersionDAL.create(
{
name: doc.name,
envId: doc.envId,
version: doc.version,
folderId: doc.id
folderId: doc.id,
description: doc.description
},
tx
);
await folderCommitService.createCommit(
{
actor: {
type: actor,
metadata: {
id: actorId
}
},
message: "Folder updated",
folderId: parentFolder.id,
changes: [
{
type: CommitType.ADD,
isUpdate: true,
folderVersionId: folderVersion.id
}
]
},
tx
);
@@ -381,7 +464,31 @@ export const secretFolderServiceFactory = ({
},
tx
);
if (!doc) throw new NotFoundError({ message: `Failed to delete folder with ID '${idOrName}', not found` });
const folderVersions = await folderVersionDAL.findLatestFolderVersions([doc.id], tx);
await folderCommitService.createCommit(
{
actor: {
type: actor,
metadata: {
id: actorId
}
},
message: "Folder deleted",
folderId: parentFolder.id,
changes: [
{
type: CommitType.DELETE,
folderVersionId: folderVersions[doc.id].id,
folderId: doc.id
}
]
},
tx
);
return doc;
});
@@ -665,6 +772,45 @@ export const secretFolderServiceFactory = ({
return environmentFolders;
};
const getFolderVersionsByIds = async ({
folderId,
folderVersions
}: {
folderId: string;
folderVersions: string[];
}) => {
const versions = await folderVersionDAL.find({
folderId,
$in: {
version: folderVersions.map((v) => Number.parseInt(v, 10))
}
});
return versions;
};
const getFolderVersions = async (
change: {
folderVersion?: string;
isUpdate?: boolean;
changeType?: string;
},
fromVersion: string,
folderId: string
) => {
const currentVersion = change.folderVersion || "1";
// eslint-disable-next-line no-await-in-loop
const versions = await getFolderVersionsByIds({
folderId,
folderVersions:
change.isUpdate || change.changeType === ChangeType.UPDATE ? [currentVersion, fromVersion] : [currentVersion]
});
return versions.map((v) => ({
version: v.version?.toString() || "1",
name: v.name,
description: v.description
}));
};
return {
createFolder,
updateFolder,
@@ -675,6 +821,8 @@ export const secretFolderServiceFactory = ({
getProjectFolderCount,
getFoldersMultiEnv,
getFoldersDeepByEnvs,
getProjectEnvironmentsFolders
getProjectEnvironmentsFolders,
getFolderVersionsByIds,
getFolderVersions
};
};
@@ -43,7 +43,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
const docs: Array<TSecretFolderVersions & { max: number }> = await (tx || db.replicaNode())(
TableName.SecretFolderVersion
)
.whereIn("folderId", folderIds)
.whereIn(`${TableName.SecretFolderVersion}.folderId`, folderIds)
.join(
(tx || db)(TableName.SecretFolderVersion)
.groupBy("folderId")
@@ -85,6 +85,8 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
.join(TableName.Project, `${TableName.Project}.id`, `${TableName.Environment}.projectId`)
.join("folder_cte", "folder_cte.id", `${TableName.SecretFolderVersion}.id`)
.whereRaw(`folder_cte.row_num > ${TableName.Project}."pitVersionLimit"`)
// Projects with version >= 3 will require to have all folder versions for PIT
.andWhere(`${TableName.Project}.version`, "<", 3)
.delete();
} catch (error) {
throw new DatabaseError({
@@ -95,5 +97,107 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret folder versions completed`);
};
return { ...secretFolderVerOrm, findLatestFolderVersions, findLatestVersionByFolderId, pruneExcessVersions };
// Get latest versions by folderIds
const getLatestFolderVersions = async (folderIds: string[], tx?: Knex): Promise<Array<TSecretFolderVersions>> => {
if (!folderIds.length) return [];
const knexInstance = tx || db.replicaNode();
return knexInstance(TableName.SecretFolderVersion)
.whereIn(`${TableName.SecretFolderVersion}.folderId`, folderIds)
.join(
knexInstance(TableName.SecretFolderVersion)
.groupBy("folderId")
.max("version")
.select("folderId")
.as("latestVersion"),
(bd) => {
bd.on(`${TableName.SecretFolderVersion}.folderId`, "latestVersion.folderId").andOn(
`${TableName.SecretFolderVersion}.version`,
"latestVersion.max"
);
}
);
};
// Get specific versions and update with max version
const getSpecificFolderVersionsWithLatest = async (
versionIds: string[],
tx?: Knex
): Promise<Array<TSecretFolderVersions>> => {
if (!versionIds.length) return [];
const knexInstance = tx || db.replicaNode();
// Get specific versions
const specificVersions = await knexInstance(TableName.SecretFolderVersion).whereIn("id", versionIds);
// Get folderIds from these versions
const specificFolderIds = [...new Set(specificVersions.map((v) => v.folderId).filter(Boolean))];
if (!specificFolderIds.length) return specificVersions;
// Get max versions for these folderIds
const maxVersionsQuery = await knexInstance(TableName.SecretFolderVersion)
.whereIn("folderId", specificFolderIds)
.groupBy("folderId")
.select("folderId")
.max("version", { as: "maxVersion" });
// Create lookup map for max versions
const maxVersionMap = maxVersionsQuery.reduce<Record<string, number>>((acc, item) => {
if (item.maxVersion) {
acc[item.folderId] = item.maxVersion;
}
return acc;
}, {});
// Replace version with max version
return specificVersions.map((version) => ({
...version,
version: maxVersionMap[version.folderId] || version.version
}));
};
const findByIdsWithLatestVersion = async (folderIds: string[], versionIds?: string[], tx?: Knex) => {
try {
if (!folderIds.length && (!versionIds || !versionIds.length)) return {};
// Run both queries in parallel
const [latestVersions, specificVersionsWithLatest] = await Promise.all([
folderIds.length ? getLatestFolderVersions(folderIds, tx) : [],
versionIds?.length ? getSpecificFolderVersionsWithLatest(versionIds, tx) : []
]);
const allDocs = [...latestVersions, ...specificVersionsWithLatest];
// Convert array to record with folderId as key
return allDocs.reduce<Record<string, TSecretFolderVersions>>(
(prev, curr) => ({ ...prev, [curr.folderId || ""]: curr }),
{}
);
} catch (error) {
throw new DatabaseError({ error, name: "FindByIdsWithLatestVersion" });
}
};
const findLatestVersion = async (folderId: string, tx?: Knex) => {
try {
const doc = await (tx || db.replicaNode())(TableName.SecretFolderVersion)
.where(`${TableName.SecretFolderVersion}.folderId`, folderId)
.select(selectAllTableCols(TableName.SecretFolderVersion))
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "findLatestVersion" });
}
};
return {
...secretFolderVerOrm,
findLatestFolderVersions,
findLatestVersionByFolderId,
pruneExcessVersions,
findByIdsWithLatestVersion,
findLatestVersion
};
};
@@ -127,6 +127,7 @@ export const OnePassSyncFns = {
syncSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => {
const {
connection,
environment,
destinationConfig: { vaultId }
} = secretSync;
@@ -164,7 +165,7 @@ export const OnePassSyncFns = {
for await (const [key, variable] of Object.entries(items)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue;
if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
if (!(key in secretMap)) {
try {
@@ -294,7 +294,7 @@ const deleteParametersBatch = async (
export const AwsParameterStoreSyncFns = {
syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig, syncOptions } = secretSync;
const { destinationConfig, syncOptions, environment } = secretSync;
const ssm = await getSSM(secretSync);
@@ -391,7 +391,7 @@ export const AwsParameterStoreSyncFns = {
const [key, parameter] = entry;
// eslint-disable-next-line no-continue
if (!matchesSchema(key, syncOptions.keySchema)) continue;
if (!matchesSchema(key, environment?.slug || "", syncOptions.keySchema)) continue;
if (!(key in secretMap) || !secretMap[key].value) {
parametersToDelete.push(parameter);
@@ -57,7 +57,11 @@ const sleep = async () =>
setTimeout(resolve, 1000);
});
const getSecretsRecord = async (client: SecretsManagerClient, keySchema?: string): Promise<TAwsSecretsRecord> => {
const getSecretsRecord = async (
client: SecretsManagerClient,
environment: string,
keySchema?: string
): Promise<TAwsSecretsRecord> => {
const awsSecretsRecord: TAwsSecretsRecord = {};
let hasNext = true;
let nextToken: string | undefined;
@@ -72,7 +76,7 @@ const getSecretsRecord = async (client: SecretsManagerClient, keySchema?: string
if (output.SecretList) {
output.SecretList.forEach((secretEntry) => {
if (secretEntry.Name && matchesSchema(secretEntry.Name, keySchema)) {
if (secretEntry.Name && matchesSchema(secretEntry.Name, environment, keySchema)) {
awsSecretsRecord[secretEntry.Name] = secretEntry;
}
});
@@ -307,11 +311,11 @@ const processTags = ({
export const AwsSecretsManagerSyncFns = {
syncSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig, syncOptions } = secretSync;
const { destinationConfig, syncOptions, environment } = secretSync;
const client = await getSecretsManagerClient(secretSync);
const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema);
const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema);
const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord);
@@ -401,7 +405,7 @@ export const AwsSecretsManagerSyncFns = {
for await (const secretKey of Object.keys(awsSecretsRecord)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(secretKey, syncOptions.keySchema)) continue;
if (!matchesSchema(secretKey, environment?.slug || "", syncOptions.keySchema)) continue;
if (!(secretKey in secretMap) || !secretMap[secretKey].value) {
try {
@@ -468,7 +472,11 @@ export const AwsSecretsManagerSyncFns = {
getSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials): Promise<TSecretMap> => {
const client = await getSecretsManagerClient(secretSync);
const awsSecretsRecord = await getSecretsRecord(client, secretSync.syncOptions.keySchema);
const awsSecretsRecord = await getSecretsRecord(
client,
secretSync.environment?.slug || "",
secretSync.syncOptions.keySchema
);
const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord);
const { destinationConfig } = secretSync;
@@ -503,11 +511,11 @@ export const AwsSecretsManagerSyncFns = {
}
},
removeSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig, syncOptions } = secretSync;
const { destinationConfig, syncOptions, environment } = secretSync;
const client = await getSecretsManagerClient(secretSync);
const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema);
const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema);
if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) {
for await (const secretKey of Object.keys(awsSecretsRecord)) {
@@ -141,7 +141,7 @@ export const azureAppConfigurationSyncFactory = ({
for await (const key of Object.keys(azureAppConfigSecrets)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue;
if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
const azureSecret = azureAppConfigSecrets[key];
if (
@@ -194,7 +194,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur
for await (const deleteSecretKey of deleteSecrets.filter(
(secret) =>
matchesSchema(secret, secretSync.syncOptions.keySchema) &&
matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) &&
!setSecrets.find((setSecret) => setSecret.key === secret)
)) {
await request.delete(`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${deleteSecretKey}?api-version=7.3`, {
@@ -118,7 +118,7 @@ export const camundaSyncFactory = ({ kmsService, appConnectionDAL }: TCamundaSec
for await (const secret of Object.keys(camundaSecrets)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(secret, secretSync.syncOptions.keySchema)) continue;
if (!matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
if (!(secret in secretMap) || !secretMap[secret].value) {
try {
@@ -117,7 +117,7 @@ export const databricksSyncFactory = ({ kmsService, appConnectionDAL }: TDatabri
for await (const secret of databricksSecretKeys) {
// eslint-disable-next-line no-continue
if (!matchesSchema(secret.key, secretSync.syncOptions.keySchema)) continue;
if (!matchesSchema(secret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
if (!(secret.key in secretMap)) {
await deleteDatabricksSecrets({
@@ -1,3 +1,63 @@
export enum GcpSyncScope {
Global = "global"
Global = "global",
Region = "region"
}
export enum GCPSecretManagerLocation {
// Asia Pacific
ASIA_SOUTHEAST3 = "asia-southeast3", // Bangkok
ASIA_SOUTH2 = "asia-south2", // Delhi
ASIA_EAST2 = "asia-east2", // Hong Kong
ASIA_SOUTHEAST2 = "asia-southeast2", // Jakarta
AUSTRALIA_SOUTHEAST2 = "australia-southeast2", // Melbourne
ASIA_SOUTH1 = "asia-south1", // Mumbai
ASIA_NORTHEAST2 = "asia-northeast2", // Osaka
ASIA_NORTHEAST3 = "asia-northeast3", // Seoul
ASIA_SOUTHEAST1 = "asia-southeast1", // Singapore
AUSTRALIA_SOUTHEAST1 = "australia-southeast1", // Sydney
ASIA_EAST1 = "asia-east1", // Taiwan
ASIA_NORTHEAST1 = "asia-northeast1", // Tokyo
// Europe
EUROPE_WEST1 = "europe-west1", // Belgium
EUROPE_WEST10 = "europe-west10", // Berlin
EUROPE_NORTH1 = "europe-north1", // Finland
EUROPE_NORTH2 = "europe-north2", // Stockholm
EUROPE_WEST3 = "europe-west3", // Frankfurt
EUROPE_WEST2 = "europe-west2", // London
EUROPE_SOUTHWEST1 = "europe-southwest1", // Madrid
EUROPE_WEST8 = "europe-west8", // Milan
EUROPE_WEST4 = "europe-west4", // Netherlands
EUROPE_WEST12 = "europe-west12", // Turin
EUROPE_WEST9 = "europe-west9", // Paris
EUROPE_CENTRAL2 = "europe-central2", // Warsaw
EUROPE_WEST6 = "europe-west6", // Zurich
// North America
US_CENTRAL1 = "us-central1", // Iowa
US_WEST4 = "us-west4", // Las Vegas
US_WEST2 = "us-west2", // Los Angeles
NORTHAMERICA_SOUTH1 = "northamerica-south1", // Mexico
NORTHAMERICA_NORTHEAST1 = "northamerica-northeast1", // Montréal
US_EAST4 = "us-east4", // Northern Virginia
US_CENTRAL2 = "us-central2", // Oklahoma
US_WEST1 = "us-west1", // Oregon
US_WEST3 = "us-west3", // Salt Lake City
US_EAST1 = "us-east1", // South Carolina
NORTHAMERICA_NORTHEAST2 = "northamerica-northeast2", // Toronto
US_EAST5 = "us-east5", // Columbus
US_SOUTH1 = "us-south1", // Dallas
US_WEST8 = "us-west8", // Phoenix
// South America
SOUTHAMERICA_EAST1 = "southamerica-east1", // São Paulo
SOUTHAMERICA_WEST1 = "southamerica-west1", // Santiago
// Middle East
ME_CENTRAL2 = "me-central2", // Dammam
ME_CENTRAL1 = "me-central1", // Doha
ME_WEST1 = "me-west1", // Tel Aviv
// Africa
AFRICA_SOUTH1 = "africa-south1" // Johannesburg
}
@@ -4,6 +4,7 @@ import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger";
import { getGcpConnectionAuthToken } from "@app/services/app-connection/gcp";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { GcpSyncScope } from "@app/services/secret-sync/gcp/gcp-sync-enums";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { SecretSyncError } from "../secret-sync-errors";
@@ -15,9 +16,17 @@ import {
TGcpSyncWithCredentials
} from "./gcp-sync-types";
const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => {
const getProjectUrl = (secretSync: TGcpSyncWithCredentials) => {
const { destinationConfig } = secretSync;
if (destinationConfig.scope === GcpSyncScope.Global) {
return `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}`;
}
return `https://secretmanager.${destinationConfig.locationId}.rep.googleapis.com/v1/projects/${destinationConfig.projectId}/locations/${destinationConfig.locationId}`;
};
const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => {
let gcpSecrets: GCPSecret[] = [];
const pageSize = 100;
@@ -31,16 +40,13 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden
});
// eslint-disable-next-line no-await-in-loop
const { data: secretsRes } = await request.get<GCPSMListSecretsRes>(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${secretSync.destinationConfig.projectId}/secrets`,
{
params,
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
const { data: secretsRes } = await request.get<GCPSMListSecretsRes>(`${getProjectUrl(secretSync)}/secrets`, {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
);
});
if (secretsRes.secrets) {
gcpSecrets = gcpSecrets.concat(secretsRes.secrets);
@@ -61,7 +67,7 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden
try {
const { data: secretLatest } = await request.get<GCPLatestSecretVersionAccess>(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}/versions/latest:access`,
`${getProjectUrl(secretSync)}/secrets/${key}/versions/latest:access`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -113,11 +119,14 @@ export const GcpSyncFns = {
if (!(key in gcpSecrets)) {
// case: create secret
await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets`,
`${getProjectUrl(secretSync)}/secrets`,
{
replication: {
automatic: {}
}
replication:
destinationConfig.scope === GcpSyncScope.Global
? {
automatic: {}
}
: undefined
},
{
params: {
@@ -131,7 +140,7 @@ export const GcpSyncFns = {
);
await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`,
`${getProjectUrl(secretSync)}/secrets/${key}:addVersion`,
{
payload: {
data: Buffer.from(secretMap[key].value).toString("base64")
@@ -155,7 +164,7 @@ export const GcpSyncFns = {
for await (const key of Object.keys(gcpSecrets)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue;
if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
try {
if (!(key in secretMap) || !secretMap[key].value) {
@@ -163,15 +172,12 @@ export const GcpSyncFns = {
if (secretSync.syncOptions.disableSecretDeletion) continue;
// case: delete secret
await request.delete(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
await request.delete(`${getProjectUrl(secretSync)}/secrets/${key}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
);
});
} else if (secretMap[key].value !== gcpSecrets[key]) {
if (!secretMap[key].value) {
logger.warn(
@@ -180,7 +186,7 @@ export const GcpSyncFns = {
}
await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`,
`${getProjectUrl(secretSync)}/secrets/${key}:addVersion`,
{
payload: {
data: Buffer.from(secretMap[key].value).toString("base64")
@@ -212,21 +218,18 @@ export const GcpSyncFns = {
},
removeSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig, connection } = secretSync;
const { connection } = secretSync;
const accessToken = await getGcpConnectionAuthToken(connection);
const gcpSecrets = await getGcpSecrets(accessToken, secretSync);
for await (const [key] of Object.entries(gcpSecrets)) {
if (key in secretMap) {
await request.delete(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
await request.delete(`${getProjectUrl(secretSync)}/secrets/${key}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
);
});
}
}
}
@@ -10,14 +10,33 @@ import {
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
import { SecretSync } from "../secret-sync-enums";
import { GcpSyncScope } from "./gcp-sync-enums";
import { GCPSecretManagerLocation, GcpSyncScope } from "./gcp-sync-enums";
const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
const GcpSyncDestinationConfigSchema = z.object({
scope: z.literal(GcpSyncScope.Global).describe(SecretSyncs.DESTINATION_CONFIG.GCP.scope),
projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GCP.projectId)
});
const GcpSyncDestinationConfigSchema = z.discriminatedUnion("scope", [
z
.object({
scope: z.literal(GcpSyncScope.Global).describe(SecretSyncs.DESTINATION_CONFIG.GCP.scope),
projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GCP.projectId)
})
.describe(
JSON.stringify({
title: "Global"
})
),
z
.object({
scope: z.literal(GcpSyncScope.Region).describe(SecretSyncs.DESTINATION_CONFIG.GCP.scope),
projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GCP.projectId),
locationId: z.nativeEnum(GCPSecretManagerLocation).describe(SecretSyncs.DESTINATION_CONFIG.GCP.locationId)
})
.describe(
JSON.stringify({
title: "Region"
})
)
]);
export const GcpSyncSchema = BaseSecretSyncSchema(SecretSync.GCPSecretManager, GcpSyncOptionsConfig).extend({
destination: z.literal(SecretSync.GCPSecretManager),
@@ -223,8 +223,9 @@ export const GithubSyncFns = {
if (secretSync.syncOptions.disableSecretDeletion) return;
for await (const encryptedSecret of encryptedSecrets) {
// eslint-disable-next-line no-continue
if (!matchesSchema(encryptedSecret.name, secretSync.syncOptions.keySchema)) continue;
if (!matchesSchema(encryptedSecret.name, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema))
// eslint-disable-next-line no-continue
continue;
if (!(encryptedSecret.name in secretMap)) {
await deleteSecret(client, secretSync, encryptedSecret);
@@ -68,6 +68,7 @@ export const HCVaultSyncFns = {
syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
const {
connection,
environment,
destinationConfig: { mount, path },
syncOptions: { disableSecretDeletion, keySchema }
} = secretSync;
@@ -97,7 +98,7 @@ export const HCVaultSyncFns = {
for await (const [key] of Object.entries(variables)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, keySchema)) continue;
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
if (!(key in secretMap)) {
delete variables[key];
@@ -200,8 +200,9 @@ export const HumanitecSyncFns = {
if (secretSync.syncOptions.disableSecretDeletion) return;
for await (const humanitecSecret of humanitecSecrets) {
// eslint-disable-next-line no-continue
if (!matchesSchema(humanitecSecret.key, secretSync.syncOptions.keySchema)) continue;
if (!matchesSchema(humanitecSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema))
// eslint-disable-next-line no-continue
continue;
if (!secretMap[humanitecSecret.key]) {
await deleteSecret(secretSync, humanitecSecret);
@@ -1,5 +1,5 @@
import { AxiosError } from "axios";
import RE2 from "re2";
import handlebars from "handlebars";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault";
@@ -70,13 +70,17 @@ type TSyncSecretDeps = {
};
// Add schema to secret keys
const addSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMap => {
const addSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => {
if (!schema) return unprocessedSecretMap;
const processedSecretMap: TSecretMap = {};
for (const [key, value] of Object.entries(unprocessedSecretMap)) {
const newKey = new RE2("{{secretKey}}").replace(schema, key);
const newKey = handlebars.compile(schema)({
secretKey: key,
environment
});
processedSecretMap[newKey] = value;
}
@@ -84,10 +88,17 @@ const addSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMa
};
// Strip schema from secret keys
const stripSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMap => {
const stripSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => {
if (!schema) return unprocessedSecretMap;
const [prefix, suffix] = schema.split("{{secretKey}}");
const compiledSchemaPattern = handlebars.compile(schema)({
secretKey: "{{secretKey}}", // Keep secretKey
environment
});
const parts = compiledSchemaPattern.split("{{secretKey}}");
const prefix = parts[0];
const suffix = parts[parts.length - 1];
const strippedMap: TSecretMap = {};
@@ -105,21 +116,40 @@ const stripSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecret
};
// Checks if a key matches a schema
export const matchesSchema = (key: string, schema?: string): boolean => {
export const matchesSchema = (key: string, environment: string, schema?: string): boolean => {
if (!schema) return true;
const [prefix, suffix] = schema.split("{{secretKey}}");
if (prefix === undefined || suffix === undefined) return true;
const compiledSchemaPattern = handlebars.compile(schema)({
secretKey: "{{secretKey}}", // Keep secretKey
environment
});
return key.startsWith(prefix) && key.endsWith(suffix);
// This edge-case shouldn't be possible
if (!compiledSchemaPattern.includes("{{secretKey}}")) {
return key === compiledSchemaPattern;
}
const parts = compiledSchemaPattern.split("{{secretKey}}");
const prefix = parts[0];
const suffix = parts[parts.length - 1];
if (prefix === "" && suffix === "") return true;
// If prefix is empty, key must end with suffix
if (prefix === "") return key.endsWith(suffix);
// If suffix is empty, key must start with prefix
if (suffix === "") return key.startsWith(prefix);
return key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length;
};
// Filter only for secrets with keys that match the schema
const filterForSchema = (secretMap: TSecretMap, schema?: string): TSecretMap => {
const filterForSchema = (secretMap: TSecretMap, environment: string, schema?: string): TSecretMap => {
const filteredMap: TSecretMap = {};
for (const [key, value] of Object.entries(secretMap)) {
if (matchesSchema(key, schema)) {
if (matchesSchema(key, environment, schema)) {
filteredMap[key] = value;
}
}
@@ -133,7 +163,7 @@ export const SecretSyncFns = {
secretMap: TSecretMap,
{ kmsService, appConnectionDAL }: TSyncSecretDeps
): Promise<void> => {
const schemaSecretMap = addSchema(secretMap, secretSync.syncOptions.keySchema);
const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema);
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
@@ -268,14 +298,16 @@ export const SecretSyncFns = {
);
}
return stripSchema(filterForSchema(secretMap), secretSync.syncOptions.keySchema);
const filtered = filterForSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema);
const stripped = stripSchema(filtered, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema);
return stripped;
},
removeSecrets: (
secretSync: TSecretSyncWithCredentials,
secretMap: TSecretMap,
{ kmsService, appConnectionDAL }: TSyncSecretDeps
): Promise<void> => {
const schemaSecretMap = addSchema(secretMap, secretSync.syncOptions.keySchema);
const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema);
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
@@ -59,6 +59,7 @@ import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/se
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
export type TSecretSyncQueueFactory = ReturnType<typeof secretSyncQueueFactory>;
@@ -94,6 +95,7 @@ type TSecretSyncQueueFactoryDep = {
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
@@ -136,6 +138,7 @@ export const secretSyncQueueFactory = ({
secretVersionV2BridgeDAL,
secretVersionTagV2BridgeDAL,
resourceMetadataDAL,
folderCommitService,
licenseService
}: TSecretSyncQueueFactoryDep) => {
const appCfg = getConfig();
@@ -167,7 +170,8 @@ export const secretSyncQueueFactory = ({
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
secretVersionTagV2BridgeDAL,
resourceMetadataDAL
resourceMetadataDAL,
folderCommitService
});
const $updateManySecretsRawFn = updateManySecretsRawFnFactory({
@@ -183,7 +187,8 @@ export const secretSyncQueueFactory = ({
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
secretVersionTagV2BridgeDAL,
resourceMetadataDAL
resourceMetadataDAL,
folderCommitService
});
const $getInfisicalSecrets = async (
@@ -373,7 +378,7 @@ export const secretSyncQueueFactory = ({
if (Object.hasOwn(secretMap, key)) {
// Only update secrets if the source value is not empty
if (value) {
if (value && value !== secretMap[key].value) {
secretsToUpdate.push(secret);
if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData;
}
@@ -28,10 +28,30 @@ const BaseSyncOptionsSchema = <T extends AnyZodObject | undefined = undefined>({
keySchema: z
.string()
.optional()
.refine((val) => !val || new RE2(/^(?:[a-zA-Z0-9_\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9_\-/]*)$/).test(val), {
message:
"Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, underscores, slashes, and the {{secretKey}} placeholder."
})
.refine(
(val) => {
if (!val) return true;
const allowedOptionalPlaceholders = ["{{environment}}"];
const allowedPlaceholdersRegexPart = ["{{secretKey}}", ...allowedOptionalPlaceholders]
.map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) // Escape regex special characters
.join("|");
const allowedContentRegex = new RE2(`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`);
const contentIsValid = allowedContentRegex.test(val);
// Check if {{secretKey}} is present
const secretKeyRegex = new RE2(/\{\{secretKey\}\}/);
const secretKeyIsPresent = secretKeyRegex.test(val);
return contentIsValid && secretKeyIsPresent;
},
{
message:
"Key schema must include exactly one {{secretKey}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders."
}
)
.describe(SecretSyncs.SYNC_OPTIONS(destination).keySchema),
disableSecretDeletion: z.boolean().optional().describe(SecretSyncs.SYNC_OPTIONS(destination).disableSecretDeletion)
});
@@ -127,7 +127,7 @@ export const TeamCitySyncFns = {
for await (const [key, variable] of Object.entries(variables)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue;
if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
if (!(key in secretMap)) {
try {
@@ -232,8 +232,11 @@ export const TerraformCloudSyncFns = {
if (secretSync.syncOptions.disableSecretDeletion) return;
for (const terraformCloudVariable of terraformCloudVariables) {
// eslint-disable-next-line no-continue
if (!matchesSchema(terraformCloudVariable.key, secretSync.syncOptions.keySchema)) continue;
if (
!matchesSchema(terraformCloudVariable.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)
)
// eslint-disable-next-line no-continue
continue;
if (!Object.prototype.hasOwnProperty.call(secretMap, terraformCloudVariable.key)) {
await deleteVariable(secretSync, terraformCloudVariable);
@@ -291,8 +291,9 @@ export const VercelSyncFns = {
if (secretSync.syncOptions.disableSecretDeletion) return;
for await (const vercelSecret of vercelSecrets) {
// eslint-disable-next-line no-continue
if (!matchesSchema(vercelSecret.key, secretSync.syncOptions.keySchema)) continue;
if (!matchesSchema(vercelSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema))
// eslint-disable-next-line no-continue
continue;
if (!secretMap[vercelSecret.key]) {
await deleteSecret(secretSync, vercelSecret);
@@ -128,6 +128,7 @@ export const WindmillSyncFns = {
syncSecrets: async (secretSync: TWindmillSyncWithCredentials, secretMap: TSecretMap) => {
const {
connection,
environment,
destinationConfig: { path },
syncOptions: { disableSecretDeletion, keySchema }
} = secretSync;
@@ -171,7 +172,7 @@ export const WindmillSyncFns = {
for await (const [key, variable] of Object.entries(variables)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, keySchema)) continue;
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
if (!(key in secretMap)) {
try {
@@ -11,6 +11,7 @@ export const secretTagDALFactory = (db: TDbClient) => {
const secretTagOrm = ormify(db, TableName.SecretTag);
const secretJnTagOrm = ormify(db, TableName.JnSecretTag);
const secretV2JnTagOrm = ormify(db, TableName.SecretV2JnTag);
const secretVersionV2TagOrm = ormify(db, TableName.SecretVersionV2Tag);
const findManyTagsById = async (projectId: string, ids: string[], tx?: Knex) => {
try {
@@ -48,14 +49,39 @@ export const secretTagDALFactory = (db: TDbClient) => {
}
};
const findSecretTagsByVersionId = async (versionId: string, tx?: Knex) => {
try {
const tags = await (tx || db.replicaNode())(TableName.SecretVersionV2Tag)
.where(`${TableName.SecretVersionV2Tag}.${TableName.SecretVersionV2}Id`, versionId)
.select(selectAllTableCols(TableName.SecretVersionV2Tag));
return tags;
} catch (error) {
throw new DatabaseError({ error, name: "Find all by version id" });
}
};
const findSecretTagsBySecretId = async (secretId: string, tx?: Knex) => {
try {
const tags = await (tx || db.replicaNode())(TableName.SecretV2JnTag)
.where(`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`, secretId)
.select(selectAllTableCols(TableName.SecretV2JnTag));
return tags;
} catch (error) {
throw new DatabaseError({ error, name: "Find all by secret id" });
}
};
return {
...secretTagOrm,
saveTagsToSecret: secretJnTagOrm.insertMany,
deleteTagsToSecret: secretJnTagOrm.delete,
saveTagsToSecretV2: secretV2JnTagOrm.batchInsert,
deleteTagsToSecretV2: secretV2JnTagOrm.delete,
saveTagsToSecretVersionV2: secretVersionV2TagOrm.insertMany,
findSecretTagsByProjectId,
deleteTagsManySecret,
findManyTagsById
findManyTagsById,
findSecretTagsByVersionId,
findSecretTagsBySecretId
};
};
@@ -8,6 +8,7 @@ import { groupBy } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { ActorType } from "../auth/auth-type";
import { CommitType } from "../folder-commit/folder-commit-service";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns";
@@ -73,6 +74,7 @@ export const fnSecretBulkInsert = async ({
resourceMetadataDAL,
secretTagDAL,
secretVersionTagDAL,
folderCommitService,
actor,
tx
}: TFnSecretBulkInsert) => {
@@ -126,11 +128,36 @@ export const fnSecretBulkInsert = async ({
userActorId,
identityActorId,
actorType,
metadata: el.metadata ? JSON.stringify(el.metadata) : [],
secretId: newSecretGroupedByKeyName[el.key][0].id
})),
tx
);
const commitChanges = secretVersions
.filter(({ type }) => type === SecretType.Shared)
.map((sv) => ({
type: CommitType.ADD,
secretVersionId: sv.id
}));
if (commitChanges.length > 0) {
await folderCommitService.createCommit(
{
actor: {
type: actorType || ActorType.PLATFORM,
metadata: {
id: actor?.actorId
}
},
message: "Secret Created",
folderId,
changes: commitChanges
},
tx
);
}
await secretDAL.upsertSecretReferences(
inputSecrets.map(({ references = [], key }) => ({
secretId: newSecretGroupedByKeyName[key][0].id,
@@ -185,6 +212,7 @@ export const fnSecretBulkUpdate = async ({
orgId,
secretDAL,
secretVersionDAL,
folderCommitService,
secretTagDAL,
secretVersionTagDAL,
resourceMetadataDAL,
@@ -246,7 +274,7 @@ export const fnSecretBulkUpdate = async ({
userId,
encryptedComment,
version,
metadata,
metadata: metadata ? JSON.stringify(metadata) : [],
reminderNote,
encryptedValue,
reminderRepeatDays,
@@ -259,6 +287,7 @@ export const fnSecretBulkUpdate = async ({
),
tx
);
await secretDAL.upsertSecretReferences(
inputSecrets
.filter(({ data: { references } }) => Boolean(references))
@@ -329,6 +358,31 @@ export const fnSecretBulkUpdate = async ({
},
{ tx }
);
const commitChanges = secretVersions
.filter(({ type }) => type === SecretType.Shared)
.map((sv) => ({
type: CommitType.ADD,
isUpdate: true,
secretVersionId: sv.id
}));
if (commitChanges.length > 0) {
await folderCommitService.createCommit(
{
actor: {
type: actorType || ActorType.PLATFORM,
metadata: {
id: actor?.actorId
}
},
message: "Secret Updated",
folderId,
changes: commitChanges
},
tx
);
}
return secretsWithTags.map((secret) => ({ ...secret, _id: secret.id }));
};
@@ -337,8 +391,11 @@ export const fnSecretBulkDelete = async ({
inputSecrets,
tx,
actorId,
actorType,
secretDAL,
secretQueueService
secretQueueService,
folderCommitService,
secretVersionDAL
}: TFnSecretBulkDelete) => {
const deletedSecrets = await secretDAL.deleteMany(
inputSecrets.map(({ type, secretKey }) => ({
@@ -358,6 +415,35 @@ export const fnSecretBulkDelete = async ({
)
);
const secretVersions = await secretVersionDAL.findLatestVersionMany(
folderId,
deletedSecrets.map(({ id }) => id),
tx
);
const commitChanges = deletedSecrets
.filter(({ type }) => type === SecretType.Shared)
.map(({ id }) => ({
type: CommitType.DELETE,
secretVersionId: secretVersions[id].id
}));
if (commitChanges.length > 0) {
await folderCommitService.createCommit(
{
actor: {
type: actorType || ActorType.PLATFORM,
metadata: {
id: actorId
}
},
message: "Secret Deleted",
folderId,
changes: commitChanges
},
tx
);
}
return deletedSecrets;
};
@@ -17,6 +17,7 @@ import {
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionCommitsActions,
ProjectPermissionSecretActions,
ProjectPermissionSet,
ProjectPermissionSub
@@ -34,6 +35,7 @@ import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { ActorType } from "../auth/auth-type";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -90,6 +92,7 @@ type TSecretV2BridgeServiceFactoryDep = {
secretVersionTagDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
secretTagDAL: TSecretTagDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
folderDAL: Pick<
TSecretFolderDALFactory,
@@ -124,6 +127,7 @@ export const secretV2BridgeServiceFactory = ({
projectEnvDAL,
secretTagDAL,
secretVersionDAL,
folderCommitService,
folderDAL,
permissionService,
snapshotService,
@@ -321,12 +325,14 @@ export const secretV2BridgeServiceFactory = ({
userId: inputSecret.type === SecretType.Personal ? actorId : null,
tagIds: inputSecret.tagIds,
references: nestedReferences,
metadata: secretMetadata ? JSON.stringify(secretMetadata) : [],
secretMetadata
}
],
resourceMetadataDAL,
secretDAL,
secretVersionDAL,
folderCommitService,
secretTagDAL,
secretVersionTagDAL,
actor: {
@@ -510,6 +516,7 @@ export const secretV2BridgeServiceFactory = ({
folderId,
orgId: actorOrgId,
resourceMetadataDAL,
folderCommitService,
inputSecrets: [
{
filter: { id: secretId },
@@ -523,6 +530,7 @@ export const secretV2BridgeServiceFactory = ({
skipMultilineEncoding: inputSecret.skipMultilineEncoding,
key: inputSecret.newSecretName || secretName,
tags: inputSecret.tagIds,
metadata: secretMetadata ? JSON.stringify(secretMetadata) : [],
secretMetadata,
...encryptedValue
}
@@ -650,6 +658,9 @@ export const secretV2BridgeServiceFactory = ({
projectId,
folderId,
actorId,
actorType: actor,
folderCommitService,
secretVersionDAL,
secretDAL,
secretQueueService,
inputSecrets: [
@@ -1590,6 +1601,7 @@ export const secretV2BridgeServiceFactory = ({
orgId: actorOrgId,
secretDAL,
resourceMetadataDAL,
folderCommitService,
secretVersionDAL,
secretTagDAL,
secretVersionTagDAL,
@@ -1859,6 +1871,7 @@ export const secretV2BridgeServiceFactory = ({
const bulkUpdatedSecrets = await fnSecretBulkUpdate({
folderId,
orgId: actorOrgId,
folderCommitService,
tx,
inputSecrets: secretsToUpdate.map((el) => {
const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0];
@@ -1928,6 +1941,7 @@ export const secretV2BridgeServiceFactory = ({
secretVersionDAL,
secretTagDAL,
secretVersionTagDAL,
folderCommitService,
actor: {
type: actor,
actorId
@@ -2061,6 +2075,8 @@ export const secretV2BridgeServiceFactory = ({
fnSecretBulkDelete({
secretDAL,
secretQueueService,
folderCommitService,
secretVersionDAL,
inputSecrets: inputSecrets.map(({ type, secretKey }) => ({
secretKey,
type: type || SecretType.Shared
@@ -2068,6 +2084,7 @@ export const secretV2BridgeServiceFactory = ({
projectId,
folderId,
actorId,
actorType: actor,
tx
})
);
@@ -2159,15 +2176,25 @@ export const secretV2BridgeServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
const canRead =
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback) ||
permission.can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits);
if (!canRead) throw new ForbiddenRequestError({ message: "You do not have permission to read secret versions" });
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: folder.projectId
});
const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors(secretId, folder.projectId, {
offset,
limit,
sort: [["createdAt", "desc"]]
const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors({
secretId,
projectId: folder.projectId,
findOpt: {
offset,
limit,
sort: [["createdAt", "desc"]]
}
});
return secretVersions.map((el) => {
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
@@ -2469,6 +2496,7 @@ export const secretV2BridgeServiceFactory = ({
tx,
secretTagDAL,
resourceMetadataDAL,
folderCommitService,
secretVersionTagDAL,
actor: {
type: actor,
@@ -2495,6 +2523,7 @@ export const secretV2BridgeServiceFactory = ({
folderId: destinationFolder.id,
orgId: actorOrgId,
resourceMetadataDAL,
folderCommitService,
secretVersionDAL,
secretDAL,
tx,
@@ -2840,6 +2869,76 @@ export const secretV2BridgeServiceFactory = ({
};
};
const getSecretVersionsByIds = async ({
actorId,
actor,
actorOrgId,
actorAuthMethod,
secretId,
secretVersionNumbers,
secretPath,
envId,
projectId
}: TGetSecretVersionsDTO & {
secretVersionNumbers: string[];
secretPath: string;
envId: string;
projectId: string;
}) => {
const environment = await projectEnvDAL.findOne({ id: envId, projectId });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
const canRead =
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback) ||
permission.can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits);
if (!canRead) throw new ForbiddenRequestError({ message: "You do not have permission to read secret versions" });
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors({
secretId,
projectId,
secretVersions: secretVersionNumbers
});
return secretVersions.map((el) => {
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
permission,
ProjectPermissionSecretActions.ReadValue,
{
environment: environment.slug,
secretPath,
secretName: el.key,
...(el.tags?.length && {
secretTags: el.tags.map((tag) => tag.slug)
})
}
);
return reshapeBridgeSecret(
projectId,
environment.slug,
secretPath,
{
...el,
value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "",
comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : ""
},
secretValueHidden
);
});
};
return {
createSecret,
deleteSecret,
@@ -2858,6 +2957,7 @@ export const secretV2BridgeServiceFactory = ({
getSecretReferenceTree,
getSecretsByFolderMappings,
getSecretById,
getAccessibleSecrets
getAccessibleSecrets,
getSecretVersionsByIds
};
};
@@ -8,6 +8,7 @@ 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";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal";
@@ -178,9 +179,10 @@ export type TFnSecretBulkInsert = {
secretVersionDAL: Pick<TSecretVersionV2DALFactory, "insertMany">;
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2" | "find">;
secretVersionTagDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
actor?: {
type: string;
actorId: string;
actorId?: string;
};
};
@@ -206,9 +208,10 @@ export type TFnSecretBulkUpdate = {
secretVersionDAL: Pick<TSecretVersionV2DALFactory, "insertMany">;
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2" | "deleteTagsToSecretV2" | "find">;
secretVersionTagDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
actor?: {
type: string;
actorId: string;
actorId?: string;
};
tx?: Knex;
};
@@ -218,11 +221,14 @@ export type TFnSecretBulkDelete = {
projectId: string;
inputSecrets: Array<{ type: SecretType; secretKey: string }>;
actorId: string;
actorType?: string;
tx?: Knex;
secretDAL: Pick<TSecretV2BridgeDALFactory, "deleteMany">;
secretQueueService: {
removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise<void>;
};
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
secretVersionDAL: Pick<TSecretVersionV2DALFactory, "findLatestVersionMany">;
};
export type THandleReminderDTO = {
@@ -4,7 +4,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { SecretVersionsV2Schema, TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships, TFindOpt } from "@app/lib/knex";
import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindOpt } from "@app/lib/knex";
import { logger } from "@app/lib/logger";
import { QueueName } from "@app/queue";
@@ -138,7 +138,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
{}
);
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestVersinMany" });
throw new DatabaseError({ error, name: "FindLatestVersionMany" });
}
};
@@ -162,6 +162,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
.join(TableName.Project, `${TableName.Project}.id`, `${TableName.Environment}.projectId`)
.join("version_cte", "version_cte.id", `${TableName.SecretVersionV2}.id`)
.whereRaw(`version_cte.row_num > ${TableName.Project}."pitVersionLimit"`)
// Projects with version >= 3 will require to have all secret versions for PIT
.andWhere(`${TableName.Project}.version`, "<", 3)
.delete();
} catch (error) {
throw new DatabaseError({
@@ -172,13 +174,21 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v2 completed`);
};
const findVersionsBySecretIdWithActors = async (
secretId: string,
projectId: string,
{ offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt<TSecretVersionsV2> = {},
tx?: Knex
) => {
const findVersionsBySecretIdWithActors = async ({
secretId,
projectId,
secretVersions,
findOpt = {},
tx
}: {
secretId: string;
projectId: string;
secretVersions?: string[];
findOpt?: TFindOpt<TSecretVersionsV2>;
tx?: Knex;
}) => {
try {
const { offset, limit, sort = [["createdAt", "desc"]] } = findOpt;
const query = (tx || db)(TableName.SecretVersionV2)
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`)
.leftJoin(
@@ -189,22 +199,24 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
.leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`)
.leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`)
.leftJoin(
TableName.SecretV2JnTag,
`${TableName.SecretV2}.id`,
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
TableName.SecretVersionV2Tag,
`${TableName.SecretVersionV2}.id`,
`${TableName.SecretVersionV2Tag}.${TableName.SecretVersionV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.where((qb) => {
void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId);
void qb.where(`${TableName.ProjectMembership}.projectId`, projectId);
if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions);
})
.orWhere((qb) => {
void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId);
void qb.whereNull(`${TableName.ProjectMembership}.projectId`);
if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions);
})
.select(
selectAllTableCols(TableName.SecretVersionV2),
@@ -260,6 +272,178 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
}
};
// Function to fetch latest versions by secretIds
const getLatestVersionsBySecretIds = async (
folderId: string,
secretIds: string[],
tx?: Knex
): Promise<Array<TSecretVersionsV2>> => {
if (!secretIds.length) return [];
const knexInstance = tx || db.replicaNode();
return knexInstance(TableName.SecretVersionV2)
.where("folderId", folderId)
.whereIn(`${TableName.SecretVersionV2}.secretId`, secretIds)
.join(
knexInstance(TableName.SecretVersionV2)
.groupBy("secretId")
.max("version")
.select("secretId")
.as("latestVersion"),
(bd) => {
bd.on(`${TableName.SecretVersionV2}.secretId`, "latestVersion.secretId").andOn(
`${TableName.SecretVersionV2}.version`,
"latestVersion.max"
);
}
);
};
// Function to fetch specific versions by versionIds
const getSpecificVersionsWithLatestInfo = async (
folderId: string,
versionIds: string[],
tx?: Knex
): Promise<Array<TSecretVersionsV2>> => {
if (!versionIds.length) return [];
const knexInstance = tx || db.replicaNode();
// Get the specific versions
const specificVersions = await knexInstance(TableName.SecretVersionV2)
.where("folderId", folderId)
.whereIn("id", versionIds);
// Get the secretIds from these versions
const specificSecretIds = [...new Set(specificVersions.map((v) => v.secretId).filter(Boolean))];
if (!specificSecretIds.length) return specificVersions;
// Get max versions for these secretIds
const maxVersionsQuery = await knexInstance(TableName.SecretVersionV2)
.whereIn("secretId", specificSecretIds)
.groupBy("secretId")
.select("secretId")
.max("version", { as: "maxVersion" });
// Create a lookup map for max versions
const maxVersionMap = maxVersionsQuery.reduce(
(acc, item) => {
acc[item.secretId] = item.maxVersion;
return acc;
},
{} as Record<string, number>
);
// Update the version field with maxVersion when needed
return specificVersions.map((version) => {
// Replace version with maxVersion
return {
...version,
version: maxVersionMap[version.secretId] || version.version
};
});
};
const findByIdsWithLatestVersion = async (
folderId: string,
secretIds: string[],
versionIds?: string[],
tx?: Knex
) => {
try {
if (!secretIds.length && (!versionIds || !versionIds.length)) return {};
const [latestVersions, specificVersionsWithLatest] = await Promise.all([
secretIds.length ? getLatestVersionsBySecretIds(folderId, secretIds, tx) : [],
versionIds?.length ? getSpecificVersionsWithLatestInfo(folderId, versionIds, tx) : []
]);
const allDocs = [...latestVersions, ...specificVersionsWithLatest];
// Convert array to record with secretId as key
return allDocs.reduce<Record<string, TSecretVersionsV2>>(
(prev, curr) => ({ ...prev, [curr.secretId || ""]: curr }),
{}
);
} catch (error) {
throw new DatabaseError({ error, name: "FindByIdsWithLatestVersion" });
}
};
const findByIdAndPreviousVersion = async (secretVersionId: string, tx?: Knex) => {
try {
const targetSecretVersion = await (tx || db.replicaNode())(TableName.SecretVersionV2)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ id: secretVersionId }, TableName.SecretVersionV2))
.leftJoin(
TableName.SecretVersionV2Tag,
`${TableName.SecretVersionV2}.id`,
`${TableName.SecretVersionV2Tag}.${TableName.SecretVersionV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.select(selectAllTableCols(TableName.SecretVersionV2))
.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"))
.first();
if (targetSecretVersion) {
const previousSecretVersion = await (tx || db.replicaNode())(TableName.SecretVersionV2)
.where(
// eslint-disable-next-line @typescript-eslint/no-misused-promises
buildFindFilter(
{ version: targetSecretVersion.version - 1, secretId: targetSecretVersion.secretId },
TableName.SecretVersionV2
)
)
.leftJoin(
TableName.SecretVersionV2Tag,
`${TableName.SecretVersionV2}.id`,
`${TableName.SecretVersionV2Tag}.${TableName.SecretVersionV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.select(selectAllTableCols(TableName.SecretVersionV2))
.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"))
.first();
if (!previousSecretVersion) return [];
const docs = [previousSecretVersion, targetSecretVersion];
const data = sqlNestRelationships({
data: docs,
key: "id",
parentMapper: (el) => ({ _id: el.id, ...SecretVersionsV2Schema.parse(el) }),
childrenMapper: [
{
key: "tagId",
label: "tags" as const,
mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
id,
color,
slug,
name: slug
})
}
]
});
return data;
}
return [];
} catch (error) {
throw new DatabaseError({ error, name: "FindByIdAndPreviousVersion" });
}
};
return {
...secretVersionV2Orm,
pruneExcessVersions,
@@ -267,6 +451,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
bulkUpdate,
findLatestVersionByFolderId,
findVersionsBySecretIdWithActors,
findBySecretId
findBySecretId,
findByIdsWithLatestVersion,
findByIdAndPreviousVersion
};
};
+5 -1
View File
@@ -778,6 +778,7 @@ export const createManySecretsRawFnFactory = ({
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
secretVersionTagV2BridgeDAL,
folderCommitService,
kmsService,
resourceMetadataDAL
}: TCreateManySecretsRawFnFactory) => {
@@ -850,6 +851,7 @@ export const createManySecretsRawFnFactory = ({
secretVersionDAL: secretVersionV2BridgeDAL,
secretTagDAL,
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
folderCommitService,
tx
})
);
@@ -942,6 +944,7 @@ export const updateManySecretsRawFnFactory = ({
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
resourceMetadataDAL,
folderCommitService,
kmsService
}: TUpdateManySecretsRawFnFactory) => {
const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL);
@@ -1032,7 +1035,8 @@ export const updateManySecretsRawFnFactory = ({
secretDAL: secretV2BridgeDAL,
secretVersionDAL: secretVersionV2BridgeDAL,
secretTagDAL,
secretVersionTagDAL: secretVersionTagV2BridgeDAL
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
folderCommitService
})
);
+9 -3
View File
@@ -35,6 +35,7 @@ import { TSecretSyncQueueFactory } from "@app/services/secret-sync/secret-sync-q
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { ActorType } from "../auth/auth-type";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TIntegrationDALFactory } from "../integration/integration-dal";
import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal";
import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service";
@@ -112,6 +113,7 @@ type TSecretQueueFactoryDep = {
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
secretReminderRecipientsDAL: Pick<
TSecretReminderRecipientsDALFactory,
"delete" | "findUsersBySecretId" | "insertMany" | "transaction"
@@ -178,7 +180,8 @@ export const secretQueueFactory = ({
projectKeyDAL,
resourceMetadataDAL,
secretReminderRecipientsDAL,
secretSyncQueue
secretSyncQueue,
folderCommitService
}: TSecretQueueFactoryDep) => {
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
@@ -366,7 +369,8 @@ export const secretQueueFactory = ({
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
secretVersionTagV2BridgeDAL,
resourceMetadataDAL
resourceMetadataDAL,
folderCommitService
});
const updateManySecretsRawFn = updateManySecretsRawFnFactory({
@@ -382,7 +386,8 @@ export const secretQueueFactory = ({
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
secretVersionTagV2BridgeDAL,
resourceMetadataDAL
resourceMetadataDAL,
folderCommitService
});
/**
@@ -1581,6 +1586,7 @@ export const secretQueueFactory = ({
projectDAL,
webhookDAL,
event: job.data,
auditLogService,
secretManagerDecryptor: (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString()
});
});
+82 -2
View File
@@ -44,7 +44,8 @@ import {
TGetSecretsRawByFolderMappingsDTO
} from "@app/services/secret-v2-bridge/secret-v2-bridge-types";
import { ActorType } from "../auth/auth-type";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { ChangeType } from "../folder-commit/folder-commit-service";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -2521,6 +2522,36 @@ export const secretServiceFactory = ({
});
};
const getSecretVersionsV2ByIds = async ({
actorId,
actor,
actorOrgId,
actorAuthMethod,
secretId,
secretVersions,
secretPath,
envId,
projectId
}: TGetSecretVersionsDTO & {
secretVersions: string[];
secretPath: string;
envId: string;
projectId: string;
}) => {
const secretVersionV2 = await secretV2BridgeService.getSecretVersionsByIds({
actorId,
actor,
actorOrgId,
actorAuthMethod,
secretId,
secretVersionNumbers: secretVersions,
secretPath,
envId,
projectId
});
return secretVersionV2;
};
const attachTags = async ({
secretName,
tagSlugs,
@@ -3279,6 +3310,53 @@ export const secretServiceFactory = ({
return secrets;
};
const getChangeVersions = async (
change: {
secretVersion: string;
secretId?: string;
id?: string;
isUpdate?: boolean;
changeType?: string;
},
previousVersion: string,
actorId: string,
actor: ActorType,
actorOrgId: string,
actorAuthMethod: ActorAuthMethod,
envId: string,
projectId: string,
secretPath: string
) => {
const currentVersion = change.secretVersion;
const secretId = change.secretId ? change.secretId : change.id;
if (!secretId) {
return;
}
const versions = await getSecretVersionsV2ByIds({
actorId,
actor,
actorOrgId,
actorAuthMethod,
secretId,
// if it's update add also the previous secretversionid
secretVersions:
change.isUpdate || change.changeType === ChangeType.UPDATE
? [currentVersion, previousVersion]
: [currentVersion],
secretPath,
envId,
projectId
});
return versions?.map((v) => ({
secretKey: v.secretKey,
secretComment: v.secretComment,
skipMultilineEncoding: v.skipMultilineEncoding,
tags: v.tags?.map((tag) => tag.slug),
metadata: v.metadata,
secretValue: v.secretValue
}));
};
return {
attachTags,
detachTags,
@@ -3309,6 +3387,8 @@ export const secretServiceFactory = ({
getSecretsRawByFolderMappings,
getSecretAccessList,
getSecretByIdRaw,
getAccessibleSecrets
getAccessibleSecrets,
getSecretVersionsV2ByIds,
getChangeVersions
};
};
@@ -14,6 +14,7 @@ import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-fold
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { ActorType } from "../auth/auth-type";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
@@ -441,6 +442,7 @@ export type TCreateManySecretsRawFnFactory = {
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
};
export type TCreateManySecretsRawFn = {
@@ -478,6 +480,7 @@ export type TUpdateManySecretsRawFnFactory = {
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
};
export type TUpdateManySecretsRawFn = {
+7 -1
View File
@@ -21,6 +21,11 @@ export const userDALFactory = (db: TDbClient) => {
const findUserByUsername = async (username: string, tx?: Knex) =>
(tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() });
const findUserByEmail = async (email: string, tx?: Knex) =>
(tx || db)(TableName.Users).whereRaw('lower("email") = :email', { email: email.toLowerCase() }).where({
isEmailVerified: true
});
const getUsersByFilter = async ({
limit,
offset,
@@ -234,6 +239,7 @@ export const userDALFactory = (db: TDbClient) => {
findOneUserAction,
createUserAction,
getUsersByFilter,
findAllMyAccounts
findAllMyAccounts,
findUserByEmail
};
};
+2 -1
View File
@@ -107,7 +107,8 @@ export const userServiceFactory = ({
});
await userDAL.updateById(user.id, {
isEmailVerified: true
isEmailVerified: true,
username: usersByusername.length === 1 && user.email ? user.email.toLowerCase() : undefined
});
};
+54 -6
View File
@@ -4,9 +4,12 @@ import { AxiosError } from "axios";
import picomatch from "picomatch";
import { TWebhooks } from "@app/db/schemas";
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
import { EventType, WebhookTriggeredEvent } from "@app/ee/services/audit-log/audit-log-types";
import { request } from "@app/lib/config/request";
import { NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { ActorType } from "@app/services/auth/auth-type";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -163,6 +166,7 @@ export type TFnTriggerWebhookDTO = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findById">;
secretManagerDecryptor: (value: Buffer) => string;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
// this is reusable function
@@ -175,7 +179,8 @@ export const fnTriggerWebhook = async ({
projectEnvDAL,
event,
secretManagerDecryptor,
projectDAL
projectDAL,
auditLogService
}: TFnTriggerWebhookDTO) => {
const webhooks = await webhookDAL.findAllWebhooks(projectId, environment);
const toBeTriggeredHooks = webhooks.filter(
@@ -200,16 +205,43 @@ export const fnTriggerWebhook = async ({
})
);
const eventPayloads: WebhookTriggeredEvent["metadata"][] = [];
// filter hooks by status
const successWebhooks = webhooksTriggered
.filter(({ status }) => status === "fulfilled")
.map((_, i) => toBeTriggeredHooks[i].id);
.map((_, i) => {
eventPayloads.push({
webhookId: toBeTriggeredHooks[i].id,
type: event.type,
payload: {
type: toBeTriggeredHooks[i].type!,
...event.payload,
projectName
},
status: "success"
} as WebhookTriggeredEvent["metadata"]);
return toBeTriggeredHooks[i].id;
});
const failedWebhooks = webhooksTriggered
.filter(({ status }) => status === "rejected")
.map((data, i) => ({
id: toBeTriggeredHooks[i].id,
error: data.status === "rejected" ? (data.reason as AxiosError).message : ""
}));
.map((data, i) => {
eventPayloads.push({
webhookId: toBeTriggeredHooks[i].id,
type: event.type,
payload: {
type: toBeTriggeredHooks[i].type!,
...event.payload,
projectName
},
status: "failed"
} as WebhookTriggeredEvent["metadata"]);
return {
id: toBeTriggeredHooks[i].id,
error: data.status === "rejected" ? (data.reason as AxiosError).message : ""
};
});
await webhookDAL.transaction(async (tx) => {
const env = await projectEnvDAL.findOne({ projectId, slug: environment }, tx);
@@ -236,5 +268,21 @@ export const fnTriggerWebhook = async ({
);
}
});
for (const eventPayload of eventPayloads) {
// eslint-disable-next-line no-await-in-loop
await auditLogService.createAuditLog({
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
projectId,
event: {
type: EventType.WEBHOOK_TRIGGERED,
metadata: eventPayload
}
});
}
logger.info({ environment, secretPath, projectId }, "Secret webhook job ended");
};