Add PIT rollback

This commit is contained in:
carlosmonastyrski
2025-05-09 16:03:50 -03:00
parent 78d5bc823d
commit 9e1d38a27b
7 changed files with 484 additions and 12 deletions

View File

@@ -1,12 +1,15 @@
import { Knex } from "knex";
import { inMemoryKeyStore } from "@app/keystore/memory";
import { ProjectType, TableName } from "../schemas";
import { getMigrationPITServices } from "./utils/services";
export async function up(knex: Knex): Promise<void> {
const hasFolderCommitTable = await knex.schema.hasTable(TableName.FolderCommit);
if (hasFolderCommitTable) {
const { folderCommitService } = await getMigrationPITServices({ db: knex });
const keyStore = inMemoryKeyStore();
const { folderCommitService } = await getMigrationPITServices({ db: knex, keyStore });
const projects = await knex(TableName.Project).where({ version: 3, type: ProjectType.SecretManager }).select("id");
await knex.transaction(async (tx) => {
for (const project of projects) {

View File

@@ -18,6 +18,7 @@ import { orgDALFactory } from "@app/services/org/org-dal";
import { projectDALFactory } from "@app/services/project/project-dal";
import { secretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal";
import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-dal";
import { userDALFactory } from "@app/services/user/user-dal";
@@ -62,7 +63,7 @@ export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore }
return { kmsService };
};
export const getMigrationPITServices = async ({ db }: { db: Knex }) => {
export const getMigrationPITServices = async ({ db, keyStore }: { db: Knex; keyStore: TKeyStoreFactory }) => {
const projectDAL = projectDALFactory(db);
const folderCommitDAL = folderCommitDALFactory(db);
const folderCommitChangesDAL = folderCommitChangesDALFactory(db);
@@ -74,6 +75,7 @@ export const getMigrationPITServices = async ({ db }: { db: Knex }) => {
const folderVersionDAL = secretFolderVersionDALFactory(db);
const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db);
const folderCheckpointResourcesDAL = folderCheckpointResourcesDALFactory(db);
const secretV2BridgeDAL = secretV2BridgeDALFactory({ db, keyStore });
const folderCommitService = folderCommitServiceFactory({
folderCommitDAL,
@@ -86,7 +88,8 @@ export const getMigrationPITServices = async ({ db }: { db: Knex }) => {
folderVersionDAL,
secretVersionV2BridgeDAL,
projectDAL,
folderCheckpointResourcesDAL
folderCheckpointResourcesDAL,
secretV2BridgeDAL
});
return { folderCommitService };

View File

@@ -24,4 +24,38 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
return backup;
}
});
server.route({
method: "POST",
url: "/rollback",
config: {
rateLimit: readLimit
},
schema: {
body: z.object({
fromCommit: z.string().trim(),
toCommit: z.string().trim(),
folderId: z.string().trim(),
projectId: z.string().trim()
}),
response: {
200: z.any()
}
},
handler: async (req) => {
const diff = await server.services.folderCommit.compareFolderStates(req.body.fromCommit, req.body.toCommit);
const response = await server.services.folderCommit.applyFolderStateDifferences(
diff,
{
actorType: req.permission?.type || "PLATFORM",
actorId: req.permission?.id,
message: "Rollback to previous commit"
},
req.body.folderId,
req.body.projectId
);
return response;
}
});
};

View File

@@ -574,7 +574,8 @@ export const registerRoutes = async (
folderVersionDAL,
secretVersionV2BridgeDAL,
projectDAL,
folderCheckpointResourcesDAL
folderCheckpointResourcesDAL,
secretV2BridgeDAL
});
const scimService = scimServiceFactory({
licenseService,

View File

@@ -14,6 +14,7 @@ import { TIdentityDALFactory } from "../identity/identity-dal";
import { TProjectDALFactory } from "../project/project-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
import { TUserDALFactory } from "../user/user-dal";
import { TFolderCommitDALFactory } from "./folder-commit-dal";
@@ -38,14 +39,33 @@ type TFolderCommitServiceFactoryDep = {
folderTreeCheckpointDAL: Pick<TFolderTreeCheckpointDALFactory, "findByProjectId" | "findLatestByProjectId">;
userDAL: Pick<TUserDALFactory, "findById">;
identityDAL: Pick<TIdentityDALFactory, "findById">;
folderDAL: Pick<TSecretFolderDALFactory, "findByParentId" | "findByProjectId">;
folderDAL: Pick<
TSecretFolderDALFactory,
"findByParentId" | "findByProjectId" | "deleteById" | "create" | "updateById" | "update"
>;
folderVersionDAL: Pick<
TSecretFolderVersionDALFactory,
"findLatestFolderVersions" | "findById" | "deleteById" | "create" | "updateById"
| "findLatestFolderVersions"
| "findById"
| "deleteById"
| "create"
| "updateById"
| "find"
| "findByIdsWithLatestVersion"
>;
secretVersionV2BridgeDAL: Pick<
TSecretVersionV2DALFactory,
"findLatestVersionByFolderId" | "findById" | "deleteById" | "create" | "updateById"
| "findLatestVersionByFolderId"
| "findById"
| "deleteById"
| "create"
| "updateById"
| "find"
| "findByIdsWithLatestVersion"
>;
secretV2BridgeDAL: Pick<
TSecretV2BridgeDALFactory,
"deleteById" | "create" | "updateById" | "update" | "insertMany" | "invalidateSecretCacheByProjectId"
>;
projectDAL: Pick<TProjectDALFactory, "findById">;
};
@@ -85,7 +105,8 @@ export const folderCommitServiceFactory = ({
folderDAL,
folderVersionDAL,
secretVersionV2BridgeDAL,
projectDAL
projectDAL,
secretV2BridgeDAL
}: TFolderCommitServiceFactoryDep) => {
const appCfg = getConfig();
@@ -356,6 +377,261 @@ export const folderCommitServiceFactory = ({
}
};
const applyFolderStateDifferences = async (
differences: Array<{
type: string;
id: string;
versionId: string;
oldVersionId?: string;
changeType: "create" | "update" | "delete";
}>,
actorInfo: {
actorType: string;
actorId?: string;
message?: string;
},
folderId: string,
projectId: string
) => {
let result = {};
await folderCommitDAL.transaction(async (tx) => {
// Group differences by type for more efficient processing
const secretChanges = differences.filter((diff) => diff.type === "secret");
const folderChanges = differences.filter((diff) => diff.type === "folder");
const secretVersions = await secretVersionV2BridgeDAL.findByIdsWithLatestVersion(
folderId,
secretChanges.map((diff) => diff.id),
secretChanges.map((diff) => diff.versionId)
);
const folderVersions = await folderVersionDAL.findByIdsWithLatestVersion(
folderChanges.map((diff) => diff.id),
folderChanges.map((diff) => diff.versionId)
);
// Track all changes for commit recording
const commitChanges = [];
// Process secret changes
for (const change of secretChanges) {
const secretVersion = secretVersions[change.id];
switch (change.changeType) {
case "create":
if (secretVersion) {
const newSecret = [
{
id: change.id,
skipMultilineEncoding: secretVersion.skipMultilineEncoding,
version: secretVersion.version + 1,
type: secretVersion.type,
key: secretVersion.key,
reminderNote: secretVersion.reminderNote,
reminderRepeatDays: secretVersion.reminderRepeatDays,
encryptedValue: secretVersion.encryptedValue,
encryptedComment: secretVersion.encryptedComment,
userId: secretVersion.userId,
metadata: secretVersion.metadata,
folderId
}
];
await secretV2BridgeDAL.insertMany(newSecret, tx);
const newVersion = await secretVersionV2BridgeDAL.create(
{
folderId,
secretId: secretVersion.secretId,
version: secretVersion.version + 1,
encryptedValue: secretVersion.encryptedValue,
key: secretVersion.key,
encryptedComment: secretVersion.encryptedComment,
skipMultilineEncoding: secretVersion.skipMultilineEncoding,
reminderNote: secretVersion.reminderNote,
reminderRepeatDays: secretVersion.reminderRepeatDays,
userId: secretVersion.userId,
metadata: secretVersion.metadata,
actorType: actorInfo.actorType,
envId: secretVersion.envId,
...(actorInfo.actorType === ActorType.IDENTITY && { identityActorId: actorInfo.actorId }),
...(actorInfo.actorType === ActorType.USER && { userActorId: actorInfo.actorId })
},
tx
);
commitChanges.push({
type: "add",
secretVersionId: newVersion.id
});
}
break;
case "update":
// Update secret to specific version
if (secretVersion) {
await secretV2BridgeDAL.updateById(
change.id,
{
skipMultilineEncoding: secretVersion?.skipMultilineEncoding,
version: secretVersion?.version,
type: secretVersion?.type,
key: secretVersion?.key,
reminderNote: secretVersion?.reminderNote,
reminderRepeatDays: secretVersion?.reminderRepeatDays,
encryptedValue: secretVersion?.encryptedValue,
encryptedComment: secretVersion?.encryptedComment,
userId: secretVersion?.userId,
metadata: secretVersion?.metadata
},
tx
);
const newVersion = await secretVersionV2BridgeDAL.create(
{
version: secretVersion.version + 1,
encryptedValue: secretVersion.encryptedValue,
key: secretVersion.key,
encryptedComment: secretVersion.encryptedComment,
skipMultilineEncoding: secretVersion.skipMultilineEncoding,
reminderNote: secretVersion.reminderNote,
reminderRepeatDays: secretVersion.reminderRepeatDays,
userId: secretVersion.userId,
metadata: secretVersion.metadata,
actorType: actorInfo.actorType,
envId: secretVersion.envId,
folderId,
secretId: secretVersion.secretId,
...(actorInfo.actorType === ActorType.IDENTITY && { identityActorId: actorInfo.actorId }),
...(actorInfo.actorType === ActorType.USER && { userActorId: actorInfo.actorId })
},
tx
);
commitChanges.push({
type: "add",
secretVersionId: newVersion.id
});
}
break;
case "delete":
await secretV2BridgeDAL.deleteById(change.id, tx);
commitChanges.push({
type: "delete",
secretVersionId: change.versionId
});
break;
default:
throw new BadRequestError({ message: `Unknown change type: ${change.changeType as string}` });
}
}
// process folder changes
for (const change of folderChanges) {
const folderVersion = folderVersions[change.id];
switch (change.changeType) {
case "create":
// Add new folder
if (folderVersion) {
const newFolder = {
id: change.id,
parentId: folderId,
envId: folderVersion.envId,
version: (folderVersion.version || 1) + 1,
name: folderVersion.name
};
await folderDAL.create(newFolder, tx);
const newFolderVersion = await folderVersionDAL.create(
{
folderId: change.id,
version: (folderVersion.version || 1) + 1,
name: folderVersion.name,
envId: folderVersion.envId
},
tx
);
commitChanges.push({
type: "add",
folderVersionId: newFolderVersion.id
});
}
break;
case "update":
// Update folder to specific version
if (change.versionId) {
await folderVersionDAL.findById(change.versionId, tx).then(async (versionDetails) => {
if (versionDetails) {
await folderDAL.updateById(
change.id,
{
parentId: folderId,
envId: versionDetails.envId,
version: (versionDetails.version || 1) + 1,
name: versionDetails.name
},
tx
);
const newFolderVersion = await folderVersionDAL.create(
{
folderId: change.id,
version: (versionDetails.version || 1) + 1,
name: versionDetails.name,
envId: versionDetails.envId
},
tx
);
commitChanges.push({
type: "add",
folderVersionId: newFolderVersion.id
});
}
});
}
break;
case "delete":
await folderDAL.deleteById(change.id, tx);
commitChanges.push({
type: "delete",
folderVersionId: change.versionId
});
break;
default:
throw new BadRequestError({ message: `Unknown change type: ${change.changeType as string}` });
}
}
await createCommit(
{
actor: {
type: actorInfo.actorType,
metadata: { id: actorInfo.actorId }
},
message: actorInfo.message || "Rolled back folder state",
folderId,
changes: commitChanges
},
tx
);
result = {
secretChangesCount: secretChanges.length,
folderChangesCount: folderChanges.length,
totalChanges: differences.length
};
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
});
return result;
};
// Retrieve a commit by ID
const getCommitById = async (id: string, tx?: Knex) => {
return folderCommitDAL.findById(id, tx);
@@ -467,7 +743,8 @@ export const folderCommitServiceFactory = ({
initializeFolder,
initializeProject,
createFolderCheckpoint,
compareFolderStates
compareFolderStates,
applyFolderStateDifferences
};
};

View File

@@ -96,5 +96,80 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret folder versions completed`);
};
return { ...secretFolderVerOrm, findLatestFolderVersions, findLatestVersionByFolderId, pruneExcessVersions };
const findByIdsWithLatestVersion = async (folderIds: string[], versionIds?: string[], tx?: Knex) => {
try {
if (!folderIds.length && (!versionIds || !versionIds.length)) return {};
const knexInstance = tx || db.replicaNode();
let allDocs: Array<TSecretFolderVersions & { max?: number }> = [];
// Process latest versions for folderIds
if (folderIds.length) {
const latestVersions: Array<TSecretFolderVersions & { max: number }> = await 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"
);
}
);
allDocs = [...allDocs, ...latestVersions];
}
// Process specific versions by versionIds
if (versionIds && versionIds.length) {
// Get the specific versions
const specificVersions = await knexInstance(TableName.SecretFolderVersion).whereIn("id", versionIds);
// Get the folderIds from these versions
const specificFolderIds = [...new Set(specificVersions.map((v) => v.folderId).filter(Boolean))];
// Get max versions for these folderIds
const maxVersionsQuery = (await knexInstance(TableName.SecretFolderVersion)
.whereIn("folderId", specificFolderIds)
.groupBy("folderId")
.select("folderId")
.max("version as max")) as Array<{ folderId: string; max: number }>;
// Create a lookup map for max versions
const maxVersionMap = maxVersionsQuery.reduce<Record<string, number>>((acc, item) => {
acc[item.folderId] = item.max;
return acc;
}, {});
// Add max version to each specific version
const specificVersionsWithMax = specificVersions.map((version) => ({
...version,
max: maxVersionMap[version.folderId]
}));
allDocs = [...allDocs, ...specificVersionsWithMax];
}
return allDocs.reduce<Record<string, TSecretFolderVersions & { max?: number }>>(
(prev, curr) => ({ ...prev, [curr.folderId || ""]: curr }),
{}
);
} catch (error) {
throw new DatabaseError({ error, name: "FindByIdsWithLatestVersion" });
}
};
return {
...secretFolderVerOrm,
findLatestFolderVersions,
findLatestVersionByFolderId,
pruneExcessVersions,
findByIdsWithLatestVersion
};
};

View File

@@ -138,7 +138,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
{}
);
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestVersinMany" });
throw new DatabaseError({ error, name: "FindLatestVersionMany" });
}
};
@@ -261,6 +261,84 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
}
};
const findByIdsWithLatestVersion = async (
folderId: string,
secretIds: string[],
versionIds?: string[],
tx?: Knex
) => {
try {
if (!secretIds.length && (!versionIds || !versionIds.length)) return {};
const knexInstance = tx || db.replicaNode();
let allDocs: Array<TSecretVersionsV2 & { max?: number }> = [];
// Process latest versions for secretIds
if (secretIds.length) {
const latestVersions: Array<TSecretVersionsV2 & { max: number }> = await 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"
);
}
);
allDocs = [...allDocs, ...latestVersions];
}
// Process specific versions by versionIds
if (versionIds && versionIds.length) {
// 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))];
// Get max versions for these secretIds
const maxVersionsQuery = (await knexInstance(TableName.SecretVersionV2)
.whereIn("secretId", specificSecretIds)
.groupBy("secretId")
.select("secretId")
.max("version as max")) as Array<{ secretId: string; max: number }>;
// Create a lookup map for max versions
const maxVersionMap = maxVersionsQuery.reduce(
(acc, item) => {
acc[item.secretId] = item.max;
return acc;
},
{} as Record<string, number>
);
// Add max version to each specific version
const specificVersionsWithMax = specificVersions.map((version) => ({
...version,
max: maxVersionMap[version.secretId]
}));
allDocs = [...allDocs, ...specificVersionsWithMax];
}
return allDocs.reduce<Record<string, TSecretVersionsV2 & { max?: number }>>(
(prev, curr) => ({ ...prev, [curr.secretId || ""]: curr }),
{}
);
} catch (error) {
throw new DatabaseError({ error, name: "FindByIdsWithLatestVersion" });
}
};
return {
...secretVersionV2Orm,
pruneExcessVersions,
@@ -268,6 +346,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
bulkUpdate,
findLatestVersionByFolderId,
findVersionsBySecretIdWithActors,
findBySecretId
findBySecretId,
findByIdsWithLatestVersion
};
};