PIT: UI views

This commit is contained in:
carlosmonastyrski
2025-05-20 08:22:14 -03:00
parent 8bfd3913da
commit 6319f53802
47 changed files with 4134 additions and 196 deletions
@@ -29,6 +29,7 @@ export async function up(knex: Knex): Promise<void> {
t.uuid("folderCommitId").notNullable();
t.foreign("folderCommitId").references("id").inTable(TableName.FolderCommit).onDelete("CASCADE");
t.string("changeType").notNullable();
t.boolean("isUpdate").notNullable().defaultTo(false);
t.uuid("secretVersionId");
t.foreign("secretVersionId").references("id").inTable(TableName.SecretVersionV2).onDelete("CASCADE");
t.uuid("folderVersionId");
@@ -11,6 +11,7 @@ export const FolderCommitChangesSchema = z.object({
id: z.string().uuid(),
folderCommitId: z.string().uuid(),
changeType: z.string(),
isUpdate: z.boolean().default(false),
secretVersionId: z.string().uuid().nullable().optional(),
folderVersionId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
+516 -48
View File
@@ -1,144 +1,612 @@
/* eslint-disable @typescript-eslint/no-base-to-string */
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { NotFoundError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { booleanSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
import { ChangeType } from "@app/services/folder-commit/folder-commit-service";
const commitHistoryItemSchema = z.object({
id: z.string(),
folderId: z.string(),
actorType: z.string(),
actorMetadata: z.unknown().optional(),
message: z.string().optional().nullable(),
commitId: z.string(),
createdAt: z.string().or(z.date()),
envId: z.string()
});
const versionSchema = z.object({
secretKey: z.string().optional(),
secretComment: z.string().optional().nullable(),
skipMultilineEncoding: z.boolean().optional().nullable(),
secretReminderRepeatDays: z.number().optional().nullable(),
secretReminderNote: z.string().optional().nullable(),
metadata: z.unknown().optional().nullable(),
tags: z.array(z.string()).optional().nullable(),
secretReminderRecipients: z.array(z.any()).optional().nullable(),
secretValue: z.string().optional().nullable(),
name: z.string().optional().nullable()
});
const folderStateSchema = z.array(
z.object({
type: z.string(),
id: z.string(),
versionId: z.string(),
secretKey: z.string().optional(),
secretVersion: z.number().optional(),
folderName: z.string().optional(),
folderVersion: z.number().optional()
})
);
export const registerPITRouter = async (server: FastifyZodProvider) => {
// Get all commits for a folder
// Get commits count for a folder
server.route({
method: "GET",
url: "/commits/:folderId",
url: "/commits/count/:workspaceId",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
folderId: z.string().trim()
workspaceId: z.string().trim()
}),
querystring: z.object({
environment: z.string().trim(),
path: z.string().trim().default("/").transform(removeTrailingSlash)
}),
response: {
200: z.any()
200: z.object({
count: z.number(),
folderId: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const commits = await server.services.folderCommit.getCommitsByFolderId(req.params.folderId);
return commits;
const res = await server.services.folderCommit.getCommitsCount({
actor: req.permission?.type,
actorId: req.permission?.id,
actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod,
projectId: req.params.workspaceId,
environment: req.query.environment,
path: req.query.path
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
event: {
type: EventType.GET_PROJECT_PIT_COMMIT_COUNT,
metadata: {
environment: req.query.environment,
path: req.query.path,
commitCount: res.count.toString()
}
}
});
return res;
}
});
// Get all commits for a folder
server.route({
method: "GET",
url: "/commits/:workspaceId",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
}),
querystring: z.object({
environment: z.string().trim(),
path: z.string().trim().default("/").transform(removeTrailingSlash)
}),
response: {
200: commitHistoryItemSchema.array()
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const commits = await server.services.folderCommit.getCommitsForFolder({
actor: req.permission?.type,
actorId: req.permission?.id,
actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod,
projectId: req.params.workspaceId,
environment: req.query.environment,
path: req.query.path
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
event: {
type: EventType.GET_PROJECT_PIT_COMMITS,
metadata: {
environment: req.query.environment,
path: req.query.path,
commitCount: commits.length.toString()
}
}
});
return commits.map((commit) => ({
...commit,
commitId: commit.commitId.toString()
}));
}
});
// Get commit changes for a specific commit
server.route({
method: "GET",
url: "/commits/:commitId/changes",
url: "/commits/:workspaceId/:commitId/changes",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim(),
commitId: z.string().trim()
}),
response: {
200: z.any()
200: z.object({
changes: 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().optional().nullable(),
folderId: z.string(),
envId: z.string(),
createdAt: z.string().or(z.date()),
updatedAt: z.string().or(z.date()),
changes: z.array(
z.object({
id: z.string(),
folderCommitId: z.string(),
changeType: z.string(),
isUpdate: z.boolean().optional(),
secretVersionId: z.string().optional().nullable(),
folderVersionId: z.string().optional().nullable(),
// Fix these two fields to accept either string or Date objects
createdAt: z.union([z.string(), z.date()]),
updatedAt: z.union([z.string(), z.date()]),
folderName: z.string().optional().nullable(),
folderChangeId: z.string().optional().nullable(),
folderVersion: z.union([z.string(), z.number()]).optional().nullable(),
secretKey: z.string().optional().nullable(),
secretVersion: z.union([z.string(), z.number()]).optional().nullable(),
secretId: z.string().optional().nullable(),
actorMetadata: z
.union([
z.object({
id: z.string().optional(),
name: z.string().optional()
}),
z.unknown()
])
.optional(),
actorType: z.string().optional(),
message: z.string().optional().nullable(),
folderId: z.string().optional().nullable(),
versions: z.array(versionSchema).optional()
})
)
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const changes = await server.services.folderCommit.getCommitChanges(req.params.commitId);
return changes;
const changes = await server.services.folderCommit.getCommitChanges({
actor: req.permission?.type,
actorId: req.permission?.id,
actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod,
projectId: req.params.workspaceId,
commitId: req.params.commitId
});
for (const change of changes.changes) {
if (change.secretVersionId) {
const currentVersion = change.secretVersion || "1";
const previousVersion = (Number.parseInt(currentVersion, 10) - 1).toString();
if (change.secretId) {
// eslint-disable-next-line no-await-in-loop
const versions = await server.services.secret.getSecretVersionsV2ByIds({
actorId: req.permission?.id,
actor: req.permission?.type,
actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod,
secretId: change.secretId,
secretVersions: change.isUpdate ? [currentVersion, previousVersion] : [currentVersion],
folderId: change.folderId
});
change.versions = versions?.map((v) => ({
secretKey: v.secretKey,
secretComment: v.secretComment,
skipMultilineEncoding: v.skipMultilineEncoding,
secretReminderRepeatDays: v.secretReminderRepeatDays,
secretReminderNote: v.secretReminderNote,
metadata: v.secretMetadata,
tags: v.tags?.map((t) => t.name),
secretReminderRecipients: v.secretReminderRecipients?.map((r) => r.toString()),
secretValue: v.secretValue
}));
}
} else if (change.folderVersionId && change.folderChangeId) {
const currentVersion = change.folderVersion || "1";
const previousVersion = (Number.parseInt(currentVersion, 10) - 1).toString();
// eslint-disable-next-line no-await-in-loop
const versions = await server.services.folder.getFolderVersionsByIds({
folderId: change.folderChangeId,
folderVersions: change.isUpdate ? [currentVersion, previousVersion] : [currentVersion]
});
change.versions = versions.map((v) => ({
name: v.name
}));
}
}
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
event: {
type: EventType.GET_PROJECT_PIT_COMMIT_CHANGES,
metadata: {
commitId: req.params.commitId,
changesCount: (changes.changes?.length || 0).toString()
}
}
});
return {
changes: {
...changes,
commitId: changes.commitId.toString()
}
};
}
});
// Compare folder states between commits
// Retrieve rollback changes for a commit
server.route({
method: "GET",
url: "/compare",
url: "/commits/:workspaceId/:commitId/compare",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim(),
commitId: z.string().trim()
}),
querystring: z.object({
fromCommit: z.string().trim(),
toCommit: z.string().trim()
folderId: z.string().trim(),
envId: z.string().trim(),
deepRollback: booleanSchema.default(false),
secretPath: z.string().trim().default("/").transform(removeTrailingSlash)
}),
response: {
200: z.any()
200: z.array(
z.object({
folderId: z.string(),
folderName: z.string(),
folderPath: z.string().optional(),
changes: z.any()
})
)
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const diff = await server.services.folderCommit.compareFolderStates({
currentCommitId: req.query.fromCommit,
targetCommitId: req.query.toCommit
const latestCommit = await server.services.folderCommit.getLatestCommit({
folderId: req.query.folderId,
actor: req.permission?.type,
actorId: req.permission?.id,
actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod,
projectId: req.params.workspaceId
});
if (!latestCommit) {
throw new NotFoundError({ message: "Latest commit not found" });
}
let diffs;
if (req.query.deepRollback) {
diffs = await server.services.folderCommit.deepCompareFolder({
targetCommitId: req.params.commitId,
envId: req.query.envId,
actorId: req.permission?.id,
actorType: req.permission?.type,
projectId: req.params.workspaceId
});
} else {
const folder = await server.services.folder.getFolderById({
actor: req.permission?.type,
actorId: req.permission?.id,
actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod,
id: req.query.folderId
});
diffs = [
{
folderId: folder.id,
folderName: folder.name,
folderPath: req.query.secretPath,
changes: await server.services.folderCommit.compareFolderStates({
targetCommitId: req.params.commitId,
currentCommitId: latestCommit.id
})
}
];
}
for (const diff of diffs) {
for (const change of diff.changes) {
if (change.secretKey) {
const currentVersion = change.secretVersion || "1";
const previousVersion = change.fromVersion || "1";
// eslint-disable-next-line no-await-in-loop
const versions = await server.services.secret.getSecretVersionsV2ByIds({
actorId: req.permission?.id,
actor: req.permission?.type,
actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod,
secretId: change.id,
// if it's update add also the previous secretversionid
secretVersions:
change.changeType === ChangeType.UPDATE ? [currentVersion, previousVersion] : [currentVersion],
folderId: req.query.folderId
});
change.versions = versions?.map((v) => ({
secretKey: v.secretKey,
secretComment: v.secretComment,
skipMultilineEncoding: v.skipMultilineEncoding,
secretReminderRepeatDays: v.secretReminderRepeatDays,
secretReminderNote: v.secretReminderNote,
metadata: v.metadata,
tags: v.tags?.map((t) => t.name),
secretReminderRecipients: v.secretReminderRecipients?.map((r) => r.toString()),
secretValue: v.secretValue
}));
}
if (change.folderVersion) {
const currentVersion = change.folderVersion || "1";
const previousVersion = change.fromVersion || "1";
// eslint-disable-next-line no-await-in-loop
const versions = await server.services.folder.getFolderVersionsByIds({
folderId: change.id,
folderVersions:
change.changeType === ChangeType.UPDATE ? [currentVersion, previousVersion] : [currentVersion]
});
change.versions = versions.map((v) => ({
name: v.name
}));
}
}
}
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
event: {
type: EventType.PIT_COMPARE_FOLDER_STATES,
metadata: {
targetCommitId: req.params.commitId,
folderId: req.query.folderId,
deepRollback: req.query.deepRollback,
diffsCount: diffs.length.toString()
}
}
});
return diff;
return diffs;
}
});
// Rollback to a previous commit
server.route({
method: "POST",
url: "/rollback",
url: "/commits/:workspaceId/:commitId/rollback",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim(),
commitId: z.string().trim()
}),
body: z.object({
fromCommit: z.string().trim(),
toCommit: z.string().trim(),
folderId: z.string().trim(),
projectId: z.string().trim(),
reconstructNewFolders: z.boolean().default(false)
deepRollback: z.boolean().default(false),
message: z.string().trim().optional()
}),
response: {
200: z.any()
200: z.object({
success: z.boolean(),
secretChangesCount: z.number().optional(),
folderChangesCount: z.number().optional(),
totalChanges: z.number().optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const latestCommit = await server.services.folderCommit.getLatestCommit({
folderId: req.body.folderId,
actor: req.permission?.type,
actorId: req.permission?.id,
actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod,
projectId: req.params.workspaceId
});
if (!latestCommit) {
throw new NotFoundError({ message: "Latest commit not found" });
}
if (req.body.deepRollback) {
await server.services.folderCommit.deepRollbackFolder(
latestCommit.id,
req.body.folderId,
req.permission.id,
req.permission.type,
req.params.workspaceId
);
return { success: true };
}
const diff = await server.services.folderCommit.compareFolderStates({
currentCommitId: req.body.fromCommit,
targetCommitId: req.body.toCommit
currentCommitId: latestCommit.id,
targetCommitId: req.params.commitId
});
const response = await server.services.folderCommit.applyFolderStateDifferences({
differences: diff,
actorInfo: {
actorType: req.permission?.type || "PLATFORM",
actorId: req.permission?.id,
message: "Rollback to previous commit"
actorType: req.permission.type,
actorId: req.permission.id,
message: req.body.message || "Rollback to previous commit"
},
folderId: req.body.folderId,
projectId: req.body.projectId,
reconstructNewFolders: req.body.reconstructNewFolders
projectId: req.params.workspaceId,
reconstructNewFolders: req.body.deepRollback
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
event: {
type: EventType.PIT_ROLLBACK_COMMIT,
metadata: {
targetCommitId: req.params.commitId,
folderId: req.body.folderId,
deepRollback: req.body.deepRollback,
message: req.body.message || "Rollback to previous commit",
totalChanges: response.totalChanges.toString()
}
}
});
return {
success: true,
secretChangesCount: response.secretChangesCount,
folderChangesCount: response.folderChangesCount,
totalChanges: response.totalChanges
};
}
});
// Revert commit
server.route({
method: "POST",
url: "/commits/:workspaceId/:commitId/revert",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim(),
commitId: z.string().trim()
}),
response: {
200: z.object({
success: z.boolean(),
message: z.string(),
originalCommitId: z.string(),
revertCommitId: z.string().optional(),
changesReverted: z.number().optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const response = await server.services.folderCommit.revertCommitChanges({
commitId: req.params.commitId,
actor: req.permission?.type,
actorId: req.permission?.id,
actorAuthMethod: req.permission?.authMethod,
actorOrgId: req.permission?.orgId,
projectId: req.params.workspaceId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
event: {
type: EventType.PIT_REVERT_COMMIT,
metadata: {
commitId: req.params.commitId,
revertCommitId: response.revertCommitId,
changesReverted: response.changesReverted?.toString()
}
}
});
return response;
}
});
// Deep rollback to a specific commit
// Folder state at commit
server.route({
method: "POST",
url: "/deep-rollback",
method: "GET",
url: "/commits/:workspaceId/:commitId",
config: {
rateLimit: readLimit
},
schema: {
body: z.object({
commitId: z.string().trim(),
envId: z.string().trim(),
projectId: z.string().trim()
params: z.object({
workspaceId: z.string().trim(),
commitId: z.string().trim()
}),
querystring: z.object({
folderId: z.string().trim()
}),
response: {
200: z.any()
200: folderStateSchema
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
await server.services.folderCommit.deepRollbackFolder(
req.body.commitId,
req.body.envId,
req.permission?.id || "PLATFORM",
req.permission?.type || "PLATFORM",
req.body.projectId
);
const response = await server.services.folderCommit.reconstructFolderState(req.params.commitId);
return { success: true };
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
event: {
type: EventType.PIT_GET_FOLDER_STATE,
metadata: {
commitId: req.params.commitId,
folderId: req.query.folderId,
resourceCount: response.length.toString()
}
}
});
return response.map((item) => ({
...item,
secretVersion: item.secretVersion ? Number(item.secretVersion) : undefined,
folderVersion: item.folderVersion ? Number(item.folderVersion) : undefined
}));
}
});
};
@@ -343,7 +343,15 @@ export enum EventType {
MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list",
PROJECT_ASSUME_PRIVILEGE_SESSION_START = "project-assume-privileges-session-start",
PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end"
PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end",
GET_PROJECT_PIT_COMMITS = "get-project-pit-commits",
GET_PROJECT_PIT_COMMIT_CHANGES = "get-project-pit-commit-changes",
GET_PROJECT_PIT_COMMIT_COUNT = "get-project-pit-commit-count",
PIT_ROLLBACK_COMMIT = "pit-rollback-commit",
PIT_REVERT_COMMIT = "pit-revert-commit",
PIT_GET_FOLDER_STATE = "pit-get-folder-state",
PIT_COMPARE_FOLDER_STATES = "pit-compare-folder-states"
}
export const filterableSecretEvents: EventType[] = [
@@ -2694,6 +2702,71 @@ interface MicrosoftTeamsWorkflowIntegrationUpdateEvent {
};
}
interface GetProjectPitCommitsEvent {
type: EventType.GET_PROJECT_PIT_COMMITS;
metadata: {
commitCount: string;
environment: string;
path: string;
};
}
interface GetProjectPitCommitChangesEvent {
type: EventType.GET_PROJECT_PIT_COMMIT_CHANGES;
metadata: {
changesCount: string;
commitId: string;
};
}
interface GetProjectPitCommitCountEvent {
type: EventType.GET_PROJECT_PIT_COMMIT_COUNT;
metadata: {
environment: string;
path: string;
commitCount: string;
};
}
interface PitRollbackCommitEvent {
type: EventType.PIT_ROLLBACK_COMMIT;
metadata: {
targetCommitId: string;
folderId: string;
deepRollback: boolean;
message: string;
totalChanges: string;
};
}
interface PitRevertCommitEvent {
type: EventType.PIT_REVERT_COMMIT;
metadata: {
commitId: string;
revertCommitId?: string;
changesReverted?: string;
};
}
interface PitGetFolderStateEvent {
type: EventType.PIT_GET_FOLDER_STATE;
metadata: {
commitId: string;
folderId: string;
resourceCount: string;
};
}
interface PitCompareFolderStatesEvent {
type: EventType.PIT_COMPARE_FOLDER_STATES;
metadata: {
targetCommitId: string;
folderId: string;
deepRollback: boolean;
diffsCount: string;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@@ -2941,4 +3014,11 @@ export type Event =
| MicrosoftTeamsWorkflowIntegrationGetTeamsEvent
| MicrosoftTeamsWorkflowIntegrationGetEvent
| MicrosoftTeamsWorkflowIntegrationListEvent
| MicrosoftTeamsWorkflowIntegrationUpdateEvent;
| MicrosoftTeamsWorkflowIntegrationUpdateEvent
| GetProjectPitCommitsEvent
| GetProjectPitCommitChangesEvent
| PitRollbackCommitEvent
| GetProjectPitCommitCountEvent
| PitRevertCommitEvent
| PitCompareFolderStatesEvent
| PitGetFolderStateEvent;
@@ -17,6 +17,11 @@ export enum ProjectPermissionActions {
Delete = "delete"
}
export enum ProjectPermissionCommitsActions {
Read = "read",
PerformRollback = "perform-rollback"
}
export enum ProjectPermissionSecretActions {
DescribeAndReadValue = "read",
DescribeSecret = "describeSecret",
@@ -126,6 +131,7 @@ export enum ProjectPermissionSub {
SecretRollback = "secret-rollback",
SecretApproval = "secret-approval",
SecretRotation = "secret-rotation",
Commits = "commits",
Identity = "identity",
CertificateAuthorities = "certificate-authorities",
Certificates = "certificates",
@@ -251,7 +257,8 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project]
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms];
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms]
| [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits];
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
@@ -557,6 +564,12 @@ const GeneralPermissionSchema = [
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionKmipActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.Commits).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCommitsActions).describe(
"Describe what action an entity can take."
)
})
];
@@ -829,6 +842,11 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretRotation
);
can(
[ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback],
ProjectPermissionSub.Commits
);
return rules;
};
@@ -1011,6 +1029,11 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.SecretSyncs
);
can(
[ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback],
ProjectPermissionSub.Commits
);
return rules;
};
@@ -1046,6 +1069,7 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs);
can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits);
return rules;
};
@@ -351,6 +351,7 @@ export const secretRotationQueueFactory = ({
folderId: secretVersions[0].folderId,
changes: secretVersions.map((sv) => ({
type: "add",
isUpdate: true,
secretVersionId: sv.id
}))
},
+2 -1
View File
@@ -587,7 +587,8 @@ export const registerRoutes = async (
folderCheckpointResourcesDAL,
secretV2BridgeDAL,
folderTreeCheckpointResourcesDAL,
folderCommitQueueService
folderCommitQueueService,
permissionService
});
const scimService = scimServiceFactory({
licenseService,
@@ -27,6 +27,10 @@ export const folderCheckpointResourcesDALFactory = (db: TDbClient) => {
(TFolderCheckpointResources & {
referencedSecretId?: string;
referencedFolderId?: string;
folderName?: string;
folderVersion?: string;
secretKey?: string;
secretVersion?: string;
})[]
> => {
try {
@@ -45,9 +49,17 @@ export const folderCheckpointResourcesDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.FolderCheckpointResources))
.select(
db.ref("secretId").withSchema(TableName.SecretVersionV2).as("referencedSecretId"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("referencedFolderId")
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;
return docs.map((doc) => ({
...doc,
folderVersion: doc.folderVersion?.toString(),
secretVersion: doc.secretVersion?.toString()
}));
} catch (error) {
throw new DatabaseError({ error, name: "FindByCheckpointId" });
}
@@ -1,7 +1,13 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TFolderCommitChanges, TFolderCommits } from "@app/db/schemas";
import {
TableName,
TFolderCommitChanges,
TFolderCommits,
TSecretFolderVersions,
TSecretVersionsV2
} from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
@@ -12,17 +18,67 @@ type CommitChangeWithCommitInfo = TFolderCommitChanges & {
actorType: string;
message?: string | null;
folderId: string;
folderName?: string;
folderVersion?: string;
secretKey?: string;
secretVersion?: string;
secretId?: string;
folderChangeId?: 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;
name?: string;
}[];
};
export const folderCommitChangesDALFactory = (db: TDbClient) => {
const folderCommitChangesOrm = ormify(db, TableName.FolderCommitChanges);
const findByCommitId = async (folderCommitId: string, tx?: Knex): Promise<TFolderCommitChanges[]> => {
const findByCommitId = async (folderCommitId: string, tx?: Knex): Promise<CommitChangeWithCommitInfo[]> => {
try {
const docs = await (tx || db.replicaNode())<TFolderCommitChanges>(TableName.FolderCommitChanges)
.where({ folderCommitId })
.select(selectAllTableCols(TableName.FolderCommitChanges));
return docs;
.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`
)
.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) => ({
...doc,
folderVersion: doc.folderVersion?.toString(),
secretVersion: doc.secretVersion?.toString()
}));
} catch (error) {
throw new DatabaseError({ error, name: "FindByCommitId" });
}
@@ -19,11 +19,36 @@ export const folderCommitDALFactory = (db: TDbClient) => {
const findByFolderId = async (folderId: string, tx?: Knex): Promise<TFolderCommits[]> => {
try {
const docs = await (tx || db.replicaNode())(TableName.FolderCommit)
const trx = tx || db.replicaNode();
// First, get all folder commits
const folderCommits = await trx(TableName.FolderCommit)
.where({ folderId })
.select(selectAllTableCols(TableName.FolderCommit))
.select("*")
.orderBy("createdAt", "desc");
return docs;
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" });
}
@@ -158,7 +183,14 @@ export const folderCommitDALFactory = (db: TDbClient) => {
tx?: Knex
): Promise<
(TFolderCommits & {
changes: (TFolderCommitChanges & { referencedSecretId?: string; referencedFolderId?: string })[];
changes: (TFolderCommitChanges & {
referencedSecretId?: string;
referencedFolderId?: string;
folderName?: string;
folderVersion?: string;
secretKey?: string;
secretVersion?: string;
})[];
})[]
> => {
try {
@@ -195,7 +227,11 @@ export const folderCommitDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.FolderCommitChanges))
.select(
db.ref("secretId").withSchema(TableName.SecretVersionV2).as("referencedSecretId"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("referencedFolderId")
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
@@ -280,6 +316,34 @@ export const folderCommitDALFactory = (db: TDbClient) => {
}
};
const findAllFolderCommitsAfter = async ({
envId,
startCommitId,
tx
}: {
folderId?: string;
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" });
}
};
return {
...restOfOrm,
findByFolderId,
@@ -291,6 +355,7 @@ export const folderCommitDALFactory = (db: TDbClient) => {
findLatestCommitBetween,
findLatestEnvCommit,
getEnvNumberOfCommitsSince,
findLatestCommitByFolderIds
findLatestCommitByFolderIds,
findAllFolderCommitsAfter
};
};
@@ -113,7 +113,8 @@ describe("folderCommitServiceFactory", () => {
create: vi.fn().mockResolvedValue({}),
updateById: vi.fn().mockResolvedValue({}),
find: vi.fn().mockResolvedValue([]),
findByIdsWithLatestVersion: vi.fn().mockResolvedValue({})
findByIdsWithLatestVersion: vi.fn().mockResolvedValue({}),
findLatestVersionMany: vi.fn().mockResolvedValue({})
};
const mockSecretV2BridgeDAL = {
@@ -133,26 +134,44 @@ describe("folderCommitServiceFactory", () => {
scheduleTreeCheckpoint: vi.fn().mockResolvedValue({})
};
const mockPermissionService = {
getProjectPermission: 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
folderCommitQueueService: mockFolderCommitQueueService,
// @ts-expect-error - Mock implementation doesn't need all interface methods for testing
permissionService: mockPermissionService
});
});
@@ -532,13 +551,31 @@ describe("folderCommitServiceFactory", () => {
mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]);
// These mocks need to return objects with an id field
mockSecretVersionV2BridgeDAL.findByIdsWithLatestVersion.mockResolvedValue(secretVersions);
mockFolderVersionDAL.findByIdsWithLatestVersion.mockResolvedValue(folderVersions);
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));
@@ -1,12 +1,15 @@
/* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability";
import { Knex } from "knex";
import { TSecretFolders, TSecretFolderVersions, TSecretVersionsV2 } from "@app/db/schemas";
import { ActionProjectType, TSecretFolders, TSecretFolderVersions, TSecretVersionsV2 } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { ActorType } from "../auth/auth-type";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { TFolderCheckpointDALFactory } from "../folder-checkpoint/folder-checkpoint-dal";
import { TFolderCheckpointResourcesDALFactory } from "../folder-checkpoint-resources/folder-checkpoint-resources-dal";
import { TFolderCommitChangesDALFactory } from "../folder-commit-changes/folder-commit-changes-dal";
@@ -22,7 +25,6 @@ import { TUserDALFactory } from "../user/user-dal";
import { TFolderCommitDALFactory } from "./folder-commit-dal";
import { TFolderCommitQueueServiceFactory } from "./folder-commit-queue";
// Define enums for better type safety
export enum ChangeType {
ADD = "add",
DELETE = "delete",
@@ -35,7 +37,6 @@ enum ResourceType {
FOLDER = "folder"
}
// Improved types for DTO objects
type TCreateCommitDTO = {
actor: {
type: string;
@@ -50,6 +51,7 @@ type TCreateCommitDTO = {
type: string;
secretVersionId?: string;
folderVersionId?: string;
isUpdate?: boolean;
}[];
};
@@ -69,6 +71,23 @@ export type ResourceChange = {
commitId: number;
createdAt?: Date;
parentId?: string;
secretKey?: string;
secretVersion?: string;
folderName?: string;
folderVersion?: string;
fromVersion?: 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;
name?: string;
}[];
};
type ActorInfo = {
@@ -84,77 +103,21 @@ type StateChangeResult = {
};
type TFolderCommitServiceFactoryDep = {
folderCommitDAL: Pick<
TFolderCommitDALFactory,
| "create"
| "findById"
| "findByFolderId"
| "findLatestCommit"
| "transaction"
| "getNumberOfCommitsSince"
| "getEnvNumberOfCommitsSince"
| "findCommitsToRecreate"
| "findMultipleLatestCommits"
| "findLatestCommitBetween"
| "findAllCommitsBetween"
| "findLatestEnvCommit"
| "findLatestCommitByFolderIds"
>;
folderCommitChangesDAL: Pick<TFolderCommitChangesDALFactory, "create" | "findByCommitId" | "insertMany">;
folderCheckpointDAL: Pick<
TFolderCheckpointDALFactory,
"create" | "findByFolderId" | "findLatestByFolderId" | "findNearestCheckpoint"
>;
folderCheckpointResourcesDAL: Pick<TFolderCheckpointResourcesDALFactory, "insertMany" | "findByCheckpointId">;
folderTreeCheckpointDAL: Pick<
TFolderTreeCheckpointDALFactory,
"create" | "findNearestCheckpoint" | "findLatestByEnvId"
>;
folderTreeCheckpointResourcesDAL: Pick<
TFolderTreeCheckpointResourcesDALFactory,
"insertMany" | "findByTreeCheckpointId"
>;
userDAL: Pick<TUserDALFactory, "findById">;
identityDAL: Pick<TIdentityDALFactory, "findById">;
folderDAL: Pick<
TSecretFolderDALFactory,
| "findByParentId"
| "findByProjectId"
| "deleteById"
| "create"
| "updateById"
| "update"
| "find"
| "findById"
| "findByEnvId"
| "findFoldersByRootAndIds"
>;
folderVersionDAL: Pick<
TSecretFolderVersionDALFactory,
| "findLatestFolderVersions"
| "findById"
| "deleteById"
| "create"
| "updateById"
| "find"
| "findByIdsWithLatestVersion"
>;
secretVersionV2BridgeDAL: Pick<
TSecretVersionV2DALFactory,
| "findLatestVersionByFolderId"
| "findById"
| "deleteById"
| "create"
| "updateById"
| "find"
| "findByIdsWithLatestVersion"
>;
secretV2BridgeDAL: Pick<
secretV2BridgeDal.TSecretV2BridgeDALFactory,
"deleteById" | "create" | "updateById" | "update" | "insertMany" | "invalidateSecretCacheByProjectId"
>;
folderCommitDAL: TFolderCommitDALFactory;
folderCommitChangesDAL: TFolderCommitChangesDALFactory;
folderCheckpointDAL: TFolderCheckpointDALFactory;
folderCheckpointResourcesDAL: TFolderCheckpointResourcesDALFactory;
folderTreeCheckpointDAL: TFolderTreeCheckpointDALFactory;
folderTreeCheckpointResourcesDAL: TFolderTreeCheckpointResourcesDALFactory;
userDAL: TUserDALFactory;
identityDAL: TIdentityDALFactory;
folderDAL: TSecretFolderDALFactory;
folderVersionDAL: TSecretFolderVersionDALFactory;
secretVersionV2BridgeDAL: TSecretVersionV2DALFactory;
secretV2BridgeDAL: secretV2BridgeDal.TSecretV2BridgeDALFactory;
projectDAL: Pick<TProjectDALFactory, "findById">;
folderCommitQueueService?: Pick<TFolderCommitQueueServiceFactory, "scheduleTreeCheckpoint">;
permissionService?: TPermissionServiceFactory;
};
export const folderCommitServiceFactory = ({
@@ -171,10 +134,39 @@ export const folderCommitServiceFactory = ({
projectDAL,
secretV2BridgeDAL,
folderTreeCheckpointResourcesDAL,
folderCommitQueueService
folderCommitQueueService,
permissionService
}: TFolderCommitServiceFactoryDep) => {
const appCfg = getConfig();
const checkProjectPermission = async ({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
}: {
actor: ActorType;
actorId: string;
projectId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
}) => {
if (!permissionService) {
throw new Error("Permission service not initialized");
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits);
};
/**
* Fetches all resources within a folder
*/
@@ -255,7 +247,17 @@ export const folderCommitServiceFactory = ({
const reconstructFolderState = async (
folderCommitId: string,
tx?: Knex
): Promise<{ type: string; id: string; versionId: string }[]> => {
): Promise<
{
type: string;
id: string;
versionId: string;
secretKey?: string;
secretVersion?: string;
folderName?: string;
folderVersion?: string;
}[]
> => {
const targetCommit = await folderCommitDAL.findById(folderCommitId, tx);
if (!targetCommit) {
throw new NotFoundError({ message: `Commit with ID ${folderCommitId} not found` });
@@ -268,7 +270,18 @@ export const folderCommitServiceFactory = ({
const checkpointResources = await folderCheckpointResourcesDAL.findByCheckpointId(nearestCheckpoint.id, tx);
const folderState: Record<string, { type: string; id: string; versionId: string }> = {};
const folderState: Record<
string,
{
type: string;
id: string;
versionId: string;
secretKey?: string;
secretVersion?: string;
folderName?: string;
folderVersion?: string;
}
> = {};
// Add all checkpoint resources to initial state
checkpointResources.forEach((resource) => {
@@ -276,13 +289,17 @@ export const folderCommitServiceFactory = ({
folderState[`secret-${resource.referencedSecretId}`] = {
type: "secret",
id: resource.referencedSecretId,
versionId: resource.secretVersionId
versionId: resource.secretVersionId,
secretKey: resource.secretKey,
secretVersion: resource.secretVersion
};
} else if (resource.folderVersionId && resource.referencedFolderId) {
folderState[`folder-${resource.referencedFolderId}`] = {
type: "folder",
id: resource.referencedFolderId,
versionId: resource.folderVersionId
versionId: resource.folderVersionId,
folderName: resource.folderName,
folderVersion: resource.folderVersion
};
}
});
@@ -307,7 +324,9 @@ export const folderCommitServiceFactory = ({
folderState[key] = {
type: "secret",
id: change.referencedSecretId,
versionId: change.secretVersionId
versionId: change.secretVersionId,
secretKey: change.secretKey,
secretVersion: change.secretVersion
};
} else if (change.changeType.toLowerCase() === "delete") {
delete folderState[key];
@@ -319,7 +338,9 @@ export const folderCommitServiceFactory = ({
folderState[key] = {
type: "folder",
id: change.referencedFolderId,
versionId: change.folderVersionId
versionId: change.folderVersionId,
folderName: change.folderName,
folderVersion: change.folderVersion
};
} else if (change.changeType.toLowerCase() === "delete") {
delete folderState[key];
@@ -356,7 +377,11 @@ export const folderCommitServiceFactory = ({
id: resource.id,
versionId: resource.versionId,
changeType: "create",
commitId: targetCommit.commitId
commitId: targetCommit.commitId,
secretKey: resource.secretKey,
secretVersion: resource.secretVersion,
folderName: resource.folderName,
folderVersion: resource.folderVersion
})) as ResourceChange[];
}
@@ -365,8 +390,32 @@ export const folderCommitServiceFactory = ({
const targetState = await reconstructFolderState(targetCommitId, tx);
// Create lookup maps for easier comparison
const currentMap: Record<string, { type: string; id: string; versionId: string }> = {};
const targetMap: Record<string, { type: string; id: string; versionId: string }> = {};
const currentMap: Record<
string,
{
type: string;
id: string;
versionId: string;
secretKey?: string;
secretVersion?: string;
folderName?: string;
folderVersion?: string;
fromVersion?: string;
}
> = {};
const targetMap: Record<
string,
{
type: string;
id: string;
versionId: string;
secretKey?: string;
secretVersion?: string;
folderName?: string;
folderVersion?: string;
fromVersion?: string;
}
> = {};
// Build lookup maps
currentState.forEach((resource) => {
@@ -393,7 +442,12 @@ export const folderCommitServiceFactory = ({
id: currentResource.id,
versionId: currentResource.versionId,
changeType: ChangeType.DELETE,
commitId: targetCommit.commitId
commitId: targetCommit.commitId,
secretKey: currentResource.secretKey,
secretVersion: currentResource.secretVersion,
folderName: currentResource.folderName,
folderVersion: currentResource.folderVersion,
fromVersion: currentResource.versionId
});
} else if (currentResource.versionId !== targetResource.versionId) {
differences.push({
@@ -401,12 +455,16 @@ export const folderCommitServiceFactory = ({
id: targetResource.id,
versionId: targetResource.versionId,
changeType: ChangeType.UPDATE,
commitId: targetCommit.commitId
commitId: targetCommit.commitId,
secretKey: targetResource.secretKey,
secretVersion: targetResource.secretVersion,
folderName: targetResource.folderName,
folderVersion: targetResource.folderVersion,
fromVersion: currentResource.folderVersion || currentResource.secretVersion
});
}
});
// Find creates
Object.keys(targetMap).forEach((key) => {
if (!currentMap[key]) {
const targetResource = targetMap[key];
@@ -416,7 +474,11 @@ export const folderCommitServiceFactory = ({
versionId: targetResource.versionId,
changeType: ChangeType.CREATE,
commitId: targetCommit.commitId,
createdAt: targetCommit.createdAt
createdAt: targetCommit.createdAt,
secretKey: targetResource.secretKey,
secretVersion: targetResource.secretVersion,
folderName: targetResource.folderName,
folderVersion: targetResource.folderVersion
});
}
});
@@ -485,7 +547,8 @@ export const folderCommitServiceFactory = ({
folderCommitId: newCommit.id,
changeType: change.type,
secretVersionId: change.secretVersionId,
folderVersionId: change.folderVersionId
folderVersionId: change.folderVersionId,
isUpdate: change.isUpdate || false
})),
tx
);
@@ -515,11 +578,21 @@ export const folderCommitServiceFactory = ({
) => {
const commitChanges = [];
// Collect all secretIds for batch lookup
const secretIds = changes.map((change) => secretVersions[change.id]?.secretId).filter(Boolean);
// Fetch all latest versions in one call
const latestVersionsMap = await secretVersionV2BridgeDAL.findLatestVersionMany(folderId, secretIds, tx);
for (const change of changes) {
const secretVersion = secretVersions[change.id];
// eslint-disable-next-line no-continue
if (!secretVersion) continue;
// Get the latest version from our batch result
const latestVersion = latestVersionsMap[secretVersion.secretId];
const nextVersion = latestVersion ? latestVersion.version + 1 : 1;
switch (change.changeType) {
case "create":
{
@@ -527,7 +600,7 @@ export const folderCommitServiceFactory = ({
{
id: change.id,
skipMultilineEncoding: secretVersion.skipMultilineEncoding,
version: secretVersion.version + 1,
version: nextVersion,
type: secretVersion.type,
key: secretVersion.key,
reminderNote: secretVersion.reminderNote,
@@ -545,7 +618,7 @@ export const folderCommitServiceFactory = ({
{
folderId,
secretId: secretVersion.secretId,
version: secretVersion.version + 1,
version: nextVersion,
encryptedValue: secretVersion.encryptedValue,
key: secretVersion.key,
encryptedComment: secretVersion.encryptedComment,
@@ -575,7 +648,7 @@ export const folderCommitServiceFactory = ({
change.id,
{
skipMultilineEncoding: secretVersion?.skipMultilineEncoding,
version: secretVersion?.version,
version: nextVersion,
type: secretVersion?.type,
key: secretVersion?.key,
reminderNote: secretVersion?.reminderNote,
@@ -590,7 +663,7 @@ export const folderCommitServiceFactory = ({
const newVersion = await secretVersionV2BridgeDAL.create(
{
version: secretVersion.version + 1,
version: nextVersion,
encryptedValue: secretVersion.encryptedValue,
key: secretVersion.key,
encryptedComment: secretVersion.encryptedComment,
@@ -611,14 +684,15 @@ export const folderCommitServiceFactory = ({
commitChanges.push({
type: ChangeType.ADD,
isUpdate: true,
secretVersionId: newVersion.id
});
}
break;
// Delete case remains unchanged
case "delete":
await secretV2BridgeDAL.deleteById(change.id, tx);
commitChanges.push({
type: ChangeType.DELETE,
secretVersionId: change.versionId
@@ -724,8 +798,13 @@ export const folderCommitServiceFactory = ({
case "update":
if (change.versionId) {
const versionDetails = await folderVersionDAL.findById(change.versionId, tx);
if (versionDetails) {
const latestVersionDetails = await folderVersionDAL.findByIdsWithLatestVersion(
[change.versionId],
undefined,
tx
);
if (latestVersionDetails && Object.keys(latestVersionDetails).length > 0) {
const versionDetails = latestVersionDetails[0];
await folderDAL.updateById(
change.id,
{
@@ -749,6 +828,7 @@ export const folderCommitServiceFactory = ({
commitChanges.push({
type: ChangeType.ADD,
isUpdate: true,
folderVersionId: newFolderVersion.id
});
}
@@ -855,11 +935,103 @@ export const folderCommitServiceFactory = ({
return folderCommitDAL.findByFolderId(folderId, tx);
};
const getCommitsForFolder = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
environment,
path
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
environment: string;
path: string;
}) => {
await checkProjectPermission({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId
});
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) {
throw new NotFoundError({
message: `Folder not found for project ID ${projectId}, environment ${environment}, path ${path}`
});
}
const folderCommits = await folderCommitDAL.findByFolderId(folder.id);
return folderCommits;
};
const getCommitsCount = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
environment,
path
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
environment: string;
path: string;
}) => {
await checkProjectPermission({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId
});
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) {
throw new NotFoundError({
message: `Folder not found for project ID ${projectId}, environment ${environment}, path ${path}`
});
}
const folderCommits = await folderCommitDAL.findByFolderId(folder.id);
return { count: folderCommits.length, folderId: folder.id };
};
/**
* Get changes for a commit
*/
const getCommitChanges = async (commitId: string, tx?: Knex) => {
return folderCommitChangesDAL.findByCommitId(commitId, tx);
const getCommitChanges = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
commitId
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
commitId: string;
}) => {
await checkProjectPermission({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId
});
const changes = await folderCommitChangesDAL.findByCommitId(commitId);
const commit = await folderCommitDAL.findById(commitId);
return { ...commit, changes };
};
/**
@@ -1021,6 +1193,100 @@ export const folderCommitServiceFactory = ({
);
};
const deepCompareFolder = async ({
targetCommitId,
envId,
projectId,
tx
}: {
targetCommitId: string;
envId: string;
actorId: string;
actorType: ActorType;
projectId: string;
tx?: Knex;
}) => {
const targetCommit = await folderCommitDAL.findById(targetCommitId, tx);
if (!targetCommit) {
throw new NotFoundError({ message: `No commit found for commit ID ${targetCommitId}` });
}
const checkpoint = await folderTreeCheckpointDAL.findNearestCheckpoint(targetCommitId, envId, tx);
if (!checkpoint) {
throw new NotFoundError({ message: `No checkpoint found for commit ID ${targetCommitId}` });
}
const folderCheckpointCommits = await folderTreeCheckpointResourcesDAL.findByTreeCheckpointId(checkpoint.id, tx);
const folderCommits = await folderCommitDAL.findAllCommitsBetween({
envId,
endCommitId: targetCommit.commitId.toString(),
startCommitId: checkpoint.commitId.toString(),
tx
});
// Group commits by folderId and keep only the latest
const folderGroups = new Map<string, { createdAt: Date; id: string }>();
if (folderCheckpointCommits && folderCheckpointCommits.length > 0) {
for (const commit of folderCheckpointCommits) {
folderGroups.set(commit.folderId, {
createdAt: commit.createdAt,
id: commit.folderCommitId
});
}
}
if (folderCommits && folderCommits.length > 0) {
for (const commit of folderCommits) {
const { folderId, createdAt, id } = commit;
const existingCommit = folderGroups.get(folderId);
if (!existingCommit || createdAt.getTime() > existingCommit.createdAt.getTime()) {
folderGroups.set(folderId, { createdAt, id });
}
}
}
const folderDiffs = new Map<string, ResourceChange[]>();
// Process each folder to determine differences
await Promise.all(
Array.from(folderGroups.entries()).map(async ([folderId, commit]) => {
const latestFolderCommit = await folderCommitDAL.findLatestCommit(folderId, tx);
if (latestFolderCommit && latestFolderCommit.id !== commit.id) {
const diff = await compareFolderStates({
currentCommitId: latestFolderCommit.id,
targetCommitId: commit.id,
tx
});
if (diff?.length > 0) {
folderDiffs.set(folderId, diff);
}
}
})
);
// Apply changes in hierarchical order
const folderIds = Array.from(folderDiffs.keys());
const folders = await folderDAL.findFoldersByRootAndIds({ rootId: targetCommit.folderId, folderIds }, tx);
const sortedFolders = sortFoldersByHierarchy(folders);
const response = [];
for (const folder of sortedFolders) {
const diff = folderDiffs.get(folder.id);
if (diff) {
const folderPath = await folderDAL.findSecretPathByFolderIds(projectId, [folder.id]);
response.push({
folderId: folder.id,
folderName: folder.name,
changes: diff,
folderPath: folderPath?.[0]?.path
});
}
}
return response;
};
/**
* Roll back a folder tree to a specific commit
*/
@@ -1117,7 +1383,139 @@ export const folderCommitServiceFactory = ({
}
};
// Return the public interface
const getLatestCommit = async ({
folderId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId
}: {
folderId: string;
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
}) => {
await checkProjectPermission({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId
});
return folderCommitDAL.findLatestCommit(folderId);
};
/**
* Revert changes made in a specific commit
*/
const revertCommitChanges = async ({
commitId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
message = "Revert commit changes"
}: {
commitId: string;
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
message?: string;
}) => {
// Check permissions first
await checkProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
});
// Get the commit to revert
const commitToRevert = await folderCommitDAL.findById(commitId);
if (!commitToRevert) {
throw new NotFoundError({ message: `Commit with ID ${commitId} not found` });
}
// Get all commits for this folder
const allCommits = await folderCommitDAL.findByFolderId(commitToRevert.folderId);
if (!allCommits || allCommits.length === 0) {
throw new NotFoundError({ message: `No commits found for folder ${commitToRevert.folderId}` });
}
// Sort commits by commitId (which appears to be numeric)
const sortedCommits = allCommits.sort((a, b) => a.commitId - b.commitId);
// Find the index of the commit to revert
const commitIndex = sortedCommits.findIndex((c) => c.id === commitId);
if (commitIndex === -1) {
throw new NotFoundError({ message: `Commit ${commitId} not found in the commit history` });
}
// If it's the first commit, we can't revert it (nothing before it)
if (commitIndex === 0) {
throw new BadRequestError({ message: "Cannot revert the first commit" });
}
// Get the commit just before the one we want to revert
const previousCommit = sortedCommits[commitIndex - 1];
// Calculate the changes needed to go from current commit back to the previous one
const inverseChanges = await compareFolderStates({
currentCommitId: commitToRevert.id,
targetCommitId: previousCommit.id
});
const latestCommit = await folderCommitDAL.findLatestCommit(commitToRevert.folderId);
if (!latestCommit) {
throw new NotFoundError({ message: `Latest commit not found for folder ${commitToRevert.folderId}` });
}
const currentState = await reconstructFolderState(latestCommit.id);
const filteredChanges = inverseChanges.filter(
(change) =>
((change.changeType === ChangeType.DELETE || change.changeType === ChangeType.UPDATE) &&
(currentState.some((c) => c.id === change.id) || currentState.some((c) => c.id === change.id))) ||
(change.changeType === ChangeType.CREATE &&
(currentState.every((c) => c.id !== change.id) || currentState.every((c) => c.id !== change.id)))
);
if (!filteredChanges || filteredChanges.length === 0) {
return {
success: true,
message: "No changes to revert",
originalCommitId: commitId
};
}
// Apply the changes to revert the commit
const revertResult = await applyFolderStateDifferences({
differences: filteredChanges,
actorInfo: {
actorType: actor,
actorId,
message: message || `Reverted changes from commit ${commitId}`
},
folderId: commitToRevert.folderId,
projectId,
reconstructNewFolders: false
});
return {
success: true,
message: "Changes reverted successfully",
originalCommitId: commitId,
revertCommitId: latestCommit?.id,
changesReverted: revertResult.totalChanges
};
};
return {
createCommit,
addCommitChange,
@@ -1132,7 +1530,13 @@ export const folderCommitServiceFactory = ({
compareFolderStates,
applyFolderStateDifferences,
createFolderTreeCheckpoint,
deepRollbackFolder
deepRollbackFolder,
getCommitsCount,
getLatestCommit,
deepCompareFolder,
reconstructFolderState,
getCommitsForFolder,
revertCommitChanges
};
};
@@ -30,7 +30,7 @@ type TSecretFolderServiceFactoryDep = {
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
folderDAL: TSecretFolderDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs" | "find">;
folderVersionDAL: Pick<TSecretFolderVersionDALFactory, "findLatestFolderVersions" | "create" | "insertMany">;
folderVersionDAL: Pick<TSecretFolderVersionDALFactory, "findLatestFolderVersions" | "create" | "insertMany" | "find">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
};
@@ -286,6 +286,7 @@ export const secretFolderServiceFactory = ({
changes: [
{
type: "add",
isUpdate: true,
folderVersionId: folderVersion.id
}
]
@@ -401,6 +402,7 @@ export const secretFolderServiceFactory = ({
changes: [
{
type: "add",
isUpdate: true,
folderVersionId: folderVersion.id
}
]
@@ -765,6 +767,22 @@ 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;
};
return {
createFolder,
updateFolder,
@@ -775,6 +793,7 @@ export const secretFolderServiceFactory = ({
getProjectFolderCount,
getFoldersMultiEnv,
getFoldersDeepByEnvs,
getProjectEnvironmentsFolders
getProjectEnvironmentsFolders,
getFolderVersionsByIds
};
};
@@ -148,7 +148,7 @@ export const fnSecretBulkInsert = async ({
id: actor?.actorId
}
},
message: "Secret Creation",
message: "Secret Created",
folderId,
changes: commitChanges
},
@@ -290,6 +290,7 @@ export const fnSecretBulkUpdate = async ({
.filter(({ type }) => type === SecretType.Shared)
.map((sv) => ({
type: "add",
isUpdate: true,
secretVersionId: sv.id
}));
if (commitChanges.length > 0) {
@@ -301,7 +302,7 @@ export const fnSecretBulkUpdate = async ({
id: actor?.actorId
}
},
message: "Secret Update",
message: "Secret Updated",
folderId,
changes: commitChanges
},
@@ -432,7 +433,7 @@ export const fnSecretBulkDelete = async ({
id: actorId
}
},
message: "Secret Delete",
message: "Secret Deleted",
folderId,
changes: commitChanges
},
@@ -2178,10 +2178,14 @@ export const secretV2BridgeServiceFactory = ({
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(
@@ -2856,6 +2860,73 @@ export const secretV2BridgeServiceFactory = ({
};
};
const getSecretVersionsByIds = async ({
actorId,
actor,
actorOrgId,
actorAuthMethod,
secretId,
secretVersionNumbers,
folderId
}: TGetSecretVersionsDTO & {
secretVersionNumbers: string[];
folderId: string;
}) => {
const folder = await folderDAL.findById(folderId);
if (!folder) throw new NotFoundError({ message: `Folder with ID '${folderId}' not found` });
const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(folder.projectId, [folder.id]);
if (!folderWithPath) {
throw new NotFoundError({ message: `Folder with ID '${folder.id}' not found` });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: folder.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: folder.projectId
});
const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors({
secretId,
projectId: folder.projectId,
secretVersions: secretVersionNumbers
});
return secretVersions.map((el) => {
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
permission,
ProjectPermissionSecretActions.ReadValue,
{
environment: folder.environment.envSlug,
secretPath: folderWithPath.path,
secretName: el.key,
...(el.tags?.length && {
secretTags: el.tags.map((tag) => tag.slug)
})
}
);
return reshapeBridgeSecret(
folder.projectId,
folder.environment.envSlug,
folderWithPath.path,
{
...el,
value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "",
comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : ""
},
secretValueHidden
);
});
};
return {
createSecret,
deleteSecret,
@@ -2874,6 +2945,7 @@ export const secretV2BridgeServiceFactory = ({
getSecretReferenceTree,
getSecretsByFolderMappings,
getSecretById,
getAccessibleSecrets
getAccessibleSecrets,
getSecretVersionsByIds
};
};
@@ -173,13 +173,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(
@@ -202,10 +210,12 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
.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),
+32 -1
View File
@@ -2521,6 +2521,36 @@ export const secretServiceFactory = ({
});
};
const getSecretVersionsV2ByIds = async ({
actorId,
actor,
actorOrgId,
actorAuthMethod,
secretId,
secretVersions,
folderId
}: TGetSecretVersionsDTO & {
secretVersions: string[];
folderId: string;
}) => {
const secretVersionV2 = await secretV2BridgeService
.getSecretVersionsByIds({
actorId,
actor,
actorOrgId,
actorAuthMethod,
secretId,
folderId,
secretVersionNumbers: secretVersions
})
.catch((err) => {
if ((err as Error).message === "BadRequest: Failed to find secret") {
return null;
}
});
return secretVersionV2;
};
const attachTags = async ({
secretName,
tagSlugs,
@@ -3309,6 +3339,7 @@ export const secretServiceFactory = ({
getSecretsRawByFolderMappings,
getSecretAccessList,
getSecretByIdRaw,
getAccessibleSecrets
getAccessibleSecrets,
getSecretVersionsV2ByIds
};
};