mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #2088 from Infisical/feat/move-secrets
feat: move secrets
This commit is contained in:
@@ -45,6 +45,7 @@ export enum EventType {
|
|||||||
CREATE_SECRETS = "create-secrets",
|
CREATE_SECRETS = "create-secrets",
|
||||||
UPDATE_SECRET = "update-secret",
|
UPDATE_SECRET = "update-secret",
|
||||||
UPDATE_SECRETS = "update-secrets",
|
UPDATE_SECRETS = "update-secrets",
|
||||||
|
MOVE_SECRETS = "move-secrets",
|
||||||
DELETE_SECRET = "delete-secret",
|
DELETE_SECRET = "delete-secret",
|
||||||
DELETE_SECRETS = "delete-secrets",
|
DELETE_SECRETS = "delete-secrets",
|
||||||
GET_WORKSPACE_KEY = "get-workspace-key",
|
GET_WORKSPACE_KEY = "get-workspace-key",
|
||||||
@@ -240,6 +241,17 @@ interface UpdateSecretBatchEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MoveSecretsEvent {
|
||||||
|
type: EventType.MOVE_SECRETS;
|
||||||
|
metadata: {
|
||||||
|
sourceEnvironment: string;
|
||||||
|
sourceSecretPath: string;
|
||||||
|
destinationEnvironment: string;
|
||||||
|
destinationSecretPath: string;
|
||||||
|
secretIds: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface DeleteSecretEvent {
|
interface DeleteSecretEvent {
|
||||||
type: EventType.DELETE_SECRET;
|
type: EventType.DELETE_SECRET;
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -1159,6 +1171,7 @@ export type Event =
|
|||||||
| CreateSecretBatchEvent
|
| CreateSecretBatchEvent
|
||||||
| UpdateSecretEvent
|
| UpdateSecretEvent
|
||||||
| UpdateSecretBatchEvent
|
| UpdateSecretBatchEvent
|
||||||
|
| MoveSecretsEvent
|
||||||
| DeleteSecretEvent
|
| DeleteSecretEvent
|
||||||
| DeleteSecretBatchEvent
|
| DeleteSecretBatchEvent
|
||||||
| GetWorkspaceKeyEvent
|
| GetWorkspaceKeyEvent
|
||||||
|
|||||||
@@ -712,7 +712,10 @@ export const registerRoutes = async (
|
|||||||
secretQueueService,
|
secretQueueService,
|
||||||
secretImportDAL,
|
secretImportDAL,
|
||||||
projectEnvDAL,
|
projectEnvDAL,
|
||||||
projectBotService
|
projectBotService,
|
||||||
|
secretApprovalPolicyService,
|
||||||
|
secretApprovalRequestDAL,
|
||||||
|
secretApprovalRequestSecretDAL
|
||||||
});
|
});
|
||||||
|
|
||||||
const secretSharingService = secretSharingServiceFactory({
|
const secretSharingService = secretSharingServiceFactory({
|
||||||
|
|||||||
@@ -1325,6 +1325,61 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/move",
|
||||||
|
config: {
|
||||||
|
rateLimit: secretsLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
projectSlug: z.string().trim(),
|
||||||
|
sourceEnvironment: z.string().trim(),
|
||||||
|
sourceSecretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||||
|
destinationEnvironment: z.string().trim(),
|
||||||
|
destinationSecretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||||
|
secretIds: z.string().array(),
|
||||||
|
shouldOverwrite: z.boolean().default(false)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
isSourceUpdated: z.boolean(),
|
||||||
|
isDestinationUpdated: z.boolean()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { projectId, isSourceUpdated, isDestinationUpdated } = await server.services.secret.moveSecrets({
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
projectId,
|
||||||
|
...req.auditLogInfo,
|
||||||
|
event: {
|
||||||
|
type: EventType.MOVE_SECRETS,
|
||||||
|
metadata: {
|
||||||
|
sourceEnvironment: req.body.sourceEnvironment,
|
||||||
|
sourceSecretPath: req.body.sourceSecretPath,
|
||||||
|
destinationEnvironment: req.body.destinationEnvironment,
|
||||||
|
destinationSecretPath: req.body.destinationSecretPath,
|
||||||
|
secretIds: req.body.secretIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSourceUpdated,
|
||||||
|
isDestinationUpdated
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
server.route({
|
server.route({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/batch",
|
url: "/batch",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
} from "@app/db/schemas";
|
} from "@app/db/schemas";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
|
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||||
|
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
|
||||||
|
import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
|
||||||
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
|
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import {
|
import {
|
||||||
@@ -18,9 +21,10 @@ import {
|
|||||||
decryptSymmetric128BitHexKeyUTF8,
|
decryptSymmetric128BitHexKeyUTF8,
|
||||||
encryptSymmetric128BitHexKeyUTF8
|
encryptSymmetric128BitHexKeyUTF8
|
||||||
} from "@app/lib/crypto";
|
} from "@app/lib/crypto";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { groupBy, pick } from "@app/lib/fn";
|
import { groupBy, pick } from "@app/lib/fn";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
|
|
||||||
import { ActorType } from "../auth/auth-type";
|
import { ActorType } from "../auth/auth-type";
|
||||||
import { TProjectDALFactory } from "../project/project-dal";
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
@@ -44,6 +48,7 @@ import {
|
|||||||
} from "./secret-fns";
|
} from "./secret-fns";
|
||||||
import { TSecretQueueFactory } from "./secret-queue";
|
import { TSecretQueueFactory } from "./secret-queue";
|
||||||
import {
|
import {
|
||||||
|
SecretOperations,
|
||||||
TAttachSecretTagsDTO,
|
TAttachSecretTagsDTO,
|
||||||
TBackFillSecretReferencesDTO,
|
TBackFillSecretReferencesDTO,
|
||||||
TCreateBulkSecretDTO,
|
TCreateBulkSecretDTO,
|
||||||
@@ -59,6 +64,7 @@ import {
|
|||||||
TGetSecretsDTO,
|
TGetSecretsDTO,
|
||||||
TGetSecretsRawDTO,
|
TGetSecretsRawDTO,
|
||||||
TGetSecretVersionsDTO,
|
TGetSecretVersionsDTO,
|
||||||
|
TMoveSecretsDTO,
|
||||||
TUpdateBulkSecretDTO,
|
TUpdateBulkSecretDTO,
|
||||||
TUpdateManySecretRawDTO,
|
TUpdateManySecretRawDTO,
|
||||||
TUpdateSecretDTO,
|
TUpdateSecretDTO,
|
||||||
@@ -84,6 +90,12 @@ type TSecretServiceFactoryDep = {
|
|||||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||||
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
||||||
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">;
|
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">;
|
||||||
|
secretApprovalPolicyService: Pick<TSecretApprovalPolicyServiceFactory, "getSecretApprovalPolicy">;
|
||||||
|
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "create" | "transaction">;
|
||||||
|
secretApprovalRequestSecretDAL: Pick<
|
||||||
|
TSecretApprovalRequestSecretDALFactory,
|
||||||
|
"insertMany" | "insertApprovalSecretTags"
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TSecretServiceFactory = ReturnType<typeof secretServiceFactory>;
|
export type TSecretServiceFactory = ReturnType<typeof secretServiceFactory>;
|
||||||
@@ -100,7 +112,10 @@ export const secretServiceFactory = ({
|
|||||||
projectDAL,
|
projectDAL,
|
||||||
projectBotService,
|
projectBotService,
|
||||||
secretImportDAL,
|
secretImportDAL,
|
||||||
secretVersionTagDAL
|
secretVersionTagDAL,
|
||||||
|
secretApprovalPolicyService,
|
||||||
|
secretApprovalRequestDAL,
|
||||||
|
secretApprovalRequestSecretDAL
|
||||||
}: TSecretServiceFactoryDep) => {
|
}: TSecretServiceFactoryDep) => {
|
||||||
const getSecretReference = async (projectId: string) => {
|
const getSecretReference = async (projectId: string) => {
|
||||||
// if bot key missing means e2e still exist
|
// if bot key missing means e2e still exist
|
||||||
@@ -1683,6 +1698,393 @@ export const secretServiceFactory = ({
|
|||||||
return { message: "Successfully backfilled secret references" };
|
return { message: "Successfully backfilled secret references" };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const moveSecrets = async ({
|
||||||
|
sourceEnvironment,
|
||||||
|
sourceSecretPath,
|
||||||
|
destinationEnvironment,
|
||||||
|
destinationSecretPath,
|
||||||
|
secretIds,
|
||||||
|
projectSlug,
|
||||||
|
shouldOverwrite,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
}: TMoveSecretsDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: "Project not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
project.id,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: sourceEnvironment, secretPath: sourceSecretPath })
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Create,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: destinationEnvironment, secretPath: destinationSecretPath })
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Edit,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: destinationEnvironment, secretPath: destinationSecretPath })
|
||||||
|
);
|
||||||
|
|
||||||
|
const botKey = await projectBotService.getBotKey(project.id);
|
||||||
|
if (!botKey) {
|
||||||
|
throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFolder = await folderDAL.findBySecretPath(project.id, sourceEnvironment, sourceSecretPath);
|
||||||
|
if (!sourceFolder) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: "Source path does not exist."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationFolder = await folderDAL.findBySecretPath(
|
||||||
|
project.id,
|
||||||
|
destinationEnvironment,
|
||||||
|
destinationSecretPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!destinationFolder) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: "Destination path does not exist."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceSecrets = await secretDAL.find({
|
||||||
|
type: SecretType.Shared,
|
||||||
|
$in: {
|
||||||
|
id: secretIds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sourceSecrets.length !== secretIds.length) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Invalid secrets"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedSourceSecrets = sourceSecrets.map((secret) => ({
|
||||||
|
...secret,
|
||||||
|
secretKey: decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
ciphertext: secret.secretKeyCiphertext,
|
||||||
|
iv: secret.secretKeyIV,
|
||||||
|
tag: secret.secretKeyTag,
|
||||||
|
key: botKey
|
||||||
|
}),
|
||||||
|
secretValue: decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
ciphertext: secret.secretValueCiphertext,
|
||||||
|
iv: secret.secretValueIV,
|
||||||
|
tag: secret.secretValueTag,
|
||||||
|
key: botKey
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
let isSourceUpdated = false;
|
||||||
|
let isDestinationUpdated = false;
|
||||||
|
|
||||||
|
// Moving secrets is a two-step process.
|
||||||
|
await secretDAL.transaction(async (tx) => {
|
||||||
|
// First step is to create/update the secret in the destination:
|
||||||
|
const destinationSecretsFromDB = await secretDAL.find(
|
||||||
|
{
|
||||||
|
folderId: destinationFolder.id
|
||||||
|
},
|
||||||
|
{ tx }
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptedDestinationSecrets = destinationSecretsFromDB.map((secret) => {
|
||||||
|
return {
|
||||||
|
...secret,
|
||||||
|
secretKey: decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
ciphertext: secret.secretKeyCiphertext,
|
||||||
|
iv: secret.secretKeyIV,
|
||||||
|
tag: secret.secretKeyTag,
|
||||||
|
key: botKey
|
||||||
|
}),
|
||||||
|
secretValue: decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
ciphertext: secret.secretValueCiphertext,
|
||||||
|
iv: secret.secretValueIV,
|
||||||
|
tag: secret.secretValueTag,
|
||||||
|
key: botKey
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const destinationSecretsGroupedByBlindIndex = groupBy(
|
||||||
|
decryptedDestinationSecrets.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)),
|
||||||
|
(i) => i.secretBlindIndex as string
|
||||||
|
);
|
||||||
|
|
||||||
|
const locallyCreatedSecrets = decryptedSourceSecrets
|
||||||
|
.filter(({ secretBlindIndex }) => !destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0])
|
||||||
|
.map((el) => ({ ...el, operation: SecretOperations.Create }));
|
||||||
|
|
||||||
|
const locallyUpdatedSecrets = decryptedSourceSecrets
|
||||||
|
.filter(
|
||||||
|
({ secretBlindIndex, secretKey, secretValue }) =>
|
||||||
|
destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0] &&
|
||||||
|
// if key or value changed
|
||||||
|
(destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey ||
|
||||||
|
destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !== secretValue)
|
||||||
|
)
|
||||||
|
.map((el) => ({ ...el, operation: SecretOperations.Update }));
|
||||||
|
|
||||||
|
if (locallyUpdatedSecrets.length > 0 && !shouldOverwrite) {
|
||||||
|
const existingKeys = locallyUpdatedSecrets.map((s) => s.secretKey);
|
||||||
|
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Failed to move secrets. The following secrets already exist in the destination: ${existingKeys.join(
|
||||||
|
","
|
||||||
|
)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = locallyCreatedSecrets.length + locallyUpdatedSecrets.length === 0;
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Selected secrets already exist in the destination."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy(
|
||||||
|
project.id,
|
||||||
|
destinationFolder.environment.slug,
|
||||||
|
destinationFolder.path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (destinationFolderPolicy && actor === ActorType.USER) {
|
||||||
|
// if secret approval policy exists for destination, we create the secret approval request
|
||||||
|
const localSecretsIds = decryptedDestinationSecrets.map(({ id }) => id);
|
||||||
|
const latestSecretVersions = await secretVersionDAL.findLatestVersionMany(
|
||||||
|
destinationFolder.id,
|
||||||
|
localSecretsIds,
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const approvalRequestDoc = await secretApprovalRequestDAL.create(
|
||||||
|
{
|
||||||
|
folderId: destinationFolder.id,
|
||||||
|
slug: alphaNumericNanoId(),
|
||||||
|
policyId: destinationFolderPolicy.id,
|
||||||
|
status: "open",
|
||||||
|
hasMerged: false,
|
||||||
|
committerUserId: actorId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const commits = locallyCreatedSecrets.concat(locallyUpdatedSecrets).map((doc) => {
|
||||||
|
const { operation } = doc;
|
||||||
|
const localSecret = destinationSecretsGroupedByBlindIndex[doc.secretBlindIndex as string]?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: operation,
|
||||||
|
keyEncoding: doc.keyEncoding,
|
||||||
|
algorithm: doc.algorithm,
|
||||||
|
requestId: approvalRequestDoc.id,
|
||||||
|
metadata: doc.metadata,
|
||||||
|
secretKeyIV: doc.secretKeyIV,
|
||||||
|
secretKeyTag: doc.secretKeyTag,
|
||||||
|
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||||
|
secretValueIV: doc.secretValueIV,
|
||||||
|
secretValueTag: doc.secretValueTag,
|
||||||
|
secretValueCiphertext: doc.secretValueCiphertext,
|
||||||
|
secretBlindIndex: doc.secretBlindIndex,
|
||||||
|
secretCommentIV: doc.secretCommentIV,
|
||||||
|
secretCommentTag: doc.secretCommentTag,
|
||||||
|
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||||
|
skipMultilineEncoding: doc.skipMultilineEncoding,
|
||||||
|
// except create operation other two needs the secret id and version id
|
||||||
|
...(operation !== SecretOperations.Create
|
||||||
|
? { secretId: localSecret.id, secretVersion: latestSecretVersions[localSecret.id].id }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await secretApprovalRequestSecretDAL.insertMany(commits, tx);
|
||||||
|
} else {
|
||||||
|
// apply changes directly
|
||||||
|
if (locallyCreatedSecrets.length) {
|
||||||
|
await fnSecretBulkInsert({
|
||||||
|
folderId: destinationFolder.id,
|
||||||
|
secretVersionDAL,
|
||||||
|
secretDAL,
|
||||||
|
tx,
|
||||||
|
secretTagDAL,
|
||||||
|
secretVersionTagDAL,
|
||||||
|
inputSecrets: locallyCreatedSecrets.map((doc) => {
|
||||||
|
return {
|
||||||
|
keyEncoding: doc.keyEncoding,
|
||||||
|
algorithm: doc.algorithm,
|
||||||
|
type: doc.type,
|
||||||
|
metadata: doc.metadata,
|
||||||
|
secretKeyIV: doc.secretKeyIV,
|
||||||
|
secretKeyTag: doc.secretKeyTag,
|
||||||
|
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||||
|
secretValueIV: doc.secretValueIV,
|
||||||
|
secretValueTag: doc.secretValueTag,
|
||||||
|
secretValueCiphertext: doc.secretValueCiphertext,
|
||||||
|
secretBlindIndex: doc.secretBlindIndex,
|
||||||
|
secretCommentIV: doc.secretCommentIV,
|
||||||
|
secretCommentTag: doc.secretCommentTag,
|
||||||
|
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||||
|
skipMultilineEncoding: doc.skipMultilineEncoding
|
||||||
|
};
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (locallyUpdatedSecrets.length) {
|
||||||
|
await fnSecretBulkUpdate({
|
||||||
|
projectId: project.id,
|
||||||
|
folderId: destinationFolder.id,
|
||||||
|
secretVersionDAL,
|
||||||
|
secretDAL,
|
||||||
|
tx,
|
||||||
|
secretTagDAL,
|
||||||
|
secretVersionTagDAL,
|
||||||
|
inputSecrets: locallyUpdatedSecrets.map((doc) => {
|
||||||
|
return {
|
||||||
|
filter: {
|
||||||
|
folderId: destinationFolder.id,
|
||||||
|
id: destinationSecretsGroupedByBlindIndex[doc.secretBlindIndex as string][0].id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
keyEncoding: doc.keyEncoding,
|
||||||
|
algorithm: doc.algorithm,
|
||||||
|
type: doc.type,
|
||||||
|
metadata: doc.metadata,
|
||||||
|
secretKeyIV: doc.secretKeyIV,
|
||||||
|
secretKeyTag: doc.secretKeyTag,
|
||||||
|
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||||
|
secretValueIV: doc.secretValueIV,
|
||||||
|
secretValueTag: doc.secretValueTag,
|
||||||
|
secretValueCiphertext: doc.secretValueCiphertext,
|
||||||
|
secretBlindIndex: doc.secretBlindIndex,
|
||||||
|
secretCommentIV: doc.secretCommentIV,
|
||||||
|
secretCommentTag: doc.secretCommentTag,
|
||||||
|
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||||
|
skipMultilineEncoding: doc.skipMultilineEncoding
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isDestinationUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next step is to delete the secrets from the source folder:
|
||||||
|
const sourceSecretsGroupByBlindIndex = groupBy(sourceSecrets, (i) => i.secretBlindIndex as string);
|
||||||
|
const locallyDeletedSecrets = decryptedSourceSecrets.map((el) => ({ ...el, operation: SecretOperations.Delete }));
|
||||||
|
|
||||||
|
const sourceFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy(
|
||||||
|
project.id,
|
||||||
|
sourceFolder.environment.slug,
|
||||||
|
sourceFolder.path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceFolderPolicy && actor === ActorType.USER) {
|
||||||
|
// if secret approval policy exists for source, we create the secret approval request
|
||||||
|
const localSecretsIds = decryptedSourceSecrets.map(({ id }) => id);
|
||||||
|
const latestSecretVersions = await secretVersionDAL.findLatestVersionMany(sourceFolder.id, localSecretsIds, tx);
|
||||||
|
const approvalRequestDoc = await secretApprovalRequestDAL.create(
|
||||||
|
{
|
||||||
|
folderId: sourceFolder.id,
|
||||||
|
slug: alphaNumericNanoId(),
|
||||||
|
policyId: sourceFolderPolicy.id,
|
||||||
|
status: "open",
|
||||||
|
hasMerged: false,
|
||||||
|
committerUserId: actorId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const commits = locallyDeletedSecrets.map((doc) => {
|
||||||
|
const { operation } = doc;
|
||||||
|
const localSecret = sourceSecretsGroupByBlindIndex[doc.secretBlindIndex as string]?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: operation,
|
||||||
|
keyEncoding: doc.keyEncoding,
|
||||||
|
algorithm: doc.algorithm,
|
||||||
|
requestId: approvalRequestDoc.id,
|
||||||
|
metadata: doc.metadata,
|
||||||
|
secretKeyIV: doc.secretKeyIV,
|
||||||
|
secretKeyTag: doc.secretKeyTag,
|
||||||
|
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||||
|
secretValueIV: doc.secretValueIV,
|
||||||
|
secretValueTag: doc.secretValueTag,
|
||||||
|
secretValueCiphertext: doc.secretValueCiphertext,
|
||||||
|
secretBlindIndex: doc.secretBlindIndex,
|
||||||
|
secretCommentIV: doc.secretCommentIV,
|
||||||
|
secretCommentTag: doc.secretCommentTag,
|
||||||
|
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||||
|
skipMultilineEncoding: doc.skipMultilineEncoding,
|
||||||
|
secretId: localSecret.id,
|
||||||
|
secretVersion: latestSecretVersions[localSecret.id].id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await secretApprovalRequestSecretDAL.insertMany(commits, tx);
|
||||||
|
} else {
|
||||||
|
// if no secret approval policy is present, we delete directly.
|
||||||
|
await secretDAL.delete(
|
||||||
|
{
|
||||||
|
$in: {
|
||||||
|
id: locallyDeletedSecrets.map(({ id }) => id)
|
||||||
|
},
|
||||||
|
folderId: sourceFolder.id
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
isSourceUpdated = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDestinationUpdated) {
|
||||||
|
await snapshotService.performSnapshot(destinationFolder.id);
|
||||||
|
await secretQueueService.syncSecrets({
|
||||||
|
projectId: project.id,
|
||||||
|
secretPath: destinationFolder.path,
|
||||||
|
environmentSlug: destinationFolder.environment.slug,
|
||||||
|
actorId,
|
||||||
|
actor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSourceUpdated) {
|
||||||
|
await snapshotService.performSnapshot(sourceFolder.id);
|
||||||
|
await secretQueueService.syncSecrets({
|
||||||
|
projectId: project.id,
|
||||||
|
secretPath: sourceFolder.path,
|
||||||
|
environmentSlug: sourceFolder.environment.slug,
|
||||||
|
actorId,
|
||||||
|
actor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: project.id,
|
||||||
|
isSourceUpdated,
|
||||||
|
isDestinationUpdated
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attachTags,
|
attachTags,
|
||||||
detachTags,
|
detachTags,
|
||||||
@@ -1703,6 +2105,7 @@ export const secretServiceFactory = ({
|
|||||||
updateManySecretsRaw,
|
updateManySecretsRaw,
|
||||||
deleteManySecretsRaw,
|
deleteManySecretsRaw,
|
||||||
getSecretVersions,
|
getSecretVersions,
|
||||||
backfillSecretReferences
|
backfillSecretReferences,
|
||||||
|
moveSecrets
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -397,3 +397,13 @@ export type TSyncSecretsDTO<T extends boolean = false> = {
|
|||||||
// used for import creation to trigger replication
|
// used for import creation to trigger replication
|
||||||
pickOnlyImportIds?: string[];
|
pickOnlyImportIds?: string[];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type TMoveSecretsDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
sourceEnvironment: string;
|
||||||
|
sourceSecretPath: string;
|
||||||
|
destinationEnvironment: string;
|
||||||
|
destinationSecretPath: string;
|
||||||
|
secretIds: string[];
|
||||||
|
shouldOverwrite: boolean;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
useCreateSecretV3,
|
useCreateSecretV3,
|
||||||
useDeleteSecretBatch,
|
useDeleteSecretBatch,
|
||||||
useDeleteSecretV3,
|
useDeleteSecretV3,
|
||||||
|
useMoveSecrets,
|
||||||
useUpdateSecretBatch,
|
useUpdateSecretBatch,
|
||||||
useUpdateSecretV3
|
useUpdateSecretV3
|
||||||
} from "./mutations";
|
} from "./mutations";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
TCreateSecretsV3DTO,
|
TCreateSecretsV3DTO,
|
||||||
TDeleteSecretBatchDTO,
|
TDeleteSecretBatchDTO,
|
||||||
TDeleteSecretsV3DTO,
|
TDeleteSecretsV3DTO,
|
||||||
|
TMoveSecretsDTO,
|
||||||
TUpdateSecretBatchDTO,
|
TUpdateSecretBatchDTO,
|
||||||
TUpdateSecretsV3DTO
|
TUpdateSecretsV3DTO
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -87,11 +88,11 @@ export const useCreateSecretV3 = ({
|
|||||||
|
|
||||||
const randomBytes = latestFileKey
|
const randomBytes = latestFileKey
|
||||||
? decryptAssymmetric({
|
? decryptAssymmetric({
|
||||||
ciphertext: latestFileKey.encryptedKey,
|
ciphertext: latestFileKey.encryptedKey,
|
||||||
nonce: latestFileKey.nonce,
|
nonce: latestFileKey.nonce,
|
||||||
publicKey: latestFileKey.sender.publicKey,
|
publicKey: latestFileKey.sender.publicKey,
|
||||||
privateKey: PRIVATE_KEY
|
privateKey: PRIVATE_KEY
|
||||||
})
|
})
|
||||||
: crypto.randomBytes(16).toString("hex");
|
: crypto.randomBytes(16).toString("hex");
|
||||||
|
|
||||||
const reqBody = {
|
const reqBody = {
|
||||||
@@ -148,11 +149,11 @@ export const useUpdateSecretV3 = ({
|
|||||||
|
|
||||||
const randomBytes = latestFileKey
|
const randomBytes = latestFileKey
|
||||||
? decryptAssymmetric({
|
? decryptAssymmetric({
|
||||||
ciphertext: latestFileKey.encryptedKey,
|
ciphertext: latestFileKey.encryptedKey,
|
||||||
nonce: latestFileKey.nonce,
|
nonce: latestFileKey.nonce,
|
||||||
publicKey: latestFileKey.sender.publicKey,
|
publicKey: latestFileKey.sender.publicKey,
|
||||||
privateKey: PRIVATE_KEY
|
privateKey: PRIVATE_KEY
|
||||||
})
|
})
|
||||||
: crypto.randomBytes(16).toString("hex");
|
: crypto.randomBytes(16).toString("hex");
|
||||||
|
|
||||||
const reqBody = {
|
const reqBody = {
|
||||||
@@ -244,11 +245,11 @@ export const useCreateSecretBatch = ({
|
|||||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||||
const randomBytes = latestFileKey
|
const randomBytes = latestFileKey
|
||||||
? decryptAssymmetric({
|
? decryptAssymmetric({
|
||||||
ciphertext: latestFileKey.encryptedKey,
|
ciphertext: latestFileKey.encryptedKey,
|
||||||
nonce: latestFileKey.nonce,
|
nonce: latestFileKey.nonce,
|
||||||
publicKey: latestFileKey.sender.publicKey,
|
publicKey: latestFileKey.sender.publicKey,
|
||||||
privateKey: PRIVATE_KEY
|
privateKey: PRIVATE_KEY
|
||||||
})
|
})
|
||||||
: crypto.randomBytes(16).toString("hex");
|
: crypto.randomBytes(16).toString("hex");
|
||||||
|
|
||||||
const reqBody = {
|
const reqBody = {
|
||||||
@@ -297,11 +298,11 @@ export const useUpdateSecretBatch = ({
|
|||||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||||
const randomBytes = latestFileKey
|
const randomBytes = latestFileKey
|
||||||
? decryptAssymmetric({
|
? decryptAssymmetric({
|
||||||
ciphertext: latestFileKey.encryptedKey,
|
ciphertext: latestFileKey.encryptedKey,
|
||||||
nonce: latestFileKey.nonce,
|
nonce: latestFileKey.nonce,
|
||||||
publicKey: latestFileKey.sender.publicKey,
|
publicKey: latestFileKey.sender.publicKey,
|
||||||
privateKey: PRIVATE_KEY
|
privateKey: PRIVATE_KEY
|
||||||
})
|
})
|
||||||
: crypto.randomBytes(16).toString("hex");
|
: crypto.randomBytes(16).toString("hex");
|
||||||
|
|
||||||
const reqBody = {
|
const reqBody = {
|
||||||
@@ -375,6 +376,73 @@ export const useDeleteSecretBatch = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useMoveSecrets = ({
|
||||||
|
options
|
||||||
|
}: {
|
||||||
|
options?: Omit<MutationOptions<{}, {}, TMoveSecretsDTO>, "mutationFn">;
|
||||||
|
} = {}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
{
|
||||||
|
isSourceUpdated: boolean;
|
||||||
|
isDestinationUpdated: boolean;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
TMoveSecretsDTO
|
||||||
|
>({
|
||||||
|
mutationFn: async ({
|
||||||
|
sourceEnvironment,
|
||||||
|
sourceSecretPath,
|
||||||
|
projectSlug,
|
||||||
|
destinationEnvironment,
|
||||||
|
destinationSecretPath,
|
||||||
|
secretIds,
|
||||||
|
shouldOverwrite
|
||||||
|
}) => {
|
||||||
|
const { data } = await apiRequest.post<{
|
||||||
|
isSourceUpdated: boolean;
|
||||||
|
isDestinationUpdated: boolean;
|
||||||
|
}>("/api/v3/secrets/move", {
|
||||||
|
sourceEnvironment,
|
||||||
|
sourceSecretPath,
|
||||||
|
projectSlug,
|
||||||
|
destinationEnvironment,
|
||||||
|
destinationSecretPath,
|
||||||
|
secretIds,
|
||||||
|
shouldOverwrite
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretKeys.getProjectSecret({
|
||||||
|
workspaceId: projectId,
|
||||||
|
environment: sourceEnvironment,
|
||||||
|
secretPath: sourceSecretPath
|
||||||
|
})
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretSnapshotKeys.list({
|
||||||
|
environment: sourceEnvironment,
|
||||||
|
workspaceId: projectId,
|
||||||
|
directory: sourceSecretPath
|
||||||
|
})
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretSnapshotKeys.count({
|
||||||
|
environment: sourceEnvironment,
|
||||||
|
workspaceId: projectId,
|
||||||
|
directory: sourceSecretPath
|
||||||
|
})
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId: projectId }));
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const createSecret = async (dto: CreateSecretDTO) => {
|
export const createSecret = async (dto: CreateSecretDTO) => {
|
||||||
const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto);
|
const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -177,6 +177,17 @@ export type TDeleteSecretBatchDTO = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TMoveSecretsDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
sourceEnvironment: string;
|
||||||
|
sourceSecretPath: string;
|
||||||
|
destinationEnvironment: string;
|
||||||
|
destinationSecretPath: string;
|
||||||
|
secretIds: string[];
|
||||||
|
shouldOverwrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateSecretDTO = {
|
export type CreateSecretDTO = {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { TypeOptions } from "react-toastify";
|
||||||
import { subject } from "@casl/ability";
|
import { subject } from "@casl/ability";
|
||||||
import {
|
import {
|
||||||
faAngleDown,
|
faAngleDown,
|
||||||
|
faAnglesRight,
|
||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
faChevronRight,
|
faChevronRight,
|
||||||
faCodeCommit,
|
faCodeCommit,
|
||||||
@@ -46,7 +48,12 @@ import {
|
|||||||
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
|
||||||
import { interpolateSecrets } from "@app/helpers/secret";
|
import { interpolateSecrets } from "@app/helpers/secret";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useCreateFolder, useDeleteSecretBatch, useGetUserWsKey } from "@app/hooks/api";
|
import {
|
||||||
|
useCreateFolder,
|
||||||
|
useDeleteSecretBatch,
|
||||||
|
useGetUserWsKey,
|
||||||
|
useMoveSecrets
|
||||||
|
} from "@app/hooks/api";
|
||||||
import { DecryptedSecret, SecretType, TImportedSecrets, WsTag } from "@app/hooks/api/types";
|
import { DecryptedSecret, SecretType, TImportedSecrets, WsTag } from "@app/hooks/api/types";
|
||||||
import { debounce } from "@app/lib/fn/debounce";
|
import { debounce } from "@app/lib/fn/debounce";
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ import { Filter, GroupBy } from "../../SecretMainPage.types";
|
|||||||
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||||
import { FolderForm } from "./FolderForm";
|
import { FolderForm } from "./FolderForm";
|
||||||
|
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
secrets?: DecryptedSecret[];
|
secrets?: DecryptedSecret[];
|
||||||
@@ -105,6 +113,7 @@ export const ActionBar = ({
|
|||||||
"addDynamicSecret",
|
"addDynamicSecret",
|
||||||
"addSecretImport",
|
"addSecretImport",
|
||||||
"bulkDeleteSecrets",
|
"bulkDeleteSecrets",
|
||||||
|
"moveSecrets",
|
||||||
"misc",
|
"misc",
|
||||||
"upgradePlan"
|
"upgradePlan"
|
||||||
] as const);
|
] as const);
|
||||||
@@ -114,6 +123,7 @@ export const ActionBar = ({
|
|||||||
|
|
||||||
const { mutateAsync: createFolder } = useCreateFolder();
|
const { mutateAsync: createFolder } = useCreateFolder();
|
||||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||||
|
const { mutateAsync: moveSecrets } = useMoveSecrets();
|
||||||
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
|
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
|
||||||
|
|
||||||
const selectedSecrets = useSelectedSecrets();
|
const selectedSecrets = useSelectedSecrets();
|
||||||
@@ -228,6 +238,55 @@ export const ActionBar = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSecretsMove = async ({
|
||||||
|
destinationEnvironment,
|
||||||
|
destinationSecretPath,
|
||||||
|
shouldOverwrite
|
||||||
|
}: {
|
||||||
|
destinationEnvironment: string;
|
||||||
|
destinationSecretPath: string;
|
||||||
|
shouldOverwrite: boolean;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id]));
|
||||||
|
const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({
|
||||||
|
projectSlug,
|
||||||
|
shouldOverwrite,
|
||||||
|
sourceEnvironment: environment,
|
||||||
|
sourceSecretPath: secretPath,
|
||||||
|
destinationEnvironment,
|
||||||
|
destinationSecretPath,
|
||||||
|
projectId: workspaceId,
|
||||||
|
secretIds: secretsToMove.map((sec) => sec.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
let notificationMessage = "";
|
||||||
|
let notificationType: TypeOptions = "info";
|
||||||
|
|
||||||
|
if (isDestinationUpdated && isSourceUpdated) {
|
||||||
|
notificationMessage = "Successfully moved selected secrets";
|
||||||
|
notificationType = "success";
|
||||||
|
} else if (isDestinationUpdated) {
|
||||||
|
notificationMessage =
|
||||||
|
"Successfully created secrets in destination. A secret approval request has been generated for the source.";
|
||||||
|
} else if (isSourceUpdated) {
|
||||||
|
notificationMessage = "A secret approval request has been generated in the destination";
|
||||||
|
} else {
|
||||||
|
notificationMessage =
|
||||||
|
"A secret approval request has been generated in both the source and the destination.";
|
||||||
|
}
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
type: notificationType,
|
||||||
|
text: notificationMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
resetSelectedSecret();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 flex items-center space-x-2">
|
<div className="mt-4 flex items-center space-x-2">
|
||||||
@@ -455,6 +514,25 @@ export const ActionBar = ({
|
|||||||
<div className="ml-4 flex-grow px-2 text-sm">
|
<div className="ml-4 flex-grow px-2 text-sm">
|
||||||
{Object.keys(selectedSecrets).length} Selected
|
{Object.keys(selectedSecrets).length} Selected
|
||||||
</div>
|
</div>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Move"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faAnglesRight} />}
|
||||||
|
className="ml-4"
|
||||||
|
onClick={() => handlePopUpOpen("moveSecrets")}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Move
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionActions.Delete}
|
I={ProjectPermissionActions.Delete}
|
||||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||||
@@ -466,7 +544,7 @@ export const ActionBar = ({
|
|||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
colorSchema="danger"
|
colorSchema="danger"
|
||||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
className="ml-4"
|
className="ml-2"
|
||||||
onClick={() => handlePopUpOpen("bulkDeleteSecrets")}
|
onClick={() => handlePopUpOpen("bulkDeleteSecrets")}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -509,6 +587,11 @@ export const ActionBar = ({
|
|||||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
|
||||||
onDeleteApproved={handleSecretBulkDelete}
|
onDeleteApproved={handleSecretBulkDelete}
|
||||||
/>
|
/>
|
||||||
|
<MoveSecretsModal
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
onMoveApproved={handleSecretsMove}
|
||||||
|
/>
|
||||||
{subscription && (
|
{subscription && (
|
||||||
<UpgradePlanModal
|
<UpgradePlanModal
|
||||||
isOpen={popUp.upgradePlan.isOpen}
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
FormControl,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Select,
|
||||||
|
SelectItem
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["moveSecrets"]>;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["moveSecrets"]>, state?: boolean) => void;
|
||||||
|
onMoveApproved: (moveParams: {
|
||||||
|
destinationEnvironment: string;
|
||||||
|
destinationSecretPath: string;
|
||||||
|
shouldOverwrite: boolean;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
environment: z.string().trim(),
|
||||||
|
secretPath: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((val) =>
|
||||||
|
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||||
|
),
|
||||||
|
shouldOverwrite: z.boolean().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: Props) => {
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm<TFormSchema>({ resolver: zodResolver(formSchema) });
|
||||||
|
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const environments = currentWorkspace?.environments || [];
|
||||||
|
const selectedEnvironment = watch("environment");
|
||||||
|
|
||||||
|
const handleFormSubmit = (data: TFormSchema) => {
|
||||||
|
onMoveApproved({
|
||||||
|
destinationEnvironment: data.environment,
|
||||||
|
destinationSecretPath: data.secretPath,
|
||||||
|
shouldOverwrite: data.shouldOverwrite
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpToggle("moveSecrets", false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp.moveSecrets.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
reset();
|
||||||
|
handlePopUpToggle("moveSecrets", isOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
title="Move Secrets"
|
||||||
|
subTitle="Move secrets from the current path to the selected destination"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="environment"
|
||||||
|
defaultValue={environments?.[0]?.slug}
|
||||||
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{environments.map(({ name, slug }) => (
|
||||||
|
<SelectItem value={slug} key={slug}>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="secretPath"
|
||||||
|
defaultValue="/"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<SecretPathInput {...field} environment={selectedEnvironment} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="shouldOverwrite"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field: { onBlur, value, onChange } }) => (
|
||||||
|
<Checkbox
|
||||||
|
id="overwrite-checkbox"
|
||||||
|
className="ml-2"
|
||||||
|
isChecked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
>
|
||||||
|
Overwrite existing secrets
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-7 flex items-center">
|
||||||
|
<Button
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
key="move-secrets-submit"
|
||||||
|
className="mr-4"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Move
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
key="move-secrets-cancel"
|
||||||
|
onClick={() => handlePopUpToggle("moveSecrets", false)}
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user